diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acb19e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# ============================================================ +# MilliSim4 .gitignore +# 规则:配置文件允许提交,构建产物排除,文档文件允许提交 +# ============================================================ + +# ======== 构建产物 ======== +# .NET 后端编译输出 +**/bin/ +**/obj/ + +# 前端构建输出 +frontend/dist/ +frontend/node_modules/ + +# 发布包 +*.publish.zip +publish/ + +# ======== 日志文件 ======== +**/logs/ +*.log +backend-startup.log +frontend-startup.log + +# ======== 运行时用户数据 ======== +# 上传的文件和数据库备份(环境相关,不入库) +**/wwwroot/uploads/ +**/wwwroot/backups/ + +# ======== Python 缓存 ======== +__pycache__/ +*.pyc + +# ======== 操作系统/IDE 临时文件 ======== +Thumbs.db +.DS_Store +*.swp +*.swo +*~ + +# ======== 敏感临时文件 ======== +*.user +*.suo \ No newline at end of file diff --git a/.trae/documents/about-us-module-and-reset.md b/.trae/documents/about-us-module-and-reset.md new file mode 100644 index 0000000..79ea47d --- /dev/null +++ b/.trae/documents/about-us-module-and-reset.md @@ -0,0 +1,187 @@ +# 关于我们模块 + 数据库重置 + COS 清理 实施计划 + +## Context(背景与目标) + +用户提出三项需求: +1. **数据库重置**:清空所有业务数据,仅保留 admin 账户,重置后保留种子数据(分类/平台/标签/示例项目/轮播图/合作伙伴由 DbSeeder 自动补回)。 +2. **COS 资源全删**:清空腾讯云 COS 存储桶内所有应用上传的文件(保留 `backups/` 下的数据库备份)。 +3. **关于我们模块**: + - 前台 About 页去掉"联系我们"模块 + - 后台新增"关于我们"管理模块:控制前台是否显示该页 + 全部内容可编辑(内联所见即所得,编辑样式与前台一致) + +当前 About 页 100% 硬编码([About.vue](file:///d:/TraeProject/MilliSim4/frontend/src/views/frontend/About.vue)),无任何后端存储。本计划从零构建内容存储 + 后台内联编辑 + 前台动态渲染。 + +## 关键设计决策(经用户确认 + 架构验证) + +1. **存储方式**:全部 About 内容作为单个 JSON 字符串存入 SystemSettings 表(Key=`AboutContent`),与现有 PlatformName/ShowPartners 等配置一致。避免新建表 + 改 seeder 补丁的复杂度。 +2. **编辑交互**:使用无边框 `el-input`/`el-textarea`(autosize),通过 hover/focus 样式提示可编辑性。不使用 `contenteditable`(Vue 响应性陷阱:光标跳动)。 +3. **数组排序**:上下箭头按钮,不引入拖拽库。 +4. **ShowAbout 控制**:隐藏导航链接 + 路由守卫拦截直接访问(重定向到首页)。 +5. **重置安全**:要求输入 "RESET" 确认 + 重置前自动创建一次数据库备份 + 操作日志记录。 +6. **COS 清理范围**:仅删除已知上传前缀(avatars/banners/logos/projects/webgl/packages/common/icons/videos/categories/platforms/partners),保留 `backups/`。 +7. **AboutContent 不进 PublicSettingsDto**:public-settings 仅加 `showAbout`(小布尔值,供导航/路由用);AboutContent 由独立端点加载,仅 About 页消费。 + +## 实施步骤 + +### 阶段 A:后端基础设施 + +#### A1. FileStorageService 增加批量删除能力 +**文件**:`d:\TraeProject\MilliSim4\backend\MilliSim.Api\Services\Infrastructure\FileStorageService.cs` + +- 接口 `IFileStorageService` 新增: + - `Task> ListAllObjectsAsync()` — 分页列出桶内所有对象 key(每页 1000,循环至结束) + - `Task DeleteObjectsAsync(IEnumerable keys)` — 批量删除(`DeleteMultiObjectRequest`,每批最多 1000) +- 实现:复用私有 `GetStorageConfigAsync()` + `GetCosXml(config)`;Local 模式则遍历 `wwwroot/uploads` 目录删除文件 +- 不暴露到控制器单独使用,仅供 factory-reset 调用 + +#### A2. AboutContent DTO + 端点 +**文件**:`d:\TraeProject\MilliSim4\backend\MilliSim.Api\Common\Dtos\Dtos.cs` + +新增 `AboutContentDto`: +```csharp +public class AboutContentDto +{ + public string HeroTag { get; set; } = "关于我们"; + public string HeroTitle { get; set; } = ""; + public string HeroDesc { get; set; } = ""; + public string IntroTitle { get; set; } = "公司简介"; + public List IntroParagraphs { get; set; } = new(); + public List Stats { get; set; } = new(); + public string MissionTitle { get; set; } = "使命与愿景"; + public List MissionCards { get; set; } = new(); + public string TimelineTitle { get; set; } = "发展历程"; + public List Timeline { get; set; } = new(); +} +public class AboutStatDto { public string Number { get; set; } = ""; public string Label { get; set; } = ""; } +public class AboutMissionCardDto { public string Title { get; set; } = ""; public string Icon { get; set; } = "rocket"; public string Desc { get; set; } = ""; } +public class AboutTimelineItemDto { public string Year { get; set; } = ""; public string Title { get; set; } = ""; public string Desc { get; set; } = ""; } + +public class AboutSettingsDto +{ + public bool ShowAbout { get; set; } = true; + public AboutContentDto Content { get; set; } = new(); +} +``` + +`PublicSettingsDto` 仅新增 `public bool ShowAbout { get; set; } = true;`(不加 AboutContent)。 + +#### A3. SystemService 关于我们读写 +**文件**:`d:\TraeProject\MilliSim4\backend\MilliSim.Api\Services\SystemService.cs` + +- `GetAboutSettingsAsync()` → 读 SystemSettings 中 `ShowAbout` + `AboutContent`(JSON 反序列化),返回 `AboutSettingsDto` +- `UpdateAboutSettingsAsync(AboutSettingsDto dto)` → 序列化 Content 为 JSON 存入 `AboutContent` key,`ShowAbout` 存 bool 字符串 +- `GetPublicSettingsAsync()` 中加入 `ShowAbout` 读取 + +#### A4. SystemController 端点 +**文件**:`d:\TraeProject\MilliSim4\backend\MilliSim.Api\Controllers\SystemController.cs` + +- `GET /api/system/about` → `[AllowAnonymous]` 返回 `AboutSettingsDto`(前台 About 页消费) +- `PUT /api/system/about` → `[Authorize(Policy="Admin")]` 保存 + +#### A5. 工厂重置端点 +**文件**:`d:\TraeProject\MilliSim4\backend\MilliSim.Api\Services\SystemService.cs` + `SystemController.cs` + +- `POST /api/system/factory-reset` → `[Authorize(Policy="Admin")]`,body 含 `ConfirmText`(必须等于 "RESET") +- 逻辑: + 1. 校验 `ConfirmText == "RESET"`,否则返回 400 + 2. 先调 `CreateBackupAsync()` 创建备份(保留安全网) + 3. `SET FOREIGN_KEY_CHECKS=0` + 4. 按依赖顺序 DELETE FROM 所有 21 张表(Users 仅删 `Username != 'admin'`,并清空 admin 的 Avatar/UpdatedAt 字段) + 5. `SET FOREIGN_KEY_CHECKS=1` + 6. 调 `DbSeeder.SeedAsync(db)` 重新补种子(admin 已存在跳过;分类/平台/标签/项目/轮播图/合作伙伴/设置按"为空才补"逻辑重新填入;AboutContent/ShowAbout 默认值也会被补入) + 7. 调 `_fileStorage.ListAllObjectsAsync()` 列出所有对象,过滤掉 `backups/` 前缀,调 `DeleteObjectsAsync()` 批量删除 + 8. 记操作日志 + 9. 返回 `{ TablesCleared, ObjectsDeleted, BackupId }` + +#### A6. DbSeeder 补种子 +**文件**:`d:\TraeProject\MilliSim4\backend\MilliSim.Api\Data\DbSeeder.cs` + +- 在 `SeedAsync` 的 SystemSettings 补种子段加入: + - `ShowAbout` = "true" + - `AboutContent` = 默认 JSON(当前 About.vue 的硬编码内容序列化) +- 仅在 key 不存在时插入(与现有逻辑一致) + +### 阶段 B:前端 — 关于我们动态化 + 去掉联系模块 + +#### B1. 类型 + API + Store +**文件**: +- `d:\TraeProject\MilliSim4\frontend\src\types\index.ts`:加 `AboutContent` / `AboutStat` / `AboutMissionCard` / `AboutTimelineItem` 接口;`PublicSettings` 加 `showAbout: boolean` +- `d:\TraeProject\MilliSim4\frontend\src\api\index.ts`:`systemApi.getAbout()` GET `/system/about`;`systemApi.updateAbout(payload)` PUT;`systemApi.factoryReset(confirmText)` POST +- `d:\TraeProject\MilliSim4\frontend\src\stores\system.ts`:`settings` 加 `showAbout`(默认 true);加 `aboutContent` ref + `loadAbout()` 方法(About 页 onMounted 调用) + +#### B2. About.vue 动态化 +**文件**:`d:\TraeProject\MilliSim4\frontend\src\views\frontend\About.vue` + +- 去掉"联系我们"section(原 L101-130)+ 相关 CTA +- script:`onMounted` 调 `systemStore.loadAbout()` 获取内容;用 `computed` 映射 store 中的 aboutContent +- template:所有硬编码文本替换为 `{{ about.heroTag }}` 等绑定;timeline 用 `v-for="item in about.timeline"` +- 无数据时显示骨架屏/loading + +#### B3. 路由 + 导航守卫 +**文件**: +- `d:\TraeProject\MilliSim4\frontend\src\router\index.ts`:`/about` 路由 `meta.requiresAbout: true`;`beforeEach` 守卫中 `if (to.meta.requiresAbout && !systemStore.settings.showAbout) return '/'`,并先 `await systemStore.ensureLoaded()` +- `d:\TraeProject\MilliSim4\frontend\src\layouts\FrontendLayout.vue`:nav 菜单"关于我们"项加 `v-if="systemStore.settings.showAbout"`;footer 链接同理 + +### 阶段 C:后台 — 关于我们内联编辑页 + +#### C1. 新建 AboutSettings.vue +**文件**:`d:\TraeProject\MilliSim4\frontend\src\views\admin\AboutSettings.vue`(新建) + +布局完全复刻前台 About.vue 结构,但所有文字改为可编辑: +- 顶部工具栏:`ShowAbout` el-switch + "保存" el-button(loading 状态) +- Hero 区:3 个无边框 el-input(tag/title/desc),inherit 前台字体样式 +- 公司简介:`el-textarea :autosize` × N 段(可增删),4 个统计卡片(number + label 各一输入),每卡右上角 hover 显示删除按钮 + 上下移按钮 +- 使命与愿景:3 张卡片(title + icon 选择 + desc textarea),增删/排序 +- 发展历程:时间线项(year + title + desc),增删/排序 +- 编辑态样式:`.editable:hover { outline: 1px dashed var(--color-primary); }` `.editable:focus { outline: 2px solid var(--color-primary); background: var(--color-primary-light-9); }` +- 数组操作按钮:hover 时浮现"上移/下移/删除","添加"按钮在列表末尾 +- `onMounted` 调 `systemApi.getAbout()` 加载;保存调 `systemApi.updateAbout()`;保存后 `systemStore.load()` 刷新前台状态 +- 图标选择用 el-select 下拉(预设 SvgIcon 列表:rocket/globe/heart/target/users/star 等) + +#### C2. 路由 + 菜单 +**文件**: +- `d:\TraeProject\MilliSim4\frontend\src\router\index.ts`:加 `/admin/about-settings` 路由(admin auth) +- `d:\TraeProject\MilliSim4\frontend\src\layouts\AdminLayout.vue`:侧栏菜单加入"关于我们"项(icon: `info` 或 `building`) + +### 阶段 D:后台 — 工厂重置入口 + +#### D1. Settings.vue 危险操作卡片 +**文件**:`d:\TraeProject\MilliSim4\frontend\src\views\admin\Settings.vue` + +- 在页面末尾加"危险操作"卡片: + - 标题 + 警告说明("将清空所有业务数据并删除 COS 资源,仅保留 admin 账户,操作前会自动创建备份") + - el-input 要求输入 "RESET" 才能激活按钮 + - el-button danger "恢复出厂设置"(disabled 直到输入正确) + - 二次确认弹窗 `showActionConfirm` + - 调 `systemApi.factoryReset(confirmText)`;成功后 `ElMessage.success` + 提示重新登录(admin 密码不变) + +### 阶段 E:编译验证 + 重启 + +1. 杀掉后端进程(PID 13788/22680)→ `dotnet build` → 重启后端 +2. 前端 Vite HMR 自动加载,确认无编译错误 +3. 浏览器测试场景 + +## 验证步骤 + +### 后端验证 +1. `dotnet build` 0 错误 0 警告 +2. 启动后端,确认 `PendingUploads` + 新增设置 key(AboutContent/ShowAbout)建表/补种子成功(查日志) +3. Swagger 确认新端点存在:GET/PUT `/api/system/about`、POST `/api/system/factory-reset` + +### 前端验证 +1. Vite HMR 无编译错误 +2. 访问 `/about` 确认内容从后端加载(非硬编码) +3. 关闭 ShowAbout 后导航不显示"关于我们",直接访问 `/about` 重定向到首页 +4. 后台 `/admin/about-settings` 编辑任意文字 → 保存 → 刷新前台确认生效 +5. 数组增删/排序正常 +6. 后台"恢复出厂设置"输入 RESET → 执行 → 确认数据库清空 + COS 清空 + admin 可登录 + 种子数据恢复 + +## 假设与决策 + +1. **不删 admin 账户**:DbSeeder 对 admin 用户按 username 幂等,重置后保留原密码 `admin123`。 +2. **保留 backups/ 前缀**:工厂重置不删 `backups/` 下的数据库备份文件,作为最后安全网。 +3. **重置前自动备份**:调用现有 `CreateBackupAsync`,即使重置失败也有回滚依据。 +4. **AboutContent 默认值**:取当前 About.vue 硬编码内容作为默认 JSON,保证未配置时前台显示不变。 +5. **不引入拖拽库**:数组排序用上下箭头按钮,与现有 SortOrder 字段约定一致。 +6. **PublicSettingsDto 不含 AboutContent**:避免每次应用启动加载大 JSON;AboutContent 由 About 页独立加载。 +7. **factory-reset 同步删 COS**:DB 清空后立即删 COS,保证一致性;COS 删除失败不回滚 DB(DB 已是干净状态,COS 残留可手动清理,记录日志)。 diff --git a/.trae/documents/about-us-module-finish-plan.md b/.trae/documents/about-us-module-finish-plan.md new file mode 100644 index 0000000..0b025ea --- /dev/null +++ b/.trae/documents/about-us-module-finish-plan.md @@ -0,0 +1,536 @@ +# 关于我们模块 + 工厂重置 — 收尾实施计划 + +## 摘要 + +本计划承接上一会话已完成的工作,完成剩余的前端改造与新增页面、编译验证、服务重启。整体目标: + +1. 前台"关于我们"页面去掉"联系我们"模块,并改为从后端动态加载内容(已基本完成) +2. 后台新增"关于我们"管理模块:控制前台是否显示该页 + 全部内容**内联所见即所得**编辑 +3. 在系统设置页新增"危险操作"卡片,提供恢复出厂设置入口(清库 + 清 COS) + +## 当前状态分析 + +### 已完成(上一会话) + +**后端(编译通过,0 错误):** + +* [FileStorageService.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/Infrastructure/FileStorageService.cs) — 新增 `ListAllObjectsAsync()` 与 `DeleteObjectsAsync(IEnumerable)`,支持 COS 分页列出 + 批量删除(每批 1000),Local 模式回退到 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) + +**变更 1(L28)**:About 路由 meta 增加 `requiresAbout: true`: + +```ts +{ path: 'about', name: 'About', component: () => import('@/views/frontend/About.vue'), meta: { title: '关于我们', requiresAbout: true } }, +``` + +**变更 2(L84 后,notifications 路由前)**:admin children 增加"关于我们"管理路由: + +```ts +{ path: 'system/about', name: 'AdminAboutSettings', component: () => import('@/views/admin/AboutSettings.vue'), meta: { title: '关于我们', admin: true } }, +``` + +**变更 3(L103-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) + +**变更 1(L192-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') +) +``` + +**变更 2(L132-138 footer 关于我们 列)**:footer 中"公司介绍"链接加 `v-if="systemStore.settings.showAbout"`: + +```html +
  • 公司介绍
  • +``` + +### 阶段 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-switch,change 时立即调 `updateAbout` 保存(不依赖主保存按钮) + +* 主"保存"按钮:保存全部内容到 `AboutContent` + +* 列表增删:每项右侧悬浮显示"+ 添加 / 删除 / 上移 / 下移"图标按钮 + +* 保存后调 `systemStore.loadAbout()` 刷新 store,使前台立即生效 + +**核心代码骨架**: + +```vue + + + + + +``` + +#### 文件 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 + +
    +
    + + 危险操作 +
    +
    +
    +
    +
    恢复出厂设置
    +
    + 清空所有业务数据(仅保留 admin 账户)并删除 COS 全部资源。系统会先自动创建数据备份,再重新写入种子数据(分类/平台/标签/示例项目/轮播图/合作伙伴)。 +
    +
    + + + 恢复出厂设置 + +
    +
    +
    + + + + +

    请输入 RESET 确认:

    + + +
    +``` + +**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 + // 刷新 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](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 server(5173 端口)应自动 HMR;检查控制台无 TypeScript 错误 +2. **后端编译验证**: + + * `taskkill /F /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.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 验证 + diff --git a/.trae/documents/authorization-system-refactor.md b/.trae/documents/authorization-system-refactor.md new file mode 100644 index 0000000..0b4e1f0 --- /dev/null +++ b/.trae/documents/authorization-system-refactor.md @@ -0,0 +1,447 @@ +# 授权系统全面重构计划 + +## 摘要 + +本次重构解决用户反馈的 5 个授权相关问题: +1. **续期 bug** — 一直提示"续期天数必须大于 0" +2. **授权卡片重新设计** — 要有设计感,状态标签中文表示 +3. **批量授权窗口重新设计** +4. **客户管理窗口重新设计** +5. **授权逻辑重大问题** — 一个客户只能有一个授权记录(不论有效/已过期/已取消),且授权卡片可操作,客户卡片显示当前授权状态 + +## 当前状态分析 + +### Bug 根因(续期失败) + +**后端** [`backend/MilliSim.Api/Controllers/AuthorizationsController.cs#L67-L73`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Controllers/AuthorizationsController.cs#L67-L73): +```csharp +[HttpPost("{id:long}/renew")] +public async Task Renew(long id, [FromQuery] int days) +``` +- 控制器使用 `[FromQuery] int days` 期望 query string + +**前端** `frontend/src/api/index.ts`: +```typescript +renew: (id: number, days: number) => http.post(`/authorizations/${id}/renew`, { days }) +``` +- 前端发送 POST body `{ days: 30 }` + +→ 后端 days 绑定为 0,触发 `if (days <= 0) return Fail("续期天数必须大于 0")`。 + +### 授权逻辑缺陷 + +**后端** [`backend/MilliSim.Api/Services/AuthorizationService.cs#L155-L157`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/AuthorizationService.cs#L155-L157): +```csharp +var existing = await _db.Authorizations + .FirstOrDefaultAsync(a => a.UserId == request.UserId + && a.Status == AuthorizationStatus.Active); // 只查 Active +``` +- 现有授权检查只查 Active 状态,对 Cancelled/Expired 状态会创建新记录 +- 这导致一个客户可能有多条授权记录,违反"一个客户一个授权"的约束 + +### 关键代码位置 + +- `AuthorizationStatus` 枚举:`ApiResponse.cs` — Cancelled=0, Active=1, Expired=2(数字序列化) +- `UserDto`:`Dtos.cs#L32-L46` — 不含授权信息字段 +- `UserService.GetUsersAsync`:`UserService.cs#L29-L90` — 不联表 Authorizations +- 前端 `Authorizations.vue`(418 行):卡片含头像+名称+时间+授权人+状态徽章+续期/取消按钮 +- 前端 `Customers.vue`(539 行):卡片含头像+用户状态徽章+用户名/手机/邮箱/注册时间+操作按钮,**不显示授权状态** + +## 提议的变更 + +### 后端修改 + +#### 1. 修复续期 bug — 改用 [FromBody] + DTO + +**文件**: [`backend/MilliSim.Api/Controllers/AuthorizationsController.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Controllers/AuthorizationsController.cs) + +修改续期端点(L67-L73): +```csharp +[HttpPost("{id:long}/renew")] +[Authorize(Policy = "Staff")] +public async Task Renew(long id, [FromBody] RenewAuthorizationRequest request) +{ + var result = await _authorizationService.RenewAuthorizationAsync(id, request.Days); + return Ok(result); +} +``` + +**文件**: [`backend/MilliSim.Api/Common/Dtos/Dtos.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Common/Dtos/Dtos.cs) + +在 `BatchAuthorizationRequest` 之后(约 L276 位置)添加新 DTO: +```csharp +public class RenewAuthorizationRequest +{ + public int Days { get; set; } = 30; +} +``` + +#### 2. 重构授权逻辑 — 一个客户一个授权记录 + +**文件**: [`backend/MilliSim.Api/Services/AuthorizationService.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/AuthorizationService.cs) + +**关键变更**:将"检查 Active 才续期"改为"任意状态则复用原记录"。 + +`CreateAuthorizationAsync`(L132-L225)重构逻辑: +```csharp +// 查询客户任意状态的现有授权(一个客户只能有一个授权记录) +var existing = await _db.Authorizations + .FirstOrDefaultAsync(a => a.UserId == request.UserId); + +if (existing != null) +{ + // 复用原记录:重置 StartTime/EndTime/Status/AuthorizedBy + var now = DateTime.Now; + existing.StartTime = now; + existing.EndTime = now.AddDays(request.Days); + existing.Status = AuthorizationStatus.Active; + existing.AuthorizedBy = _currentUser.UserId; + existing.ProjectId = null; // 客户级授权 + existing.UpdatedAt = now; + await _db.SaveChangesAsync(); + + // 操作日志:标记"重新授权"或"开通"(根据原状态) + var actionLabel = existing.Status == AuthorizationStatus.Cancelled ? "重新激活" : "重新授权"; + // ... 通知逻辑(区分续期/重新授权) + + return ApiResponse.Success(MapToDto(...), "授权成功"); +} +// 否则新建(同原逻辑) +``` + +`BatchAuthorizeAsync`(L230-L333)同样改造:对每个 UserId 调用相同复用/新建逻辑。 + +`CancelAuthorizationAsync`(L400-L441)添加状态检查: +```csharp +if (entity.Status == AuthorizationStatus.Cancelled) +{ + return ApiResponse.Fail("该授权已取消,无需重复操作"); +} +if (entity.Status == AuthorizationStatus.Expired) +{ + return ApiResponse.Fail("该授权已过期,请使用重新授权功能"); +} +// 仅 Active 可取消 +``` + +`RenewAuthorizationAsync`(L338-L395)添加状态检查(仅 Active 可续期): +```csharp +if (entity.Status != AuthorizationStatus.Active) +{ + return ApiResponse.Fail( + entity.Status == AuthorizationStatus.Cancelled ? "已取消的授权请使用重新授权功能" + : "已过期的授权请使用重新授权功能"); +} +// 续期:EndTime += days +``` + +**关键决策**:取消 `CreateAuthorizationAsync` 内部"已有 Active 就续期"的特殊分支 — 改为统一逻辑:客户有任何状态的授权都复用原记录(重置 StartTime + EndTime + Status=Active)。这样从 Customers.vue 点击"为客户授权"或"重新授权"都调用同一接口,后端自动处理复用。 + +#### 3. 客户列表返回授权信息 + +**文件**: [`backend/MilliSim.Api/Common/Dtos/Dtos.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Common/Dtos/Dtos.cs) + +修改 `UserDto`(L32-L46)添加两个字段: +```csharp +public AuthorizationStatus? AuthorizationStatus { get; set; } +public DateTime? AuthorizationEndTime { get; set; } +``` + +**文件**: [`backend/MilliSim.Api/Services/UserService.cs`](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/UserService.cs) + +修改 `GetUsersAsync`(L29-L90),在取出 `users` 列表后联表查询 Authorizations: + +```csharp +// 一个客户只有一个授权记录,按 UserId 一次性取出 +var userIds = users.Select(u => u.Id).ToList(); +var authDict = await _db.Authorizations + .Where(a => userIds.Contains(a.UserId)) + .ToDictionaryAsync(a => a.UserId, a => a); // 一个客户一条记录 + +var items = users.Select(u => +{ + var dto = AuthService.MapToDto(u); + if (u.CreatedBy.HasValue && creators.TryGetValue(u.CreatedBy.Value, out var name)) + { + dto.CreatedByName = name; + } + if (authDict.TryGetValue(u.Id, out var auth)) + { + dto.AuthorizationStatus = auth.Status; + dto.AuthorizationEndTime = auth.EndTime; + } + return dto; +}).ToList(); +``` + +> 注意:`MarkExpiredAsync` 不在 UserService 调用,授权状态可能有"过期未刷新"的窗口期。前端展示时若 `AuthorizationEndTime < now` 且 `AuthorizationStatus == Active`,UI 显示为"已过期"(前端容错)。 + +#### 4. 后端重启注意事项 + +后端运行在 `http://localhost:5041`(background job: `job-a1f30a33998f4065abe22b34018dcc7a`)。 +- 修改代码后需 `Stop-Process -Name "MilliSim.Api" -Force`,再 `dotnet build` 重新构建 +- 启动新进程 + +### 前端修改 + +#### 5. 类型定义 + +**文件**: [`frontend/src/types/index.ts`](file:///d:/TraeProject/MilliSim4/frontend/src/types/index.ts) + +修改 `User` 接口(L61-L74)添加授权字段: +```typescript +export interface User { + // ... 原有字段 + authorizationStatus?: AuthorizationStatus | null + authorizationEndTime?: string | null +} +``` + +#### 6. 授权卡片重新设计 + 批量授权窗口重新设计 + +**文件**: [`frontend/src/views/admin/Authorizations.vue`](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/Authorizations.vue) + `Authorizations.css` + +参考 `Categories.vue` / `Platforms.vue` 的设计语言(蓝紫渐变 + 卡片渐变背景层 + 状态胶囊)。 + +##### 授权卡片新结构 + +``` +┌────────────────────────────────────┐ +│ [渐变背景层] │ +│ [头像 56x56] [状态胶囊 中文] │ +│ │ +│ 客户昵称 (15px 600) │ +│ @用户名 │ +│ │ +│ 有效期:2025-01-01 至 2025-12-31 │ +│ 剩余天数:180 天 │ +│ 授权人:销售员 │ +│ 创建时间:2025-01-01 │ +│ ───────────────────────────────── │ +│ [按钮组 - 按状态显示] │ +└────────────────────────────────────┘ +``` + +##### 状态徽章中文映射 + +新增工具函数(在 Authorizations.vue 的 script 中): +```typescript +const statusConfig: Record = { + [AuthorizationStatus.Active]: { label: '有效', class: 'active' }, + [AuthorizationStatus.Expired]: { label: '已过期', class: 'expired' }, + [AuthorizationStatus.Cancelled]: { label: '已取消', class: 'cancelled' }, +} +``` + +CSS 新增 `.status-pill.expired`(橙色)和 `.status-pill.cancelled`(红色)样式: +```css +.status-pill.expired { color: #FA8C16; background: rgba(250, 140, 22, 0.12); } +.status-pill.expired .status-dot { background: #FA8C16; } +.status-pill.cancelled { color: #F5222D; background: rgba(245, 34, 45, 0.12); } +.status-pill.cancelled .status-dot { background: #F5222D; } +``` + +##### 按状态显示操作按钮 + +```typescript +function getCardActions(status: AuthorizationStatus) { + if (status === AuthorizationStatus.Active) { + return [ + { key: 'renew', label: '续期', icon: 'time', onClick: openRenewDialog }, + { key: 'cancel', label: '取消授权', icon: 'close', onClick: handleCancel, danger: true }, + { key: 'detail', label: '查看详情', icon: 'eye', onClick: openDetailDialog }, + ] + } + // Cancelled / Expired + return [ + { key: 'reactivate', label: '重新授权', icon: 'refresh', onClick: openReactivateDialog }, + { key: 'detail', label: '查看详情', icon: 'eye', onClick: openDetailDialog }, + ] +} +``` + +##### 新增对话框 + +- **续期对话框**(已有,复用):天数输入,调用 `authorizationApi.renew(id, days)` +- **重新授权对话框**(新增):天数输入(默认 365),调用 `authorizationApi.create({ userId, days })` — 后端自动复用原记录 +- **查看详情对话框**(新增):展示完整授权信息(客户、授权人、起止时间、状态、剩余天数等) + +##### 批量授权窗口重新设计 + +参考设计: +``` +┌── 批量授权 ──────────────────────────┐ +│ [图标] 批量授权 │ +│ 为多个客户一次性开通授权 │ +│ │ +│ ┌─ 选择客户 ─────────┐ ┌─ 授权配置 ─┐│ +│ │ [搜索框] │ │ 授权天数: ││ +│ │ ☐ 用户A 未授权 │ │ [输入框] ││ +│ │ ☐ 用户B 有效 │ │ 默认 365 ││ +│ │ ☐ 用户C 已过期 │ │ ││ +│ │ ... │ │ ││ +│ └────────────────────┘ └────────────┘│ +│ │ +│ 已选 3 个客户:用户A、用户B、用户C │ +│ [取消] [确认授权] │ +└────────────────────────────────────┘ +``` + +实现要点: +- 左侧客户列表(显示客户名 + 当前授权状态徽章),支持多选 +- 右侧授权配置(仅天数输入,客户级授权不关联项目) +- 底部显示已选客户数量 + 确认按钮 +- 调用 `authorizationApi.batch({ userIds, days })` — 后端批量处理复用/新建 + +#### 7. 客户管理卡片重新设计 + +**文件**: [`frontend/src/views/admin/Customers.vue`](file:///d:/TraeProject/MilliSim4/frontend/src/views/admin/Customers.vue) + `Customers.css` + +##### 客户卡片新结构 + +``` +┌────────────────────────────────────┐ +│ [渐变背景层] │ +│ [头像 56x56] [用户状态胶囊] │ +│ │ +│ 客户昵称 (15px 600) │ +│ @用户名 │ +│ │ +│ 授权状态:[徽章 - 中文] │ +│ 到期时间:2025-12-31 (剩余 180天) │ +│ │ +│ 手机:138****0000 │ +│ 邮箱:user@example.com │ +│ 注册时间:2025-01-01 │ +│ ───────────────────────────────── │ +│ [按钮组] │ +└────────────────────────────────────┘ +``` + +##### 授权状态徽章(中文) + +```typescript +const authStatusConfig: Record = { + [AuthorizationStatus.Active]: { label: '授权有效', class: 'active', showExpiry: true }, + [AuthorizationStatus.Expired]: { label: '已过期', class: 'expired', showExpiry: true }, + [AuthorizationStatus.Cancelled]: { label: '已取消', class: 'cancelled', showExpiry: false }, +} +// 无授权 +const noAuthConfig = { label: '未授权', class: 'none', showExpiry: false } +``` + +##### 授权按钮文案动态化 + +```typescript +function getAuthButton(status?: AuthorizationStatus | null) { + if (status === undefined || status === null) return { label: '为客户授权', icon: 'plus' } + if (status === AuthorizationStatus.Active) return { label: '续期授权', icon: 'time' } + return { label: '重新授权', icon: 'refresh' } // Cancelled / Expired +} +``` + +##### 操作按钮(卡片底部) + +- 编辑(编辑客户信息) +- 授权按钮(动态文案) +- 重置密码 +- 删除(红色 danger) + +授权按钮点击行为: +- 无授权 / Cancelled / Expired → 打开授权对话框(天数输入),调用 `authorizationApi.create({ userId, days })` +- Active → 打开续期对话框,调用 `authorizationApi.renew(authId, days)` + +> 注:需要先调用 `authorizationApi.check(userId)` 或在卡片数据中带 `authorizationId`。考虑在 UserDto 额外返回 `authorizationId?: long?`,这样前端续期可直接用。**决策**:UserDto 也增加 `AuthorizationId?: long?` 字段,简化前端续期调用。 + +## 假设与决策 + +### 决策记录 + +1. **重新授权策略**:复用原记录(更新 Status=Active + 重置 StartTime/EndTime/AuthorizedBy)。原因:用户确认 + 历史可追溯 + 符合"一个客户一个授权"约束。 + +2. **客户卡片授权状态来源**:后端联表查询(UserDto 增加 AuthorizationStatus/AuthorizationEndTime/AuthorizationId 字段)。原因:用户确认 + 一次查询返回所有数据,避免 N+1 请求。 + +3. **续期 vs 重新授权 分工**: + - **续期**(仅 Active):EndTime += days,状态不变 + - **重新授权**(Cancelled/Expired):调用 create API,后端自动复用原记录重置 StartTime=now, EndTime=now+days, Status=Active + - 两个独立操作,前端通过不同对话框触发 + +4. **批量授权窗口的"复用"语义**:批量授权调用同一 API,对已有 Cancelled/Expired 状态的客户自动复用原记录,对已有 Active 状态的客户自动续期(EndTime += days)。 + +### 假设 + +- AuthorizationStatus 数字序列化已配置(项目记忆显示 ApiResponse.cs 已移除 [JsonConverter]) +- Authorizations.ProjectId 已通过 DbSeeder 改为可空(已完成) +- 前端 `authorizationApi.renew(id, days)` 已发送 `{ days }` body(无需修改前端 API 调用,仅需后端 controller 接收方式调整) +- 前端 Vite dev server 热更新中,修改 .vue 文件后自动生效 + +## 验证步骤 + +### 后端验证 + +1. **构建检查**: + ```powershell + Stop-Process -Name "MilliSim.Api" -Force + cd d:\TraeProject\MilliSim4\backend\MilliSim.Api + dotnet build + ``` + 确认无编译错误。 + +2. **启动后端**:通过 background 命令启动 `dotnet run --project backend/MilliSim.Api` + +### 前端验证(浏览器手动测试) + +1. **续期 bug 修复**: + - 进入授权管理页面 + - 对一个 Active 状态的授权点击"续期" + - 输入 30 天,确认提交 + - 预期:续期成功,不再提示"续期天数必须大于 0" + +2. **授权逻辑 — 一个客户一个授权**: + - 在客户管理页面,为客户 A 授权(首次创建) + - 取消该授权 + - 再次点击"重新授权"为客户 A 授权 + - 预期:后端复用原记录(更新状态为 Active),不创建新记录 + - 数据库验证:`SELECT * FROM Authorizations WHERE UserId = ?` 只有一条记录 + +3. **客户卡片授权状态显示**: + - 客户管理页面,每个客户卡片应显示授权状态徽章(有效/已过期/已取消/未授权) + - 不同状态显示不同颜色和文案 + +4. **授权卡片操作按钮**: + - Active 卡片:显示"续期/取消授权/查看详情"3 个按钮 + - Cancelled/Expired 卡片:显示"重新授权/查看详情"2 个按钮 + +5. **批量授权**: + - 选择多个客户(含不同状态:未授权、有效、已过期、已取消) + - 设置 30 天 + - 预期:所有客户都成功,原有记录被复用 + +6. **状态徽章中文**: + - Active → "有效"(绿色) + - Expired → "已过期"(橙色) + - Cancelled → "已取消"(红色) + +## 文件变更清单 + +### 后端 +1. `backend/MilliSim.Api/Controllers/AuthorizationsController.cs` — Renew 端点改 [FromBody] +2. `backend/MilliSim.Api/Common/Dtos/Dtos.cs` — 添加 RenewAuthorizationRequest;UserDto 增加 3 个授权字段 +3. `backend/MilliSim.Api/Services/AuthorizationService.cs` — 重构 CreateAuthorization/BatchAuthorize(复用原记录);Cancel/Renew 增加状态检查 +4. `backend/MilliSim.Api/Services/UserService.cs` — GetUsersAsync 联表查询 Authorizations + +### 前端 +5. `frontend/src/types/index.ts` — User 接口增加 authorizationStatus/authorizationEndTime/authorizationId 字段 +6. `frontend/src/views/admin/Authorizations.vue` + `Authorizations.css` — 卡片重新设计 + 新对话框 + 中文状态徽章 +7. `frontend/src/views/admin/Customers.vue` + `Customers.css` — 卡片重新设计 + 授权状态徽章 + 动态授权按钮 + +## 实施顺序 + +1. 后端 DTO + Controller 修复(续期 bug) +2. 后端 AuthorizationService 重构(授权逻辑) +3. 后端 UserService + UserDto 修改(联表查询) +4. 后端构建 + 重启 +5. 前端 types/index.ts 修改 +6. 前端 Authorizations.vue + CSS 重新设计 +7. 前端 Customers.vue + CSS 重新设计 +8. 浏览器手动验证全部场景 diff --git a/.trae/documents/customers-page-redesign.md b/.trae/documents/customers-page-redesign.md new file mode 100644 index 0000000..824c6a2 --- /dev/null +++ b/.trae/documents/customers-page-redesign.md @@ -0,0 +1,371 @@ +# 客户管理页面重新设计实施计划 + +## Summary + +承接授权系统重构批次,前 6 项变更(后端续期 bug 修复、AuthorizationService 复用记录逻辑、UserService 联表查询、types/index.ts 字段扩展、Authorizations.vue/css 重新设计)已全部完成并验证。本计划仅聚焦最后一项剩余工作:**重新设计 `Customers.vue` + `Customers.css`**,让客户卡片显示当前授权状态,并将"授权"按钮的文案与行为根据授权状态动态化,与 `Authorizations.vue` 的蓝紫渐变设计语言保持一致。 + +## Current State Analysis + +### 已就绪的基础设施(无需重复实现) + +- **后端** `UserDto` 已新增 3 个字段:`AuthorizationId` / `AuthorizationStatus` / `AuthorizationEndTime`([Dtos.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Common/Dtos/Dtos.cs#L49-L57)) +- **后端** `UserService.GetUsersAsync` 已联表查询 `Authorizations` 表,按 UserId 分组取最新一条填充到 UserDto([UserService.cs](file:///d:/TraeProject/MilliSim4/backend/MilliSim.Api/Services/UserService.cs#L82-L98)) +- **前端** `User` 接口已扩展 3 个可选字段([types/index.ts](file:///d:/TraeProject/MilliSim4/frontend/src/types/index.ts#L73-L78)) +- **前端** `Authorizations.vue` 已建立设计语言基准(蓝紫渐变 + 统计栏 + 状态胶囊 + 卡片渐变背景层),可作为本页面的视觉参考 +- **前端** `authorizationApi.renew(id, days)` 已发送 `{ days }` body,与后端 `[FromBody] RenewAuthorizationRequest` 修复匹配 +- **后端** `CreateAuthorizationAsync` 已重构为"复用原记录"逻辑(存在任意状态授权时复用,不新建),因此客户卡片上的"为客户授权"和"重新授权"都可调用 `authorizationApi.create({ userId, days })` + +### 当前 Customers.vue 的不足 + +1. **卡片不显示授权状态**:仅显示用户状态徽章(启用/禁用),无授权信息 +2. **授权按钮文案固定**:"授权"图标按钮,无状态感知 +3. **授权弹窗过于简单**:仅一个天数输入框,无快捷按钮、无续期后到期时间预览 +4. **视觉设计缺乏设计感**:与重新设计后的 Authorizations.vue 风格不一致 +5. **续期无法在此页面完成**:必须跳转到授权管理页面 + +## Proposed Changes + +### 文件 1: `d:\TraeProject\MilliSim4\frontend\src\views\admin\Customers.vue` + +**保留**:页头卡片、筛选区(关键词 + 用户状态 TabFilter)、新增/编辑客户弹窗(含头像上传、账号信息、联系方式、状态设置三个分区)、重置密码、删除客户逻辑。 + +**变更 1:卡片结构重新设计**(替换原 L59-L112 的 `data-grid` 内容) + +新卡片结构: +```vue +
    +
    +
    +
    +
    + + + +
    + + + + {{ getAuthStatusLabel(row.authorizationStatus) }} + +
    +
    {{ row.nickname || row.username }}
    +
    @{{ row.username }}
    + + +
    +
    + + 用户状态 + + {{ row.status === 1 ? '启用' : '禁用' }} + +
    +
    + + 手机 + {{ row.phone }} +
    +
    + + 邮箱 + {{ row.email }} +
    + +
    + + 到期 + + {{ formatTime(row.authorizationEndTime) }} + (剩 {{ remainingDays(row.authorizationEndTime) }} 天) + +
    +
    + + 注册 + {{ formatTime(row.createdAt) }} +
    +
    +
    + + +
    +``` + +**变更 2:脚本逻辑扩展**(在 `",High,https://nuxt.com/docs/guide/concepts/nuxt-lifecycle +18,Lifecycle,Use onMounted for DOM access,Access DOM only after component is mounted,onMounted for DOM manipulation,Direct DOM access in setup,"onMounted(() => { document.getElementById('el') })","",High,https://nuxt.com/docs/api/composables/on-mounted +19,Lifecycle,Use nextTick for post-render access,Wait for DOM updates before accessing elements,await nextTick() after state changes,Immediate DOM access after state change,"count.value++; await nextTick(); el.value.focus()","count.value++; el.value.focus()",Medium,https://nuxt.com/docs/api/utils/next-tick +20,Lifecycle,Use onPrehydrate for pre-hydration logic,Run code before Nuxt hydrates the page,onPrehydrate for client setup,onMounted for hydration-critical code,"onPrehydrate(() => { console.log(window) })",onMounted for pre-hydration needs,Low,https://nuxt.com/docs/api/composables/on-prehydrate +21,Server,Use server/api for API routes,Create API endpoints in server/api directory,server/api/users.ts for /api/users,Manual Express setup,server/api/hello.ts -> /api/hello,app.get('/api/hello'),High,https://nuxt.com/docs/guide/directory-structure/server +22,Server,Use defineEventHandler for handlers,Define server route handlers,defineEventHandler for all handlers,export default function,"export default defineEventHandler((event) => { return { hello: 'world' } })","export default function(req, res) {}",High,https://nuxt.com/docs/guide/directory-structure/server +23,Server,Use server/routes for non-api routes,Routes without /api prefix,server/routes for custom paths,server/api for non-api routes,server/routes/sitemap.xml.ts,server/api/sitemap.xml.ts,Medium,https://nuxt.com/docs/guide/directory-structure/server +24,Server,Use getQuery and readBody for input,Access query params and request body,getQuery(event) readBody(event),Direct event access,"const { id } = getQuery(event)",event.node.req.query,Medium,https://nuxt.com/docs/guide/directory-structure/server +25,Server,Validate server input,Always validate input in server handlers,Zod or similar for validation,Trust client input,"const body = await readBody(event); schema.parse(body)",const body = await readBody(event),High,https://nuxt.com/docs/guide/directory-structure/server +26,State,Use useState for shared reactive state,SSR-friendly shared state across components,useState for cross-component state,ref for shared state,"const count = useState('count', () => 0)",const count = ref(0) in composable,High,https://nuxt.com/docs/api/composables/use-state +27,State,Use unique keys for useState,Prevent state conflicts with unique keys,Descriptive unique keys for each state,Generic or duplicate keys,"useState('user-preferences', () => ({}))",useState('data') in multiple places,Medium,https://nuxt.com/docs/api/composables/use-state +28,State,Use Pinia for complex state,Pinia for advanced state management,@pinia/nuxt for complex apps,Custom state management,useMainStore() with Pinia,Custom reactive store implementation,Medium,https://nuxt.com/docs/getting-started/state-management +29,State,Use callOnce for one-time async operations,Ensure async operations run only once,callOnce for store initialization,Direct await in component,"await callOnce(store.fetch)",await store.fetch() on every render,Medium,https://nuxt.com/docs/api/utils/call-once +30,SEO,Use useSeoMeta for SEO tags,Type-safe SEO meta tag management,useSeoMeta for meta tags,useHead for simple meta,"useSeoMeta({ title: 'Home', ogTitle: 'Home', description: '...' })","useHead({ meta: [{ name: 'description', content: '...' }] })",High,https://nuxt.com/docs/api/composables/use-seo-meta +31,SEO,Use reactive values in useSeoMeta,Dynamic SEO tags with refs or getters,Computed getters for dynamic values,Static values for dynamic content,"useSeoMeta({ title: () => post.value.title })","useSeoMeta({ title: post.value.title })",Medium,https://nuxt.com/docs/api/composables/use-seo-meta +32,SEO,Use useHead for non-meta head elements,Scripts styles links in head,useHead for scripts and links,useSeoMeta for scripts,"useHead({ script: [{ src: '/analytics.js' }] })","useSeoMeta({ script: '...' })",Medium,https://nuxt.com/docs/api/composables/use-head +33,SEO,Include OpenGraph tags,Add OG tags for social sharing,ogTitle ogDescription ogImage,Missing social preview,"useSeoMeta({ ogImage: '/og.png', twitterCard: 'summary_large_image' })",No OG configuration,Medium,https://nuxt.com/docs/api/composables/use-seo-meta +34,Middleware,Use defineNuxtRouteMiddleware,Define route middleware properly,defineNuxtRouteMiddleware wrapper,export default function,"export default defineNuxtRouteMiddleware((to, from) => {})","export default function(to, from) {}",High,https://nuxt.com/docs/guide/directory-structure/middleware +35,Middleware,Use navigateTo for redirects,Redirect in middleware with navigateTo,return navigateTo('/login'),router.push in middleware,"if (!auth) return navigateTo('/login')","if (!auth) router.push('/login')",High,https://nuxt.com/docs/api/utils/navigate-to +36,Middleware,Reference middleware in definePageMeta,Apply middleware to specific pages,middleware array in definePageMeta,Global middleware for page-specific,definePageMeta({ middleware: ['auth'] }),Global auth check for one page,Medium,https://nuxt.com/docs/guide/directory-structure/middleware +37,Middleware,Use .global suffix for global middleware,Apply middleware to all routes,auth.global.ts for app-wide auth,Manual middleware on every page,middleware/auth.global.ts,middleware: ['auth'] on every page,Medium,https://nuxt.com/docs/guide/directory-structure/middleware +38,ErrorHandling,Use createError for errors,Create errors with proper status codes,createError with statusCode,throw new Error,"throw createError({ statusCode: 404, statusMessage: 'Not Found' })",throw new Error('Not Found'),High,https://nuxt.com/docs/api/utils/create-error +39,ErrorHandling,Use NuxtErrorBoundary for local errors,Handle errors within component subtree,NuxtErrorBoundary for component errors,Global error page for local errors,"