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

23 KiB
Raw Blame History

关于我们模块 + 工厂重置 — 收尾实施计划

摘要

本计划承接上一会话已完成的工作,完成剩余的前端改造与新增页面、编译验证、服务重启。整体目标:

  1. 前台"关于我们"页面去掉"联系我们"模块,并改为从后端动态加载内容(已基本完成)
  2. 后台新增"关于我们"管理模块:控制前台是否显示该页 + 全部内容内联所见即所得编辑
  3. 在系统设置页新增"危险操作"卡片,提供恢复出厂设置入口(清库 + 清 COS

当前状态分析

已完成(上一会话)

后端编译通过0 错误):

  • FileStorageService.cs — 新增 ListAllObjectsAsync()DeleteObjectsAsync(IEnumerable<string>),支持 COS 分页列出 + 批量删除(每批 1000Local 模式回退到 Directory.GetFiles

  • Dtos.csPublicSettingsDtoShowAbout;新增 AboutStatDto/AboutMissionCardDto/AboutTimelineItemDto/AboutContentDto/AboutSettingsDto/FactoryResetRequest/FactoryResetResultDto

  • SystemService.csGetPublicSettingsAsyncShowAbout;新增 GetAboutSettingsAsyncUpdateAboutSettingsAsync(AboutSettingsDto)FactoryResetAsync(FactoryResetRequest)

  • ISystemService.cs — 新增 3 个方法签名

  • SystemController.cs — 新增 GET /api/system/aboutAllowAnonymousPUT /api/system/aboutAdminPOST /api/system/factory-resetAdmin

  • DbSeeder.cs — allSettings 新增 ShowAbout=trueAboutContent=JSON(默认内容);工厂重置后重新种子可恢复默认数据

前端(部分完成):

  • types/index.tsPublicSettingsshowAbout;新增 AboutStat/AboutMissionCard/AboutTimelineItem/AboutContent/AboutSettings/FactoryResetResult 接口

  • api/index.ts — systemApi 加 getAbout()updateAbout(data)factoryReset(confirmText)

  • stores/system.ts — 加 aboutContent ref、aboutLoaded ref、loadAbout() 方法

  • About.vue — 已重写,去掉"联系我们"模块,所有内容从 systemStore.aboutContent 加载,骨架屏 loading

待完成

关键决策(用户已确认)

  1. 数据库重置范围保留种子数据DbSeeder.SeedAsync 重新补回分类/平台/标签/示例项目/轮播图/合作伙伴admin 账户保留,密码 admin123
  2. 编辑交互方式:内联所见即所得(非表单式),编辑样式与前台 About 页一致,每个内容可直接编辑

提议变更

阶段 B 收尾 — 路由 + 前台导航

文件 1frontend/src/router/index.ts

变更 1L28About 路由 meta 增加 requiresAbout: true

{ path: 'about', name: 'About', component: () => import('@/views/frontend/About.vue'), meta: { title: '关于我们', requiresAbout: true } },

变更 2L84 后notifications 路由前)admin children 增加"关于我们"管理路由:

{ path: 'system/about', name: 'AdminAboutSettings', component: () => import('@/views/admin/AboutSettings.vue'), meta: { title: '关于我们', admin: true } },

变更 3L103-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不会引入明显延迟。

文件 2frontend/src/layouts/FrontendLayout.vue

变更 1L192-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')
)

变更 2L132-138 footer 关于我们 列)footer 中"公司介绍"链接加 v-if="systemStore.settings.showAbout"

<li v-if="systemStore.settings.showAbout"><router-link to="/about">公司介绍</router-link></li>

阶段 C — 后台内联编辑页 AboutSettings.vue

文件 3frontend/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使前台立即生效

核心代码骨架

<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>

文件 4frontend/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 危险操作卡片

文件 5frontend/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
      // 刷新 systemStoreadmin 头像被清空、Logo 等可能变化)
      await systemStore.load()
      await systemStore.loadAbout()
    }
  } catch {
    // 错误已由拦截器处理
  } finally {
    resetting.value = false
  }
}

关于 FactoryResetResultDto 字段:后端 DTO 已定义 DeletedTablesDeletedCosObjectsBackupId 等字段(与前端 FactoryResetResult 接口对应)。

文件 6frontend/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 — 编译验证 + 重启

  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.csprojrun_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 已使用 PascalCaseDeletedTablesDeletedCosObjects),前端 ApiResponse 拦截器已统一转 camelCasedeletedTablesdeletedCosObjects),按前端命名规范使用

验证步骤

实施完成后:

  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 验证