MilliSim/.trae/documents/about-us-module-finish-plan.md

537 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 关于我们模块 + 工厂重置 — 收尾实施计划
## 摘要
本计划承接上一会话已完成的工作,完成剩余的前端改造与新增页面、编译验证、服务重启。整体目标:
1. 前台"关于我们"页面去掉"联系我们"模块,并改为从后端动态加载内容(已基本完成)
2. 后台新增"关于我们"管理模块:控制前台是否显示该页 + 全部内容**内联所见即所得**编辑
3. 在系统设置页新增"危险操作"卡片,提供恢复出厂设置入口(清库 + 清 COS
## 当前状态分析
### 已完成(上一会话)
**后端编译通过0 错误):**
* [FileStorageService.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/Infrastructure/FileStorageService.cs) — 新增 `ListAllObjectsAsync()``DeleteObjectsAsync(IEnumerable<string>)`,支持 COS 分页列出 + 批量删除(每批 1000Local 模式回退到 Directory.GetFiles
* [Dtos.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Common/Dtos/Dtos.cs) — `PublicSettingsDto``ShowAbout`;新增 `AboutStatDto`/`AboutMissionCardDto`/`AboutTimelineItemDto`/`AboutContentDto`/`AboutSettingsDto`/`FactoryResetRequest`/`FactoryResetResultDto`
* [SystemService.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/SystemService.cs) — `GetPublicSettingsAsync``ShowAbout`;新增 `GetAboutSettingsAsync``UpdateAboutSettingsAsync(AboutSettingsDto)``FactoryResetAsync(FactoryResetRequest)`
* [ISystemService.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/ISystemService.cs) — 新增 3 个方法签名
* [SystemController.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Controllers/SystemController.cs) — 新增 `GET /api/system/about`AllowAnonymous`PUT /api/system/about`Admin`POST /api/system/factory-reset`Admin
* [DbSeeder.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Data/DbSeeder.cs) — allSettings 新增 `ShowAbout=true``AboutContent=JSON(默认内容)`;工厂重置后重新种子可恢复默认数据
**前端(部分完成):**
* [types/index.ts](file:///d:/TraeProject/MilliSim4/frontend/src/types/index.ts) — `PublicSettings``showAbout`;新增 `AboutStat`/`AboutMissionCard`/`AboutTimelineItem`/`AboutContent`/`AboutSettings`/`FactoryResetResult` 接口
* [api/index.ts](file:///d:/TraeProject/MilliSim4/frontend/src/api/index.ts) — systemApi 加 `getAbout()``updateAbout(data)``factoryReset(confirmText)`
* [stores/system.ts](file:///d:/TraeProject/MilliSim4/frontend/src/stores/system.ts) — 加 `aboutContent` ref、`aboutLoaded` ref、`loadAbout()` 方法
* [About.vue](file:///d:/TraeProject/MilliSim4/frontend/src/views/frontend/About.vue) — 已重写,去掉"联系我们"模块,所有内容从 `systemStore.aboutContent` 加载,骨架屏 loading
### 待完成
* **B 收尾**[router/index.ts](file:///d:/TraeProject/MilliSim4/frontend/src/router/index.ts) 与 [FrontendLayout.vue](file:///d:/TraeProject/MilliSim4/frontend/src/layouts/FrontendLayout.vue) 改造未完成
* **C**[AboutSettings.vue](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/AboutSettings.vue) 与配套 CSS 文件尚未创建
* **D**[Settings.vue](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/Settings.vue) 危险操作卡片未添加
* **E**:编译验证 + 重启前后端
## 关键决策(用户已确认)
1. **数据库重置范围**保留种子数据DbSeeder.SeedAsync 重新补回分类/平台/标签/示例项目/轮播图/合作伙伴admin 账户保留,密码 admin123
2. **编辑交互方式**:内联所见即所得(非表单式),编辑样式与前台 About 页一致,每个内容可直接编辑
## 提议变更
### 阶段 B 收尾 — 路由 + 前台导航
#### 文件 1[frontend/src/router/index.ts](file:///d:/TraeProject/MilliSim4/frontend/src/router/index.ts)
**变更 1L28**About 路由 meta 增加 `requiresAbout: true`
```ts
{ path: 'about', name: 'About', component: () => import('@/views/frontend/About.vue'), meta: { title: '关于我们', requiresAbout: true } },
```
**变更 2L84 后notifications 路由前)**admin children 增加"关于我们"管理路由:
```ts
{ path: 'system/about', name: 'AdminAboutSettings', component: () => import('@/views/admin/AboutSettings.vue'), meta: { title: '关于我们', admin: true } },
```
**变更 3L103-139 beforeEach 守卫)**:守卫改为 async并在权限校验前加 `showAbout` 检查。完整守卫:
```ts
router.beforeEach(async (to, _from, next) => {
document.title = `${to.meta.title || ''} - 毫秒数仿`
const userStore = useUserStore()
const systemStore = useSystemStore()
// 确保 systemStore 已加载(用于 showAbout 判断)
await systemStore.ensureLoaded()
// 校验关于我们页面是否对外显示
if (to.meta.requiresAbout && !systemStore.settings.showAbout) {
next('/')
return
}
// 校验登录状态
if (to.meta.auth && !userStore.isLoggedIn) {
next(to.path.startsWith('/admin') ? '/admin/login' : '/login')
return
}
// ... 其余 staff/admin/pm/sales 校验保持不变
next()
})
```
> 注意:原守卫是同步函数,需改为 `async`,因为 `ensureLoaded()` 返回 Promise。系统已在 main.ts 启动时调 `systemStore.load()`,此处 `ensureLoaded` 通常是 no-op不会引入明显延迟。
#### 文件 2[frontend/src/layouts/FrontendLayout.vue](file:///d:/TraeProject/MilliSim4/frontend/src/layouts/FrontendLayout.vue)
**变更 1L192-198 menuItems**:改为 computed让"关于我们"项根据 `systemStore.settings.showAbout` 过滤:
```ts
import { ref, computed, onMounted, onUnmounted } from 'vue'
// ...
const allMenuItems = [
{ path: '/', label: '首页', icon: 'home' },
{ path: '/projects', label: '项目中心', icon: 'grid' },
{ path: '/tech', label: '技术能力', icon: 'zap' },
{ path: '/about', label: '关于我们', icon: 'building' },
{ path: '/contact', label: '联系咨询', icon: 'message' },
]
const menuItems = computed(() =>
systemStore.settings.showAbout
? allMenuItems
: allMenuItems.filter(i => i.path !== '/about')
)
```
**变更 2L132-138 footer 关于我们 列)**footer 中"公司介绍"链接加 `v-if="systemStore.settings.showAbout"`
```html
<li v-if="systemStore.settings.showAbout"><router-link to="/about">公司介绍</router-link></li>
```
### 阶段 C — 后台内联编辑页 AboutSettings.vue
#### 文件 3[frontend/src/views/admin/AboutSettings.vue](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/AboutSettings.vue)(新建)
**目标**:内联所见即所得编辑器,编辑样式尽量与前台 About 一致,每个文本内容可直接点击编辑。
**结构**
* 顶部页头卡片(与其他 admin 页一致),含"显示开关"和"保存"按钮
* 内容区按 Hero / 公司简介 / 使命愿景 / 发展历程分块,每块都是可编辑卡片
* 列表型数据introParagraphs / stats / missionCards / timeline支持增删和上下移动
* 使用 `el-input`(无边框 + autosize实现内联编辑hover/focus 时显示虚线边框提示可编辑
**关键交互**
* `showAbout` 开关el-switchchange 时立即调 `updateAbout` 保存(不依赖主保存按钮)
* 主"保存"按钮:保存全部内容到 `AboutContent`
* 列表增删:每项右侧悬浮显示"+ 添加 / 删除 / 上移 / 下移"图标按钮
* 保存后调 `systemStore.loadAbout()` 刷新 store使前台立即生效
**核心代码骨架**
```vue
<template>
<div class="admin-page about-settings-page">
<!-- 页头 -->
<div class="page-header-card">
<div class="page-title-area">
<div class="page-title-icon"><SvgIcon name="building" :size="20" color="#fff" /></div>
<div class="page-title-text">
<h2>关于我们管理</h2>
<p>控制前台"关于我们"页面是否显示并编辑全部内容所见即所得</p>
</div>
</div>
<div class="page-header-actions">
<el-switch v-model="form.showAbout" active-text="前台显示" @change="onToggleShowAbout" />
<el-button type="primary" :loading="saving" @click="saveAll">
<SvgIcon name="save" :size="14" color="#fff" /><span>保存全部内容</span>
</el-button>
</div>
</div>
<el-skeleton v-if="loading" :rows="12" animated />
<template v-else>
<!-- Hero -->
<div class="editor-card hero-editor">
<div class="editor-section-title">主视觉 Hero</div>
<div class="hero-preview">
<el-input v-model="form.content.heroTag" class="inline-input hero-tag-input" placeholder="标签" />
<el-input v-model="form.content.heroTitle" class="inline-input hero-title-input" placeholder="主标题" />
<el-input v-model="form.content.heroDesc" type="textarea" :autosize="{ minRows: 2 }" class="inline-input hero-desc-input" placeholder="副标题描述" />
</div>
</div>
<!-- 公司简介 -->
<div class="editor-card">
<div class="editor-section-title">
<span>公司简介</span>
<el-button text @click="addIntroParagraph"><SvgIcon name="plus" :size="14" /> 段落</el-button>
</div>
<el-input v-model="form.content.introTitle" class="inline-input block-title-input" placeholder="板块标题" />
<div v-for="(p, i) in form.content.introParagraphs" :key="i" class="list-item">
<el-input v-model="form.content.introParagraphs[i]" type="textarea" :autosize="{ minRows: 2 }" class="inline-input" />
<div class="list-actions">
<button @click="moveItem(form.content.introParagraphs, i, -1)" :disabled="i === 0"><SvgIcon name="arrowUp" :size="14" /></button>
<button @click="moveItem(form.content.introParagraphs, i, 1)" :disabled="i === form.content.introParagraphs.length - 1"><SvgIcon name="arrowDown" :size="14" /></button>
<button @click="form.content.introParagraphs.splice(i, 1)"><SvgIcon name="trash" :size="14" /></button>
</div>
</div>
<div class="editor-section-subtitle">数据统计</div>
<div v-for="(s, i) in form.content.stats" :key="i" class="stat-row list-item">
<el-input v-model="form.content.stats[i].number" placeholder="数字" />
<el-input v-model="form.content.stats[i].label" placeholder="标签" />
<div class="list-actions">...</div>
</div>
<el-button text @click="form.content.stats.push({ number: '', label: '' })"><SvgIcon name="plus" :size="14" /> 统计项</el-button>
</div>
<!-- 使命愿景 -->
<div class="editor-card">
<div class="editor-section-title">
<span>使命与愿景</span>
<el-button text @click="form.content.missionCards.push({ title: '', icon: 'rocket', desc: '' })"><SvgIcon name="plus" :size="14" /> 卡片</el-button>
</div>
<el-input v-model="form.content.missionTitle" class="inline-input block-title-input" placeholder="板块标题" />
<div v-for="(c, i) in form.content.missionCards" :key="i" class="mission-card list-item">
<div class="mission-card-head">
<el-input v-model="form.content.missionCards[i].title" placeholder="卡片标题" />
<el-input v-model="form.content.missionCards[i].icon" placeholder="图标名 (如 rocket)" />
<div class="list-actions">...</div>
</div>
<el-input v-model="form.content.missionCards[i].desc" type="textarea" :autosize="{ minRows: 2 }" placeholder="描述" />
</div>
</div>
<!-- 发展历程 -->
<div class="editor-card">
<div class="editor-section-title">
<span>发展历程</span>
<el-button text @click="form.content.timeline.push({ year: '', title: '', desc: '' })"><SvgIcon name="plus" :size="14" /> 时间线项</el-button>
</div>
<el-input v-model="form.content.timelineTitle" class="inline-input block-title-input" placeholder="板块标题" />
<div v-for="(t, i) in form.content.timeline" :key="i" class="timeline-row list-item">
<el-input v-model="form.content.timeline[i].year" placeholder="年份" />
<el-input v-model="form.content.timeline[i].title" placeholder="标题" />
<el-input v-model="form.content.timeline[i].desc" type="textarea" :autosize="{ minRows: 1 }" placeholder="描述" />
<div class="list-actions">...</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { systemApi } from '@/api'
import { useSystemStore } from '@/stores/system'
import SvgIcon from '@/components/SvgIcon.vue'
import { showActionConfirm } from '@/utils/confirm'
import type { AboutSettings, AboutContent } from '@/types'
const systemStore = useSystemStore()
const loading = ref(false)
const saving = ref(false)
const form = reactive<AboutSettings>({
showAbout: true,
content: { heroTag: '', heroTitle: '', heroDesc: '', introTitle: '', introParagraphs: [], stats: [], missionTitle: '', missionCards: [], timelineTitle: '', timeline: [] },
})
async function fetchAbout() {
loading.value = true
try {
const res = await systemApi.getAbout()
if (res.data) {
form.showAbout = res.data.showAbout !== false
form.content = res.data.content || form.content
}
} finally { loading.value = false }
}
function moveItem<T>(arr: T[], i: number, dir: number) {
const j = i + dir
if (j < 0 || j >= arr.length) return
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
async function onToggleShowAbout(val: boolean) {
// 开关独立保存,不影响未保存的内容编辑
try {
await systemApi.updateAbout({ showAbout: val, content: form.content })
await systemStore.loadAbout()
ElMessage.success(val ? '已开启前台显示' : '已关闭前台显示')
} catch { form.showAbout = !val }
}
async function saveAll() {
if (saving.value) return
saving.value = true
try {
await systemApi.updateAbout({ showAbout: form.showAbout, content: form.content })
await systemStore.loadAbout()
ElMessage.success('保存成功')
} finally { saving.value = false }
}
function addIntroParagraph() {
form.content.introParagraphs.push('')
}
onMounted(fetchAbout)
</script>
<style scoped src="./AboutSettings.css"></style>
```
#### 文件 4[frontend/src/views/admin/AboutSettings.css](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/AboutSettings.css)(新建)
**目标**:内联编辑样式 — 让 `el-input` 看起来像前台 About 页的文字,但 hover/focus 时显示虚线边框提示可编辑。
**关键样式**
```css
/* 内联输入:默认无边框、透明背景,模拟前台文本外观 */
.inline-input :deep(.el-input__wrapper),
.inline-input :deep(.el-textarea__inner) {
background: transparent;
box-shadow: none !important;
border: 1px dashed transparent;
padding: 4px 8px;
border-radius: 4px;
transition: border-color .2s;
}
.inline-input :deep(.el-input__wrapper):hover,
.inline-input :deep(.el-textarea__inner):hover {
border-color: var(--color-primary-light);
}
.inline-input :deep(.el-input__wrapper).is-focus,
.inline-input :deep(.el-textarea__inner):focus {
border-color: var(--color-primary);
background: rgba(22, 119, 255, 0.04);
}
/* Hero 区编辑样式(白字) */
.hero-preview {
background: var(--gradient-hero);
padding: 40px 24px;
border-radius: 12px;
text-align: center;
}
.hero-preview .hero-tag-input :deep(.el-input__inner),
.hero-preview .hero-title-input :deep(.el-input__inner),
.hero-preview .hero-desc-input :deep(.el-textarea__inner) {
color: #fff;
text-align: center;
}
.hero-title-input :deep(.el-input__inner) { font-size: 32px; font-weight: 700; }
/* 列表项容器 */
.list-item {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
}
.list-item > .el-input,
.list-item > .el-textarea { flex: 1; }
.list-actions {
display: flex;
gap: 4px;
padding-top: 4px;
}
.list-actions button {
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
color: var(--color-text-secondary);
border-radius: 4px;
}
.list-actions button:hover:not(:disabled) {
background: var(--color-bg-elevated);
color: var(--color-primary);
}
.list-actions button:disabled { opacity: .3; cursor: not-allowed; }
```
### 阶段 D — Settings.vue 危险操作卡片
#### 文件 5[frontend/src/views/admin/Settings.vue](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/Settings.vue)
在"上传限制"卡片之后追加"危险操作"卡片:
```html
<!-- ===== 6. 危险操作卡片 ===== -->
<div class="form-card danger-card">
<div class="form-section-title danger-title">
<SvgIcon name="alert" :size="16" color="#E5484D" />
<span>危险操作</span>
</div>
<div class="danger-body">
<div class="danger-item">
<div class="danger-info">
<div class="danger-name">恢复出厂设置</div>
<div class="danger-desc">
清空所有业务数据(仅保留 admin 账户)并删除 COS 全部资源。系统会先自动创建数据备份,再重新写入种子数据(分类/平台/标签/示例项目/轮播图/合作伙伴)。
</div>
</div>
<el-button type="danger" @click="openFactoryReset">
<SvgIcon name="trash" :size="14" color="#fff" />
<span>恢复出厂设置</span>
</el-button>
</div>
</div>
</div>
<!-- 恢复出厂设置确认弹窗 -->
<el-dialog v-model="resetDialogVisible" title="恢复出厂设置确认" width="480px" :close-on-click-modal="false">
<el-alert type="error" :closable="false" show-icon title="此操作不可逆" description="将清空所有业务数据并删除 COS 全部资源。系统会先自动创建数据备份。" style="margin-bottom: 16px" />
<p style="margin-bottom: 8px">请输入 <code>RESET</code> 确认:</p>
<el-input v-model="resetConfirmText" placeholder="请输入 RESET" />
<template #footer>
<el-button @click="resetDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="resetting" :disabled="resetConfirmText !== 'RESET'" @click="confirmFactoryReset">
确认重置
</el-button>
</template>
</el-dialog>
```
**script 增加**
```ts
import { systemApi } from '@/api'
// ...
const resetDialogVisible = ref(false)
const resetConfirmText = ref('')
const resetting = ref(false)
function openFactoryReset() {
resetConfirmText.value = ''
resetDialogVisible.value = true
}
async function confirmFactoryReset() {
if (resetting.value) return
resetting.value = true
try {
const res = await systemApi.factoryReset(resetConfirmText.value)
if (res.data) {
ElMessage.success(`重置成功:已清空 ${res.data.deletedTables} 张表,删除 ${res.data.deletedCosObjects} 个 COS 对象`)
resetDialogVisible.value = false
// 刷新 systemStoreadmin 头像被清空、Logo 等可能变化)
await systemStore.load()
await systemStore.loadAbout()
}
} catch {
// 错误已由拦截器处理
} finally {
resetting.value = false
}
}
```
**关于 FactoryResetResultDto 字段**:后端 DTO 已定义 `DeletedTables``DeletedCosObjects``BackupId` 等字段(与前端 `FactoryResetResult` 接口对应)。
#### 文件 6[frontend/src/views/admin/Settings.css](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/Settings.css)(追加样式)
追加:
```css
.danger-card { border: 1px solid #E5484D; }
.danger-title { color: #E5484D; }
.danger-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.danger-info { flex: 1; }
.danger-name { font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; }
.danger-desc { font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
```
### 阶段 E — 编译验证 + 重启
1. **前端 HMR 验证**保存所有改动后Vite dev server5173 端口)应自动 HMR检查控制台无 TypeScript 错误
2. **后端编译验证**
* `taskkill /F /PID <pid>` 杀掉运行中的 MilliSim.Api.exe如 PID 13788/22680但需 `tasklist | findstr MilliSim` 确认当前 PID
* `cd d:\TraeProject\MilliSim4\backend\MilliSim.Api && dotnet build`(预期 0 错误,仅有 2 个预存警告)
* 后台启动 `dotnet run --project d:\TraeProject\MilliSim4\backend\MilliSim.Api\MilliSim.Api.csproj`run\_in\_background=true
3. **功能验证清单**(不强制要求用户操作,但建议):
* 访问 `/admin/system/about` → 加载默认内容 → 修改 Hero 标题 → 保存 → 访问前台 `/about` 看到新内容
* 切换"前台显示"开关为关闭 → 前台导航不再显示"关于我们" → 直接访问 `/about` 重定向到首页
* 在 Settings 页点"恢复出厂设置" → 输入 RESET → 确认 → 看到"重置成功"提示 → 数据库业务表清空admin 仍可登录
## 假设与决策
1. **AboutSettings.vue 列表交互**:使用数组 `splice`/`push` 直接操作reactive 深层响应),不引入额外的拖拽库;上下移动用图标按钮
2. **图标名输入**missionCards 的 `icon` 字段直接用文本输入框(让管理员填 `rocket`/`globe`/`heart` 等 SvgIcon 名称),不引入图标选择器,保持简单
3. **保存策略**`showAbout` 开关 change 即时保存(独立于内容);主"保存全部内容"按钮保存 `AboutContent` JSON。这样切换开关不会丢失未保存的内容编辑因为 updateAbout 同时发送 showAbout + content
4. **路由守卫 async 化**:原 `beforeEach` 是同步,改为 async 会让首次路由跳转等待一次 `systemStore.load()`main.ts 已先调过,通常是 no-op
5. **不修改 main.ts**:保持 main.ts 已有的 `systemStore.load()` 调用,不在启动时调 `loadAbout()`About.vue 自己 onMounted 调即可,避免首次启动多一次请求)
6. **危险操作不放在独立路由**:直接在 Settings.vue 末尾加卡片,符合"系统设置集中"的产品语义;不新建 `DangerZone.vue`
7. **FactoryResetResult 字段命名**:后端 DTO 已使用 PascalCase`DeletedTables``DeletedCosObjects`),前端 ApiResponse 拦截器已统一转 camelCase`deletedTables``deletedCosObjects`),按前端命名规范使用
## 验证步骤
实施完成后:
1. 前端 Vite 控制台无 TS 报错,`/admin/system/about` 路由可访问
2. 后端 `dotnet build` 输出 `Build succeeded, 0 Error`
3. 访问后台"系统设置"侧边栏看到"关于我们"子菜单
4. AboutSettings.vue 加载默认内容、修改保存、开关切换均生效
5. 前台 `/about` 显示修改后的内容;关闭开关后导航隐藏、直接访问重定向到首页
6. Settings.vue 危险操作卡片显示,输入 RESET 后调用工厂重置成功
## 任务清单(执行顺序)
* [ ] B1: 修改 router/index.ts加 requiresAbout meta + admin 路由 + 守卫 showAbout 检查)
* [ ] B2: 修改 FrontendLayout.vuemenuItems 改 computed + footer 链接加 v-if
* [ ] C1: 创建 AboutSettings.vue内联所见即所得编辑器
* [ ] C2: 创建 AboutSettings.css内联编辑样式
* [ ] D1: Settings.vue 追加危险操作卡片 + 工厂重置弹窗 + script 逻辑
* [ ] D2: Settings.css 追加 danger-card 样式
* [ ] E1: 杀掉后端进程 + dotnet build 验证
* [ ] E2: 重启后端 + 前端 HMR 验证