23 KiB
关于我们模块 + 工厂重置 — 收尾实施计划
摘要
本计划承接上一会话已完成的工作,完成剩余的前端改造与新增页面、编译验证、服务重启。整体目标:
- 前台"关于我们"页面去掉"联系我们"模块,并改为从后端动态加载内容(已基本完成)
- 后台新增"关于我们"管理模块:控制前台是否显示该页 + 全部内容内联所见即所得编辑
- 在系统设置页新增"危险操作"卡片,提供恢复出厂设置入口(清库 + 清 COS)
当前状态分析
已完成(上一会话)
后端(编译通过,0 错误):
-
FileStorageService.cs — 新增
ListAllObjectsAsync()与DeleteObjectsAsync(IEnumerable<string>),支持 COS 分页列出 + 批量删除(每批 1000),Local 模式回退到 Directory.GetFiles -
Dtos.cs —
PublicSettingsDto加ShowAbout;新增AboutStatDto/AboutMissionCardDto/AboutTimelineItemDto/AboutContentDto/AboutSettingsDto/FactoryResetRequest/FactoryResetResultDto -
SystemService.cs —
GetPublicSettingsAsync加ShowAbout;新增GetAboutSettingsAsync、UpdateAboutSettingsAsync(AboutSettingsDto)、FactoryResetAsync(FactoryResetRequest) -
ISystemService.cs — 新增 3 个方法签名
-
SystemController.cs — 新增
GET /api/system/about(AllowAnonymous)、PUT /api/system/about(Admin)、POST /api/system/factory-reset(Admin) -
DbSeeder.cs — allSettings 新增
ShowAbout=true与AboutContent=JSON(默认内容);工厂重置后重新种子可恢复默认数据
前端(部分完成):
-
types/index.ts —
PublicSettings加showAbout;新增AboutStat/AboutMissionCard/AboutTimelineItem/AboutContent/AboutSettings/FactoryResetResult接口 -
api/index.ts — systemApi 加
getAbout()、updateAbout(data)、factoryReset(confirmText) -
stores/system.ts — 加
aboutContentref、aboutLoadedref、loadAbout()方法 -
About.vue — 已重写,去掉"联系我们"模块,所有内容从
systemStore.aboutContent加载,骨架屏 loading
待完成
-
B 收尾:router/index.ts 与 FrontendLayout.vue 改造未完成
-
C:AboutSettings.vue 与配套 CSS 文件尚未创建
-
D:Settings.vue 危险操作卡片未添加
-
E:编译验证 + 重启前后端
关键决策(用户已确认)
- 数据库重置范围:保留种子数据(DbSeeder.SeedAsync 重新补回分类/平台/标签/示例项目/轮播图/合作伙伴;admin 账户保留,密码 admin123)
- 编辑交互方式:内联所见即所得(非表单式),编辑样式与前台 About 页一致,每个内容可直接编辑
提议变更
阶段 B 收尾 — 路由 + 前台导航
文件 1:frontend/src/router/index.ts
变更 1(L28):About 路由 meta 增加 requiresAbout: true:
{ path: 'about', name: 'About', component: () => import('@/views/frontend/About.vue'), meta: { title: '关于我们', requiresAbout: true } },
变更 2(L84 后,notifications 路由前):admin children 增加"关于我们"管理路由:
{ path: 'system/about', name: 'AdminAboutSettings', component: () => import('@/views/admin/AboutSettings.vue'), meta: { title: '关于我们', admin: true } },
变更 3(L103-139 beforeEach 守卫):守卫改为 async,并在权限校验前加 showAbout 检查。完整守卫:
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
变更 1(L192-198 menuItems):改为 computed,让"关于我们"项根据 systemStore.settings.showAbout 过滤:
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')
)
变更 2(L132-138 footer 关于我们 列):footer 中"公司介绍"链接加 v-if="systemStore.settings.showAbout":
<li v-if="systemStore.settings.showAbout"><router-link to="/about">公司介绍</router-link></li>
阶段 C — 后台内联编辑页 AboutSettings.vue
文件 3:frontend/src/views/admin/AboutSettings.vue(新建)
目标:内联所见即所得编辑器,编辑样式尽量与前台 About 一致,每个文本内容可直接点击编辑。
结构:
-
顶部页头卡片(与其他 admin 页一致),含"显示开关"和"保存"按钮
-
内容区按 Hero / 公司简介 / 使命愿景 / 发展历程分块,每块都是可编辑卡片
-
列表型数据(introParagraphs / stats / missionCards / timeline)支持增删和上下移动
-
使用
el-input(无边框 + autosize)实现内联编辑,hover/focus 时显示虚线边框提示可编辑
关键交互:
-
showAbout开关:el-switch,change 时立即调updateAbout保存(不依赖主保存按钮) -
主"保存"按钮:保存全部内容到
AboutContent -
列表增删:每项右侧悬浮显示"+ 添加 / 删除 / 上移 / 下移"图标按钮
-
保存后调
systemStore.loadAbout()刷新 store,使前台立即生效
核心代码骨架:
<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(新建)
目标:内联编辑样式 — 让 el-input 看起来像前台 About 页的文字,但 hover/focus 时显示虚线边框提示可编辑。
关键样式:
/* 内联输入:默认无边框、透明背景,模拟前台文本外观 */
.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
在"上传限制"卡片之后追加"危险操作"卡片:
<!-- ===== 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 增加:
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
// 刷新 systemStore(admin 头像被清空、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(追加样式)
追加:
.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 — 编译验证 + 重启
-
前端 HMR 验证:保存所有改动后,Vite dev server(5173 端口)应自动 HMR;检查控制台无 TypeScript 错误
-
后端编译验证:
-
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)
-
-
功能验证清单(不强制要求用户操作,但建议):
-
访问
/admin/system/about→ 加载默认内容 → 修改 Hero 标题 → 保存 → 访问前台/about看到新内容 -
切换"前台显示"开关为关闭 → 前台导航不再显示"关于我们" → 直接访问
/about重定向到首页 -
在 Settings 页点"恢复出厂设置" → 输入 RESET → 确认 → 看到"重置成功"提示 → 数据库业务表清空,admin 仍可登录
-
假设与决策
- AboutSettings.vue 列表交互:使用数组
splice/push直接操作(reactive 深层响应),不引入额外的拖拽库;上下移动用图标按钮 - 图标名输入:missionCards 的
icon字段直接用文本输入框(让管理员填rocket/globe/heart等 SvgIcon 名称),不引入图标选择器,保持简单 - 保存策略:
showAbout开关 change 即时保存(独立于内容);主"保存全部内容"按钮保存AboutContentJSON。这样切换开关不会丢失未保存的内容编辑(因为 updateAbout 同时发送 showAbout + content) - 路由守卫 async 化:原
beforeEach是同步,改为 async 会让首次路由跳转等待一次systemStore.load()(main.ts 已先调过,通常是 no-op) - 不修改 main.ts:保持 main.ts 已有的
systemStore.load()调用,不在启动时调loadAbout()(About.vue 自己 onMounted 调即可,避免首次启动多一次请求) - 危险操作不放在独立路由:直接在 Settings.vue 末尾加卡片,符合"系统设置集中"的产品语义;不新建
DangerZone.vue页 - FactoryResetResult 字段命名:后端 DTO 已使用 PascalCase(
DeletedTables、DeletedCosObjects),前端 ApiResponse 拦截器已统一转 camelCase(deletedTables、deletedCosObjects),按前端命名规范使用
验证步骤
实施完成后:
- 前端 Vite 控制台无 TS 报错,
/admin/system/about路由可访问 - 后端
dotnet build输出Build succeeded, 0 Error - 访问后台"系统设置"侧边栏看到"关于我们"子菜单
- AboutSettings.vue 加载默认内容、修改保存、开关切换均生效
- 前台
/about显示修改后的内容;关闭开关后导航隐藏、直接访问重定向到首页 - Settings.vue 危险操作卡片显示,输入 RESET 后调用工厂重置成功
任务清单(执行顺序)
-
B1: 修改 router/index.ts(加 requiresAbout meta + admin 路由 + 守卫 showAbout 检查)
-
B2: 修改 FrontendLayout.vue(menuItems 改 computed + footer 链接加 v-if)
-
C1: 创建 AboutSettings.vue(内联所见即所得编辑器)
-
C2: 创建 AboutSettings.css(内联编辑样式)
-
D1: Settings.vue 追加危险操作卡片 + 工厂重置弹窗 + script 逻辑
-
D2: Settings.css 追加 danger-card 样式
-
E1: 杀掉后端进程 + dotnet build 验证
-
E2: 重启后端 + 前端 HMR 验证