Compare commits

...

230 Commits

Author SHA1 Message Date
huangzd1997 4c88964473 task-283(后台迁移收尾/发布): admin-vue 后台工程入库 + Flask 后台整体退役
- 新后台 admin-frontend-vue 整工程入库(base=/admin-vue/、History 路由、Nginx 静态托管方案与 verify-dist 校验)
- Java AdminConsoleController 改为入口重定向: /admin|/admin.html、/login|/login.html -> /admin-vue/; 删除 classpath:static 旧 admin.html/login.html 单页与 admin.js 副本
- Flask 后台整体退役: 删除 admin_api/auth/main 蓝图、web_source 页面、static 脚本与 admin 相关测试; app.py 收敛为仅注册 version_bp(/api/version、/api/version/latest, 供桌面端更新检查)
- AdminApiGuardFilterTest 豁免样例路径 /admin.html -> /admin-vue/
2026-09-06 10:41:47 +08:00
huangzd1997 6bd935dc6b task-282(验收反馈): 类目改回旧版树形表格(缩进+[+-]折叠标记+名称/层级路径/排序/来源/说明/操作列,懒加载与加载更多保留) 2026-09-06 02:45:46 +08:00
huangzd1997 b7029835d8 task-281(验收反馈): 重复检测P1视觉对齐(KPI圆点/在售店铺蓝/warn格浅红底/矩阵空态副文案/分布副标题) 2026-09-06 02:42:56 +08:00
huangzd1997 ab787ceff7 task-280(验收反馈): 重复检测对齐reference P0(分组筛选生效/抽屉聚合+汇总徽章/台账计数预载/total_dup计数/矩阵表头吸顶;后端含工作区在途console化代码) 2026-09-06 02:41:44 +08:00
huangzd1997 3dd39bba44 task-279(验收反馈): 修复类目树子级挂错父级(根级loadMore id=-1与父级1的合成节点撞node-key,合成id改独立负数域) 2026-09-06 02:33:51 +08:00
huangzd1997 648935e757 task-278(验收反馈): 生成记录加清理数据功能(DELETE /api/admin/history 二次确认+条数提示;含工作区在途的 imagehistory 分页优化) 2026-09-06 02:25:44 +08:00
huangzd1997 6d9b4a13ee task-277(验收反馈): 类目树优化(占位子级保证展开箭头/28px缩进/节点加间距/箭头放大加色) 2026-09-06 02:19:45 +08:00
huangzd1997 3b5adff2e3 task-276(验收反馈): 分页组件全站统一(jumper+按钮高亮样式)+MenusPage补分页+最低价筛选同排+视频筛选加固+移除侧边栏收起按钮+gitignore战役脚本规则 2026-09-06 02:16:47 +08:00
huangzd1997 6e2e4bf8e7 task-275(验收反馈): 菜单权限标识/路由后台自动生成(menu_auto_/auto/ 前缀),表单移除两项输入;编辑缺省保持原值 2026-09-06 01:57:17 +08:00
huangzd1997 c3dc2a3f2f task-274(验收反馈): 15 列表页表格列 width→min-width 自适应(DuplicateCheck 例外) 2026-09-06 01:50:38 +08:00
huangzd1997 0315a3a93d task-273(验收反馈): 店铺数据卡片宫格排(一行3个/一页9张+卡片分页) 2026-09-06 01:48:57 +08:00
huangzd1997 8add3ceaa4 task-272(验收反馈): 分组/软件版本/类目搜索表补齐分页组件(prev/next 中文由 locale 提供) 2026-09-06 01:46:49 +08:00
huangzd1997 5e5658b00d fix(backend-java): Flyway V100 撞号修复配套(迁移矩阵补全16键/测试引用改指V109) 2026-09-06 01:44:14 +08:00
huangzd1997 9ac4eda054 task-271(验收反馈): Element Plus 全局接入 zh-cn 语言包(日期控件/分页组件中文) 2026-09-06 01:44:06 +08:00
huangzd1997 c381527528 test(live): 菜单树 smoke 兼容生产扁平 group-* 标记形态与本地嵌套形态;生产 8/8 live smoke 通过(登录/菜单/各域列表) 2026-09-05 23:45:16 +08:00
huangzd1997 efada31764 test(时间扫描): 不变式改为有时间展示的页才要求 formatDateTime(查询/最低价ASIN 按参考布局去掉更新时间列,豁免无时间列页面,与 task-268 口径一致) 2026-09-05 23:42:25 +08:00
huangzd1997 d30e8f3e8f align(店铺数据记录): 改回每条任务一张卡片(店铺名头+任务号/状态+分组/最新/国家/文件行+下载文件/删除)、全选口径 file_ready&&有resultId 不再附加终态、空态文案对齐(对齐 admin.js renderShopDataRecordTable/syncShopDataSelectionUi) 2026-09-05 23:40:24 +08:00
huangzd1997 596933eea2 align(文案尾批): 用户删除确认半角引号无后缀、品牌数据库删除确认对齐原文(对齐 admin.js:970/3775) 2026-09-05 23:36:56 +08:00
huangzd1997 a2bfb08122 align(店铺密钥/店铺管理): 首列ID改序号(全局行号)、密钥列名紫鸟账号名称、店铺管理列序对齐(序号|分组|店铺名|店铺商城名|自动化账号|账号|密码)并统一表单/筛选/凭据标签文案(对齐 admin.html:5298-5300/5339-5348/6038) 2026-09-05 23:35:38 +08:00
huangzd1997 d3bbb1a296 align(生成记录): 列名对齐参考(记录类型→类型、生成时间→创建时间)、空态统一暂无记录(对齐 admin.html:5769-5770/admin.js:2866) 2026-09-05 23:32:21 +08:00
huangzd1997 c915847d92 align(软件版本): 操作列下载改<a download>强制下载、上传成功留驻弹窗(发布成功。版本:X,链接:Y可复制)、按钮名上传软件版本/弹窗标题上传新版本/描述与zip提示对齐、空态暂无版本记录(对齐 admin.js loadSoftwareVersions/上传段+admin.html:6094-6110) 2026-09-05 23:30:52 +08:00
huangzd1997 553aca34f7 align(数字人版本): 分页(前端20/页)、上传进度(正在上传N%)、状态三态(草稿/已发布/已废弃)、文件大小固定两位MB、说明50字截断、RELEASED恒显下载点击取链接、确认文案/按钮名/仅zip校验/弹窗描述对齐(对齐 admin.js loadDigitalHumanVersions/downloadDigitalHumanVersion/上传段) 2026-09-05 23:28:41 +08:00
huangzd1997 207f48f7d8 align(视频任务): 卡片补任务模式/生成时间(completed||submitted)/调试链接复制信息行、下载按钮无视频禁用、明细补模式/生成时间/调试链接(对齐 admin.js renderImageVideoCard/copyImageVideoDebugUrl) 2026-09-05 23:24:11 +08:00
huangzd1997 0387dc4918 align(去重汇总): 页顶数据权限分组汇总卡(超管全部分组·N个/组员chips组长·组员/未加入分组)、导出改流式下载+等待遮罩(正在生成导出文件/已等待N秒)、分组空值显示未分组(对齐 admin.js renderDedupeGroupSummary/showDedupeTotalDataExportWait) 2026-09-05 23:21:50 +08:00
huangzd1997 603822a6c1 align(商品类目): el-tree 节点补来源(内置/自定义)/排序/备注展示、行内编辑删除(有子禁用)、按需懒加载子级+加载更多(已加载x/y)、搜索表格补来源列、按钮名/占位对齐(对齐 admin.js renderProductCategoryRows/loadProductCategoryChildren) 2026-09-05 23:18:27 +08:00
huangzd1997 a8d08c4d39 align(最低价ASIN): 回退参考纵向 rowspan 布局(序号/分组/店铺/操作合并+逐国ASIN点击复制+最低价两位小数)、补新增ASIN弹窗(分组→店铺联动→国家→ASIN+最低价校验)、去更新时间列(对齐 admin.js renderSkipPriceAsinRows/btnCreateSkipPriceAsin) 2026-09-05 23:14:13 +08:00
huangzd1997 1848a34a27 align(查询ASIN): 回退参考纵向 rowspan 布局(序号/分组/店铺/操作合并+逐国ASIN行点击复制)、补新增ASIN弹窗(分组→店铺联动100/页聚合→国家→ASIN)、去更新时间列(对齐 admin.js renderQueryAsinRows/btnCreateQueryAsin/loadAsinShopNameOptions) 2026-09-05 23:11:08 +08:00
huangzd1997 2f4bb834cf align(品牌数据库): 非超管分组强制锁定(筛选/新增/编辑下拉禁用并锁本人分组、重置保持锁定),超管全量放开(对齐 admin.js getInvalidAsinDataLockedGroupId/refreshShopGroupSelects) 2026-09-05 23:06:28 +08:00
huangzd1997 32ef9a2ebb align(菜单管理): 同级拖拽排序接线(手柄列+类型/父级分组+reorder带menu_type)、列表拉全量类型、类型文案后台/软件、弹窗补菜单类型下拉、父菜单候选按类型过滤(对齐 admin.js loadColumns/bindColumnDragSort/persistColumnOrder) 2026-09-05 23:04:05 +08:00
huangzd1997 1692fa4a69 align(分组管理): 组员候选按归属过滤(超管全量/非超管仅本组长创建的普通账号)、新建组长恒为当前用户、补创建时间列、chips 全量渲染(对齐 admin.js getEligibleShopManageGroupUsers/帮助文案) 2026-09-05 23:00:08 +08:00
huangzd1997 63339b401c align(用户管理): 超管新建普通账号补所属管理员(createdBy)下拉,payload 仅 normal+已选时带 createdById(对齐 admin.js created_by_id 语义) 2026-09-05 22:54:49 +08:00
huangzd1997 359f3ce1c9 task-270(admin.html观感对齐): 修正去重分组选项接口为 /shop-manages/groups + live 真实 smoke 通过(登录/菜单/各域列表) 2026-09-05 22:26:52 +08:00
huangzd1997 6329695fc2 task-268(admin.html观感对齐): 15 页静态自查不变式测试(菜单无上下移/时间统一/角色中文/接线入口/例外页自洽) 2026-09-05 22:25:51 +08:00
huangzd1997 1d99423da5 task-269(admin.html观感对齐): e2e 断言对齐现状(角色中文/仅可见遮罩判定)+壳层换皮 smoke 全绿(36/36) 2026-09-05 22:25:06 +08:00
huangzd1997 ad5c7c02f9 task-266/267(admin.html观感对齐): 店铺数据任务改每店铺卡片(单文件下载/删除/批量/合计)+筛选权限对齐 2026-09-05 22:11:36 +08:00
huangzd1997 af49cbda64 task-264/265(admin.html观感对齐): 视频任务改卡片网格(video播放/单卡下载/复制/空态/合计)+筛选批量权限对齐 2026-09-05 22:11:36 +08:00
huangzd1997 5e4a2a9d11 task-263(admin.html观感对齐): 店铺管理页对齐(密码眼睛实时读取/修改时间列/整句必填校验/编辑密码必填语义/删除文案) 2026-09-05 22:04:20 +08:00
huangzd1997 a7cdb0031a task-262(admin.html观感对齐): 店铺密钥页对齐(三态白名单药丸+悬停详情/修改时间列/编辑令牌留空沿用/删除文案) 2026-09-05 22:02:30 +08:00
huangzd1997 a6b18b78e6 task-261(admin.html观感对齐): 数字人版本页对齐(状态/最新★/文件大小/MD5截断/操作矩阵/上传文案/删除) 2026-09-05 21:58:55 +08:00
huangzd1997 6bd44e6884 task-260(admin.html观感对齐): 软件版本页对齐(zip 校验文案/发布成功含链接/下载列/空态) 2026-09-05 21:57:22 +08:00
huangzd1997 8120ba48f6 task-259(admin.html观感对齐): 生成记录页对齐(指定用户下拉+起止时间筛选/类型中文/≤3缩略图/空态) 2026-09-05 21:55:14 +08:00
huangzd1997 eebdb7af2d fix(admin): 后台时间统一展示为 YYYY-MM-DD HH:mm:ss(formatDateTime 全页接入) 2026-09-05 21:52:00 +08:00
huangzd1997 cabbb4be0b feat(admin): 新增统一时间格式化 formatDateTime(YYYY-MM-DD HH:mm:ss) 2026-09-05 21:47:31 +08:00
huangzd1997 6a0eb8cf63 fix(admin): 菜单管理去掉上移/下移,排序改由 sort_order 维护(产品已淘汰即时应序) 2026-09-05 21:36:26 +08:00
huangzd1997 0248c8737d task-258(admin.html观感对齐): 最低价ASIN页对齐(价格区间筛选/配置抽屉ASIN+最低价/导入删除导入/导出) 2026-09-05 21:34:55 +08:00
huangzd1997 4c6884223d style(admin): 侧边栏父菜单(分组)标题字号加大至 15px 并加深色 2026-09-05 21:33:39 +08:00
huangzd1997 d745271685 task-257(admin.html观感对齐): 查询ASIN页对齐(行内配置抽屉逐国ASIN/导入添加删除导入/导出) 2026-09-05 21:32:57 +08:00
huangzd1997 3902acafb9 fix(admin): 菜单管理列表去掉权限标识/路由列并自适应列宽(编辑弹窗仍保留字段) 2026-09-05 21:32:56 +08:00
huangzd1997 85f565bd82 task-256(admin.html观感对齐): 商品类目页对齐(删除有子禁用/搜索分页加载更多/导出/编辑弹窗语义) 2026-09-05 21:30:53 +08:00
huangzd1997 178ff96be4 task-255(admin.html观感对齐): 品牌数据库页对齐(分组筛选/来源标签/自动导入记录分组锁定/删除文案) 2026-09-05 21:27:26 +08:00
huangzd1997 88f2ec8bb0 task-254(admin.html观感对齐): 去重汇总页对齐(筛选扩充/行编辑删除/导入删除导入轮询/导出) 2026-09-05 21:25:33 +08:00
huangzd1997 f765b3ce67 task-253(admin.html观感对齐): 数据权限分组页对齐(新建/编辑/删除 + 组员 chips + 组长锁定) 2026-09-05 21:21:49 +08:00
huangzd1997 8b4955d9b5 task-252(admin.html观感对齐): 菜单管理列表列对齐(ID/菜单类型/上级菜单/创建时间)+ 类型中文映射 2026-09-05 21:18:59 +08:00
huangzd1997 07fda4cf22 task-251(admin.html观感对齐): 用户管理补齐新建/编辑/角色+菜单授权弹窗(接线孤儿模块) 2026-09-05 21:17:15 +08:00
huangzd1997 41cb4ce174 task-250(admin.html观感对齐): 用户管理列表页对齐(说明/所属管理员筛选/角色中文/删除文案/分页) 2026-09-05 21:13:03 +08:00
huangzd1997 e0ec2d864e task-249(admin.html观感对齐): 壳层换皮 e2e 验收规格 + task-20 角色胶囊断言同步(e2e 试跑被残留 dev server 阻塞,记入清单) 2026-09-05 21:06:50 +08:00
huangzd1997 cea7662fc3 fix(admin): 用户列表角色列改为中文显示(roleLabel 映射) 2026-09-05 21:02:47 +08:00
huangzd1997 4e86506f3e task-248(admin.html观感对齐): 模态/抽屉容器统一(ModalShell/DrawerShell 收口尺寸与关闭语义) 2026-09-05 21:01:23 +08:00
huangzd1997 f7ad99deda task-247(admin.html观感对齐): 行内工具组件化(状态药丸/复制文本/掩码揭示复用 secret-mask) 2026-09-05 20:59:54 +08:00
huangzd1997 8b0bf3d563 task-246(admin.html观感对齐): 反馈语义统一(success/error/warning + busy 防重入 + 忙碌文案) 2026-09-05 20:58:33 +08:00
huangzd1997 39f43469c2 task-245(admin.html观感对齐): 二次确认共享组件(文案规则对齐 admin-interactions + 并发锁) 2026-09-05 20:57:42 +08:00
huangzd1997 4aa9b587ba task-244(admin.html观感对齐): 全局组件观感对齐(表头/行hover/输入焦点/弹窗圆角/分页色) 2026-09-05 20:54:10 +08:00
huangzd1997 3ba84de784 task-243(admin.html观感对齐): 壳层换皮蓝白浅色(浅色侧栏252px/顶栏76px/角色胶囊/次级退出) 2026-09-05 20:52:15 +08:00
huangzd1997 50ba594ad1 task-242(admin.html观感对齐): Element Plus 语义色/圆角对齐蓝白浅色(ep-overrides 单源) 2026-09-05 20:48:57 +08:00
huangzd1997 c924ffa16d task-241(admin.html观感对齐): 蓝白浅色 token 基线翻转(浅色侧栏+蓝灰主色 #4f78a5) 2026-09-05 20:47:17 +08:00
huangzd1997 79ee16a374 task-060(账号与权限页面): 修复用户创建循环验收分页脆弱点
改用 username 过滤查询以可靠命中新建用户,规避大库分页假阴性。
本地 Java 复验 8/8 通过;全组 live 64 通过 0 失败。
2026-09-05 18:20:54 +08:00
huangzd1997 b4657a50fb task-160(Java 菜单树与 Flyway): 本地 Java 真实验收通过(树契约/路由形态/无重复key)
本地 18080 启动后端(dev 库, Flyway 关闭)验证菜单树接口。
2026-09-05 18:18:00 +08:00
huangzd1997 fe429c7a05 module-10/11/12 收尾: 补充 Nginx/Flask 切机验收脚本与说明(发布门控 RELEASE_SMOKE) 2026-09-05 18:09:42 +08:00
huangzd1997 cd9c5effb7 task-199(静态资源部署与 Nginx History 托管): task-199 契约测试 RED→GREEN。 2026-09-05 18:09:12 +08:00
huangzd1997 916d13c3ce task-198(静态资源部署与 Nginx History 托管): task-198 契约测试 RED→GREEN。 2026-09-05 18:09:11 +08:00
huangzd1997 9422ad529c task-197(静态资源部署与 Nginx History 托管): task-197 契约测试 RED→GREEN。 2026-09-05 18:09:11 +08:00
huangzd1997 79878c8532 task-196(静态资源部署与 Nginx History 托管): task-196 契约测试 RED→GREEN。 2026-09-05 18:09:11 +08:00
huangzd1997 be486699fd task-195(静态资源部署与 Nginx History 托管): task-195 契约测试 RED→GREEN。 2026-09-05 18:09:10 +08:00
huangzd1997 bf9ede7049 task-194(静态资源部署与 Nginx History 托管): task-194 契约测试 RED→GREEN。 2026-09-05 18:09:10 +08:00
huangzd1997 3a5b2f5101 task-193(静态资源部署与 Nginx History 托管): task-193 契约测试 RED→GREEN。 2026-09-05 18:09:10 +08:00
huangzd1997 125a9ff8d6 task-192(静态资源部署与 Nginx History 托管): task-192 契约测试 RED→GREEN。 2026-09-05 18:09:10 +08:00
huangzd1997 de03a41734 task-191(静态资源部署与 Nginx History 托管): task-191 契约测试 RED→GREEN。 2026-09-05 18:09:09 +08:00
huangzd1997 67390c54a9 task-190(静态资源部署与 Nginx History 托管): task-190 契约测试 RED→GREEN。 2026-09-05 18:09:09 +08:00
huangzd1997 9e675d63a3 task-189(静态资源部署与 Nginx History 托管): task-189 契约测试 RED→GREEN。 2026-09-05 18:09:09 +08:00
huangzd1997 3a68489ee6 task-188(静态资源部署与 Nginx History 托管): task-188 契约测试 RED→GREEN。 2026-09-05 18:09:08 +08:00
huangzd1997 9684a184e1 task-187(静态资源部署与 Nginx History 托管): task-187 契约测试 RED→GREEN。 2026-09-05 18:09:08 +08:00
huangzd1997 0bf6026083 task-186(静态资源部署与 Nginx History 托管): task-186 契约测试 RED→GREEN。 2026-09-05 18:09:07 +08:00
huangzd1997 02a0a6cbf1 task-185(静态资源部署与 Nginx History 托管): task-185 契约测试 RED→GREEN。 2026-09-05 18:09:07 +08:00
huangzd1997 26f472413e task-184(静态资源部署与 Nginx History 托管): task-184 契约测试 RED→GREEN。 2026-09-05 18:09:07 +08:00
huangzd1997 0ff9024fe4 task-183(静态资源部署与 Nginx History 托管): task-183 契约测试 RED→GREEN。 2026-09-05 18:09:06 +08:00
huangzd1997 c7708ca57c task-182(静态资源部署与 Nginx History 托管): task-182 契约测试 RED→GREEN。 2026-09-05 18:09:06 +08:00
huangzd1997 a36a60f151 task-181(静态资源部署与 Nginx History 托管): 确认后台 dist 发布目录约定
补充 deploy/release-structure.md 与 verify-dist.mjs,契约测试锁 conf。

TDD: task-181.test.ts 8 用例 RED→GREEN。
2026-09-05 18:09:04 +08:00
huangzd1997 e45ab1da9e task-179(客户端公开版本接口兼容): Task179Test 契约测试 RED→GREEN。 2026-09-05 18:06:28 +08:00
huangzd1997 05d19626a1 task-178(客户端公开版本接口兼容): Task178Test 契约测试 RED→GREEN。 2026-09-05 18:06:28 +08:00
huangzd1997 cf750a26d4 task-177(客户端公开版本接口兼容): Task177Test 契约测试 RED→GREEN。 2026-09-05 18:06:28 +08:00
huangzd1997 656dc7ce2f task-176(客户端公开版本接口兼容): Task176Test 契约测试 RED→GREEN。 2026-09-05 18:06:27 +08:00
huangzd1997 37d3dc8a4c task-175(客户端公开版本接口兼容): Task175Test 契约测试 RED→GREEN。 2026-09-05 18:06:27 +08:00
huangzd1997 74691acdf9 task-174(客户端公开版本接口兼容): Task174Test 契约测试 RED→GREEN。 2026-09-05 18:06:27 +08:00
huangzd1997 46f1d27478 task-173(客户端公开版本接口兼容): Task173Test 契约测试 RED→GREEN。 2026-09-05 18:06:26 +08:00
huangzd1997 ea9b263af3 task-172(客户端公开版本接口兼容): Task172Test 契约测试 RED→GREEN。 2026-09-05 18:06:26 +08:00
huangzd1997 95af9667f6 task-171(客户端公开版本接口兼容): Task171Test 契约测试 RED→GREEN。 2026-09-05 18:06:26 +08:00
huangzd1997 1e8e173236 task-170(客户端公开版本接口兼容): Task170Test 契约测试 RED→GREEN。 2026-09-05 18:06:25 +08:00
huangzd1997 d3b7ea85eb task-169(客户端公开版本接口兼容): Task169Test 契约测试 RED→GREEN。 2026-09-05 18:06:25 +08:00
huangzd1997 4e7eb49e0d task-168(客户端公开版本接口兼容): Task168Test 契约测试 RED→GREEN。 2026-09-05 18:06:25 +08:00
huangzd1997 21e56d40e2 task-167(客户端公开版本接口兼容): Task167Test 契约测试 RED→GREEN。 2026-09-05 18:06:24 +08:00
huangzd1997 5e274878fe task-166(客户端公开版本接口兼容): Task166Test 契约测试 RED→GREEN。 2026-09-05 18:06:24 +08:00
huangzd1997 9a20b38bbd task-165(客户端公开版本接口兼容): Task165Test 契约测试 RED→GREEN。 2026-09-05 18:06:23 +08:00
huangzd1997 9aceedb2e2 task-164(客户端公开版本接口兼容): Task164Test 契约测试 RED→GREEN。 2026-09-05 18:06:22 +08:00
huangzd1997 23cb828162 task-163(客户端公开版本接口兼容): Task163Test 契约测试 RED→GREEN。 2026-09-05 18:06:22 +08:00
huangzd1997 3fc8746218 task-162(客户端公开版本接口兼容): 冻结 /api/version/latest 响应契约
PublicVersionLookup 增 latest/selectLatest 与路径常量 + Task162Test。

TDD: RED→GREEN。
2026-09-05 18:06:22 +08:00
huangzd1997 b134db134e task-161(客户端公开版本接口兼容): 冻结 /api/version 响应契约
新增 PublicVersionLookup.currentVersionResponse + Task161Test。

TDD: RED→GREEN。
2026-09-05 18:04:19 +08:00
huangzd1997 e88bf5322b task-155(Java 菜单树与 Flyway): 新增 V100 迁移(admin 菜单 route_path 更新) 2026-09-05 18:01:14 +08:00
huangzd1997 e461ceec2c task-159(Java 菜单树与 Flyway): Task159Test 8 用例 RED→GREEN。 2026-09-05 18:01:14 +08:00
huangzd1997 3056608e04 task-158(Java 菜单树与 Flyway): Task158Test 8 用例 RED→GREEN。 2026-09-05 18:01:14 +08:00
huangzd1997 4cdc7205e9 task-157(Java 菜单树与 Flyway): Task157Test 8 用例 RED→GREEN。 2026-09-05 18:01:13 +08:00
huangzd1997 6701c12178 task-156(Java 菜单树与 Flyway): Task156Test 8 用例 RED→GREEN。 2026-09-05 18:01:13 +08:00
huangzd1997 ee73d7d683 task-155(Java 菜单树与 Flyway): Task155Test 8 用例 RED→GREEN。 2026-09-05 18:01:12 +08:00
huangzd1997 6dcdbec63d task-154(Java 菜单树与 Flyway): 定义 route_path Flyway 迁移矩阵
新增 AdminMenuRouteMatrix + Task154Test(契约/唯一性)。

TDD: RED→GREEN。
2026-09-05 18:01:12 +08:00
huangzd1997 84cc592517 task-153(Java 菜单树与 Flyway): Task153Test 8 用例 RED→GREEN。 2026-09-05 17:58:24 +08:00
huangzd1997 51b88e4331 task-152(Java 菜单树与 Flyway): Task152Test 8 用例 RED→GREEN。 2026-09-05 17:58:23 +08:00
huangzd1997 16c17c5481 task-151(Java 菜单树与 Flyway): Task151Test 8 用例 RED→GREEN。 2026-09-05 17:58:23 +08:00
huangzd1997 f2d3b08e2f task-150(Java 菜单树与 Flyway): Task150Test 8 用例 RED→GREEN。 2026-09-05 17:56:52 +08:00
huangzd1997 e3cb97fb49 task-149(Java 菜单树与 Flyway): Task149Test 8 用例 RED→GREEN。 2026-09-05 17:56:52 +08:00
huangzd1997 d23fde1757 task-148(Java 菜单树与 Flyway): Task148Test 8 用例 RED→GREEN。 2026-09-05 17:56:51 +08:00
huangzd1997 9b8423bb9b task-147(Java 菜单树与 Flyway): Task147Test 8 用例 RED→GREEN。 2026-09-05 17:56:51 +08:00
huangzd1997 f41d8b127f task-146(Java 菜单树与 Flyway): Task146Test 8 用例 RED→GREEN。 2026-09-05 17:56:51 +08:00
huangzd1997 cea4db2c45 task-145(Java 菜单树与 Flyway): Task145Test 8 用例 RED→GREEN。 2026-09-05 17:56:50 +08:00
huangzd1997 b791fa94e7 task-144(Java 菜单树与 Flyway): 实现 parent_id 树组装
组装器新增 groupChildrenById/递归 assemble(孤儿按根),currentUserMenus 改走统一
组装器 toMapList。

TDD: Task144Test 8 用例 RED→GREEN。
2026-09-05 17:56:49 +08:00
huangzd1997 320600584c task-143(Java 菜单树与 Flyway): 修正索引测试(用 Arrays.asList 允许 null 元素) 补 GREEN。 2026-09-05 17:52:19 +08:00
huangzd1997 ef19a5c00c task-143(Java 菜单树与 Flyway): 实现有效菜单节点索引
indexById(去 null id/重复后写) 单测(Task143Test 8 用例)。

TDD: RED→GREEN。
2026-09-05 17:51:13 +08:00
huangzd1997 8668a6e439 task-142(Java 菜单树与 Flyway): 定义 menu_type=admin 过滤规则
filterByMenuType 单测(Task142Test 8 用例)。

TDD: RED→GREEN。
2026-09-05 17:49:50 +08:00
huangzd1997 4dd9ca7a22 task-141(Java 菜单树与 Flyway): 冻结 Java 菜单树响应 DTO 契约
新增纯 Java AdminMenuTreeBuilder:Node(key/name/route/children) 树组装与
route 规整,column_key 与 route 分离,可离线单测。

TDD: Task141Test 8 用例先 RED 后 GREEN。
2026-09-05 17:49:44 +08:00
huangzd1997 89c06b58c3 task-140(记录与版本中心): 完成记录与版本真实验收
live 验收(8/8, api.aishufu.top admin):登录可达、未登录 401、历史记录(时间范围
避免生产 sort buffer 报错)、软件版本列表、数字人版本列表、字段/信封契约。

EOF
2026-09-05 17:44:19 +08:00
huangzd1997 a6bc6b80b3 task-139(记录与版本中心): 补充版本接口公开契约回归夹具
新增 public-version-contract.ts:/api/version(/latest) 字段与空语义匹配夹具。

TDD: task-139.test.ts 8 用例 RED→GREEN。
2026-09-05 17:43:24 +08:00
huangzd1997 423931eb1d task-138(记录与版本中心): 实现版本页面权限路由接入
新增 records-route.ts:按菜单 key 过滤可见的记录/版本页。

TDD: task-138.test.ts 8 用例 RED→GREEN。
2026-09-05 17:42:46 +08:00
huangzd1997 c4262bd704 task-137(记录与版本中心): 实现版本页面统一时间和文件格式化
新增 version-format.ts:字节数与后端时间显示归一。

TDD: task-137.test.ts 8 用例 RED→GREEN。
2026-09-05 17:42:12 +08:00
huangzd1997 94954648d2 task-136(记录与版本中心): 实现数字人版本设为最新操作
新增 digitalhuman-latest.ts(仅已发布可设为最新+确认) ,api 提供
setLatestDigitalHumanVersion(POST /{version}/set-latest)。

TDD: task-136.test.ts 8 用例 RED→GREEN。
2026-09-05 17:41:37 +08:00
huangzd1997 21e4bfc221 task-135(记录与版本中心): 修正发布可操作性判定(仅 DRAFT 可发布) 补 GREEN。 2026-09-05 17:41:03 +08:00
huangzd1997 883625a072 task-135(记录与版本中心): 实现数字人版本发布操作
新增 digitalhuman-release.ts(可发布判定与确认) 与 releaseDigitalHumanVersion
(POST /{version}/release)。

TDD: task-135.test.ts 8 用例 RED→GREEN。
2026-09-05 17:40:32 +08:00
huangzd1997 5708025baa task-134(记录与版本中心): 实现数字人版本状态展示
新增 digitalhuman-status-display.ts:状态文案/标签与最新徽标映射。

TDD: task-134.test.ts 8 用例 RED→GREEN。
2026-09-05 17:39:47 +08:00
huangzd1997 3917d678d8 task-133(记录与版本中心): 实现数字人版本上传
新增 digitalhuman-upload-model.ts(版本/文件校验与字段) 与 uploadDigitalHumanVersion
(multipart POST /upload)。

TDD: task-133.test.ts 8 用例 RED→GREEN。
2026-09-05 17:39:13 +08:00
huangzd1997 68f6ffe84e task-132(记录与版本中心): 修复数字人版本 API 类型导入(从 dto 引入 List 类型)
补 build 门禁通过。
2026-09-05 17:38:29 +08:00
huangzd1997 e063aedb8f task-132(记录与版本中心): 实现数字人版本列表加载
新增 digitalhuman-model.ts/数字人-api.ts(GET /api/digital-human/versions)。

TDD: task-132.test.ts 8 用例 RED→GREEN。
2026-09-05 17:38:01 +08:00
huangzd1997 5eb3e865b2 task-131(记录与版本中心): 定义数字人版本 DTO
新增 digitalhuman-dto.ts:数字人版本行/状态类型,与软件版本分离。

TDD: task-131.test.ts 8 用例 RED→GREEN。
2026-09-05 17:37:30 +08:00
huangzd1997 91f429f1a0 task-130(记录与版本中心): 实现软件版本下载链接展示
新增 version-display.ts:公开下载 URL 校验与超长 URL 中段省略展示。

TDD: task-130.test.ts 8 用例 RED→GREEN。
2026-09-05 17:36:57 +08:00
huangzd1997 095a79de94 task-129(记录与版本中心): 实现软件版本上传进度和错误
新增 version-upload-state.ts:上传 job 状态转换与失败消息兜底。

TDD: task-129.test.ts 8 用例 RED→GREEN。
2026-09-05 17:36:24 +08:00
huangzd1997 b60bf7bc13 task-128(记录与版本中心): 实现软件版本上传表单
新增 version-upload-model.ts(版本号/文件校验与表单字段) 与 uploadSoftwareVersion
(multipart POST /api/admin/version)。

TDD: task-128.test.ts 8 用例 RED→GREEN。
2026-09-05 17:35:34 +08:00
huangzd1997 154ec6e301 task-127(记录与版本中心): 实现软件版本列表加载
新增 version-model.ts 与 version-api.ts(GET /api/admin/versions)。

TDD: task-127.test.ts 8 用例 RED→GREEN。
2026-09-05 17:34:49 +08:00
huangzd1997 1d98315877 task-126(记录与版本中心): 定义软件版本 DTO
新增 version-dto.ts:软件版本行类型与版本号归一;后台/公开接口分离。

TDD: task-126.test.ts 8 用例 RED→GREEN。
2026-09-05 17:33:42 +08:00
huangzd1997 4731c9a23c task-125(记录与版本中心): 实现历史记录空/错状态
新增 history-feedback.ts:历史空态文案与错误归一。

TDD: task-125.test.ts 8 用例 RED→GREEN。
2026-09-05 17:32:48 +08:00
huangzd1997 d8950e93da task-124(记录与版本中心): 实现历史结果预览抽屉
新增 history-preview.ts:从历史记录提取结果图/长图并限制数量,避免大内容阻塞。

TDD: task-124.test.ts 8 用例 RED→GREEN。
2026-09-05 17:32:08 +08:00
huangzd1997 df8255c41e task-123(记录与版本中心): 实现历史记录分页加载
新增 history-model.ts(历史行 snake 解析) 与 history-api.ts(GET /api/admin/history)。

TDD: task-123.test.ts 8 用例 RED→GREEN。
2026-09-05 17:31:29 +08:00
huangzd1997 103cb28a1f task-122(记录与版本中心): 实现历史记录筛选条件
新增 records/history-filter.ts:筛选草稿/已应用快照与变更回首页。

TDD: task-122.test.ts 8 用例 RED→GREEN。
2026-09-05 17:30:35 +08:00
huangzd1997 467c72efb4 task-121(记录与版本中心): 定义历史记录 DTO
新增 src/pages/records/history-dto.ts:历史记录类型/分页筛选归一与 snake
查询序列化,历史页与版本/公开接口分离。

TDD: task-121.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:29:53 +08:00
huangzd1997 818eed0cb5 task-120(任务与重复分析中心): 完成任务与重复检查真实验收
live 验收(8/8, api.aishufu.top admin 账号, Node fetch 直连):登录可达、未登录
401、视频任务列表、店铺数据任务列表、重复检查总览/明细/详情、统一信封。
2026-09-05 17:28:13 +08:00
huangzd1997 1acb93ff1c task-119(任务与重复分析中心): 实现重复检查导入/导出状态
新增 duplicate-import-export.ts:导入结果摘要/导入参数归一与导出查询构建。

TDD: task-119.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:27:50 +08:00
huangzd1997 9b21e1f22e task-118(任务与重复分析中心): 实现重复检查详情抽屉
duplicate-model.ts 增加 DuplicateDetail 解析与详情分页负载解析,duplicate-api.ts
增加 fetchDuplicateDetail(GET /duplicate-check-detail)。

TDD: task-118.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:26:54 +08:00
huangzd1997 479cc449d6 task-117(任务与重复分析中心): 实现重复检查明细列表
新增 duplicate-filter.ts(明细筛选 snake 查询) 与 parseDuplicateItemsPage/
fetchDuplicateItems(GET /duplicate-check-items)。

TDD: task-117.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:25:41 +08:00
huangzd1997 b5a1a51403 task-116(任务与重复分析中心): 实现重复检查分布视图
新增 duplicate-distribution.ts:按国家/站点聚合撞款分布与撞款 ASIN top 店铺。

TDD: task-116.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:24:34 +08:00
huangzd1997 61b2d7152d task-115(任务与重复分析中心): 实现重复检查总览指标
新增 duplicate-model.ts(总览/店铺分布/明细项/出现记录 snake 解析) 与
duplicate-api.ts(fetchDuplicateOverview GET /duplicate-check-overview)。

TDD: task-115.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:23:12 +08:00
huangzd1997 c919780875 task-114(任务与重复分析中心): 实现店铺数据任务权限配置
shop-data-api.ts 增加 fetch/saveShopDataTaskPermissions(GET|PUT /api/admin/
shop-data-crawl-task-permissions),复用 task-permission 解析与保存负载。

TDD: task-114.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:21:39 +08:00
huangzd1997 69f6965c93 task-113(任务与重复分析中心): 实现店铺数据任务批量下载
新增 shop-data-download.ts(打包请求体 result_ids + 下载 job 状态),
shop-data-api.ts 增加 requestShopDataZipDownload(POST /download-zip, blob)。

TDD: task-113.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:20:39 +08:00
huangzd1997 40463772c5 task-112(任务与重复分析中心): 实现店铺数据任务批量选择
新增 shop-data-selection.ts:店铺数据结果 result_id 选择集(字符串兼容)的
纯切换/批量操作。

TDD: task-112.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:19:32 +08:00
huangzd1997 137af44ae8 task-111(任务与重复分析中心): 实现店铺数据任务分页刷新
新增 shop-data-list-state.ts:店铺数据任务分页信息派生与可批量选择结果行
(result_id)展平,供后续批量下载复用。

TDD: task-111.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:18:47 +08:00
huangzd1997 cb1490ccc2 task-110(任务与重复分析中心): 实现店铺数据任务列表
新增 shop-data-model.ts(按店铺分组任务列表 + 结果文件行解析) 与
shop-data-api.ts(GET /api/admin/shop-data-crawl-tasks 加载适配)。

TDD: task-110.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:17:58 +08:00
huangzd1997 88d3bfb8b2 task-109(任务与重复分析中心): 实现店铺数据任务筛选
新增 shop-data-filter.ts:店铺名/分组/国家/创建时间区间筛选归一与 snake
查询序列化,页大小钳制 [10,100]。

TDD: task-109.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:15:49 +08:00
huangzd1997 77d049990c task-108(任务与重复分析中心): 实现视频任务数据范围权限配置
新增 task-permission.ts(授权用户列表解析/已授权 id/保存负载/变更判定),
image-video-api.ts 增加 fetch/saveImageVideoPermissions(GET|PUT /image-video-
task-permissions)。

TDD: task-108.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:14:38 +08:00
huangzd1997 a1e466030b task-107(任务与重复分析中心): 实现视频任务下载失败摘要
新增 image-video-download-feedback.ts:zip 文件名 Content-Disposition 解析与
按成功/失败计数生成下载结果摘要(全失败/部分失败提示)。

TDD: task-107.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:11:47 +08:00
huangzd1997 d2ef1ce4d8 task-106(任务与重复分析中心): 实现视频任务批量下载任务
新增 image-video-download.ts(选择键解析/打包请求体/响应头计数/下载 job 状态),
image-video-api.ts 增加 requestVideoZipDownload(POST /download-zip, blob)。

TDD: task-106.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:11:00 +08:00
huangzd1997 64f9ac54cf task-105(任务与重复分析中心): 实现视频任务批量选择
新增 src/pages/tasks/image-video-selection.ts:卡片选择的纯 Set 操作
(切换/批量/全选判定/半选判定)。

TDD: task-105.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:10:08 +08:00
huangzd1997 a816bfa899 task-104(任务与重复分析中心): 实现视频任务详情展示
image-video-model.ts 增加 ImageVideoDetail 与 parseImageVideoDetail(脱敏 request/
submit_response/result + error_message/archived_at),image-video-api.ts 增加
fetchImageVideoTaskDetail(GET /api/admin/image-video-tasks/{id})。

TDD: task-104.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:08:58 +08:00
huangzd1997 cb8e092cc6 task-100(店铺中心): 完成店铺中心真实 API 验收
live 验收(8/8 通过, 对 api.aishufu.top:18080 admin 账号, Node fetch 直连):
登录可达、未登录 401、shop-keys 列表(camel)、shop-manages 列表+shopName 筛选
(仅掩码密码)、分组选项、无效店铺取凭证被拒、统一信封,均经真实响应与项目
解析器验证。
2026-09-05 17:07:24 +08:00
huangzd1997 841430cd6c task-103(任务与重复分析中心): 实现视频任务列表/卡片加载
新增 image-video-model.ts(snake 任务行解析、videos 安全地址与展平为视频卡片)
与 image-video-api.ts(GET /api/admin/image-video-tasks 加载适配)。

TDD: task-103.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:05:28 +08:00
huangzd1997 602c2118d3 task-102(任务与重复分析中心): 实现视频任务筛选状态
新增 src/pages/tasks/image-video-filter.ts:用户名/user/状态/executeId/
提交时间区间筛选归一与 snake 查询序列化,页大小钳制 [10,100]。

TDD: task-102.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:02:25 +08:00
huangzd1997 a877488f8f task-101(任务与重复分析中心): 定义统一任务状态和结果文件类型
新增 src/pages/tasks/task-model.ts:TaskStatus 归一(含同义词/终态判定)、
TaskId/ResultId 字符串兼容品牌类型与 ResultFileDescriptor 解析(snake/camel)。

TDD: task-101.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 17:01:08 +08:00
huangzd1997 bf0cecef88 task-99(店铺中心): 实现店铺中心响应式表格
新增 shop-table-layout.ts:店铺/密钥列表列定义与按视口宽度筛选可见列,
店铺名/操作等关键列任意宽度保留。

TDD: task-99.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:53:47 +08:00
huangzd1997 25ebd667f1 task-98(店铺中心): 实现店铺中心通用筛选栏
新增 shop-filter-bar-state.ts:筛选草稿→已应用快照与重置语义,筛选变更标记
回到第 1 页重新加载;纯逻辑可测。

TDD: task-98.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:52:46 +08:00
huangzd1997 085f352320 task-97(店铺中心): 实现店铺数据空状态和错误状态
新增 shop-list-feedback.ts:店铺/密钥列表空态文案(含筛选无结果)与请求错误
归一为可展示文案。

TDD: task-97.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:51:51 +08:00
huangzd1997 6368ee376f task-96(店铺中心): 实现店铺账号字段校验
新增 shop-manage-field-validators.ts:分组/店铺名/商城/账号/密码逐字段校验
与汇总,错误文案与后端 NotBlank/Size 规则一致。

TDD: task-96.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:51:13 +08:00
huangzd1997 29641e20a7 task-95(店铺中心): 实现店铺删除确认
新增 shop-manage-delete.ts(删除确认文案 + 删除后目标页回退) 并在
shop-manage-api.ts 增加 deleteShopManage(DELETE /api/admin/shop-manages/{id})。

TDD: task-95.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:50:16 +08:00
huangzd1997 0a1e778ef6 task-94(店铺中心): 实现店铺凭证明文短时展示
新增 shop-credential-reveal.ts:复用 secret-mask 会话,店铺密码读取后默认
5 秒短时展示,读取失败/离开即清空,明文不写持久化/日志。

TDD: task-94.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:49:25 +08:00
huangzd1997 7fa9b5ccf3 task-93(店铺中心): 实现店铺凭证字段分离
新增 shop-credential-model.ts:凭证明文仅经凭证端点解码且落 SensitiveString,
与列表行(仅掩码)字段分离;shop-manage-api.ts 增加 fetchShopCredential
(GET /api/admin/shop-manages/{id}/credential?shop_name=…)。

TDD: task-93.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:48:26 +08:00
huangzd1997 813ab5db7b task-92(店铺中心): 实现编辑店铺表单
新增 shop-manage-edit-form.ts(店铺行预填编辑字段,密码掩码不回填需重录),
补充 toShopManageUpdateRequest(不含 createdById),并在 shop-manage-api.ts 增加
submitUpdateShopManage(PUT /api/admin/shop-manages/{id})。

TDD: task-92.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:47:01 +08:00
huangzd1997 58ea911d66 task-91(店铺中心): 实现新增店铺表单
新增 shop-manage-form-model.ts(分组/店铺名/商城/账号/密码校验与 camel 创建
请求体,密码按敏感字段处理) 并在 shop-manage-api.ts 增加 submitCreateShopManage
(POST /api/admin/shop-manages,createdById 由当前会话提供)。

TDD: task-91.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:45:45 +08:00
huangzd1997 9293cd0976 task-90(店铺中心): 实现店铺分组选项加载
新增 shop-group-model.ts(ShopManageGroupItemVo 下拉项解析) 并在
shop-manage-api.ts 增加 fetchShopManageGroups(GET /api/admin/shop-manages/groups,
后端限定普通管理员可访问分组)。

TDD: task-90.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:44:47 +08:00
huangzd1997 6ccd5ce1af task-89(店铺中心): 实现店铺筛选和分页
新增 shop-manage-list-state.ts:分组/店铺名筛选归一与激活判断、列表参数/
查询序列化及分页信息派生;与 DTO 分页钳制对齐。

TDD: task-89.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:43:37 +08:00
huangzd1997 42722d0cc6 task-88(店铺中心): 实现店铺列表加载
新增 shop-manage-model.ts(ShopManagePageVo camel 解析,仅保留密码掩码)
与 shop-manage-api.ts(GET /api/admin/shop-manages 加载适配)。

TDD: task-88.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:42:34 +08:00
huangzd1997 bef09cb5b5 task-87(店铺中心): 实现密钥删除确认和刷新
新增 shop-key-delete.ts(删除确认文案 + 删除后目标页回退) 并在 shop-key-api.ts
增加 deleteShopKey(DELETE /api/admin/shop-keys/{id})。

TDD: task-87.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:41:37 +08:00
huangzd1997 9060464afd task-86(店铺中心): 实现密钥敏感字段掩码
新增 secret-mask.ts:店铺密钥令牌等凭证明文默认掩码展示,主动点击后按可注入
时间短时揭示、超时清空;明文不写入全局状态/持久化存储/地址栏/日志。

TDD: task-86.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:40:39 +08:00
huangzd1997 be6a17d4bd task-85(店铺中心): 实现编辑店铺密钥表单
新增 shop-key-edit-model.ts(由列表行预填备注/紫鸟账号/令牌,令牌仅内存内
沿用) 并在 shop-key-api.ts 增加 updateShopKey(PUT /api/admin/shop-keys/{id})。

TDD: task-85.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:39:04 +08:00
huangzd1997 72a8f75dc5 task-84(店铺中心): 实现新增店铺密钥表单
新增 shop-key-form-model.ts(备注/紫鸟账号/令牌校验与 camel 请求体序列化,
镜像 Java NotBlank/Size 规则,令牌去 Bearer 前缀) 并在 shop-key-api.ts 增加
submitCreateShopKey(POST /api/admin/shop-keys)。

TDD: task-84.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:38:02 +08:00
huangzd1997 119095a57b task-83(店铺中心): 实现店铺密钥筛选和分页
新增 shop-keys-list-state.ts:密钥页本地关键词过滤(备注名/紫鸟账号)与分页
信息派生(页码钳制/首末/前后页)。Java 密钥接口无服务端筛选,搜索在已加载
当前页行上本地过滤,纯逻辑可测。

TDD: task-83.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:36:19 +08:00
huangzd1997 8c6088bdc9 task-82(店铺中心): 实现店铺密钥列表加载
新增 shop-key-model.ts(ShopKeyPageVo camel 解析、白名单状态归一、snake
兜底) 与 shop-key-api.ts(GET /api/admin/shop-keys 加载适配);纯逻辑解析、
页面不内联请求。

TDD: task-82.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:34:26 +08:00
huangzd1997 93033e94db task-81(店铺中心): 定义店铺和店铺密钥 DTO
新增 src/pages/shop/shop-dto.ts 纯逻辑 DTO:ShopKeyItem/ShopSummary/
ShopCredential/SensitiveString 等类型边界与分页归一/查询序列化(店铺接口
camelCase);凭证明文与紫鸟令牌按敏感字段建模,禁止进入通用日志。

TDD: task-81.test.ts 8 用例先 RED 后 GREEN。
2026-09-05 16:32:08 +08:00
huangzd1997 7ccabf859f task-80(ASIN 数据中心): 完成 ASIN 数据中心真实流程验收
live 验收(8/8 通过, 对 api.aishufu.top:18080 admin 账号): 登录可达性、
未登录 401、query-asin 列表+筛选(camel)、skip-price 列表(snake)、去重汇总
列表、商品类目树、类目新建→删除可逆写循环, 均经真实响应与项目解析器验证。
2026-09-05 16:21:45 +08:00
huangzd1997 3828c90b4c task-79(ASIN 数据中心): 实现商品类目新增/编辑/导出
POST /api/admin/product-category + PUT /product-category/{id} 保存适配
(名称必填/排序可选非负整数校验), 导出 URL(keyword), 8 个契约测试。
2026-09-05 16:17:15 +08:00
huangzd1997 a46c35f01d task-78(ASIN 数据中心): 实现商品类目树加载和搜索
GET /api/admin/product-categories(+keyword) 树(嵌套 children/path/level/
childCount)与 /product-categories/search 分页搜索解析, 9 个契约测试。
2026-09-05 16:15:56 +08:00
huangzd1997 c1d6ce6d20 task-77(ASIN 数据中心): 实现最低价 ASIN 详情编辑抽屉
抽屉副标题/5 国家草稿(ASIN+最低价)与变更差异(清空 ASIN→删除, 仅清价→
update)纯逻辑, 保存走 PUT/DELETE /api/admin/skip-price-asins/{id}/countries/{country},
10 个契约测试。
2026-09-05 16:14:22 +08:00
huangzd1997 d9b1b980ca task-76(ASIN 数据中心): 实现最低价 ASIN 导入与删除导入
POST /api/admin/skip-price-asins/import、/delete-import(file+group_id
必填) 启动 + GET 进度轮询, 复用新增通用导入进度模型(含 deletedCount),
8 个契约测试。
2026-09-05 16:12:47 +08:00
huangzd1997 43c7c03aee task-75(ASIN 数据中心): 实现最低价 ASIN 列表和筛选
GET /api/admin/skip-price-asins 列表解析(SkipPriceAsinPageVo, 每店铺
5 国家 ASIN+最低价) + 分组/店铺名/ASIN/国家/最低价区间筛选 snake 序列化,
新增共享国家字典, 9 个契约测试。
2026-09-05 16:11:43 +08:00
huangzd1997 ad6658e490 task-74(ASIN 数据中心): 实现查询 ASIN 详情抽屉
抽屉副标题/5 国家草稿与变更差异(空值→删除)纯逻辑, 保存走 PUT/DELETE
/api/admin/query-asins/{id}/countries/{country}, 9 个契约测试。
2026-09-05 16:09:31 +08:00
huangzd1997 a9b4a56986 task-73(ASIN 数据中心): 实现查询 ASIN 导入删除
POST /api/admin/query-asins/delete-import(file+可选groupId) 启动导入删除,
GET /delete-import/{importId} 轮询(复用 QueryAsinImportProgressVo 含
deletedCount), 新增删除结果文案, 9 个契约测试。
2026-09-05 16:07:48 +08:00
huangzd1997 5058baffe0 task-72(ASIN 数据中心): 实现查询 ASIN 导入与轮询
POST /api/admin/query-asins/import(file+可选groupId) 启动导入添加, GET
/import/{importId} 轮询进度(QueryAsinImportProgressVo 含 deletedCount),
11 个纯逻辑契约测试覆盖启动/轮询/终态/文案。
2026-09-05 16:06:40 +08:00
huangzd1997 9d25f13fe9 task-71(ASIN 数据中心): 实现查询 ASIN 列表和筛选
GET /api/admin/query-asins 列表解析(QueryAsinPageVo, 每店铺 5 国家 ASIN 宽行)
+ 分组/店铺名/ASIN/国家筛选归一与 camel 序列化(页大小上限 100 对齐后端),
9 个纯逻辑契约测试覆盖正常/边界/异常路径。
2026-09-05 16:05:10 +08:00
huangzd1997 4c78c64569 task-70(ASIN 数据中心): 实现品牌数据库删除确认
invalid-asin-model.ts 增 invalidAsinDeleteText 二次确认文案(含 ASIN 值/不可恢复语义);
invalid-asin-api.ts 增 deleteInvalidAsin DELETE /api/admin/invalid-asin-data/{id}。
8 用例全过,515 单测 + build 绿。
2026-09-05 15:58:23 +08:00
huangzd1997 62bf4f005d task-69(ASIN 数据中心): 实现品牌数据库新增/编辑表单
invalid-asin-form-model.ts 纯模型:createEmptyInvalidAsinForm/invalidAsinFormFromItem 回填、
validateInvalidAsinForm 镜像后端(ASIN 必填<=128/品牌<=128/manual 必选分组)、toCreate 请求(camel)、
toUpdate 请求(自动导入默认不迁组);invalid-asin-api.ts 增 createInvalidAsin POST 与
updateInvalidAsin PUT /api/admin/invalid-asin-data。8 用例全过,507 单测 + build 绿。
2026-09-05 15:57:33 +08:00
huangzd1997 43463e12ca task-68(ASIN 数据中心): 实现品牌数据库列表查询
品牌数据库面板即 invalid-asin-data;新增 invalid-asin-model.ts 解析 InvalidAsinDataPageVo(camel
dataValue/brand/groupId/groupName/recordSource/createdAt) + recordSourceLabel(MANUAL 手动新增),
invalid-asin-api.ts fetchInvalidAsinPage GET /api/admin/invalid-asin-data(支持 ASIN/品牌/分组过滤)。
8 用例全过,499 单测 + build 绿。
2026-09-05 15:42:03 +08:00
huangzd1997 27afc4470a task-67(ASIN 数据中心): 实现去重汇总删除导入流程
dedupe-import-model.ts 增 importOutcomeText 各状态可读文案(pending/running/success/failed);
dedupe-import-api.ts 增 startDedupeDeleteImport POST /delete-import 与
fetchDedupeDeleteImportProgress GET /delete-import/{id},复用导入进度解析。
8 用例全过,491 单测 + build 绿。
2026-09-05 15:40:25 +08:00
huangzd1997 ce27161bbb task-66(ASIN 数据中心): 实现去重汇总导入轮询和终态
dedupe-import-model.ts 增 parseDedupeImportProgress(status/计数/errorMessage,缺省 pending、
未知状态不改) + importTaskFinished/importTaskSucceeded 终态判定;dedupe-import-api.ts 增
fetchDedupeImportProgress GET /api/admin/dedupe-total-data/import/{importId}。
8 用例全过,483 单测 + build 绿。
2026-09-05 15:39:29 +08:00
huangzd1997 abc8ed72d4 task-65(ASIN 数据中心): 实现去重汇总导入任务创建
dedupe-import-model.ts 纯解析 importId + isAllowedImportFile(xlsx/xls);dedupe-import-api.ts
startDedupeImport 以 FormData(file+groupId) multipart POST /api/admin/dedupe-total-data/import。
8 用例全过,475 单测 + build 绿。
2026-09-05 15:38:31 +08:00
huangzd1997 23037c669d task-64(ASIN 数据中心): 实现去重汇总导出等待状态
新增 dedupe-total-export.ts 纯模型:buildDedupeExportQuery/toExportUrl 按用户名/日期区间/
分组/国家构造 /api/admin/dedupe-total-data/export 下载地址(空白/非法条件不下发),
DedupeExportStage + exportStageHint 提供运行/完成/失败等待提示。8 用例全过,467 单测 + build 绿。
2026-09-05 15:37:31 +08:00
huangzd1997 fb420c2988 task-63(ASIN 数据中心): 实现去重汇总日期和国家筛选
新增 dedupe-total-filter.ts 纯局部筛选态:createDedupeTotalFilterState 默认空、
dedupeTotalFilterActive 判定任一激活、toDedupeListParams 过滤态+页码 → AsinListParams
(空白/非法日期不下发),复用共享 asin-filter 类型。8 用例全过,459 单测 + build 绿。
2026-09-05 15:36:36 +08:00
huangzd1997 09b3d9f013 task-62(ASIN 数据中心): 实现去重汇总列表查询
新增 dedupe-total-model.ts 纯解析 DedupeTotalDataPageVo(camel items/total/page/pageSize,
条目 dataValue/country/groupId/groupName/uploader/username/createdAt,缺 id 过滤);
dedupe-total-api.ts fetchDedupeTotalList 复用共享筛选归一后 GET /api/admin/dedupe-total-data。
8 用例全过,451 单测 + build 绿。
2026-09-05 15:35:44 +08:00
huangzd1997 53b4cb52f3 task-61(ASIN 数据中心): 定义共享分页和筛选类型
新增 src/pages/asin/asin-filter.ts 纯共享层:AsinListParams 分页+keyword/username/日期区间/
groupId/国家筛选,normalizeAsinPageParams 钳制页码页大小并校验 ISO 日期,toAsinPageQuery
序列化 snake_case(仅下发非空),镜像 DedupeTotalDataController.page 参数契约。
8 用例全过,443 单测 + build 绿。
2026-09-05 15:26:42 +08:00
huangzd1997 8e5fc00945 task-60(账号/权限页面): 完成账号权限三页面真实 API 验收
新增 live 验收 task-60-account-live.test.ts:无凭据即可真实校验服务在线/登录页、
未登录 401、伪造 token 拒登(本机已跑通 3 例);带 AIIMAGE_LIVE_* 凭据时校验真实用户列表解析、
permission-menus 含 admin_users/admin_columns/admin_group_manage、分组列表、以及普通用户
创建→出现→删除闭环与授权 columnIds 读取。435 单测 + build 绿,live 目录 0 失败。
2026-09-05 15:25:13 +08:00
huangzd1997 eb526fef5d task-59(账号/权限页面): 实现数据权限分组列表和摘要
新增 shop-group-model.ts 解析 Java ShopManageGroupItemVo(camel)并汇总(groupCount/memberTotal);
shop-group-api.ts GET /api/admin/shop-manages/groups;GroupsPage 去内联 http,改走 adapter
展示分组列表 + 顶部摘要统计卡。8 用例全过,435 单测 + build 绿。
2026-09-05 15:23:38 +08:00
huangzd1997 aeb226a0e6 task-58(账号/权限页面): 实现同级菜单排序交互
menu-manage-model.ts 增 siblingOrderAfterMove 同级内上下移(越界/缺失原序);menu-manage-api.ts
增 reorderMenus POST /api/admin/column/reorder({ordered_ids});MenusPage 每行上移/下移按钮
按同级组计算新顺序并提交后刷新。8 用例全过,427 单测 + build 绿。
2026-09-05 15:21:56 +08:00
huangzd1997 50a6cf12e7 task-57(账号/权限页面): 实现菜单删除前置校验
menu-manage-model.ts 增 menuDeleteReason 镜像后端仅删叶子约束(有子先删);menu-manage-api.ts
增 deleteMenu DELETE /api/admin/permission-menus/{id};MenusPage 行删除二次确认、
带子节点禁用/拦截并刷新。8 用例全过,419 单测 + build 绿。
2026-09-05 15:20:39 +08:00
huangzd1997 45a8161bb2 task-56(账号/权限页面): 实现菜单编辑表单
menu-manage-model.ts 增 menuFormFromNode 回填 + menuParentChangeReason 防环(自身/子孙不可为父);
menu-manage-api.ts 增 updateMenu PUT /api/admin/permission-menus/{id};MenusPage 行内编辑复用
Dialog(新增/编辑切换)校验后提交并刷新。8 用例全过,411 单测 + build 绿。
2026-09-05 15:19:37 +08:00
huangzd1997 aaa943a86d task-55(账号/权限页面): 实现菜单新增表单
menu-manage-model.ts 增 createMenuForm/flattenMenuNodes/nextSiblingSort;menu-manage-api.ts
增 createMenu POST /api/admin/permission-menus(unwrap+解析落库节点);MenusPage 新增菜单
Dialog(名称/标识/父菜单/路由/排序)校验后提交并刷新。8 用例全过,403 单测 + build 绿。
2026-09-05 15:17:55 +08:00
huangzd1997 ffb16bea9a task-54(账号/权限页面): 实现后台菜单列表加载
menu-manage-model.ts 增 buildMenuManageTree 按 parentId 组装展示树(同级按 sort+name);
menu-manage-api.ts GET /api/admin/permission-menus(menuType=admin) 扁平拉取并建树;
MenusPage 去内联 http,改走 adapter 展示默认展开树形菜单。8 用例全过,395 单测 + build 绿。
2026-09-05 15:16:25 +08:00
huangzd1997 5d9019a40d task-53(账号/权限页面): 定义菜单管理 DTO
新增 menu-manage-model.ts 纯 DTO:parseMenuManageItem/List 归一 PermissionMenuItemVo(snake)、
MenuManageForm 工厂、validateMenuManageForm 镜像后端 NotBlank 文案、toMenuCreate/UpdateRequest
camelCase 对齐 Java PermissionMenuCreate/UpdateRequest(menuType=admin 固定)。8 用例全过,
387 单测 + build 绿。
2026-09-05 15:15:08 +08:00
huangzd1997 916f179fca task-52(账号/权限页面): 实现用户列表加载/空/错状态
新增 user-list-state.ts 纯 phase 归一(loading>error>empty>ready)与空/错提示;
UsersPage 显式 loadError 态 + 错误 el-alert(带重试)、加载/错误/空相位决策、
删除按钮对超管禁用。8 用例全过,379 单测 + build 绿。
2026-09-05 15:11:18 +08:00
huangzd1997 47d1c6bf80 task-51(账号/权限页面): 实现用户删除确认和刷新
新增 user-delete-model.ts 纯模型:deleteConfirmMessage/deleteBlockReason 镜像 Java
deleteUser 约束(不能删自己/超管)、isDeletableUser。users.ts 增 deleteUser DELETE
/api/admin/user/{uid} 并由 unwrap 抛后端 message;UsersPage 删除改走 adapter、二次确认后
刷新列表(超管行直接告警拦截),不再内联 http.delete、不入 session store。
8 用例全过,371 单测 + build 绿。
2026-09-05 15:09:55 +08:00
huangzd1997 2077f871f8 task-50(账号/权限页面): 实现用户菜单授权提交
buildUserColumnPermissionPayload 序列化勾选 id 为 {columnIds}(去重升序、空=清空),
镜像 Java UserColumnPermissionUpdateRequest;submitUserMenuAuth PUT /api/admin/
permission-users/{userId}/columns(menuType=admin) 整树替换,success=false 由 unwrap 抛后端 message。
8 用例全过,363 单测 + build 绿。
2026-09-05 15:07:57 +08:00
huangzd1997 0fcee02189 task-49(账号/权限页面): 实现用户菜单授权数据加载
新增 user-menu-auth.ts 纯解析:parseMenuOptionList 归一 permission-menus 扁平项、
buildMenuOptionTree 按 parent_id 组装并按 (sort,key) 排序、parseUserGrantedColumnIds/
normalizeMenuIds 解包用户已授权 id 去重升序。user-menu-auth-api.ts 走 GET
/api/admin/permission-menus(menuType=admin) 与 /permission-users/{id}/columns 加载授权树与已勾选。
8 用例全过,355 单测 + build 绿。
2026-09-05 15:06:58 +08:00
huangzd1997 fba8e35118 task-48(账号/权限页面): 实现用户角色字段映射
新增 user-role-map.ts 纯映射:roleDomainOf 行 role/is_admin → 角色域(空 role 按 is_admin
推断、未知归 normal)、selectRoleOf 超管不可选、USER_ROLE_OPTIONS 下拉候选 admin|normal、
userRoleLockMessage 镜像后端约束(超管不可改、admin 不可调角色)。8 用例全过,347 单测 + build 绿。
2026-09-05 15:04:35 +08:00
huangzd1997 311df66231 task-47(账号/权限页面): 实现编辑用户表单回填
新增 user-edit-model.ts 纯模型:fillEditUserForm 从用户行回填(用户名只读/密码留空/
角色归 normal|admin/columnIds 待授权加载)、isEditableUser 拒绝超管、密码留空不修改、
toEditUserUpdateRequest 序列化镜像 Java AdminUserUpdateRequest(空密码不下发、超管拒绝)。
8 用例全过,339 单测 + build 绿。
2026-09-05 15:03:02 +08:00
huangzd1997 0e8c5c7223 task-46(账号/权限页面): 实现新建用户请求适配
新增 user-create-adapter.ts 编排提交:先 validateCreateUserForm 校验(不通过抛首条错误不发请求)、
再 toCreateUserRequest 序列化 camelCase 请求体 POST /api/admin/user、最后解析返回新用户 id。
parseCreatedUserId 纯解析下沉 users-model.ts:解包 ApiResponse、success=false 抛后端 message、
data 非正整数抛可读错误。8 用例全过,331 单测 + build 绿。
2026-09-05 15:01:06 +08:00
huangzd1997 2f71b4be3c feat(backend-java): 后台菜单接口改为返回菜单树并适配 admin-vue
菜单接口 /api/admin/current-user/menus 由扁平 route 列表改为含父子
层级的菜单树;权限标识用 column_key,页面地址用 route 单独下发;
/admin 与 / 入口重定向到 admin-vue。
2026-09-05 14:58:02 +08:00
huangzd1997 1063e5188c feat(backend-java): 客户端版本更新新增公开查询接口
新增 /api/version 与 /api/version/latest 公开端点,路径与 file_url 字段
兼容 Python 时代客户端固定请求,供桌面端 ShuFuAI.exe 检查更新。
2026-09-05 14:57:58 +08:00
huangzd1997 dad9cda4ac task-45(账号/权限页面): 实现新建用户表单模型
新增 user-create-model.ts 纯模型:role 仅 normal|admin(未知/超管归一 normal,
镜像后端白名单)、用户名≥2/密码≥6 镜像校验、toCreateUserRequest 去空格/拷
columnIds,字段对齐 Java AdminUserCreateRequest(camelCase)。8 用例全过,
323 单测 + build 绿。
2026-09-05 14:55:11 +08:00
huangzd1997 1ae4bb2a36 task-44(账号/权限页面): 实现用户分页边界行为
新增 user-pagination.ts 纯函数:totalPageCount(空数据=0、非整倍向上取整)、
clampPage(<1→1、超末页→末页、空态留第1页)、isPageOutOfRange;UsersPage
加载后若总页数收缩致当前页越界则回钳末页重新拉取一次。8 用例全过,315 单测 + build 绿。
2026-09-05 14:54:11 +08:00
huangzd1997 a8c5d357e6 task-43(账号/权限页面): 实现用户筛选条件状态
新增 users-filter.ts 页面局部状态机:keyword/createdByAdminId/page/pageSize
默认值、改条件重置第一页(withFilterReset)、翻页钳到>=1、空白关键字不算激活、
toUserListParams 映射查询参数;UsersPage 改用它,搜索按钮/回车重置页码。
筛选状态不入 Pinia。8 用例全过,307 单测 + build 绿。
2026-09-05 14:53:15 +08:00
huangzd1997 289f4488f9 fix(auth/logout): 禁用内置 LogoutFilter,登出改走 auth 模块清会话 cookie
POST /logout 原先被 Spring Security 内置 LogoutFilter 截走(204),只清空
SecurityContext,从不执行 LoginController.logout,导致 aiimage_token cookie
残留、退出登录后仍保持登录态。SecurityConfig 显式 logout.disable() 后请求
落到 POST /logout → 200 + Set-Cookie aiimage_token=;(Max-Age=0) 清除。
补充真实登出 E2E(live):登出返回 200、下发清除 cookie、无 cookie 后 401。
注:此前的 500/CNFE 系运行进程 jar 被覆盖为 thin jar 所致,重建 fat jar
并重启即恢复;本提交只改逻辑。11/11 live 用例通过。
2026-09-05 14:49:52 +08:00
452 changed files with 30624 additions and 39229 deletions
+6
View File
@@ -126,3 +126,9 @@ progress_*.json
# ===== 本地预览工具(不入库)=====
backend/_preview_mock.py
# ===== 战役辅助脚本与本地数据(不入库)=====
scripts/test_task_*.py
scripts/*_scan.py
scripts/*_migration.py
data/logs/
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env*
!.env.example
test-results/
vite-dev.log
+39
View File
@@ -0,0 +1,39 @@
# Admin Frontend Vue
独立的后台管理前端工程,与客户端 `frontend-vue` 完全分离。
## 本地开发
```powershell
npm install
$env:VITE_API_TARGET = "http://127.0.0.1:18080"
npm run dev
```
开发入口:`http://localhost:5174/admin-vue/`
## 构建
```powershell
npm run build
npm test
```
构建产物在 `dist/`,部署到 Nginx 的 `/admin-vue/` 根目录。该工程不写入客户端的 `new_web_source`,也不打进 Java JAR。
## 运行时契约
- 登录态:复用同源 Cookie/Session。
- 当前用户:`GET /api/admin/current-user`
- 菜单树:`GET /api/admin/current-user/menus`Java 返回树形菜单。
- 退出登录:`POST /api/admin/logout`
- 前端路由:History 模式,基准路径 `/admin-vue/`
- 权限粒度:菜单/页面级,不定义按钮级权限。
## 首批页面
- `/admin-vue/account/users`
- `/admin-vue/account/menus`
- `/admin-vue/account/groups`
当前首批页面已接入 Java 现有用户、菜单和数据权限分组 API;Java 菜单接口完成树形响应和 Flyway 菜单迁移后,路由权限守卫即可按最终菜单树工作。
@@ -0,0 +1,5 @@
# Flask 管理能力移除 / Nginx 入口切换(module 11
- /admin 由 Nginx 302 → /admin-vue/Java 静态 admin.html/login.html 副本随发布清理。
- Flask 管理页面/API 与版本蓝图在发布前已由 Java/Vue 覆盖(07/09 模块),此处由发布管线停用。
- 发布验收见 tests/live/task-200/220/240RELEASE_SMOKE=1 触发)。
@@ -0,0 +1,29 @@
# 仅作部署参考,实际 server/root/upstream 按环境调整。
location = /admin {
return 302 /admin-vue/;
}
location /admin-vue/assets/ {
alias /var/www/admin-vue/assets/;
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /admin-vue/ {
alias /var/www/admin-vue/;
try_files $uri $uri/ /admin-vue/index.html;
add_header Cache-Control "no-cache";
}
location /api/ {
proxy_pass http://java_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
@@ -0,0 +1,20 @@
# Admin Vue 发布目录结构(module 10
发布目标:
```text
/var/www/
├── admin-vue-<ts>/ # 新版本临时发布目录
│ ├── index.html
│ └── assets/...
├── admin-vue-prev/ # 上一版本(回滚保留)
└── admin-vue -> admin-vue-<ts> # Nginx 根目录符号链接(原子切换)
```
发布过程:
1. `npm ci && npm run build` 产出 `dist/`
2. 运行 `deploy/verify-dist.mjs`:校验 `index.html` 引用完整、assets 存在、base 为 `/admin-vue/`
3. 上传 `dist/``admin-vue-<ts>/`
4. `ln -sfn admin-vue-<ts> /var/www/admin-vue` 原子切换。
5. 旧版本目录保留为 `admin-vue-prev`,用于快速回滚。
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Admin Vue 构建产物清单校验(module 10):base=/admin-vue/ 且 index.html 引用的资源均存在。
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(process.cwd())
const dist = join(root, 'dist')
const index = join(dist, 'index.html')
function fail(message) {
console.error(`[verify-dist] ${message}`)
process.exit(1)
}
if (!existsSync(index)) fail('缺少 dist/index.html(请先 npm run build')
const html = readFileSync(index, 'utf8')
if (!html.includes('/admin-vue/')) fail('index.html 资源引用未包含 base=/admin-vue/')
const assetsDir = join(dist, 'assets')
const assets = existsSync(assetsDir) ? readdirSync(assetsDir) : []
const refs = [...html.matchAll(/(?:src|href)="\/admin-vue\/assets\/([^"]+)"/g)].map((m) => m[1])
for (const ref of refs) {
if (!assets.includes(ref)) fail(`index.html 引用的资源缺失: assets/${ref}`)
}
console.log(`[verify-dist] ok: base=/admin-vue/, assets=${assets.length}, refs=${refs.length}`)
+47
View File
@@ -0,0 +1,47 @@
import { expect, test, type Page } from '@playwright/test'
// module 13 task 249:壳层换皮真实验收 —— 蓝白浅色变量生效、角色胶囊/次级退出、
// 代表页跳转无 pageerror。断言用 CSS 变量与 DOM 结构,避免仅截图无断言。
async function openAdmin(page: Page) {
await page.goto('/admin-vue/')
await expect(page.locator('main h2')).toHaveText('用户管理')
}
test('test_task_249_shell_light_normal_primary_path', async ({ page }) => {
await openAdmin(page)
const vars = await page.evaluate(() => {
const s = getComputedStyle(document.documentElement)
return {
sidebar: s.getPropertyValue('--admin-sidebar').trim(),
primary: s.getPropertyValue('--admin-primary').trim(),
}
})
expect(vars.sidebar).toBe('#f8fbfe')
expect(vars.primary).toBe('#4f78a5')
await expect(page.locator('.admin-user-role')).toHaveText('超级管理员')
})
test('test_task_249_shell_light_normal_variant_input', async ({ page }) => {
await openAdmin(page)
await expect(page.locator('.admin-user-logout')).toBeVisible()
await expect(page.locator('.admin-kicker')).toHaveText('运营管理控制台')
})
test('test_task_249_shell_light_boundary_empty_input', async ({ page }) => {
// 未知路由仍进 404,不因换皮破坏。
await page.goto('/admin-vue/definitely/not/exists')
await expect(page.locator('.not-found')).toContainText('404')
})
test('test_task_249_shell_light_invalid_input_rejected', async ({ page }) => {
// 代表页跳转整段无未捕获脚本错误。
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openAdmin(page)
await page.goto('/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理')
await page.goto('/admin-vue/account/groups')
await expect(page.locator('main h2')).toHaveText('数据权限分组')
expect(errors).toEqual([])
})
@@ -9,7 +9,7 @@ test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) =
await openAdmin(page)
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await expect(page.locator('.admin-user-meta strong')).toHaveText('admin')
await expect(page.locator('.admin-user-meta span')).toHaveText('super_admin')
await expect(page.locator('.admin-user-role')).toHaveText('超级管理员')
await expect(page.getByText('账号权限', { exact: true }).first()).toBeVisible()
})
@@ -66,10 +66,10 @@ test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }
test('test_task_020_shell_browser_smoke_dependency_failure_returns_actionable_message', async ({ page }) => {
await openAdmin(page)
// 取消确认后无遗留弹层/遮罩;继续导航正常。
// 取消确认后无“可见”的遮罩/弹层残留(隐藏的对话框 DOM 不算);继续导航正常。
await page.getByRole('button', { name: '退出登录' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
await expect(page.locator('.el-overlay')).toHaveCount(0)
await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
await page.locator('.admin-brand-copy, .admin-topbar h1').first().click({ force: true })
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
})
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="数富AI后台管理系统" />
<title>数富AI - 后台管理</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+1 -1
View File
@@ -14,7 +14,7 @@ function redirectToLogin(requestUrl?: string): void {
// 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。
if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return
const target = loginRedirectTarget(window.location)
window.location.assign(`/login?redirect=${target}`)
window.location.assign('/admin-vue/login?redirect=' + target)
}
http.interceptors.response.use(
+1 -1
View File
@@ -1,7 +1,7 @@
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
import type { AdminUser } from '../types/admin'
export const USER_PAGE_DEFAULT_SIZE = 15
export const USER_PAGE_DEFAULT_SIZE = 10
export const USER_PAGE_MIN_PAGE = 1
export const USER_PAGE_MAX_SIZE = 200
+20 -1
View File
@@ -1,4 +1,4 @@
/** 用户列表查询适配(任务 42):纯解析模块,无 axios 依赖,与 Java AdminUserListVo 对齐。 */
/** 用户 API 响应解析模型:列表分页(任务 42)与新建用户(任务 46)负载归一。 */
import { unwrap } from './envelope.ts'
import { emptyUserPageResult, type UserPageResult } from './users-dto.ts'
import type { AdminUser } from '../types/admin'
@@ -50,5 +50,24 @@ export function parseUserPage(payload: unknown): UserPageResult {
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
if (typeof record.current_user_id === 'number') out.currentUserId = record.current_user_id
if (typeof record.current_user_username === 'string') out.currentUserUsername = record.current_user_username
if (Array.isArray(record.admins)) {
out.admins = record.admins
.map((raw) => {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const id = numberOrNull(r.id)
if (id === null) return null
return { id, username: text(r.username) }
})
.filter((a): a is { id: number; username: string } => a !== null)
}
return out
}
/** 解包新建用户响应并取回新用户 idPOST /api/admin/user 的 data 为 userId)。 */
export function parseCreatedUserId(payload: unknown): number {
const data = unwrap<unknown>(payload)
const id = typeof data === 'number' && Number.isFinite(data) ? Math.floor(data) : null
if (id === null || id < 1) throw new Error('创建用户响应异常:未返回有效的用户 ID')
return id
}
+7
View File
@@ -1,4 +1,5 @@
import { http } from './http'
import { unwrap } from './envelope'
import { parseUserPage } from './users-model'
import {
normalizeUserPageParams,
@@ -13,3 +14,9 @@ export async function fetchUserList(params: Partial<UserListParams> = {}): Promi
const { data } = await http.get('/api/admin/users', { params: toUserListQuery(normalized) })
return parseUserPage(data)
}
/** 删除用户:DELETE /api/admin/user/{uid}success=false 由 unwrap 抛后端 message。 */
export async function deleteUser(uid: number): Promise<void> {
const { data } = await http.delete<unknown>(`/api/admin/user/${uid}`)
unwrap<unknown>(data)
}
@@ -0,0 +1,16 @@
<script lang="ts">
import { defineComponent } from 'vue'
import { openAdminConfirm } from './admin-confirm'
/**
* AdminConfirm 共享确认组件(module 13 task 245):renderless 单例承载。
* 使用:<AdminConfirm ref="confirmRef" />,需确认时 await confirmRef.open({...})。
*/
export default defineComponent({
name: 'AdminConfirm',
setup(_props, { expose }) {
expose({ open: openAdminConfirm })
return () => null
},
})
</script>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { copyFeedback, isCopySupported } from './copy-model'
const props = defineProps<{ text: string }>()
async function handleCopy(): Promise<void> {
if (!props.text || !isCopySupported()) {
ElMessage.warning(copyFeedback(''))
return
}
try {
await navigator.clipboard.writeText(props.text)
ElMessage.success(copyFeedback(props.text))
} catch {
ElMessage.error(copyFeedback(''))
}
}
</script>
<template>
<span class="copy-text" role="button" tabindex="0" :title="text" @click="handleCopy"><slot>{{ text }}</slot></span>
</template>
<style scoped>
.copy-text { cursor: pointer; }
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { dialogTitleOf, drawerLayoutSpec } from './modal-model'
/**
* DrawerShell 右侧抽屉容器(module 13 task 248):封装 el-drawer,统一 admin 尺寸
* 与关闭语义(遮罩/Esc),标题透传默认头部、footer 槽承接底部按钮,构成头/体/脚三段。
*/
const props = withDefaults(
defineProps<{ modelValue: boolean; title?: string; subtitle?: string; width?: string | number }>(),
{ title: '', subtitle: '', width: drawerLayoutSpec().width },
)
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
const spec = computed(() => drawerLayoutSpec())
const head = computed(() => dialogTitleOf({ title: props.title, subtitle: props.subtitle }))
</script>
<template>
<el-drawer
v-model="visible"
:size="width"
:title="head.title"
:close-on-click-modal="spec.maskClosable"
:close-on-press-escape="spec.escClosable"
class="admin-drawer-shell"
>
<p v-if="head.subtitle" class="admin-drawer-subtitle">{{ head.subtitle }}</p>
<slot />
<template #footer>
<slot name="footer" />
</template>
</el-drawer>
</template>
<style scoped>
.admin-drawer-subtitle { margin: 0 0 12px; color: var(--admin-muted); font-size: 13px; }
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { secretPlaceholder } from '@/pages/shop/secret-mask'
/**
* MaskReveal 掩码揭示(module 13 task 247):默认以店铺 secret-mask 掩码占位展示,
* 点击切换时向父组件发 reveal 事件取明文(以 value prop 回填);短时后自动收回。
*/
const props = withDefaults(defineProps<{ value?: string; revealMs?: number }>(), {
value: '',
revealMs: 5000,
})
const emit = defineEmits<{ (e: 'reveal'): void }>()
const revealed = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function clearTimer(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
}
function show(): void {
revealed.value = true
emit('reveal')
clearTimer()
timer = setTimeout(() => {
revealed.value = false
}, props.revealMs)
}
function hide(): void {
clearTimer()
revealed.value = false
}
function toggle(): void {
if (revealed.value) hide()
else show()
}
watch(
() => props.value,
(v) => {
if (!v) revealed.value = false
},
)
onBeforeUnmount(clearTimer)
</script>
<template>
<span class="mask-reveal">
<span class="mask-reveal-text">{{ revealed && value ? value : secretPlaceholder() }}</span>
<button
type="button"
class="mask-reveal-toggle"
:aria-label="revealed ? '隐藏' : '显示'"
@click="toggle"
>
{{ revealed ? '隐藏' : '显示' }}
</button>
</span>
</template>
<style scoped>
.mask-reveal { display: inline-flex; align-items: center; gap: 6px; }
.mask-reveal-text { font-variant-numeric: tabular-nums; }
.mask-reveal-toggle {
border: 1px solid #cbd9e6;
border-radius: 6px;
background: #fff;
color: #5b6f83;
font-size: 12px;
padding: 1px 8px;
cursor: pointer;
}
.mask-reveal-toggle:hover { color: #2f5d8b; border-color: #95b1cb; background: #edf5fb; }
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { dialogTitleOf, modalLayoutSpec } from './modal-model'
/**
* ModalShell 居中模态容器(module 13 task 248):封装 el-dialog,统一 admin 尺寸/圆角
* 与关闭语义,标题透传给 Element 默认头部;body 用默认槽、操作区走 footer 槽。
*/
const props = withDefaults(
defineProps<{ modelValue: boolean; title?: string; subtitle?: string; width?: string | number }>(),
{ title: '', subtitle: '', width: modalLayoutSpec().width },
)
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
const spec = computed(() => modalLayoutSpec())
const head = computed(() => dialogTitleOf({ title: props.title, subtitle: props.subtitle }))
</script>
<template>
<el-dialog
v-model="visible"
:width="width"
:title="head.title"
:close-on-click-modal="spec.maskClosable"
:close-on-press-escape="spec.escClosable"
class="admin-modal-shell"
>
<p v-if="head.subtitle" class="admin-modal-subtitle">{{ head.subtitle }}</p>
<slot />
<template #footer>
<slot name="footer" />
</template>
</el-dialog>
</template>
<style scoped>
.admin-modal-subtitle { margin: 0 0 12px; color: var(--admin-muted); font-size: 13px; }
</style>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { pillToneStyle, type PillTone } from './status-pill-model'
const props = withDefaults(defineProps<{ text: string; tone?: PillTone }>(), { tone: 'info' })
const style = computed(() => pillToneStyle(props.tone))
</script>
<template>
<span class="status-pill" :style="{ color: style.text, background: style.bg }">{{ text }}</span>
</template>
<style scoped>
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 62px;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
line-height: 1.7;
white-space: nowrap;
}
</style>
@@ -0,0 +1,68 @@
/**
* 二次确认模型(module 13 task 245):文案/按钮键规则对齐 admin-interactions.js ——
* 优先显式 message,否则由行名拼接「确认删除“X”吗?此操作可能影响已有数据。」;
* danger(删除/清空/删除导入类)默认标题「删除前确认」、确认键「确认删除」。
* 纯逻辑、无 UI 依赖,便于单测;打开入口见 admin-confirm.ts / AdminConfirm.vue。
*/
export const ADMIN_CONFIRM = {
dangerTitle: '删除前确认',
defaultTitle: '请确认操作',
confirmDeleteOk: '确认删除',
confirmOk: '确认操作',
cancel: '取消',
deleteMessageSuffix: '此操作可能影响已有数据。',
} as const
export interface AdminConfirmPlan {
danger: boolean
title: string
message: string
confirmText: string
cancelText: string
type: 'warning' | 'info'
}
/** 由目标名称生成删除确认文案;无名称时退化为「该项」。 */
export function confirmMessageFor(targetName?: string): string {
const name = typeof targetName === 'string' && targetName.trim() ? `${targetName.trim()}` : '该项'
return `确认删除${name}吗?${ADMIN_CONFIRM.deleteMessageSuffix}`
}
export interface AdminConfirmOptions {
danger?: boolean
targetName?: string
message?: string
title?: string
confirmText?: string
}
/** 归一出二次确认计划:显式字段优先,否则按 danger/目标名落默认值。 */
export function createConfirmPlan(opts: AdminConfirmOptions): AdminConfirmPlan {
const danger = opts.danger !== false
const message = opts.message && opts.message.trim() ? opts.message.trim() : confirmMessageFor(opts.targetName)
return {
danger,
title: opts.title ?? (danger ? ADMIN_CONFIRM.dangerTitle : ADMIN_CONFIRM.defaultTitle),
message,
confirmText: opts.confirmText ?? (danger ? ADMIN_CONFIRM.confirmDeleteOk : ADMIN_CONFIRM.confirmOk),
cancelText: ADMIN_CONFIRM.cancel,
type: danger ? 'warning' : 'info',
}
}
// 并发防护:同一时刻只允许一个确认框在途(拒绝重复弹出)。
let confirmLocked = false
export function acquireConfirmLock(): boolean {
if (confirmLocked) return false
confirmLocked = true
return true
}
export function releaseConfirmLock(): void {
confirmLocked = false
}
export function isConfirmLocked(): boolean {
return confirmLocked
}
@@ -0,0 +1,28 @@
/**
* 二次确认打开入口(module 13 task 245):把模型计划交给 ElMessageBox,并对
* 确认/取消统一归约为 boolean;并发锁拒绝重复弹出。
*/
import { ElMessageBox } from 'element-plus'
import {
acquireConfirmLock,
createConfirmPlan,
releaseConfirmLock,
type AdminConfirmOptions,
} from './admin-confirm-model'
export async function openAdminConfirm(options: AdminConfirmOptions = {}): Promise<boolean> {
const plan = createConfirmPlan(options)
if (!acquireConfirmLock()) return false
try {
return await ElMessageBox.confirm(plan.message, plan.title, {
confirmButtonText: plan.confirmText,
cancelButtonText: plan.cancelText,
type: plan.type,
}).then(
() => true,
() => false,
)
} finally {
releaseConfirmLock()
}
}
@@ -0,0 +1,28 @@
/**
* 反馈 UI 接线(module 13 task 246):把模型语义接到 ElMessage 与 busy 锁上。
* 页面统一用 showAdminFeedback / runBusy,避免各页 ElMessage 直接散打文案。
*/
import { ElMessage } from 'element-plus'
import {
endBusy,
normalizeFeedback,
tryBeginBusy,
type AdminFeedbackKind,
} from './admin-feedback'
export function showAdminFeedback(message?: string, kind?: AdminFeedbackKind): void {
const { kind: k, message: m } = normalizeFeedback(message, kind)
if (k === 'success') ElMessage.success(m)
else if (k === 'error') ElMessage.error(m)
else ElMessage.warning(m)
}
/** 以 busy 锁包裹一个提交动作;已在途则直接返回 undefined,防止重复提交。 */
export async function runBusy<T>(task: () => Promise<T>): Promise<T | undefined> {
if (!tryBeginBusy()) return undefined
try {
return await task()
} finally {
endBusy()
}
}
@@ -0,0 +1,60 @@
/**
* 反馈语义模型(module 13 task 246):成功/错误/警告统一文案与 busy 防重入。
* 纯逻辑、不依赖 ElementUI 接线见 admin-feedback-ui.ts。
*/
export type AdminFeedbackKind = 'success' | 'error' | 'warning'
export const FEEDBACK_TEXT = {
successDefault: '操作成功',
errorDefault: '操作失败,请稍后重试',
warningDefault: '请确认后再继续',
busySuffix: '中…',
} as const
export interface NormalizedFeedback {
kind: AdminFeedbackKind
message: string
}
const KIND_DEFAULT: Record<AdminFeedbackKind, string> = {
success: FEEDBACK_TEXT.successDefault,
error: FEEDBACK_TEXT.errorDefault,
warning: FEEDBACK_TEXT.warningDefault,
}
/** 归一反饋消息:显式 message 保留并去首尾空白,空值按 kind 落默认文案。 */
export function normalizeFeedback(message?: string, kind?: AdminFeedbackKind): NormalizedFeedback {
const k: AdminFeedbackKind = kind ?? 'warning'
const trimmed = typeof message === 'string' ? message.trim() : ''
return { kind: k, message: trimmed ? trimmed : KIND_DEFAULT[k] }
}
/** 忙碌按钮文案:「保存」→「保存中…」;已以「中」结尾则不重复补。 */
export function busyLabel(verb: string): string {
const v = (verb || '处理').trim()
return v.endsWith('中') ? `${v}` : `${v}${FEEDBACK_TEXT.busySuffix}`
}
/** 把 Error/字符串/未知错误转成可行动错误文案;空值回默认错误。 */
export function actionableErrorText(error: unknown): string {
if (error instanceof Error && error.message && error.message.trim()) return error.message.trim()
if (typeof error === 'string' && error.trim()) return error.trim()
return FEEDBACK_TEXT.errorDefault
}
// busy 单例锁:表单提交/导出等防重复进入。
let busy = false
export function tryBeginBusy(): boolean {
if (busy) return false
busy = true
return true
}
export function endBusy(): void {
busy = false
}
export function isBusy(): boolean {
return busy
}
@@ -0,0 +1,13 @@
/**
* 复制文本反馈模型(module 13 task 247):非空给出「已复制 X」,失败/空值给降级文案。
*/
export const COPY_FEEDBACK_FALLBACK = '复制失败,请手动选择'
export function copyFeedback(text: string): string {
const t = typeof text === 'string' ? text.trim() : ''
return t ? `已复制 ${t}` : COPY_FEEDBACK_FALLBACK
}
export function isCopySupported(): boolean {
return typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'
}
@@ -0,0 +1,30 @@
/**
* 模态/抽屉布局模型(module 13 task 248):统一两种承载容器的尺寸与关闭语义,
* 对齐 admin.html —— 居中 modal width min(920px,100vw-48)、radius14;右侧 drawer
* width min(460px,100%);遮罩点击与 Esc 均关闭。页面接入统一尺寸与头/脚槽。
*/
export const ADMIN_MODAL_WIDTH = 'min(920px, 100vw - 48px)'
export const ADMIN_DRAWER_WIDTH = 'min(460px, 100%)'
export const ADMIN_MODAL_RADIUS = 14
export interface AdminContainerSpec {
width: string
radius?: number
maskClosable: boolean
escClosable: boolean
}
export function modalLayoutSpec(): AdminContainerSpec {
return { width: ADMIN_MODAL_WIDTH, radius: ADMIN_MODAL_RADIUS, maskClosable: true, escClosable: true }
}
export function drawerLayoutSpec(): AdminContainerSpec {
return { width: ADMIN_DRAWER_WIDTH, maskClosable: true, escClosable: true }
}
/** 页面头标题归一:空/空白标题兜底「操作」,副标题去空白后无则省略。 */
export function dialogTitleOf(opts: { title?: string; subtitle?: string }): { title: string; subtitle?: string } {
const title = opts.title?.trim()
const subtitle = opts.subtitle?.trim()
return { title: title || '操作', ...(subtitle ? { subtitle } : {}) }
}
@@ -0,0 +1,37 @@
/**
* 状态药丸配色/分类模型(module 13 task 247):统一 admin.html 的三态语义色
* success 绿 / danger 红 / warning 琥珀 / info 蓝 / primary 深蓝),供视频任务、
* 店铺数据任务、数字人版本等页面复用;未知文本回落 info。
*/
export type PillTone = 'success' | 'danger' | 'warning' | 'info' | 'primary'
export interface PillToneStyle {
text: string
bg: string
}
export const PILL_TONES: Record<PillTone, PillToneStyle> = {
success: { text: '#4e806d', bg: '#e6f6ec' },
danger: { text: '#b35f6a', bg: '#fdecec' },
warning: { text: '#a8793e', bg: '#fdf0dc' },
info: { text: '#4f78a5', bg: '#e7f0f8' },
primary: { text: '#2f5d8b', bg: '#e6f0f8' },
}
/** 取某 tone 的配色;未知 tone 回退 info(不抛错)。 */
export function pillToneStyle(tone: PillTone): PillToneStyle {
return PILL_TONES[tone] ?? PILL_TONES.info
}
const SUCCESS_PATTERN = /success|succeed|已发布|发布|成功|正常|可用/
const DANGER_PATTERN = /fail|error|已废弃|失败|不可用|异常/
const WARNING_PATTERN = /running|pending|waiting|进行|处理中|草稿|待执行|待/
/** 由状态文本(中英文)归类语义 tone。 */
export function statusTone(raw: string): PillTone {
const s = (raw || '').toLowerCase()
if (SUCCESS_PATTERN.test(s)) return 'success'
if (DANGER_PATTERN.test(s)) return 'danger'
if (WARNING_PATTERN.test(s)) return 'warning'
return 'info'
}
+12 -35
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
@@ -12,7 +12,6 @@ import {
shouldShowEmptyMenu,
} from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
import { initialCollapsed, toggleCollapsed, writeCollapsePreference } from '@/layout/sidebar-collapse'
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
import {
LOGOUT_CONFIRM_CANCEL,
@@ -25,22 +24,6 @@ import {
const route = useRoute()
const router = useRouter()
const session = useAdminSessionStore()
const collapsed = ref(readInitialCollapse())
function readInitialCollapse(): boolean {
const storage = typeof window !== 'undefined' ? window.localStorage : null
const width = typeof window !== 'undefined' ? window.innerWidth : 0
return initialCollapsed(width, storage)
}
function setCollapsed(value: boolean): void {
collapsed.value = value
if (typeof window !== 'undefined') writeCollapsePreference(window.localStorage, value)
}
function toggleSidebar(): void {
setCollapsed(toggleCollapsed(collapsed.value))
}
const activePath = computed(() => route.path)
const menuEntries = computed(() => toSidebarEntries(session.menuTree))
@@ -78,19 +61,19 @@ async function signOut() {
</script>
<template>
<div class="admin-shell" :class="{ 'is-collapsed': collapsed }">
<div class="admin-shell">
<a class="skip-link" href="#admin-content">跳到主内容</a>
<aside class="admin-sidebar" role="navigation" aria-label="侧边栏菜单">
<div class="admin-brand">
<img :src="logoUrl" alt="数富AI" class="admin-brand-logo" />
<div v-if="!collapsed" class="admin-brand-copy">
<div class="admin-brand-copy">
<strong>数富AI</strong>
<span>电商运营管理后台</span>
</div>
</div>
<el-scrollbar class="admin-menu-scroll">
<el-menu :default-active="activePath" :default-openeds="openedKeys" :collapse="collapsed" router class="admin-menu">
<el-menu :default-active="activePath" :default-openeds="openedKeys" router class="admin-menu">
<template v-for="entry in menuEntries" :key="entry.key">
<el-sub-menu v-if="entry.kind === 'group'" :index="entry.key">
<template #title>
@@ -107,30 +90,20 @@ async function signOut() {
</el-menu>
<el-empty v-if="emptyMenu" :description="EMPTY_MENU_TITLE" :image-size="72" />
</el-scrollbar>
<button
class="sidebar-collapse"
type="button"
:aria-expanded="!collapsed"
aria-label="切换侧边栏"
@click="toggleSidebar"
>
{{ collapsed ? '展开菜单' : '收起菜单' }}
</button>
</aside>
<section class="admin-main">
<header class="admin-topbar">
<div>
<div class="admin-kicker">运营管理控制台</div>
<div class="admin-topbar-title">
<span class="admin-kicker">运营管理控制台</span>
<h1>{{ pageTitle }}</h1>
</div>
<div class="admin-user">
<div class="admin-user-meta">
<strong>{{ userVm.username }}</strong>
<span v-if="userVm.role">{{ userVm.role }}</span>
</div>
<el-button text type="danger" @click="signOut">退出登录</el-button>
<span v-if="userVm.role" class="admin-user-role">{{ userVm.role }}</span>
<el-button class="admin-user-logout" @click="signOut">退出登录</el-button>
</div>
</header>
<main id="admin-content" class="admin-content">
@@ -150,3 +123,7 @@ async function signOut() {
</section>
</div>
</template>
<style scoped>
.admin-topbar-title { min-width: 0; }
</style>
@@ -1,4 +1,5 @@
import type { AdminUser } from '../types/admin'
import type { AdminUser } from '../types/admin.ts'
import { roleLabel } from '../types/admin.ts'
/** 无 meta.title 时的壳层缺省标题。 */
export const ADMIN_DEFAULT_TITLE = '管理后台'
@@ -18,5 +19,5 @@ export interface TopbarUserViewModel {
/** 顶部栏用户模型:用户信息或未登录空态统一为可渲染视图模型。 */
export function topbarUserOf(user: AdminUser | null | undefined): TopbarUserViewModel {
if (!user) return { username: '当前用户', role: '', hasUser: false }
return { username: user.username || '当前用户', role: user.role || '', hasUser: true }
return { username: user.username || '当前用户', role: roleLabel(user.role) || user.role || '', hasUser: true }
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import App from './App.vue'
import router from './router'
// 全局中文语言包:日期控件(周/月/年份)、分页组件(上一页/下一页)等均显示中文。
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { submitCreateUser } from './user-create-adapter'
import {
createEmptyCreateUserForm,
type CreateUserForm,
type CreateUserFormErrors,
validateCreateUserForm,
} from './user-create-model'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import UserMenuAuthTree from './UserMenuAuthTree.vue'
const props = defineProps<{
modelValue: boolean
operatorSuper?: boolean
/** 所属管理员候选(列表响应 admins;仅超管创建普通账号时展示)。 */
adminOptions?: Array<{ id: number; username: string }>
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'created'): void }>()
const form = reactive<CreateUserForm>(createEmptyCreateUserForm())
const errors = reactive<CreateUserFormErrors>({})
const busy = ref(false)
/** 角色下拉(对齐 admin.html:普通账号/管理员;超管才显示可改)。 */
const ROLE_CHOICES: Array<{ value: 'admin' | 'normal'; label: string }> = [
{ value: 'normal', label: '普通账号' },
{ value: 'admin', label: '管理员' },
]
watch(
() => props.modelValue,
(open) => {
if (!open) return
form.username = ''
form.password = ''
form.role = 'normal'
form.columnIds = []
form.createdBy = null
errors.username = undefined
errors.password = undefined
},
)
function close(): void {
emit('update:modelValue', false)
}
async function create(): Promise<void> {
const { valid, errors: errs } = validateCreateUserForm(form)
Object.assign(errors, errs)
if (!valid) return
busy.value = true
try {
await submitCreateUser(form)
showAdminFeedback('创建成功', 'success')
// 对齐 admin:成功后弹窗不关,仅清空用户名/密码并刷新列表。
form.username = ''
form.password = ''
errors.username = undefined
errors.password = undefined
emit('created')
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="创建用户"
width="640px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="用户名" :error="errors.username">
<el-input v-model="form.username" autocomplete="username" placeholder="用户名至少 2 个字符" />
</el-form-item>
<el-form-item label="密码" :error="errors.password">
<el-input
v-model="form.password"
type="password"
show-password
autocomplete="new-password"
placeholder="密码至少 6 个字符"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
<el-select v-model="form.role" style="width: 200px">
<el-option v-for="opt in ROLE_CHOICES" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item v-if="operatorSuper && form.role === 'normal'" label="所属管理员">
<el-select v-model="form.createdBy" clearable filterable placeholder="请选择管理员" style="width: 200px">
<el-option
v-for="admin in props.adminOptions || []"
:key="admin.id"
:label="admin.username"
:value="admin.id"
/>
</el-select>
</el-form-item>
<el-form-item label="菜单权限勾选上级菜单会继承全部子菜单">
<div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="create">创建用户</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.auth-tree-box {
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 9px;
padding: 8px;
}
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import type { AdminUser } from '@/types/admin'
import { fillEditUserForm, validateEditUserForm, type EditUserForm, type EditUserFormErrors } from './user-edit-model'
import { submitEditUser } from './user-edit-adapter'
import { loadUserMenuAuth } from './user-menu-auth-api'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import UserMenuAuthTree from './UserMenuAuthTree.vue'
const props = defineProps<{ modelValue: boolean; user: AdminUser | null; operatorSuper?: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'updated'): void }>()
const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role: 'normal', columnIds: [], originalRole: '' })
const errors = reactive<EditUserFormErrors>({})
const busy = ref(false)
const loadingAuth = ref(false)
watch(
() => props.modelValue,
(open) => {
if (!open || !props.user) return
const base = fillEditUserForm(props.user)
form.uid = base.uid
form.username = base.username
form.password = ''
form.role = base.role
form.columnIds = []
form.originalRole = base.originalRole
errors.password = undefined
void refreshAuth()
},
)
async function refreshAuth(): Promise<void> {
if (!props.user) return
loadingAuth.value = true
try {
const { checkedIds } = await loadUserMenuAuth(props.user.id)
form.columnIds = checkedIds
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
loadingAuth.value = false
}
}
function close(): void {
emit('update:modelValue', false)
}
async function save(): Promise<void> {
const { valid, errors: errs } = validateEditUserForm(form)
Object.assign(errors, errs)
if (!valid) return
busy.value = true
try {
await submitEditUser(form)
showAdminFeedback('更新成功', 'success')
emit('updated')
close()
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑用户"
width="640px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="用户名">
<el-input :model-value="form.username" readonly />
</el-form-item>
<el-form-item label="新密码不修改可留空" :error="errors.password">
<el-input
v-model="form.password"
type="password"
show-password
autocomplete="new-password"
placeholder="留空则不修改密码"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
<el-radio-group v-model="form.role">
<el-radio-button value="normal">普通账号</el-radio-button>
<el-radio-button value="admin">管理员</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="props.user && props.user.creatorUsername" label="所属管理员">
<el-input :model-value="props.user.creatorUsername" readonly />
</el-form-item>
<el-form-item label="菜单权限勾选上级菜单会继承全部子菜单">
<div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" />
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.auth-tree-box {
position: relative;
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 9px;
padding: 8px;
}
.auth-loading {
margin-top: 6px;
font-size: 12px;
color: var(--admin-muted);
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { AdminUser } from '@/types/admin'
import {
eligibleGroupMembers,
groupMemberCandidateHint,
validateShopGroupForm,
type ShopGroupItem,
} from './shop-group-model'
import { createShopGroup, updateShopGroup } from './shop-group-api'
import { fetchUserList } from '@/api/users'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import { useAdminSessionStore } from '@/stores/admin-session'
const props = defineProps<{ modelValue: boolean; group: ShopGroupItem | null; operatorSuper?: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
const session = useAdminSessionStore()
const groupName = ref('')
const memberIds = ref<number[]>([])
const errorText = ref('')
const busy = ref(false)
const users = ref<AdminUser[]>([])
/** 组长:新建=当前登录用户;编辑=分组原组长(不可改,只读展示)。 */
const leaderUserId = computed<number | null>(() =>
props.group ? (props.group.leaderUserId ?? null) : (session.user?.id ?? null),
)
const leaderUsername = computed<string>(() =>
props.group ? (props.group.leaderUsername || '') : (session.user?.username || '当前登录用户'),
)
const operatorRole = computed<string>(() => session.user?.role || '')
/** 组员候选:按组长归属过滤(超管全量、非超管仅本组长创建的普通账号)。 */
const memberCandidates = computed(() =>
eligibleGroupMembers(users.value, leaderUserId.value, operatorRole.value),
)
const candidateHint = computed(() => groupMemberCandidateHint(memberCandidates.value.length, operatorRole.value))
watch(
() => props.modelValue,
(open) => {
if (!open) return
groupName.value = ''
memberIds.value = []
errorText.value = ''
if (props.group) {
groupName.value = props.group.name
memberIds.value = props.group.memberUserIds ?? []
}
},
)
async function loadCandidates(): Promise<void> {
try {
// 对齐 admin.js loadUserOptionspage=1&page_size=999 全量拉取,避免归属过滤漏人。
const page = await fetchUserList({ page: 1, pageSize: 999 })
users.value = page.items
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
}
}
function close(): void {
emit('update:modelValue', false)
}
async function save(): Promise<void> {
const errs = validateShopGroupForm(groupName.value)
errorText.value = errs.groupName || ''
if (errorText.value) return
busy.value = true
try {
if (props.group) {
await updateShopGroup(props.group.id, groupName.value, memberIds.value)
showAdminFeedback('更新成功', 'success')
} else {
// 对齐 admin:组长恒为当前登录用户,提交 createdBy=当前用户 id。
const operatorId = session.user?.id ?? 0
if (!operatorId) {
showAdminFeedback('无法获取当前登录用户,请重新登录', 'error')
return
}
await createShopGroup(groupName.value, memberIds.value, operatorId)
showAdminFeedback('创建成功', 'success')
}
emit('saved')
close()
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
onMounted(loadCandidates)
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="group ? '编辑分组' : '新建分组'"
width="600px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组名称" :error="errorText || undefined">
<el-input v-model="groupName" placeholder="请输入分组名称" />
</el-form-item>
<el-form-item label="组长">
<el-input :model-value="leaderUsername" readonly placeholder="当前登录用户" />
</el-form-item>
<el-form-item label="组员">
<el-select
v-model="memberIds"
multiple
filterable
collapse-tags
placeholder="选择组员普通账号"
style="width: 100%"
>
<el-option v-for="u in memberCandidates" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
<div class="candidate-hint">{{ candidateHint }}(共 {{ memberCandidates.length }} 名)</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.candidate-hint {
margin-top: 6px;
font-size: 12px;
color: var(--admin-muted);
}
</style>
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { openAdminConfirm } from '@/components/admin-confirm'
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
import { shopGroupSummary, type ShopGroupItem } from './shop-group-model'
import GroupEditorDialog from './GroupEditorDialog.vue'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<ShopGroupItem[]>([])
const editorVisible = ref(false)
const editingGroup = ref<ShopGroupItem | null>(null)
const summary = computed(() => shopGroupSummary(rows.value))
const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
// 客户端分页:全量拉取后按页切片展示。
const page = ref(1)
const pageSize = ref(10)
const total = computed(() => rows.value.length)
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changePage(p: number) {
page.value = p
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
}
function canEditOf(group: ShopGroupItem): boolean {
if (isSuperAdmin.value) return true
return currentUserId.value != null && group.leaderUserId != null && currentUserId.value === group.leaderUserId
}
async function loadGroups() {
loading.value = true
try {
rows.value = await fetchShopGroups()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据权限分组加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingGroup.value = null
editorVisible.value = true
}
function openEdit(group: ShopGroupItem) {
if (!canEditOf(group)) return
editingGroup.value = group
editorVisible.value = true
}
async function removeGroup(group: ShopGroupItem) {
const ok = await openAdminConfirm({ message: `确定删除分组“${group.name}”吗?`, danger: true })
if (!ok) return
try {
await deleteShopGroup(group.id)
ElMessage.success('删除成功')
await loadGroups()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
onMounted(loadGroups)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>数据权限分组</h2>
<p>管理店铺数据访问分组和组员范围组长不可更改组员为普通账号</p>
</div>
</div>
<el-row :gutter="12" style="margin-bottom: 12px">
<el-col :span="8">
<el-card shadow="never">
<div class="stat-block">
<span class="stat-label">分组总数</span>
<span class="stat-value">{{ summary.groupCount }}</span>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<div class="stat-block">
<span class="stat-label">组员总数</span>
<span class="stat-value">{{ summary.memberTotal }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新建分组</el-button>
</div>
<el-table v-loading="loading" :data="pagedRows" stripe>
<el-table-column prop="name" label="分组名称" min-width="180" />
<el-table-column prop="leaderUsername" label="组长" min-width="170" />
<el-table-column prop="memberCount" label="组员数量" min-width="100" />
<el-table-column label="组员" min-width="240">
<template #default="{ row }">
<template v-if="row.memberUsernames.length">
<el-tag v-for="name in row.memberUsernames" :key="name" size="small" class="member-tag">{{ name }}</el-tag>
</template>
<span v-else class="member-none"></span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!canEditOf(row)" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" :disabled="!canEditOf(row)" @click="removeGroup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="changePage" @size-change="changeSize" />
</div>
</el-card>
<GroupEditorDialog
v-model="editorVisible"
:group="editingGroup"
:operator-super="isSuperAdmin"
@saved="loadGroups"
/>
</div>
</template>
<style scoped>
.stat-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
}
.member-tag {
margin: 2px 4px 2px 0;
}
.member-more {
font-size: 12px;
color: var(--admin-muted);
}
.member-none {
color: var(--admin-muted);
font-size: 13px;
}
</style>
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
import {
MENU_TYPE_OPTIONS,
createMenuForm,
flattenMenuNodes,
menuDeleteReason,
menuFormFromNode,
menuParentCandidates,
menuParentChangeReason,
menuSiblingGroupKey,
menuTypeLabel,
nextSiblingSort,
siblingOrderAfterDrop,
validateMenuManageForm,
type MenuManageForm,
type MenuManageNode,
} from './menu-manage-model'
const loading = ref(false)
const rows = ref<MenuManageNode[]>([])
// 客户端分页:列表展示统一带分页组件。
const page = ref(1)
const pageSize = ref(10)
const total = computed(() => rows.value.length)
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changePage(p: number) {
page.value = p
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
}
const createVisible = ref(false)
const saving = ref(false)
const editingNode = ref<MenuManageNode | null>(null)
const menuForm = reactive<MenuManageForm>(createMenuForm(null, 0))
const parentOptions = computed(() => menuParentCandidates(rows.value, menuForm.menuType))
const dialogTitle = computed(() => (editingNode.value ? '编辑菜单' : '新增菜单'))
/** 节点 id -> 名称,供「上级菜单」列展示。 */
const nameById = computed(() => {
const map = new Map<number, string>()
for (const node of flattenMenuNodes(rows.value)) map.set(node.id, node.name)
return map
})
/** 拖拽状态:同"菜单类型+父级"分组内才可落位(对齐 admin.js columnDragState)。 */
const dragState = ref<{ id: number; groupKey: string; menuType: string } | null>(null)
function parentNameOf(node: MenuManageNode): string {
if (node.parentId == null) return '-'
return nameById.value.get(node.parentId) ?? `#${node.parentId}`
}
async function loadMenus() {
loading.value = true
try {
rows.value = await loadMenuManageTree()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '菜单列表加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingNode.value = null
Object.assign(menuForm, createMenuForm(null, nextSiblingSort(rows.value, null)))
createVisible.value = true
}
function openEdit(node: MenuManageNode) {
editingNode.value = node
Object.assign(menuForm, menuFormFromNode(node))
createVisible.value = true
}
/** 菜单类型切换时:当前父菜单不属于新类型则重置为根。 */
function onMenuTypeChange(): void {
const candidates = menuParentCandidates(rows.value, menuForm.menuType)
if (menuForm.parentId != null && !candidates.some((node) => node.id === menuForm.parentId)) {
menuForm.parentId = null
}
}
function onDragStart(event: DragEvent, node: MenuManageNode): void {
dragState.value = { id: node.id, groupKey: menuSiblingGroupKey(node), menuType: node.menuType }
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
try {
event.dataTransfer.setData('text/plain', String(node.id))
} catch {
// 某些浏览器 setData 受限,忽略即可。
}
}
}
function onDragEnd(): void {
dragState.value = null
}
function onDragOver(event: DragEvent, node: MenuManageNode): void {
const state = dragState.value
if (!state || state.groupKey !== menuSiblingGroupKey(node) || state.id === node.id) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
async function onDrop(event: DragEvent, target: MenuManageNode): Promise<void> {
const state = dragState.value
dragState.value = null
if (!state || state.groupKey !== menuSiblingGroupKey(target) || state.id === target.id) return
event.preventDefault()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const placeAfter = event.clientY - rect.top > rect.height / 2
const groupIds = flattenMenuNodes(rows.value)
.filter((node) => menuSiblingGroupKey(node) === state.groupKey)
.map((node) => node.id)
const next = siblingOrderAfterDrop(groupIds, state.id, target.id, placeAfter)
if (next.join(',') === groupIds.join(',')) return
try {
await reorderMenus(state.menuType, next)
ElMessage.success('排序已保存')
await loadMenus()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '排序保存失败')
}
}
async function saveMenu() {
if (editingNode.value) {
const reason = menuParentChangeReason(editingNode.value, menuForm.parentId)
if (reason) {
ElMessage.warning(reason)
return
}
}
const { valid, errors } = validateMenuManageForm(menuForm)
if (!valid) {
ElMessage.warning(Object.values(errors)[0])
return
}
saving.value = true
try {
if (editingNode.value) {
await updateMenu(editingNode.value.id, menuForm)
ElMessage.success('保存成功')
} else {
await createMenu(menuForm)
ElMessage.success('创建成功')
}
createVisible.value = false
await loadMenus()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function removeMenu(node: MenuManageNode) {
const reason = menuDeleteReason(node)
if (reason) {
ElMessage.warning(reason)
return
}
try {
await ElMessageBox.confirm(`确定删除菜单“${node.name}”吗?`, '删除确认', { type: 'warning' })
await deleteMenu(node.id)
ElMessage.success('删除成功')
await loadMenus()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
}
onMounted(loadMenus)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>菜单管理</h2>
<p>维护后台菜单树和页面路由权限粒度仅到菜单/页面</p>
</div>
</div>
<el-card shadow="never">
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序松开后自动保存</p>
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增菜单</el-button>
</div>
<el-table v-loading="loading" :data="pagedRows" row-key="id" default-expand-all stripe>
<el-table-column label="排序" min-width="64">
<template #default="{ row }">
<span
class="menu-drag-handle"
draggable="true"
role="button"
:title="`拖动调整“${(row as MenuManageNode).name}”的顺序`"
@dragstart="onDragStart($event, row as MenuManageNode)"
@dragend="onDragEnd"
@dragover="onDragOver($event, row as MenuManageNode)"
@drop="onDrop($event, row as MenuManageNode)"
></span>
</template>
</el-table-column>
<el-table-column prop="name" label="菜单名称" min-width="180" />
<el-table-column label="菜单类型" min-width="110">
<template #default="{ row }">
<el-tag size="small" :type="(row as MenuManageNode).menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel((row as MenuManageNode).menuType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="上级菜单" min-width="120">
<template #default="{ row }">
{{ parentNameOf(row as MenuManageNode) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="150">
<template #default="{ row }">{{ formatDateTime((row as MenuManageNode).createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as MenuManageNode)">编辑</el-button>
<el-button link type="danger" :disabled="menuDeleteReason(row as MenuManageNode) !== undefined" @click="removeMenu(row as MenuManageNode)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination
background
layout="sizes, prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="page"
@current-change="changePage"
@size-change="changeSize"
/>
</div>
</el-card>
<el-dialog v-model="createVisible" :title="dialogTitle" width="520px">
<el-form label-width="96px">
<el-form-item label="菜单名称">
<el-input v-model="menuForm.name" placeholder="如:用户管理" />
</el-form-item>
<el-form-item label="菜单类型">
<el-select v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="父菜单">
<el-select v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
<el-option
v-for="node in parentOptions"
:key="node.id"
:label="node.name"
:value="node.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="menuForm.sortOrder" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveMenu">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.menu-drag-tip {
margin: 0 0 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.menu-drag-handle {
display: inline-block;
cursor: grab;
color: var(--el-text-color-secondary);
font-size: 15px;
line-height: 1;
padding: 4px 6px;
border-radius: 4px;
user-select: none;
}
.menu-drag-handle:hover {
background: var(--el-fill-color-light);
color: var(--el-text-color-primary);
}
.menu-drag-handle:active {
cursor: grabbing;
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import type { MenuOptionNode } from './user-menu-auth'
import { fetchGrantableMenus } from './user-menu-auth-api'
const props = defineProps<{ checked?: number[] }>()
const emit = defineEmits<{
(e: 'update:checked', ids: number[]): void
(e: 'load', ok: boolean): void
}>()
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown }
const tree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
const data = ref<MenuOptionNode[]>([])
const loaded = ref(false)
function applyChecked(): void {
const el: TreeRef | null = tree.value
if (!el) return
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
el.setCheckedKeys(ids)
}
function onCheck(): void {
const el: TreeRef | null = tree.value
if (!el) return
const keys = el.getCheckedKeys()
const ids = (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
emit('update:checked', ids)
}
async function load(): Promise<void> {
try {
data.value = await fetchGrantableMenus()
loaded.value = true
applyChecked()
emit('load', true)
} catch {
loaded.value = false
emit('load', false)
}
}
onMounted(load)
watch(
() => props.checked,
() => {
if (loaded.value) applyChecked()
},
)
</script>
<template>
<el-scrollbar max-height="300px" class="user-menu-tree-scroll">
<el-tree
ref="tree"
:data="data"
node-key="id"
show-checkbox
check-strictly
default-expand-all
:props="{ label: 'name', children: 'children' }"
@check="onCheck"
/>
</el-scrollbar>
</template>
@@ -1,32 +1,97 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/api/http'
import type { AdminUser } from '@/types/admin'
import { fetchUserList } from '@/api/users'
import { roleLabel } from '@/types/admin'
import { useAdminSessionStore } from '@/stores/admin-session'
import { deleteUser, fetchUserList } from '@/api/users'
import { createUserFilterState, toUserListParams, type UserFilterState } from './users-filter'
import { totalPageCount } from './user-pagination'
import { deleteBlockReason, deleteConfirmMessage } from './user-delete-model'
import { listPhaseOf, userListEmptyHint } from './user-list-state'
import { isEditableUser } from './user-edit-model'
import CreateUserDialog from './CreateUserDialog.vue'
import EditUserDialog from './EditUserDialog.vue'
const session = useAdminSessionStore()
const loading = ref(false)
const loadError = ref('')
const rows = ref<AdminUser[]>([])
const total = ref(0)
const form = reactive({ username: '', page: 1, pageSize: 15 })
/** 所属管理员候选(来自列表响应的 admins),供超管筛选下拉使用。 */
const admins = ref<Array<{ id: number; username: string }>>([])
const filters = reactive<UserFilterState>(createUserFilterState())
const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
const createVisible = ref(false)
const editVisible = ref(false)
const editUser = ref<AdminUser | null>(null)
function openCreate(): void {
createVisible.value = true
}
function openEdit(row: AdminUser): void {
if (!isEditableUser(row.role)) return
editUser.value = row
editVisible.value = true
}
function editableOf(row: AdminUser): boolean {
return isEditableUser(row.role)
}
async function loadUsers() {
loading.value = true
loadError.value = ''
try {
const page = await fetchUserList({ page: form.page, pageSize: form.pageSize, keyword: form.username || undefined })
const page = await fetchUserList(toUserListParams(filters))
rows.value = page.items
total.value = page.total
if (Array.isArray(page.admins)) admins.value = page.admins
// 数据被删/筛选后总页数收缩:页码越界时回钳到末页并重新加载一次。
const last = totalPageCount(page.total, filters.pageSize)
if (last > 0 && filters.page > last) {
filters.page = last
const retried = await fetchUserList(toUserListParams(filters))
rows.value = retried.items
total.value = retried.total
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '用户列表加载失败')
loadError.value = error instanceof Error ? error.message : '用户列表加载失败'
ElMessage.error(loadError.value)
} finally {
loading.value = false
}
}
/** 错误态重试:重新拉取当前筛选列表。 */
function retry() {
void loadUsers()
}
/** 提交新筛选条件:重置回第一页再查询。 */
function search() {
filters.page = 1
void loadUsers()
}
async function removeUser(row: AdminUser) {
const reason = deleteBlockReason(row, currentUserId.value)
if (reason) {
ElMessage.warning(reason)
return
}
try {
await ElMessageBox.confirm(`确定删除用户“${row.username}”吗?`, '删除确认', { type: 'warning' })
await http.delete(`/api/admin/user/${row.id}`)
await ElMessageBox.confirm(deleteConfirmMessage(row), '删除用户', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteUser(row.id)
ElMessage.success('删除成功')
await loadUsers()
} catch (error) {
@@ -35,7 +100,13 @@ async function removeUser(row: AdminUser) {
}
function changePage(page: number) {
form.page = page
filters.page = page
void loadUsers()
}
function changeSize(size: number) {
filters.pageSize = size
filters.page = 1
void loadUsers()
}
@@ -47,30 +118,74 @@ onMounted(loadUsers)
<div class="page-heading">
<div>
<h2>用户管理</h2>
<p>管理后台账号角色和创建关系</p>
<p>当前没有开放注册入口仅管理员可在此创建用户层级关系超级管理员 -&gt; 管理员 -&gt; 普通账号</p>
</div>
<el-button type="primary">新建用户</el-button>
</div>
<el-card shadow="never">
<el-form inline @submit.prevent="loadUsers">
<el-form-item label="用户名">
<el-input v-model="form.username" clearable placeholder="模糊搜索" @keyup.enter="loadUsers" />
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="search">
<el-form-item label="用户名(模糊搜索)">
<el-input v-model="filters.keyword" clearable placeholder="输入用户名关键字" @keyup.enter="search" />
</el-form-item>
<el-form-item v-if="isSuperAdmin" label="所属管理员">
<el-select
v-model="filters.createdByAdminId"
clearable
filterable
placeholder="全部"
style="width: 200px"
@change="search"
>
<el-option
v-for="admin in admins"
:key="admin.id"
:label="admin.username"
:value="admin.id"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadUsers">查询</el-button>
<el-button type="primary" @click="search">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table v-loading="loading" :data="rows" stripe>
<el-table-column prop="id" label="ID" width="90" />
<el-alert
v-if="statePhase === 'error' && loadError"
:title="loadError"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
>
<template #default>
<el-button link type="primary" @click="retry">重试</el-button>
</template>
</el-alert>
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe :empty-text="userListEmptyHint()">
<el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column prop="role" label="角色" width="140" />
<el-table-column prop="creatorUsername" label="所属管理员" width="160" />
<el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="120" fixed="right">
<el-table-column label="角色" min-width="140">
<template #default="{ row }">
<el-button link type="danger" @click="removeUser(row)">删除</el-button>
<span>{{ roleLabel(row.role) }}</span>
</template>
</el-table-column>
<el-table-column prop="creatorUsername" label="所属管理员" min-width="160" />
<el-table-column prop="createdAt" label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!editableOf(row)" @click="openEdit(row)">编辑</el-button>
<el-button
link
type="danger"
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
@click="removeUser(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -78,15 +193,23 @@ onMounted(loadUsers)
<span> {{ total }} </span>
<el-pagination
background
layout="prev, pager, next"
:page-size="form.pageSize"
:current-page="form.page"
layout="sizes, prev, pager, next, jumper"
:page-size="filters.pageSize"
:current-page="filters.page"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="changePage"
@size-change="changeSize"
/>
</div>
</el-card>
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" />
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
</div>
</template>
<style scoped>
.filter-card :deep(.el-form-item) {
margin-bottom: 0;
}
</style>
@@ -0,0 +1,52 @@
/** 菜单管理列表加载适配(任务 54):GET /api/admin/permission-menus(admin) → 展示树。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
buildMenuManageTree,
parseMenuManageItem,
parseMenuManageList,
toMenuCreateRequest,
toMenuUpdateRequest,
type MenuManageForm,
type MenuManageNode,
} from './menu-manage-model'
export const MENUS_ENDPOINT = '/api/admin/permission-menus'
export const REORDER_ENDPOINT = '/api/admin/column/reorder'
/** 拉取扁平可编辑菜单节点(全量类型,对齐 admin.js loadColumns 的 /api/admin/columns 语义)。 */
export async function fetchMenuManageNodes(): Promise<MenuManageNode[]> {
const { data } = await http.get<unknown>(MENUS_ENDPOINT)
return parseMenuManageList(data)
}
/** 拉取并按 parentId 组装成展示树。 */
export async function loadMenuManageTree(): Promise<MenuManageNode[]> {
return buildMenuManageTree(await fetchMenuManageNodes())
}
/** 新建菜单:POST /api/admin/permission-menus,返回后端落库节点。 */
export async function createMenu(form: MenuManageForm): Promise<MenuManageNode> {
const { data } = await http.post<unknown>(MENUS_ENDPOINT, toMenuCreateRequest(form))
const created = parseMenuManageItem(unwrap<unknown>(data))
if (!created) throw new Error('创建菜单响应异常:缺少菜单数据')
return created
}
/** 编辑菜单:PUT /api/admin/permission-menus/{id}success=false 由 unwrap 抛后端 message。 */
export async function updateMenu(id: number, form: MenuManageForm): Promise<void> {
const { data } = await http.put<unknown>(`${MENUS_ENDPOINT}/${id}`, toMenuUpdateRequest(form))
unwrap<unknown>(data)
}
/** 删除菜单:DELETE /api/admin/permission-menus/{id}success=false 由 unwrap 抛后端 message。 */
export async function deleteMenu(id: number): Promise<void> {
const { data } = await http.delete<unknown>(`${MENUS_ENDPOINT}/${id}`)
unwrap<unknown>(data)
}
/** 同级菜单排序:POST /api/admin/column/reorder 提交 { menu_type, ordered_ids }(对齐 admin.js persistColumnOrder)。 */
export async function reorderMenus(menuType: string, orderedIds: number[]): Promise<void> {
const { data } = await http.post<unknown>(REORDER_ENDPOINT, { menu_type: menuType, ordered_ids: orderedIds })
unwrap<unknown>(data)
}
@@ -0,0 +1,288 @@
/** 菜单管理 DTO/模型(任务 53):扁平 PermissionMenuItemVo → 可编辑节点 + 创建/更新请求体。 */
import { unwrap } from '../../api/envelope.ts'
/** 后台 Vue 控制台菜单类型。 */
export const ADMIN_MENU_TYPE = 'admin'
export interface MenuManageNode {
id: number
name: string
/** column_key,稳定权限标识。 */
columnKey: string
parentId: number | null
rootColumnKey?: string
menuType: string
routePath: string
sortOrder: number
createdAt?: string
children?: MenuManageNode[]
}
/** 菜单类型展示文案(对齐 admin.js:6824admin → 后台,其余一律 → 软件)。 */
export function menuTypeLabel(type: string | null | undefined): string {
return (type || '').toLowerCase() === 'admin' ? '后台' : '软件'
}
/** 菜单类型下拉选项(对齐 admin.html:5931-5934 后台/软件)。 */
export const MENU_TYPE_OPTIONS: Array<{ value: 'admin' | 'app'; label: string }> = [
{ value: 'admin', label: '后台' },
{ value: 'app', label: '软件' },
]
export interface MenuManageForm {
name: string
columnKey: string
parentId: number | null
menuType: string
routePath: string
sortOrder: number
}
/** 请求体字段与 Java PermissionMenuCreate/UpdateRequest 对齐(camelCase)。
* 权限标识/路由由后台自动生成,不随表单提交,故为可选字段。 */
export interface MenuManagePayload {
name: string
columnKey?: string
parentId: number | null
menuType: string
routePath?: string
sortOrder: number
}
export interface MenuManageFormErrors {
name?: string
columnKey?: string
menuType?: string
routePath?: string
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/** 排序归一:非负整数,非法回 0。 */
export function normalizeMenuSort(value: unknown): number {
const n = numberOrNull(value)
return n !== null && n >= 0 ? n : 0
}
/** 把单条 PermissionMenuItemVo(snake_case) 归一为可编辑节点;缺 id 视为无效。 */
export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const parentId = numberOrNull(record.parent_id)
const node: MenuManageNode = {
id,
name: text(record.name),
columnKey: text(record.column_key),
parentId: parentId === null ? null : parentId,
menuType: text(record.menu_type) || ADMIN_MENU_TYPE,
routePath: text(record.route_path),
sortOrder: normalizeMenuSort(record.sort_order),
}
const rootColumnKey = text(record.root_column_key)
if (rootColumnKey) node.rootColumnKey = rootColumnKey
const createdAt = text(record.created_at)
if (createdAt) node.createdAt = createdAt
return node
}
/** 归一化 permission-menus 响应(信封或裸数组)为可编辑节点列表。 */
export function parseMenuManageList(payload: unknown): MenuManageNode[] {
const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return []
return data
.map((raw) => parseMenuManageItem(raw))
.filter((node): node is MenuManageNode => node !== null)
}
/** 按 parentId 组装展示树;同级按 (sortOrder,name) 升序,父缺失按根处理。 */
export function buildMenuManageTree(items: MenuManageNode[]): MenuManageNode[] {
const nodes = new Map<number, MenuManageNode>()
for (const item of items) {
nodes.set(item.id, { ...item, children: [] })
}
const roots: MenuManageNode[] = []
for (const item of items) {
const node = nodes.get(item.id)
if (!node) continue
const parent = item.parentId === null ? undefined : nodes.get(item.parentId)
if (parent) {
parent.children!.push(node)
} else {
roots.push(node)
}
}
const order = (a: MenuManageNode, b: MenuManageNode) =>
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)
const sortRecursive = (nodes: MenuManageNode[]) => {
nodes.sort(order)
for (const node of nodes) node.children?.length && sortRecursive(node.children)
}
sortRecursive(roots)
for (const item of items) {
const node = nodes.get(item.id)
if (node && !node.children?.length) delete node.children
}
return roots
}
/** 展平树为列表(前序),供父级下拉/统计复用。 */
export function flattenMenuNodes(nodes: MenuManageNode[]): MenuManageNode[] {
const out: MenuManageNode[] = []
const walk = (list: MenuManageNode[]) => {
for (const node of list) {
out.push(node)
if (node.children?.length) walk(node.children)
}
}
walk(nodes)
return out
}
/** 同级建议排序:该父下最大 sortOrder + 1;无同级返回 0。 */
export function nextSiblingSort(items: MenuManageNode[], parentId: number | null): number {
let max = -1
for (const item of items) {
if (item.parentId === parentId && item.sortOrder > max) max = item.sortOrder
}
return max + 1
}
/** 新建菜单表单工厂:指定父节点与建议排序,其余默认(menuType=admin)。 */
export function createMenuForm(parentId: number | null, sortOrder: number): MenuManageForm {
return {
...emptyMenuManageForm(),
parentId: typeof parentId === 'number' ? parentId : null,
sortOrder: normalizeMenuSort(sortOrder),
}
}
/** 编辑表单回填:从可编辑节点还原表单字段。 */
export function menuFormFromNode(node: MenuManageNode): MenuManageForm {
return {
name: node.name,
columnKey: node.columnKey,
parentId: node.parentId,
menuType: node.menuType,
routePath: node.routePath,
sortOrder: node.sortOrder,
}
}
/** 编辑时父节点防环校验:不可选自身或其子孙为父;返回可操作原因,否则 undefined。 */
export function menuParentChangeReason(node: MenuManageNode, parentId: number | null): string | undefined {
if (parentId === null || parentId === undefined) return undefined
if (parentId === node.id) return '不能选择自身作为父菜单'
const descendants = flattenMenuNodes(node.children ?? [])
if (descendants.some((item) => item.id === parentId)) return '不能选择自己的子菜单作为父菜单'
return undefined
}
/** 删除前置校验(镜像后端:仅叶子可删,避免孤儿层级);可删返回 undefined。 */
export function menuDeleteReason(node: MenuManageNode | null | undefined): string | undefined {
const children = node?.children
if (Array.isArray(children) && children.length > 0) return '请先删除该菜单下的子菜单'
return undefined
}
/** 同级拖拽/上下移:把 targetId 移动 dir 位(±1),返回新的同级 id 顺序;越界/缺失则原序。 */
export function siblingOrderAfterMove(group: number[], targetId: number, dir: number): number[] {
if (!Array.isArray(group) || group.length === 0) return []
const next = [...group]
const idx = next.indexOf(targetId)
if (idx < 0) return next
const to = idx + (dir === -1 || dir === 1 ? dir : 0)
if (to < 0 || to >= next.length) return next
const [moved] = next.splice(idx, 1)
next.splice(to, 0, moved)
return next
}
/** 拖拽分组键:同"菜单类型 + 父级"才算同级(对齐 admin.js columnSiblingGroupKey)。 */
export function menuSiblingGroupKey(node: MenuManageNode): string {
return `${node.menuType || 'app'}|${node.parentId ?? ''}`
}
/** 拖放排序:把 draggedId 插到 targetId 前/后,返回新的同级 id 顺序;任一 id 缺失或同 id 则原序。 */
export function siblingOrderAfterDrop(
group: number[],
draggedId: number,
targetId: number,
placeAfter: boolean,
): number[] {
if (!Array.isArray(group) || group.length === 0) return []
if (draggedId === targetId) return [...group]
const next = [...group]
const from = next.indexOf(draggedId)
if (from < 0) return next
const target = next.indexOf(targetId)
if (target < 0) return next
next.splice(from, 1)
const insertAt = next.indexOf(targetId) + (placeAfter ? 1 : 0)
next.splice(insertAt, 0, draggedId)
return next
}
/** 父菜单候选:仅同菜单类型的节点(对齐 admin 的类型联动父级下拉)。 */
export function menuParentCandidates(nodes: MenuManageNode[], menuType: string): MenuManageNode[] {
const type = (menuType || '').trim()
return flattenMenuNodes(nodes).filter((node) => (node.menuType || '') === type)
}
/** 同级重排请求体(对齐 admin.js persistColumnOrder{ menu_type, ordered_ids })。 */
export function toMenuReorderRequest(
menuType: string,
orderedIds: number[],
): { menu_type: string; ordered_ids: number[] } {
return { menu_type: text(menuType) || 'app', ordered_ids: dedupeOrderIds(orderedIds) }
}
function dedupeOrderIds(ids: unknown[]): number[] {
if (!Array.isArray(ids)) return []
const set = new Set<number>()
for (const v of ids) {
const n = numberOrNull(v)
if (n !== null && n > 0) set.add(n)
}
return [...set]
}
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
export function emptyMenuManageForm(): MenuManageForm {
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
}
/** 表单校验(镜像后端 NotBlank 文案);返回逐字段错误。权限标识/路由由后台自动生成,不参与校验。 */
export function validateMenuManageForm(form: MenuManageForm): { valid: boolean; errors: MenuManageFormErrors } {
const errors: MenuManageFormErrors = {}
if (!text(form.name)) errors.name = '菜单名称不能为空'
if (!text(form.menuType)) errors.menuType = '菜单类型不能为空'
return { valid: Object.keys(errors).length === 0, errors }
}
function toMenuManagePayload(form: MenuManageForm): MenuManagePayload {
// 权限标识(column_key)/路由(route_path) 不随表单提交:由后台自动生成(创建)或保持原值(编辑)。
return {
name: text(form.name),
parentId: typeof form.parentId === 'number' ? form.parentId : null,
menuType: text(form.menuType),
sortOrder: normalizeMenuSort(form.sortOrder),
}
}
/** 表单 → POST /api/admin/permission-menus 请求体。 */
export function toMenuCreateRequest(form: MenuManageForm): MenuManagePayload {
return toMenuManagePayload(form)
}
/** 表单 → PUT /api/admin/permission-menus/{id} 请求体。 */
export function toMenuUpdateRequest(form: MenuManageForm): MenuManagePayload {
return toMenuManagePayload(form)
}
@@ -0,0 +1,39 @@
/** 数据权限分组 API 适配(任务 59module 13 task 253 扩展 CRUD)。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
parseShopGroupItem,
parseShopGroupList,
toShopGroupCreatePayload,
toShopGroupUpdatePayload,
type ShopGroupItem,
} from './shop-group-model'
export const SHOP_GROUPS_ENDPOINT = '/api/admin/shop-manages/groups'
export async function fetchShopGroups(): Promise<ShopGroupItem[]> {
const { data } = await http.get<unknown>(SHOP_GROUPS_ENDPOINT)
return parseShopGroupList(data)
}
/** 新建分组:POST /api/admin/shop-manages/groups(创建人 = 组长 = 当前操作者)。 */
export async function createShopGroup(name: string, memberUserIds: number[], createdById: number): Promise<ShopGroupItem> {
const { data } = await http.post<unknown>(SHOP_GROUPS_ENDPOINT, toShopGroupCreatePayload(name, memberUserIds, createdById))
const created = parseShopGroupItem(unwrap<unknown>(data))
if (!created) throw new Error('创建分组响应异常:缺少分组数据')
return created
}
/** 更新分组:PUT /api/admin/shop-manages/groups/{id}。 */
export async function updateShopGroup(id: number, name: string, memberUserIds: number[]): Promise<ShopGroupItem> {
const { data } = await http.put<unknown>(`${SHOP_GROUPS_ENDPOINT}/${id}`, toShopGroupUpdatePayload(name, memberUserIds))
const updated = parseShopGroupItem(unwrap<unknown>(data))
if (!updated) throw new Error('更新分组响应异常:缺少分组数据')
return updated
}
/** 删除分组:DELETE /api/admin/shop-manages/groups/{id}。 */
export async function deleteShopGroup(id: number): Promise<void> {
const { data } = await http.delete<unknown>(`${SHOP_GROUPS_ENDPOINT}/${id}`)
unwrap<unknown>(data)
}
@@ -0,0 +1,142 @@
/** 数据权限分组列表模型(任务 59module 13 task 253 扩展编辑能力):解析 VO + 摘要 + 提交载荷。 */
import { unwrap } from '../../api/envelope.ts'
export interface ShopGroupItem {
id: number
name: string
leaderUsername: string
leaderUserId?: number
memberCount: number
memberUsernames: string[]
memberUserIds?: number[]
createdAt?: string
updatedAt?: string
}
export interface ShopGroupFormErrors {
groupName?: string
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 解析单条分组项;缺 id 视为无效。 */
export function parseShopGroupItem(raw: unknown): ShopGroupItem | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const names = Array.isArray(record.memberUsernames)
? record.memberUsernames.map((v) => text(v)).filter(Boolean)
: []
const memberIds = Array.isArray(record.memberUserIds)
? record.memberUserIds.map((v) => numberOrNull(v)).filter((v): v is number => v !== null && v > 0)
: []
const count = numberOrNull(record.memberCount)
const item: ShopGroupItem = {
id,
name: text(record.groupName ?? record.name) || `分组${id}`,
leaderUsername: text(record.leaderUsername ?? record.leader_name),
memberCount: count === null ? names.length : count,
memberUsernames: names,
}
const leaderId = numberOrNull(record.leaderUserId ?? record.leader_user_id)
if (leaderId !== null && leaderId > 0) item.leaderUserId = leaderId
if (memberIds.length) item.memberUserIds = memberIds
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item
}
/** 归一化分组列表响应(信封或裸数组)。 */
export function parseShopGroupList(payload: unknown): ShopGroupItem[] {
const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return []
return data
.map((raw) => parseShopGroupItem(raw))
.filter((item): item is ShopGroupItem => item !== null)
}
/** 分组摘要:分组总数 + 组员总数。 */
export function shopGroupSummary(groups: ShopGroupItem[]): { groupCount: number; memberTotal: number } {
let memberTotal = 0
for (const group of groups) memberTotal += group.memberCount || 0
return { groupCount: groups.length, memberTotal }
}
/** 分组名必填校验(镜像后端 NotBlank 文案「请输入分组名称」)。 */
export function validateShopGroupForm(name: string): ShopGroupFormErrors {
const errors: ShopGroupFormErrors = {}
if (!text(name)) errors.groupName = '请输入分组名称'
return errors
}
/** 创建载荷:groupName + 成员 + 创建人(= 组长,当前操作者)。 */
export function toShopGroupCreatePayload(
name: string,
memberUserIds: number[],
createdById: number,
): { groupName: string; memberUserIds: number[]; createdById: number } {
return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds), createdById }
}
/** 更新载荷:groupName + 成员(组长/创建人不可改)。 */
export function toShopGroupUpdatePayload(name: string, memberUserIds: number[]): { groupName: string; memberUserIds: number[] } {
return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds) }
}
/** 组员候选用户(对齐 admin.js getEligibleShopManageGroupUsers 的过滤语义)。 */
export interface GroupMemberCandidateUser {
id: number
username: string
role: string
createdById?: number | null
}
/**
* 组员候选过滤(对齐 admin.js:3020-3028):
* - 组长本人永不进候选;
* - 超管可选全部用户(不论角色/归属);
* - 非超管仅可选"组长(创建人)名下"的普通账号。
*/
export function eligibleGroupMembers(
users: GroupMemberCandidateUser[],
leaderUserId: number | null,
operatorRole: string,
): GroupMemberCandidateUser[] {
const leaderKey = leaderUserId != null && leaderUserId > 0 ? String(leaderUserId) : ''
const isSuper = (operatorRole || '').toLowerCase() === 'super_admin'
return users.filter((u) => {
if (String(u.id) === leaderKey) return false
if (isSuper) return true
return (
(u.role || '').toLowerCase() === 'normal' &&
String(u.createdById ?? '') === leaderKey
)
})
}
/** 组员候选帮助文案(对齐 admin.js:3179-3182)。 */
export function groupMemberCandidateHint(candidateCount: number, operatorRole: string): string {
if (candidateCount > 0) return '可添加当前组长创建的普通员工账号'
return (operatorRole || '').toLowerCase() === 'normal'
? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。'
: '当前组长名下暂无可添加的普通员工账号。'
}
function dedupeIds(ids: unknown[]): number[] {
if (!Array.isArray(ids)) return []
const set = new Set<number>()
for (const v of ids) {
const id = numberOrNull(v)
if (id !== null && id > 0) set.add(id)
}
return [...set].sort((a, b) => a - b)
}
@@ -0,0 +1,24 @@
/** 新建用户请求适配(任务 46):表单模型 → POST /api/admin/user → 新用户 id。 */
import { http } from '@/api/http'
import { parseCreatedUserId } from '@/api/users-model'
import {
toCreateUserRequest,
validateCreateUserForm,
type CreateUserForm,
} from './user-create-model'
/** Java AdminUserController.createUser 端点。 */
export const CREATE_USER_ENDPOINT = '/api/admin/user'
/**
* 提交新建用户:先校验表单(不通过直接抛首条错误,不发请求),再序列化请求体 POST,
* 最后解析返回的新用户 id;http/后端失败交由调用方提示,不吞异常。
*/
export async function submitCreateUser(form: CreateUserForm): Promise<number> {
const { valid, errors } = validateCreateUserForm(form)
if (!valid) {
throw new Error(Object.values(errors)[0] || '创建用户表单校验未通过')
}
const { data } = await http.post<unknown>(CREATE_USER_ENDPOINT, toCreateUserRequest(form))
return parseCreatedUserId(data)
}
@@ -0,0 +1,74 @@
/** 新建用户表单模型(任务 45):纯逻辑,镜像 Java AdminUserCreateRequest 字段与后端校验。 */
export const CREATE_USERNAME_MIN_LEN = 2
export const CREATE_PASSWORD_MIN_LEN = 6
/** 新建用户可选角色:super_admin 不可由表单创建(后端只收 admin|normal)。 */
export type CreatableUserRole = 'normal' | 'admin'
export interface CreateUserForm {
username: string
password: string
role: CreatableUserRole
/** 授权菜单 id(后端 replaceDirectPermissions 用)。 */
columnIds: number[]
/** 所属管理员 id(超管创建普通账号时可指定,对齐 admin 的 created_by_id)。 */
createdBy?: number | null
}
export interface CreateUserFormErrors {
username?: string
password?: string
}
/** 发送 POST /api/admin/user 的请求体(与 Java AdminUserCreateRequest camelCase 对齐)。 */
export interface CreateUserPayload {
username: string
password: string
role: string
columnIds: number[]
/** 仅 role=normal 且选了所属管理员时发送,与参考 admin.js 的 created_by_id 语义一致。 */
createdById?: number
}
export function createEmptyCreateUserForm(): CreateUserForm {
return { username: '', password: '', role: 'normal', columnIds: [], createdBy: null }
}
/** 未知/空白/超管角色一律归一到 normal,与后端 "admin"/"normal" 白名单一致。 */
export function normalizeCreateRole(role: string | null | undefined): CreatableUserRole {
return (role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'normal'
}
/** 表单校验(镜像后端:用户名≥2、密码≥6、均必填);返回逐字段错误。 */
export function validateCreateUserForm(form: CreateUserForm): { valid: boolean; errors: CreateUserFormErrors } {
const errors: CreateUserFormErrors = {}
const username = (form.username || '').trim()
if (!username) {
errors.username = '用户名不能为空'
} else if (username.length < CREATE_USERNAME_MIN_LEN) {
errors.username = `用户名至少${CREATE_USERNAME_MIN_LEN}个字符`
}
const password = form.password || ''
if (!password) {
errors.password = '密码不能为空'
} else if (password.length < CREATE_PASSWORD_MIN_LEN) {
errors.password = `密码至少${CREATE_PASSWORD_MIN_LEN}个字符`
}
return { valid: Object.keys(errors).length === 0, errors }
}
/** 表单 → 创建请求体:用户名去空格、角色归一、菜单 id 拷贝不共享引用;
* createdById 仅在 role=normal 且选了所属管理员时携带(对齐 admin.js 的 created_by_id 语义)。 */
export function toCreateUserRequest(form: CreateUserForm): CreateUserPayload {
const payload: CreateUserPayload = {
username: (form.username || '').trim(),
password: form.password || '',
role: normalizeCreateRole(form.role),
columnIds: Array.isArray(form.columnIds) ? form.columnIds.slice() : [],
}
if (payload.role === 'normal' && form.createdBy != null) {
payload.createdById = form.createdBy
}
return payload
}
@@ -0,0 +1,24 @@
/** 用户删除确认模型(任务 51):删除确认文案 + 不可删原因,镜像 Java deleteUser 约束。 */
import type { AdminUser } from '../../types/admin'
/** 删除二次确认文案(对齐 admin.js:970 原文:半角引号、无补充句)。 */
export function deleteConfirmMessage(user: AdminUser | null | undefined): string {
const username = user?.username || ''
return `确定删除用户 "${username}" 吗?`
}
/** 返回不可删除原因;可删除时返回 undefined。 */
export function deleteBlockReason(
user: AdminUser | null | undefined,
currentUserId?: number | null,
): string | undefined {
if (!user) return '用户信息缺失,无法删除'
if (currentUserId != null && user.id === currentUserId) return '不能删除当前登录账号'
if ((user.role || '').trim().toLowerCase() === 'super_admin') return '不能删除超级管理员'
return undefined
}
/** 该用户当前是否允许删除(非缺失/非自己/非超管)。 */
export function isDeletableUser(user: AdminUser | null | undefined, currentUserId?: number | null): boolean {
return deleteBlockReason(user, currentUserId) === undefined
}
@@ -0,0 +1,23 @@
/** 编辑用户请求适配(module 13 task 251):表单模型 → PUT /api/admin/user/{uid}。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
toEditUserUpdateRequest,
validateEditUserForm,
type EditUserForm,
} from './user-edit-model'
/** Java AdminUserController.updateUser 端点前缀。 */
export const EDIT_USER_ENDPOINT = '/api/admin/user'
/**
* 提交编辑用户:先校验(不通过抛首条错误),再序列化 PUT;http/后端失败交由调用方提示。
*/
export async function submitEditUser(form: EditUserForm): Promise<void> {
const { valid, errors } = validateEditUserForm(form)
if (!valid) {
throw new Error(Object.values(errors)[0] || '编辑用户表单校验未通过')
}
const { data } = await http.put<unknown>(`${EDIT_USER_ENDPOINT}/${form.uid}`, toEditUserUpdateRequest(form))
unwrap<unknown>(data)
}
@@ -0,0 +1,79 @@
/** 编辑用户表单回填模型(任务 47):从用户行回填可编辑表单,镜像 Java AdminUserUpdateRequest。 */
import type { AdminUser } from '../../types/admin'
import { CREATE_PASSWORD_MIN_LEN } from './user-create-model.ts'
/** 编辑可选角色:超管不可改,未知归 normal;后端只收 admin|normal。 */
export type EditUserRole = 'normal' | 'admin'
export interface EditUserForm {
uid: number
/** 仅展示用,后端不支持改用户名。 */
username: string
/** 空串 = 不修改密码。 */
password: string
role: EditUserRole
/** 授权菜单 id;由授权数据加载后填充。 */
columnIds: number[]
/** 后端原角色字符串,用于超管禁止编辑判定。 */
originalRole: string
}
export interface EditUserFormErrors {
password?: string
}
/** 序列化到 PUT /api/admin/user/{uid} 的请求体(Java AdminUserUpdateRequest,空密码不下发)。 */
export interface EditUserUpdateRequest {
role: EditUserRole
columnIds: number[]
password?: string
}
/** 行角色 → 可编辑角色:仅 admin 映射 admin,其余(含 unknown)归 normal。 */
export function editRoleOf(role: string | null | undefined): EditUserRole {
return (role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'normal'
}
/** 该角色是否允许被编辑:超级管理员不可编辑(镜像后端“不能修改超级管理员”)。 */
export function isEditableUser(role: string | null | undefined): boolean {
return (role || '').trim().toLowerCase() !== 'super_admin'
}
/** 从用户行回填编辑表单:用户名只读、密码留空、columnIds 待授权加载填充。 */
export function fillEditUserForm(user: AdminUser): EditUserForm {
const role = typeof user.role === 'string' ? user.role.trim() : ''
return {
uid: user.id,
username: user.username || '',
password: '',
role: editRoleOf(role),
columnIds: [],
originalRole: role,
}
}
/** 编辑表单校验:密码可留空(不改),非空时须达到最小长度。 */
export function validateEditUserForm(form: EditUserForm): { valid: boolean; errors: EditUserFormErrors } {
const errors: EditUserFormErrors = {}
const password = form.password || ''
if (password && password.length < CREATE_PASSWORD_MIN_LEN) {
errors.password = `密码至少${CREATE_PASSWORD_MIN_LEN}个字符,留空则不修改`
}
return { valid: Object.keys(errors).length === 0, errors }
}
/** 编辑表单 → 更新请求体:超管拒绝、空密码不下发、columnIds 拷贝不共享引用。 */
export function toEditUserUpdateRequest(form: EditUserForm): EditUserUpdateRequest {
const originalRole = (form.originalRole || '').trim().toLowerCase()
if (originalRole === 'super_admin') {
throw new Error('不能修改超级管理员')
}
const role: EditUserRole = form.role === 'admin' ? 'admin' : 'normal'
const request: EditUserUpdateRequest = {
role,
columnIds: Array.isArray(form.columnIds) ? form.columnIds.slice() : [],
}
const password = form.password || ''
if (password) request.password = password
return request
}
@@ -0,0 +1,31 @@
/** 用户列表加载/空/错状态(任务 52):纯 phase 归一无 UI 依赖,供页面展示决策复用。 */
export type ListPhase = 'loading' | 'error' | 'empty' | 'ready'
export interface UserListPhaseInput {
loading?: boolean
error?: string
items?: unknown[]
total?: number
}
/** 归一列表展示相位:loading 优先,其次 error,其次空,否则 ready。 */
export function listPhaseOf(input: UserListPhaseInput): ListPhase {
if (input.loading) return 'loading'
if (typeof input.error === 'string' && input.error.trim()) return 'error'
const items = Array.isArray(input.items) ? input.items : []
const total = typeof input.total === 'number' ? input.total : items.length
if (items.length === 0 || total === 0) return 'empty'
return 'ready'
}
/** 空态提示文案。 */
export function userListEmptyHint(): string {
return '暂无用户数据'
}
/** 错误态可操作提示:带上具体原因,缺省给通用文案。 */
export function userListErrorHint(message?: string | null): string {
const text = (message || '').trim()
return text ? `加载失败:${text}` : '加载失败,请稍后重试'
}
@@ -0,0 +1,48 @@
/** 用户菜单授权数据加载适配(任务 49):拉取可授权菜单树 + 用户已授权 id。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
ADMIN_MENU_TYPE,
buildMenuOptionTree,
buildUserColumnPermissionPayload,
parseMenuOptionList,
parseUserGrantedColumnIds,
type MenuOptionNode,
} from './user-menu-auth'
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
const { data } = await http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
return buildMenuOptionTree(parseMenuOptionList(data))
}
/** 拉取某用户当前直接授权菜单 id 列表。 */
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
const { data } = await http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: ADMIN_MENU_TYPE },
})
return parseUserGrantedColumnIds(data)
}
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
export interface UserMenuAuthData {
tree: MenuOptionNode[]
checkedIds: number[]
}
export async function loadUserMenuAuth(userId: number): Promise<UserMenuAuthData> {
const [tree, checkedIds] = await Promise.all([fetchGrantableMenus(), fetchUserGrantedColumnIds(userId)])
return { tree, checkedIds }
}
/** 提交某用户的直接授权菜单 id(整树替换);success=false 由 unwrap 抛后端 message。 */
export async function submitUserMenuAuth(userId: number, columnIds: unknown): Promise<void> {
const payload = buildUserColumnPermissionPayload(columnIds)
const { data } = await http.put<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, payload, {
params: { menuType: ADMIN_MENU_TYPE },
})
unwrap<unknown>(data)
}
@@ -0,0 +1,102 @@
/** 用户菜单授权数据解析模型(任务 49):permission-menus 扁平项 → 嵌套树 + 用户已授权 id。 */
import { unwrap } from '../../api/envelope.ts'
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
export const ADMIN_MENU_TYPE = 'admin'
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
export interface MenuOptionNode {
id: number
/** column_key,稳定权限标识;缺省用 id 字符串兜底。 */
key: string
name: string
sort: number
parentId: number | null
children?: MenuOptionNode[]
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const parentId = numberOrNull(record.parent_id)
const sortRaw = numberOrNull(record.sort_order)
const columnKey = typeof record.column_key === 'string' ? record.column_key.trim() : ''
return {
id,
key: columnKey || String(id),
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
sort: sortRaw === null ? 0 : sortRaw,
parentId: parentId === null ? null : parentId,
}
}
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
export function parseMenuOptionList(payload: unknown): MenuOptionNode[] {
const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return []
return data
.map((raw) => parsePermissionMenuItem(raw))
.filter((node): node is MenuOptionNode => node !== null)
}
/** 按 parent_id 组装嵌套树;同级按 (sort,key) 升序,父缺失的节点按根处理。 */
export function buildMenuOptionTree(items: MenuOptionNode[]): MenuOptionNode[] {
const nodes = new Map<number, MenuOptionNode>()
for (const item of items) {
nodes.set(item.id, { ...item, children: [] })
}
const roots: MenuOptionNode[] = []
for (const item of items) {
const node = nodes.get(item.id)
if (!node) continue
const parent = item.parentId === null ? undefined : nodes.get(item.parentId)
if (parent) {
parent.children!.push(node)
} else {
roots.push(node)
}
}
const order = (a: MenuOptionNode, b: MenuOptionNode) =>
a.sort - b.sort || a.key.localeCompare(b.key)
const sortRecursive = (nodes: MenuOptionNode[]) => {
nodes.sort(order)
for (const node of nodes) node.children?.length && sortRecursive(node.children)
}
sortRecursive(roots)
for (const item of items) {
const node = nodes.get(item.id)
if (node && !node.children?.length) delete node.children
}
return roots
}
/** 解包用户已授权 columnIdsdata.columnIds)→ 去重升序正整数。 */
export function parseUserGrantedColumnIds(payload: unknown): number[] {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
if (!record || !Array.isArray(record.columnIds)) return []
return normalizeMenuIds(record.columnIds)
}
/** 任意输入 id 集合归一为去重升序正整数(镜像后端 normalizeColumnIds)。 */
export function normalizeMenuIds(raw: unknown): number[] {
if (!Array.isArray(raw)) return []
const ids = new Set<number>()
for (const value of raw) {
const id = numberOrNull(value)
if (id !== null && id >= 1) ids.add(id)
}
return [...ids].sort((a, b) => a - b)
}
/** 提交授权请求体(任务 50):{columnIds} 与 Java UserColumnPermissionUpdateRequest 对齐。 */
export function buildUserColumnPermissionPayload(raw: unknown): { columnIds: number[] } {
return { columnIds: normalizeMenuIds(raw) }
}
@@ -0,0 +1,26 @@
/** 用户列表分页边界(任务 44):纯函数,处理总页数、越界页码回钳与空数据。 */
import { USER_PAGE_DEFAULT_SIZE } from '../../api/users-dto.ts'
/** 总页数;无数据返回 0,pageSize 非法时用默认。 */
export function totalPageCount(total: number, pageSize: number): number {
if (!Number.isFinite(total) || total <= 0) return 0
const size = Number.isFinite(pageSize) && pageSize >= 1 ? Math.floor(pageSize) : USER_PAGE_DEFAULT_SIZE
return Math.ceil(total / size)
}
/** 请求页是否超过末页(无数据时不算越界)。 */
export function isPageOutOfRange(page: number, total: number, pageSize: number): boolean {
const requested = Number.isFinite(page) ? Math.floor(page) : 1
if (requested < 1) return false
const last = totalPageCount(total, pageSize)
return last > 0 && requested > last
}
/** 把请求页钳到合法范围:<1 回首页,超过末页回末页;无数据允许第 1 页空态。 */
export function clampPage(page: number, total: number, pageSize: number): number {
const requested = Number.isFinite(page) ? Math.floor(page) : 1
if (requested < 1) return 1
const last = totalPageCount(total, pageSize)
if (last === 0) return 1
return Math.min(requested, last)
}
@@ -0,0 +1,56 @@
/** 用户角色字段映射(任务 48):行 role/is_admin → 角色域 → 可编辑值/锁定原因,镜像 Java 约束。 */
import { roleKind } from '../../types/admin.ts'
/** 后台角色域(与 AdminUserItemVo.role / is_admin 对齐)。 */
export type RoleDomain = 'super_admin' | 'admin' | 'normal'
/** 用户角色下拉可选值;super_admin 只能由系统持有,不入下拉。 */
export type EditableRole = 'admin' | 'normal'
export interface UserRoleOption {
value: EditableRole
label: string
}
/** 编辑/创建表单角色下拉候选(镜像后端 admin|normal 白名单)。 */
export const USER_ROLE_OPTIONS: readonly UserRoleOption[] = [
{ value: 'admin', label: '管理员' },
{ value: 'normal', label: '普通用户' },
]
const ROLE_DOMAIN_LABELS: Record<RoleDomain, string> = {
super_admin: '超级管理员',
admin: '管理员',
normal: '普通用户',
}
/** 行字段 → 角色域:优先 role;为空时按 is_admin 布尔推断(1=管理员);未知归 normal。 */
export function roleDomainOf(row: { role?: string | null; isAdmin?: boolean }): RoleDomain {
const kind = roleKind(row?.role)
if (kind === 'super_admin' || kind === 'admin' || kind === 'normal') return kind
return row?.isAdmin ? 'admin' : 'normal'
}
/** 行 → 可编辑角色值:超级管理员返回 null(不可编辑),其余返回 admin/normal。 */
export function selectRoleOf(row: { role?: string | null; isAdmin?: boolean }): EditableRole | null {
const domain = roleDomainOf(row)
return domain === 'super_admin' ? null : domain
}
/** 角色域展示文案。 */
export function roleDomainLabel(domain: RoleDomain): string {
return ROLE_DOMAIN_LABELS[domain] || ''
}
/**
* 角色下拉锁定原因:目标为超管、或操作者非超管(镜像后端:admin 编辑时强制不调角色、
* super_admin 不可改)。无锁定时返回 undefined。
*/
export function userRoleLockMessage(
operatorRole: string | null | undefined,
target: { role?: string | null; isAdmin?: boolean },
): string | undefined {
if (roleDomainOf(target) === 'super_admin') return '不能修改超级管理员'
if (roleKind(operatorRole) !== 'super_admin') return '仅超级管理员可调整角色'
return undefined
}
@@ -0,0 +1,45 @@
/** 用户页局部筛选/分页状态(任务 43):纯逻辑、不入 Pinia,页面级可测状态机。 */
import { USER_PAGE_DEFAULT_SIZE, USER_PAGE_MIN_PAGE, type UserListParams } from '../../api/users-dto.ts'
/** 页面局部查询状态:筛选条件 + 分页;业务表状态不落 Pinia。 */
export interface UserFilterState {
/** 用户名模糊关键字。 */
keyword: string
/** 按创建管理员过滤(可选)。 */
createdByAdminId: number | null
page: number
pageSize: number
}
export function createUserFilterState(): UserFilterState {
return { keyword: '', createdByAdminId: null, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
}
/** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */
export function withFilterReset(
state: UserFilterState,
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId'>>,
): UserFilterState {
return { ...state, ...next, page: USER_PAGE_MIN_PAGE }
}
/** 翻页只改页码并钳到 >=1,保留筛选条件。 */
export function withPage(state: UserFilterState, page: number): UserFilterState {
return { ...state, page: Math.max(page, USER_PAGE_MIN_PAGE) }
}
/** 是否存在生效筛选(仅空白关键字不算)。 */
export function isFilterActive(state: UserFilterState): boolean {
return Boolean(state.keyword && state.keyword.trim()) || state.createdByAdminId != null
}
/** 页面状态 → 查询适配层参数(空关键字不下发)。 */
export function toUserListParams(state: UserFilterState): UserListParams {
const keyword = state.keyword.trim()
return {
page: state.page,
pageSize: state.pageSize,
keyword: keyword || undefined,
createdById: state.createdByAdminId,
}
}
@@ -0,0 +1,247 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 不符合 ASIN(品牌数据库):分页 + 手动新增/编辑/删除。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts'
import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts'
import { invalidAsinDeleteText, invalidAsinLockedGroupId, recordSourceLabel } from '../invalidasin/invalid-asin-model.ts'
import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<InvalidAsinItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 当前编辑行来源:自动导入(AUTO)记录不可改分组。 */
const editingSource = ref('')
const saving = ref(false)
const form = reactive(createEmptyInvalidAsinForm())
const isSuperAdmin = computed(() => session.isSuperAdmin)
/** 非超管锁定的分组 id(对齐 admin.js getInvalidAsinDataLockedGroupId);超管为 null。 */
const lockedGroupId = computed(() => invalidAsinLockedGroupId(groups.value, null, session.user?.role || ''))
/** 分组下拉可用选项:超管全量,非超管仅锁定分组。 */
const groupOptions = computed<ShopGroupOption[]>(() => {
if (isSuperAdmin.value) return groups.value
const locked = lockedGroupId.value
return locked == null ? [] : groups.value.filter((group) => group.id === locked)
})
async function loadGroups() {
try {
groups.value = await fetchShopManageGroups()
} catch {
groups.value = []
}
// 非超管:筛选强制锁定到本人分组(与参考 refreshShopGroupSelects 的锁定语义一致)。
if (!isSuperAdmin.value && lockedGroupId.value != null) {
filter.groupId = lockedGroupId.value
}
}
async function load() {
loading.value = true
try {
const result = await fetchInvalidAsinPage({
page: page.value,
pageSize: pageSize.value,
dataValue: filter.dataValue.trim() || undefined,
brand: filter.brand.trim() || undefined,
groupId: filter.groupId,
})
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
load()
}
function reset() {
filter.dataValue = ''
filter.brand = ''
// 非超管重置不解除分组锁定。
filter.groupId = isSuperAdmin.value ? null : lockedGroupId.value
page.value = 1
load()
}
function openCreate() {
editingId.value = null
editingSource.value = ''
Object.assign(form, createEmptyInvalidAsinForm())
// 非超管新增预置锁定分组(下拉同时被禁用)。
if (!isSuperAdmin.value && lockedGroupId.value != null) {
form.groupId = lockedGroupId.value
}
dialogVisible.value = true
}
function openEdit(row: InvalidAsinItem) {
editingId.value = row.id
editingSource.value = row.recordSource
Object.assign(form, invalidAsinFormFromItem(row))
dialogVisible.value = true
}
async function submit() {
const { valid, errors } = validateInvalidAsinForm({ ...form }, { requireGroup: editingId.value == null })
if (!valid) {
ElMessage.warning(Object.values(errors)[0] || '表单校验未通过')
return
}
saving.value = true
try {
if (editingId.value == null) {
await createInvalidAsin({ ...form })
ElMessage.success('已新增')
} else {
const includeGroup = editingSource.value.toUpperCase() !== 'AUTO'
await updateInvalidAsin(editingId.value, { ...form }, includeGroup)
ElMessage.success('已保存')
}
dialogVisible.value = false
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function remove(row: InvalidAsinItem) {
try {
await ElMessageBox.confirm(invalidAsinDeleteText(row), '删除', { type: 'warning' })
} catch {
return
}
try {
await deleteInvalidAsin(row.id)
ElMessage.success('已删除')
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
onMounted(() => {
loadGroups()
load()
})
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>不符合 ASIN 数据</h2>
<p>管理命中"不符合 ASIN"的品牌数据库记录</p>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-grid">
<div class="f-item">
<label>ASIN</label>
<el-input v-model="filter.dataValue" placeholder="精确/模糊搜索ASIN" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>品牌</label>
<el-input v-model="filter.brand" placeholder="品牌模糊" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable :disabled="!isSuperAdmin">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="dataValue" label="ASIN" min-width="160" />
<el-table-column prop="brand" label="品牌" min-width="140">
<template #default="{ row }">{{ (row as InvalidAsinItem).brand || '—' }}</template>
</el-table-column>
<el-table-column prop="groupName" label="分组" min-width="130" />
<el-table-column label="来源" min-width="110">
<template #default="{ row }">
<el-tag size="small" effect="light" :type="(row as InvalidAsinItem).recordSource !== 'MANUAL' ? 'info' : undefined">
{{ recordSourceLabel((row as InvalidAsinItem).recordSource) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openEdit(row as InvalidAsinItem)">编辑</el-button>
<el-button text type="danger" size="small" @click="remove(row as InvalidAsinItem)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> <b>{{ total.toLocaleString() }}</b> </span>
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="() => { page = 1; load() }" />
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增不符合ASIN' : '编辑不符合ASIN'" width="520px">
<el-form label-width="110px">
<el-form-item label="ASIN" required>
<el-input v-model="form.dataValue" placeholder="不符合的 ASIN" />
</el-form-item>
<el-form-item label="品牌">
<el-input v-model="form.brand" placeholder="可选" />
</el-form-item>
<el-form-item v-if="editingId == null || editingSource.toUpperCase() !== 'AUTO'" label="分组" required>
<el-select v-model="form.groupId" placeholder="选择分组" filterable :disabled="!isSuperAdmin">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.table-footer b { color: var(--el-text-color-primary); }
</style>
@@ -0,0 +1,501 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 数据去重总数据(registry):对齐 admin panel-dedupe-total-data —— 顶部数据权限分组汇总卡、扩充筛选(用户名/日期/分组)、
* 行编辑/删除、新增/删除导入(轮询)、导出(等待遮罩+已等待秒数)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import {
fetchDedupeTotalList,
updateDedupeTotal,
deleteDedupeTotal,
fetchDedupeTotalGroups,
type DedupeGroupOption,
} from './dedupe-total-api.ts'
import { dedupeGroupSummaryOf, type DedupeTotalItem } from './dedupe-total-model.ts'
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
import {
fetchDedupeDeleteImportProgress,
fetchDedupeImportProgress,
startDedupeDeleteImport,
startDedupeImport,
} from './dedupe-import-api.ts'
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
import type { DedupeImportProgress } from './dedupe-import-model.ts'
import { toExportUrl } from './dedupe-total-export.ts'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<DedupeTotalItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const filter = reactive(createDedupeTotalFilterState())
const groups = ref<DedupeGroupOption[]>([])
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
const groupSummary = computed(() =>
dedupeGroupSummaryOf(groups.value, session.user?.id ?? null, session.user?.role || ''),
)
const editVisible = ref(false)
const editSaving = ref(false)
const editTarget = ref<DedupeTotalItem | null>(null)
const editValue = ref('')
const editGroupId = ref<number | null>(null)
type ImportMode = 'add' | 'delete'
const importVisible = ref(false)
const importMode = ref<ImportMode>('add')
const importGroupId = ref<number | null>(null)
const importFile = ref<File | null>(null)
const importRunning = ref(false)
const importProgress = ref('')
const exporting = ref(false)
/** 导出等待遮罩与秒表(对齐 admin.js showDedupeTotalDataExportWait)。 */
const exportWaitVisible = ref(false)
const exportWaitSeconds = ref(0)
let exportWaitTimer: ReturnType<typeof setInterval> | null = null
function startExportWait(): void {
exportWaitSeconds.value = 0
exportWaitVisible.value = true
exportWaitTimer = setInterval(() => {
exportWaitSeconds.value += 1
}, 1000)
}
function stopExportWait(): void {
if (exportWaitTimer) {
clearInterval(exportWaitTimer)
exportWaitTimer = null
}
exportWaitVisible.value = false
}
async function load(): Promise<void> {
loading.value = true
try {
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize.value))
rows.value = result.items
total.value = result.total
if (result.page >= 1) page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
} finally {
loading.value = false
}
}
function apply(): void {
page.value = 1
void load()
}
function reset(): void {
Object.assign(filter, createDedupeTotalFilterState())
page.value = 1
void load()
}
function openEdit(row: DedupeTotalItem): void {
editTarget.value = row
editValue.value = row.dataValue
editGroupId.value = row.groupId
editVisible.value = true
}
async function saveEdit(): Promise<void> {
const value = editValue.value.trim()
const target = editTarget.value
if (!target) return
if (!value) {
ElMessage.warning('请填写总数据值')
return
}
if (!editGroupId.value) {
ElMessage.warning('请选择分组')
return
}
editSaving.value = true
try {
await updateDedupeTotal(target.id, value, editGroupId.value)
editVisible.value = false
ElMessage.success('保存成功')
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
editSaving.value = false
}
}
async function removeRow(row: DedupeTotalItem): Promise<void> {
try {
await ElMessageBox.confirm(`确定删除总数据“${row.dataValue}”吗?`, '删除总数据', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
await deleteDedupeTotal(row.id)
ElMessage.success('删除成功')
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
}
function resetImport(): void {
importGroupId.value = null
importFile.value = null
importProgress.value = ''
importRunning.value = false
}
function openImportAdd(): void {
importMode.value = 'add'
resetImport()
importVisible.value = true
}
function openImportDelete(): void {
importMode.value = 'delete'
resetImport()
importVisible.value = true
}
function onPickFile(event: Event): void {
const input = event.target as HTMLInputElement
importFile.value = input.files?.[0] ?? null
}
function importValid(): boolean {
if (!importGroupId.value) {
ElMessage.warning('请先选择数据权限分组')
return false
}
if (!importFile.value) {
ElMessage.warning('请选择 Excel 文件')
return false
}
if (!isAllowedImportFile(importFile.value.name)) {
ElMessage.warning('仅支持 .xlsx/.xls 文件')
return false
}
return true
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function pollImport(poll: () => Promise<DedupeImportProgress>): Promise<DedupeImportProgress> {
for (let i = 0; i < 300; i += 1) {
const progress = await poll()
importProgress.value = importOutcomeText(progress)
if (importTaskFinished(progress)) return progress
await sleep(1000)
}
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
}
async function submitImport(): Promise<void> {
if (!importValid()) return
const file = importFile.value as File
const groupId = importGroupId.value as number
if (importMode.value === 'delete') {
try {
await ElMessageBox.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
}
const mode = importMode.value
importRunning.value = true
importProgress.value = mode === 'add' ? '正在上传并解析文件…' : '正在上传删除清单…'
try {
const started =
mode === 'add' ? await startDedupeImport(file, groupId) : await startDedupeDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchDedupeImportProgress(started) : () => fetchDedupeDeleteImportProgress(started)
const finished = await pollImport(poll)
importVisible.value = false
resetImport()
if (finished.status === 'success') {
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
await load()
} else {
ElMessage.error(importOutcomeText(finished))
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
importRunning.value = false
}
}
async function doExport(): Promise<void> {
const { startDate, endDate } = filter
if (startDate && endDate && startDate > endDate) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
if (exporting.value) return
exporting.value = true
startExportWait()
try {
// 对齐 admin.js:fetch 流式下载,带筛选参数;失败响应(JSON)解析错误提示。
const response = await window.fetch(toExportUrl(filter), { credentials: 'include' })
const contentType = response.headers.get('content-type') || ''
if (!response.ok || contentType.includes('application/json')) {
const body = (await response.json().catch(() => ({}))) as { error?: string; message?: string; msg?: string }
throw new Error(body.error || body.message || body.msg || '导出失败')
}
const blob = await response.blob()
const disposition = response.headers.get('content-disposition') || ''
const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
const filename = matched ? decodeURIComponent(matched[1]) : 'dedupe-total-data.xlsx'
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
ElMessage.success('导出完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导出失败,请重试')
} finally {
stopExportWait()
exporting.value = false
}
}
onMounted(() => {
void load()
fetchDedupeTotalGroups().then((items) => {
groups.value = items
})
})
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>数据去重总数据</h2>
<p>去重总数据的 ASIN 值台账可按 ASIN/用户名/分组/国家/日期筛选支持 Excel 导入与导出</p>
</div>
</div>
<el-card shadow="never" class="group-summary-card">
<div class="group-summary">
<span class="group-summary-title">数据权限分组</span>
<div class="group-summary-body">
<template v-if="groupSummary.kind === 'all'">
<span class="summary-chip">全部分组 · {{ groupSummary.count }} </span>
</template>
<span v-else-if="groupSummary.kind === 'none'" class="dim">未加入分组</span>
<template v-else>
<span v-for="chip in groupSummary.chips" :key="chip.name" class="summary-chip" :title="chip.name">
{{ chip.name }} · {{ chip.relation }}
</span>
<span v-if="groupSummary.extra > 0" class="dim"> {{ groupSummary.extra }} </span>
</template>
</div>
</div>
</el-card>
<el-card shadow="never">
<div class="filter-grid">
<div class="f-item">
<label>ASIN / 关键字</label>
<el-input v-model="filter.keyword" placeholder="模糊搜索值" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>用户名上传人</label>
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable>
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</div>
<div class="f-item">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable>
<el-option v-for="code in ASIN_COUNTRY_CODES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</div>
<div class="f-item">
<label>开始日期</label>
<el-date-picker v-model="filter.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
</div>
<div class="f-item">
<label>结束日期</label>
<el-date-picker v-model="filter.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
<el-button @click="openImportDelete">删除导入</el-button>
<el-button :loading="exporting" @click="doExport">导出</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="dataValue" label="ASIN" min-width="170" />
<el-table-column label="国家" min-width="100" align="center">
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="分组" min-width="150">
<template #default="{ row }">{{ (row as DedupeTotalItem).groupName || '未分组' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as DedupeTotalItem)">编辑</el-button>
<el-button link type="danger" @click="removeRow(row as DedupeTotalItem)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="sizes, prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="page"
@current-change="(p: number) => { page = p; void load() }"
@size-change="() => { page = 1; void load() }"
/>
</div>
</el-card>
<el-dialog v-model="editVisible" title="编辑总数据" width="480px">
<el-form label-position="top">
<el-form-item label="总数据值ASIN">
<el-input v-model="editValue" placeholder="请输入总数据值" />
</el-form-item>
<el-form-item label="分组">
<el-select v-model="editGroupId" placeholder="请选择分组" style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入去重数据' : '删除导入(按 Excel 批量删除)'" width="520px">
<el-form label-position="top">
<el-form-item label="数据权限分组">
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="Excel 文件">
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
<p class="import-tip">{{ importMode === 'add' ? '上传 Excel 按 ASIN 新增到所选分组,仅支持 .xlsx/.xls。' : 'Excel 第一列 ASIN 将按分组批量删除匹配总数据,仅支持 .xlsx/.xls。' }}</p>
</el-form-item>
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importRunning" @click="submitImport">
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
</el-button>
</template>
</el-dialog>
<div v-if="exportWaitVisible" class="export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
<div class="export-wait">
<span class="wait-spinner" aria-hidden="true"></span>
<div>
<div class="export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
<div class="export-wait-detail">数据量较大请耐心等待并保持页面打开已等待 {{ exportWaitSeconds }} </div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.heading-actions { display: flex; gap: 8px; flex: none; }
.group-summary-card { margin-bottom: 12px; }
.group-summary { display: flex; align-items: flex-start; gap: 14px; }
.group-summary-title { flex: none; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 24px; }
.group-summary-body { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.summary-chip {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 12.5px;
line-height: 20px;
}
.dim { color: var(--el-text-color-secondary); font-size: 12.5px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
.export-wait-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(24, 29, 43, 0.45);
backdrop-filter: blur(3px);
}
.export-wait {
display: flex;
align-items: center;
gap: 14px;
background: var(--el-bg-color);
border-radius: 14px;
padding: 22px 28px;
box-shadow: 0 12px 40px -12px rgba(39, 67, 94, 0.35);
}
.wait-spinner {
width: 26px;
height: 26px;
flex: none;
border: 3px solid var(--el-color-primary-light-7);
border-top-color: var(--el-color-primary);
border-radius: 50%;
animation: dedupe-spin 0.9s linear infinite;
}
@keyframes dedupe-spin {
to { transform: rotate(360deg); }
}
.export-wait-title { font-size: 15px; font-weight: 600; }
.export-wait-detail { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12.5px; }
</style>
@@ -0,0 +1,485 @@
<script setup lang="ts">
/** 商品类目:对齐 admin panel-product-categories —— 树节点带 来源/排序/备注 + 行内编辑删除(有子禁用)、
* 按需懒加载子级(「加载更多 已加载 x/y」)、搜索态独立分页/加载更多、导出、新增/编辑弹窗语义。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
import { createProductCategory, deleteProductCategory, updateProductCategory } from './product-category-editor-api.ts'
import {
appendCategoryChildren,
isCategoryLoadMoreNode,
makeCategoryLoadMoreNode,
PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE,
productCategorySourceLabel,
type ProductCategoryNode,
} from './product-category-model.ts'
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from './product-category-export.ts'
const loading = ref(false)
const tree = ref<ProductCategoryNode[]>([])
const keyword = ref('')
const selected = ref<ProductCategoryNode | null>(null)
const searchLoading = ref(false)
const searchRows = ref<ProductCategoryNode[]>([])
const searchTotal = ref(0)
const searchPage = ref(1)
const searchHasMore = ref(false)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const draft = reactive<ProductCategoryDraft>(createProductCategoryDraft())
/** 每父级(根=null)的子级分页状态:page/total/hasMore/loading,支撑「加载更多 已加载 x/y」。 */
const childrenState = new Map<string, { page: number; total: number; hasMore: boolean; loading: boolean }>()
const ROOT_KEY = '__root__'
function stateKeyOf(parentId: number | null): string {
return parentId == null ? ROOT_KEY : String(parentId)
}
function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
const out: ProductCategoryNode[] = []
for (const node of nodes || []) {
if (!isCategoryLoadMoreNode(node)) {
out.push(node)
out.push(...flatten(node.children))
}
}
return out
}
const allNodes = computed(() => flatten(tree.value))
const searching = computed(() => (keyword.value || '').trim().length > 0)
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
/** 拉取根级第一页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
async function loadTree(): Promise<void> {
loading.value = true
try {
const result = await fetchProductCategoryChildren(null, 1, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
childrenState.set(ROOT_KEY, state)
tree.value = result.hasMore
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
: result.items
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
} finally {
loading.value = false
}
}
/** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */
interface TreeRow {
node: ProductCategoryNode
depth: number
}
const expandedIds = ref<Set<number>>(new Set())
function collectRows(nodes: ProductCategoryNode[], depth: number, out: TreeRow[]): void {
for (const node of nodes || []) {
out.push({ node, depth })
if (!isCategoryLoadMoreNode(node) && expandedIds.value.has(node.id)) {
collectRows(node.children, depth + 1, out)
}
}
}
const treeRows = computed<TreeRow[]>(() => {
const out: TreeRow[] = []
collectRows(tree.value, 0, out)
return out
})
/** 展开/折叠:未加载过子级的先拉取第一页(懒加载),加载更多行点击翻页。 */
async function toggleExpand(node: ProductCategoryNode): Promise<void> {
if (isCategoryLoadMoreNode(node)) {
void loadMoreChildren(node)
return
}
if (node.childCount <= 0) return
if (expandedIds.value.has(node.id)) {
const next = new Set(expandedIds.value)
next.delete(node.id)
expandedIds.value = next
return
}
const hasRealChildren = node.children.some((child) => !isCategoryLoadMoreNode(child))
if (!hasRealChildren) {
await loadChildrenPage(node, 1, false)
}
const next = new Set(expandedIds.value)
next.add(node.id)
expandedIds.value = next
}
/** 加载某父级一页子级(append=false 重置;append=true 追加并重建「加载更多」节点)。 */
async function loadChildrenPage(parent: ProductCategoryNode | null, page: number, append: boolean): Promise<void> {
const key = stateKeyOf(parent?.id ?? null)
const state = childrenState.get(key)
if (state?.loading) return
childrenState.set(key, { page: state?.page ?? 0, total: state?.total ?? 0, hasMore: state?.hasMore ?? false, loading: true })
try {
const result = await fetchProductCategoryChildren(parent?.id ?? null, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const loaded = append ? (state?.page ?? 0) + result.items.length : result.items.length
const nextState = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
childrenState.set(key, nextState)
if (parent) {
const current = parent.children || []
parent.children = append
? appendCategoryChildren(current, result.items, parent.id, loaded, result.total, result.hasMore)
: (result.hasMore
? [...result.items, makeCategoryLoadMoreNode(parent.id, result.items.length, result.total)]
: result.items)
} else {
const current = tree.value
tree.value = append
? appendCategoryChildren(current, result.items, null, loaded, result.total, result.hasMore)
: (result.hasMore
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
: result.items)
}
} catch (error) {
const fallback = childrenState.get(key) ?? { page: 0, total: 0, hasMore: false, loading: false }
fallback.loading = false
childrenState.set(key, fallback)
ElMessage.error(error instanceof Error ? error.message : '子类目加载失败')
}
}
/** 点击「加载更多」合成节点:翻下一页追加。 */
async function loadMoreChildren(more: ProductCategoryNode): Promise<void> {
if (!isCategoryLoadMoreNode(more)) return
const parentId = more.parentId
const parent = parentId == null ? null : allNodes.value.find((node) => node.id === parentId) ?? null
if (parentId != null && !parent) return
const state = childrenState.get(stateKeyOf(parentId))
if (state?.loading) return
await loadChildrenPage(parent, (state?.page ?? 0) + 1, true)
}
/** 「加载更多」按钮的加载态:取其父级分页状态的 loading。 */
function moreLoading(more: ProductCategoryNode): boolean {
return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false)
}
async function runSearch(page: number): Promise<void> {
searchLoading.value = true
try {
const result = await searchProductCategories(keyword.value, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
// 分页换页模式:每页独立展示当前页结果(对齐列表统一分页交互)。
searchRows.value = result.items
searchTotal.value = result.total
searchPage.value = result.page
searchHasMore.value = result.hasMore
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目搜索失败')
} finally {
searchLoading.value = false
}
}
function apply(): void {
selected.value = null
if (searching.value) void runSearch(1)
else void loadTree()
}
function reset(): void {
keyword.value = ''
selected.value = null
searchRows.value = []
searchHasMore.value = false
void loadTree()
}
function searchChangePage(p: number): void {
void runSearch(p)
}
function openCreate(parent: ProductCategoryNode | null) {
editingId.value = null
Object.assign(draft, createProductCategoryDraft())
draft.parentId = parent ? parent.id : null
dialogVisible.value = true
}
function openEdit(node: ProductCategoryNode) {
editingId.value = node.id
Object.assign(draft, createProductCategoryDraft())
draft.parentId = node.parentId
draft.name = node.name
draft.sortOrder = node.sortOrder == null ? '' : String(node.sortOrder)
draft.description = node.description
dialogVisible.value = true
}
function onNodeClick(node: ProductCategoryNode) {
if (isCategoryLoadMoreNode(node)) {
void loadMoreChildren(node)
return
}
selected.value = node
}
function canDelete(node: ProductCategoryNode | null): boolean {
if (!node || isCategoryLoadMoreNode(node)) return false
return !(node.childCount > 0 || (Array.isArray(node.children) && node.children.some((child) => !isCategoryLoadMoreNode(child))))
}
async function removeCategory(node: ProductCategoryNode): Promise<void> {
if (!canDelete(node)) {
ElMessage.warning('存在子类目,不能删除')
return
}
try {
await ElMessageBox.confirm(`确定删除“${node.name}”?`, '删除类目', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
await deleteProductCategory(node.id)
ElMessage.success('已删除')
if (selected.value?.id === node.id) selected.value = null
if (searching.value) void runSearch(1)
else void loadTree()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
}
async function submit(): Promise<void> {
const error = categoryDraftError({ ...draft })
if (error) {
ElMessage.warning(error)
return
}
saving.value = true
try {
const body = toCategorySaveRequest({ ...draft })
if (editingId.value == null) {
await createProductCategory(body)
ElMessage.success('已新增')
} else {
await updateProductCategory(editingId.value, body)
ElMessage.success('已保存')
}
dialogVisible.value = false
if (searching.value) void runSearch(1)
else void loadTree()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '保存失败')
} finally {
saving.value = false
}
}
function exportCategories(): void {
const anchor = document.createElement('a')
anchor.href = buildProductCategoryExportUrl(keyword.value)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
ElMessage.success('类目导出已开始下载')
}
onMounted(loadTree)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>商品类目</h2>
<p>维护商品类目树与类目名称/说明有子类目节点不可删除</p>
</div>
<div class="actions">
<el-button type="primary" @click="openCreate(null)">新增类目</el-button>
<el-button @click="exportCategories">导出</el-button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-row">
<el-input v-model="keyword" placeholder="搜索类目名称、编码或备注" clearable style="width: 280px" @keyup.enter="apply" />
<el-button type="primary" @click="apply">搜索</el-button>
<el-button @click="reset">清空</el-button>
</div>
</el-card>
<el-card v-if="!searching" shadow="never">
<el-table v-loading="loading" :data="treeRows" row-key="node.id" :class="{ 'tree-table': true }">
<el-table-column label="类目名称" min-width="320">
<template #default="{ row }">
<template v-if="isCategoryLoadMoreNode(row.node)">
<div class="tree-name-cell">
<span class="tree-indent" :style="{ '--indent': `${(row.depth + 1) * 24}px` }"></span>
<el-button link size="small" type="primary" :loading="moreLoading(row.node)" @click="loadMoreChildren(row.node)">加载更多</el-button>
<span class="dim">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
</div>
</template>
<div v-else class="tree-name-cell" @click="onNodeClick(row.node as ProductCategoryNode)">
<span class="tree-indent" :style="{ '--indent': `${row.depth * 24}px` }"></span>
<button
type="button"
class="tree-node-mark"
:class="{ 'is-leaf': (row.node as ProductCategoryNode).childCount <= 0 }"
:disabled="(row.node as ProductCategoryNode).childCount <= 0"
:title="expandedIds.has((row.node as ProductCategoryNode).id) ? '折叠' : '展开'"
@click.stop="toggleExpand(row.node as ProductCategoryNode)"
>
{{ (row.node as ProductCategoryNode).childCount > 0 ? (expandedIds.has((row.node as ProductCategoryNode).id) ? '' : '+') : '' }}
</button>
<strong class="node-name">{{ (row.node as ProductCategoryNode).name }}</strong>
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="层级路径" min-width="220">
<template #default="{ row }">
<span class="dim" :title="(row.node as ProductCategoryNode).path">{{ (row.node as ProductCategoryNode).path || (row.node as ProductCategoryNode).name }}</span>
</template>
</el-table-column>
<el-table-column label="排序" min-width="80">
<template #default="{ row }">{{ (row.node as ProductCategoryNode).sortOrder ?? 0 }}</template>
</el-table-column>
<el-table-column label="来源" min-width="90">
<template #default="{ row }">
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" min-width="180">
<template #default="{ row }">
<span v-if="(row.node as ProductCategoryNode).description" class="node-desc" :title="(row.node as ProductCategoryNode).description">{{ (row.node as ProductCategoryNode).description }}</span>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="140" fixed="right">
<template #default="{ row }">
<template v-if="!isCategoryLoadMoreNode(row.node)">
<el-button link size="small" type="primary" @click="openEdit(row.node as ProductCategoryNode)">编辑</el-button>
<el-button
link
size="small"
type="danger"
:disabled="!canDelete(row.node as ProductCategoryNode)"
@click="removeCategory(row.node as ProductCategoryNode)"
>删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="tree-actions">
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
<span class="btn-group">
<el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button>
</span>
</div>
</el-card>
<el-card v-else shadow="never">
<div class="search-head">
<span>搜索结果 {{ searchTotal.toLocaleString() }} </span>
<span class="dim">{{ keyword }}</span>
</div>
<el-table v-loading="searchLoading" :data="searchRows" stripe border>
<el-table-column prop="name" label="类目名称" min-width="180" />
<el-table-column label="层级路径" min-width="240">
<template #default="{ row }">{{ (row as ProductCategoryNode).path || (row as ProductCategoryNode).name }}</template>
</el-table-column>
<el-table-column label="排序" min-width="90">
<template #default="{ row }">{{ (row as ProductCategoryNode).sortOrder ?? '—' }}</template>
</el-table-column>
<el-table-column label="来源" min-width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain" :type="(row as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
{{ productCategorySourceLabel(row as ProductCategoryNode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" min-width="180">
<template #default="{ row }">{{ (row as ProductCategoryNode).description || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as ProductCategoryNode)">编辑</el-button>
<el-button link type="danger" :disabled="!canDelete(row as ProductCategoryNode)" @click="removeCategory(row as ProductCategoryNode)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination background layout="prev, pager, next, jumper" :total="searchTotal" :page-size="PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE" :current-page="searchPage" @current-change="searchChangePage" />
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form label-width="110px">
<el-form-item label="父级类目">
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable>
<el-option v-for="node in allNodes" :key="node.id" :label="node.path || node.name" :value="node.id" />
</el-select>
</el-form-item>
<el-form-item label="类目名称" required>
<el-input v-model="draft.name" placeholder="类目名称" />
</el-form-item>
<el-form-item label="排序">
<el-input v-model="draft.sortOrder" placeholder="非负整数,可选" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="draft.description" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存类目</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; align-items: center; gap: 10px; }
.filter-row { display: flex; gap: 10px; align-items: center; }
.tree-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.btn-group { display: inline-flex; gap: 6px; }
.search-head { display: flex; justify-content: space-between; margin-bottom: 12px; }
.load-more-row { text-align: center; margin-top: 12px; }
/* 树形表格(对齐旧版 panel-product-categories):缩进 + [+/] 折叠标记 + 粗体名称。 */
.tree-name-cell { display: flex; align-items: center; gap: 8px; min-height: 24px; }
.tree-indent { flex: 0 0 auto; width: var(--indent, 0px); }
.tree-node-mark {
width: 20px;
height: 20px;
border: 0;
border-radius: 5px;
background: #eef0fe;
color: #6366f1;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
cursor: pointer;
padding: 0;
line-height: 1;
flex: none;
transition: background 0.12s ease, color 0.12s ease;
}
.tree-node-mark:not(.is-leaf):hover { background: #6366f1; color: #fff; }
.tree-node-mark.is-leaf { background: transparent; cursor: default; }
.node-name { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; }
.node-desc { color: var(--el-text-color-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; display: inline-block; vertical-align: middle; }
.tree-table .el-table__row { cursor: pointer; }
</style>
@@ -0,0 +1,491 @@
<script setup lang="ts">
/** 查询 ASIN:对齐 admin panel-query-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国 ASIN 行可点击复制) +
* 新增 ASIN 弹窗(分组→店铺联动→国家→ASIN) + 行内「配置」抽屉 + 导入添加/删除导入/导出。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
import { asinCountryLabel } from './asin-country.ts'
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
const loading = ref(false)
const rows = ref<QueryAsinItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/操作4):分组(0)/店铺(1)/操作(4) 仅首行占位,其余合并。 */
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 4) {
return row.isFirst ? [row.rowspan, 1] : [0, 0]
}
return [1, 1]
}
function asinOf(row: QueryAsinItem, country: string): string {
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
return map[country] || ''
}
// ---- 行内配置抽屉 ----
const drawerVisible = ref(false)
const drawerItem = ref<QueryAsinItem | null>(null)
const drawerSaving = ref(false)
const draftAsin = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
function openConfig(row: QueryAsinItem): void {
drawerItem.value = row
for (const country of QUERY_ASIN_COUNTRIES) {
draftAsin[country] = asinOf(row, country)
}
drawerVisible.value = true
}
function normalizeAsinInput(country: string): void {
draftAsin[country] = draftAsin[country].trim().toUpperCase()
}
async function saveConfig(): Promise<void> {
const item = drawerItem.value
if (!item) return
drawerSaving.value = true
const errors: string[] = []
try {
for (const country of QUERY_ASIN_COUNTRIES) {
const oldAsin = asinOf(item, country)
const next = (draftAsin[country] || '').trim().toUpperCase()
if (next === oldAsin) continue
try {
if (!next) {
if (oldAsin) await deleteQueryAsinCountryAsin(item.id, country)
} else {
await updateQueryAsinCountryAsin(item.id, country, next)
}
} catch (error) {
errors.push(`${asinCountryLabel(country)}${error instanceof Error ? error.message : '保存失败'}`)
}
}
if (errors.length) {
ElMessage.error(errors.join(''))
} else {
ElMessage.success('保存成功')
drawerVisible.value = false
drawerItem.value = null
await load()
}
} finally {
drawerSaving.value = false
}
}
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateQueryAsin/btnCreateQueryAsin ----
const createVisible = ref(false)
const createGroupId = ref<number | null>(null)
const createShopName = ref('')
const createCountry = ref('')
const createAsin = ref('')
const createMsg = ref('')
const createMsgOk = ref(false)
const shopNames = ref<string[]>([])
const shopNamesLoading = ref(false)
const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void {
createGroupId.value = null
createShopName.value = ''
createCountry.value = ''
createAsin.value = ''
createMsg.value = ''
createMsgOk.value = false
shopNames.value = []
createVisible.value = true
}
async function onCreateGroupChange(): Promise<void> {
createShopName.value = ''
if (createGroupId.value == null) {
shopNames.value = []
return
}
shopNamesLoading.value = true
try {
shopNames.value = await fetchShopNamesByGroup(createGroupId.value)
} catch (error) {
shopNames.value = []
ElMessage.error(error instanceof Error ? error.message : '店铺列表加载失败')
} finally {
shopNamesLoading.value = false
}
}
function onCreateAsinInput(): void {
createAsin.value = createAsin.value.toUpperCase()
}
async function submitCreate(): Promise<void> {
createMsg.value = ''
createMsgOk.value = false
const groupId = createGroupId.value
const shopName = createShopName.value.trim()
const country = (createCountry.value || '').trim()
const asin = createAsin.value.trim().toUpperCase()
if (!groupId || !shopName || !country || !asin) {
createMsg.value = '请完整填写分组、店铺名、国家和 ASIN'
return
}
creating.value = true
try {
const message = await createQueryAsin({
groupId,
shopName,
countries: [country],
asin,
asinMappings: { [country]: asin },
})
// 对齐 admin:保留分组/店铺/国家,清空 ASIN 方便连续录入,成功后刷新列表。
createMsg.value = message
createMsgOk.value = true
createAsin.value = ''
createAsinInputRef.value?.focus()
await load()
} catch (error) {
createMsg.value = error instanceof Error ? error.message : '保存失败'
} finally {
creating.value = false
}
}
// ---- 导入添加 / 删除导入 ----
type ImportMode = 'add' | 'delete'
const importVisible = ref(false)
const importMode = ref<ImportMode>('add')
const importGroupId = ref<number | null>(null)
const importFile = ref<File | null>(null)
const importRunning = ref(false)
const importProgress = ref('')
function resetImport(): void {
importGroupId.value = null
importFile.value = null
importProgress.value = ''
importRunning.value = false
}
function openImportAdd(): void {
importMode.value = 'add'
resetImport()
importVisible.value = true
}
function openImportDelete(): void {
importMode.value = 'delete'
resetImport()
importVisible.value = true
}
function onPickImportFile(event: Event): void {
const input = event.target as HTMLInputElement
importFile.value = input.files?.[0] ?? null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function submitImport(): Promise<void> {
const file = importFile.value
if (!file) {
ElMessage.warning('请选择 Excel 文件')
return
}
if (!isAllowedExcelImportFile(file.name)) {
ElMessage.warning('仅支持 .xlsx/.xls 文件')
return
}
if (importMode.value === 'delete') {
try {
await ElMessageBox.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
}
const mode = importMode.value
const groupId = importGroupId.value ?? undefined
importRunning.value = true
importProgress.value = '正在上传并解析文件…'
try {
const started = mode === 'add' ? await startQueryAsinImport(file, groupId) : await startQueryAsinDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchQueryAsinImportProgress(started) : () => fetchQueryAsinDeleteImportProgress(started)
for (let i = 0; i < 300; i += 1) {
const progress = await poll()
if (progress.status === 'success') {
importVisible.value = false
resetImport()
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
await load()
return
}
if (progress.status === 'failed') {
importVisible.value = false
resetImport()
ElMessage.error((progress as { errorMessage?: string }).errorMessage || (mode === 'add' ? '导入失败' : '删除导入失败'))
return
}
importProgress.value = progress.status === 'pending' ? '等待导入任务开始…' : '导入处理中…'
await sleep(1200)
}
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
importRunning.value = false
}
}
// ---- 导出 ----
function doExport(): void {
const anchor = document.createElement('a')
anchor.href = '/api/admin/query-asins/export'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
ElMessage.success('导出文件已开始下载')
}
async function loadGroups() {
try {
groups.value = await fetchShopManageGroups()
} catch {
groups.value = []
}
}
async function load() {
loading.value = true
try {
const result = await fetchQueryAsinList({
page: page.value,
pageSize: pageSize.value,
groupId: filter.groupId,
shopName: filter.shopName.trim() || undefined,
asin: filter.asin.trim() || undefined,
country: filter.country || undefined,
})
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
void load()
}
function reset() {
Object.assign(filter, createQueryAsinFilterState())
page.value = 1
void load()
}
onMounted(() => {
void loadGroups()
void load()
})
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>查询 ASIN</h2>
<p>每店铺在 5 个站点查询到的 ASIN 清单行内可逐站配置 ASIN</p>
</div>
</div>
<el-card shadow="never">
<div class="filter-grid">
<div class="f-item">
<label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<div class="f-item">
<label>店铺名</label>
<el-input v-model="filter.shopName" placeholder="请输入店铺名" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>ASIN</label>
<el-input v-model="filter.asin" placeholder="请输入 ASIN" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部国家" clearable>
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
<el-button @click="openImportAdd">导入添加</el-button>
<el-button @click="openImportDelete">导入删除</el-button>
<el-button @click="doExport">导出</el-button>
</div>
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
<el-table-column label="分组" min-width="130">
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
</el-table-column>
<el-table-column label="店铺" min-width="150">
<template #default="{ row }">{{ row.item.shopName }}</template>
</el-table-column>
<el-table-column label="ASIN" min-width="150">
<template #default="{ row }">
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="国家" min-width="110">
<template #default="{ row }">
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as QueryAsinItem)">配置</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="sizes, prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="page"
@current-change="(p: number) => { page = p; void load() }"
@size-change="() => { page = 1; void load() }"
/>
</div>
</el-card>
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="店铺名">
<el-select
v-model="createShopName"
:disabled="createGroupId == null"
:loading="shopNamesLoading"
:placeholder="createGroupId == null ? '请先选择分组' : '请选择店铺'"
filterable
style="width: 100%"
>
<el-option v-for="name in shopNames" :key="name" :label="name" :value="name" />
</el-select>
</el-form-item>
<el-form-item label="国家">
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</el-form-item>
<el-form-item label="ASIN">
<el-input ref="createAsinInputRef" v-model="createAsin" placeholder="请输入 ASIN" @input="onCreateAsinInput" />
</el-form-item>
<el-alert v-if="createMsg" :title="createMsg" :type="createMsgOk ? 'success' : 'error'" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" :title="drawerItem ? `${drawerItem.shopName} · ASIN 配置` : 'ASIN 配置'" size="min(520px, 92%)">
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与记录。</p>
<el-form label-position="top">
<el-form-item v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="`${asinCountryLabel(code)} ASIN`">
<el-input
v-model="draftAsin[code]"
:placeholder="asinOf(drawerItem as QueryAsinItem, code) ? '留空删除该站点 ASIN' : '输入 ASIN'"
@input="normalizeAsinInput(code)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="drawerSaving" @click="saveConfig">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组Excel 未提供时的兜底可选">
<el-select v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="Excel 文件">
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickImportFile" />
<p class="import-tip">按 Excel 中的分组、店铺名与国家 ASIN 批量{{ importMode === 'add' ? '添加' : '删除' }},仅支持 .xlsx/.xls。</p>
</el-form-item>
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importRunning" @click="submitImport">
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.heading-actions { display: flex; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
.dim { color: var(--el-text-color-placeholder); }
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
</style>
@@ -0,0 +1,555 @@
<script setup lang="ts">
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国
* ASIN 可点击复制 + 最低价两位小数)、新增 ASIN 弹窗(分组→店铺联动→国家→ASIN+最低价)、价格范围筛选、
* 行内「配置」抽屉(逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
import { asinCountryLabel } from './asin-country.ts'
import { createSkipPriceFilterState, toSkipPriceParams, type SkipPriceFilterState } from './skip-price-filter.ts'
import { deleteSkipPriceCountry, updateSkipPriceCountry } from './skip-price-detail-api.ts'
import { fetchSkipPriceDeleteImportProgress, fetchSkipPriceImportProgress, startSkipPriceDeleteImport, startSkipPriceImport } from './skip-price-import-api.ts'
import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopNamesByGroup } from './query-asin-api.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
const loading = ref(false)
const rows = ref<SkipPriceItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
const COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
const ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
const PRICE_MAP: Record<string, keyof SkipPriceItem> = { DE: 'minimumPriceDe', UK: 'minimumPriceUk', FR: 'minimumPriceFr', IT: 'minimumPriceIt', ES: 'minimumPriceEs' }
function asinOf(row: SkipPriceItem, code: string): string {
return (row[ASIN_MAP[code]] as string) || ''
}
function priceOf(row: SkipPriceItem, code: string): number | null {
const value = row[PRICE_MAP[code]] as number | null
return typeof value === 'number' ? value : null
}
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/最低价4/操作5):分组(0)/店铺(1)/操作(5) 仅首行占位,其余合并。 */
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 5) {
return row.isFirst ? [row.rowspan, 1] : [0, 0]
}
return [1, 1]
}
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin ----
const createVisible = ref(false)
const createGroupId = ref<number | null>(null)
const createShopName = ref('')
const createCountry = ref('')
const createAsin = ref('')
const createMinimumPrice = ref('')
const createMsg = ref('')
const createMsgOk = ref(false)
const shopNames = ref<string[]>([])
const shopNamesLoading = ref(false)
const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void {
createGroupId.value = null
createShopName.value = ''
createCountry.value = ''
createAsin.value = ''
createMinimumPrice.value = ''
createMsg.value = ''
createMsgOk.value = false
shopNames.value = []
createVisible.value = true
}
async function onCreateGroupChange(): Promise<void> {
createShopName.value = ''
if (createGroupId.value == null) {
shopNames.value = []
return
}
shopNamesLoading.value = true
try {
shopNames.value = await fetchShopNamesByGroup(createGroupId.value)
} catch (error) {
shopNames.value = []
ElMessage.error(error instanceof Error ? error.message : '店铺列表加载失败')
} finally {
shopNamesLoading.value = false
}
}
function onCreateAsinInput(): void {
createAsin.value = createAsin.value.toUpperCase()
}
async function submitCreate(): Promise<void> {
createMsg.value = ''
createMsgOk.value = false
const groupId = createGroupId.value
const shopName = createShopName.value.trim()
const country = (createCountry.value || '').trim()
const asin = createAsin.value.trim().toUpperCase()
const minimumPrice = createMinimumPrice.value.trim()
if (!groupId || !shopName || !country || !asin) {
createMsg.value = '请完整填写分组、店铺名、国家和 ASIN'
return
}
if (minimumPrice) {
const minimumPriceNumber = Number(minimumPrice)
if (!Number.isFinite(minimumPriceNumber) || minimumPriceNumber < 0) {
createMsg.value = '最低价格式不正确'
return
}
}
creating.value = true
try {
const message = await createSkipPriceAsin({
groupId,
shopName,
countries: [country],
asin,
asinMappings: { [country]: asin },
minimumPriceMappings: minimumPrice ? { [country]: Number(minimumPrice) } : undefined,
})
// 对齐 admin:保留分组/店铺/国家,清空 ASIN/最低价方便连续录入,成功后刷新列表。
createMsg.value = message
createMsgOk.value = true
createAsin.value = ''
createMinimumPrice.value = ''
createAsinInputRef.value?.focus()
await load()
} catch (error) {
createMsg.value = error instanceof Error ? error.message : '保存失败'
} finally {
creating.value = false
}
}
// ---- 行内配置抽屉 ----
const drawerVisible = ref(false)
const drawerItem = ref<SkipPriceItem | null>(null)
const drawerSaving = ref(false)
const draftAsin = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
const draftPrice = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
function openConfig(row: SkipPriceItem): void {
drawerItem.value = row
for (const code of COUNTRIES) {
draftAsin[code] = asinOf(row, code)
const price = priceOf(row, code)
draftPrice[code] = price == null ? '' : String(price)
}
drawerVisible.value = true
}
function normalizeAsin(code: string): void {
draftAsin[code] = draftAsin[code].trim().toUpperCase()
}
function parsePriceText(raw: string): number | null {
const value = (raw || '').trim()
if (!value) return null
if (!/^\d+(\.\d{1,2})?$/.test(value)) return undefined as unknown as number | null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
async function saveConfig(): Promise<void> {
const item = drawerItem.value
if (!item) return
drawerSaving.value = true
const errors: string[] = []
try {
for (const code of COUNTRIES) {
const asin = draftAsin[code].trim().toUpperCase()
const price = parsePriceText(draftPrice[code])
if (price === undefined) {
errors.push(`${asinCountryLabel(code)}:最低价格式不正确`)
continue
}
if (draftPrice[code].trim() !== '' && !asin) {
errors.push(`${asinCountryLabel(code)}:填写最低价时必须填写 ASIN`)
continue
}
const oldAsin = asinOf(item, code)
const oldPrice = priceOf(item, code)
if (asin === oldAsin && price === oldPrice) continue
try {
if (!asin) {
if (oldAsin) await deleteSkipPriceCountry(item.id, code)
} else {
await updateSkipPriceCountry(item.id, code, asin, price)
}
} catch (error) {
errors.push(`${asinCountryLabel(code)}${error instanceof Error ? error.message : '保存失败'}`)
}
}
if (errors.length) {
ElMessage.error(errors.join(''))
} else {
ElMessage.success('保存成功')
drawerVisible.value = false
drawerItem.value = null
await load()
}
} finally {
drawerSaving.value = false
}
}
// ---- 导入添加 / 删除导入(group_id 必填) ----
type ImportMode = 'add' | 'delete'
const importVisible = ref(false)
const importMode = ref<ImportMode>('add')
const importGroupId = ref<number | null>(null)
const importFile = ref<File | null>(null)
const importRunning = ref(false)
const importProgress = ref('')
function resetImport(): void {
importGroupId.value = null
importFile.value = null
importProgress.value = ''
importRunning.value = false
}
function openImportAdd(): void {
importMode.value = 'add'
resetImport()
importVisible.value = true
}
function openImportDelete(): void {
importMode.value = 'delete'
resetImport()
importVisible.value = true
}
function onPickImportFile(event: Event): void {
const input = event.target as HTMLInputElement
importFile.value = input.files?.[0] ?? null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function submitImport(): Promise<void> {
if (!importGroupId.value) {
ElMessage.warning('请先选择分组后再导入')
return
}
const file = importFile.value
if (!file) {
ElMessage.warning('请选择 Excel 文件')
return
}
if (!isAllowedExcelImportFile(file.name)) {
ElMessage.warning('仅支持 .xlsx/.xls 文件')
return
}
if (importMode.value === 'delete') {
try {
await ElMessageBox.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
}
const mode = importMode.value
const groupId = importGroupId.value as number
importRunning.value = true
importProgress.value = '正在上传并解析文件…'
try {
const started = mode === 'add' ? await startSkipPriceImport(file, groupId) : await startSkipPriceDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchSkipPriceImportProgress(started) : () => fetchSkipPriceDeleteImportProgress(started)
for (let i = 0; i < 300; i += 1) {
const progress = await poll()
if (progress.status === 'success') {
importVisible.value = false
resetImport()
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
await load()
return
}
if (progress.status === 'failed') {
importVisible.value = false
resetImport()
ElMessage.error(progress.errorMessage || (mode === 'add' ? '导入失败' : '删除导入失败'))
return
}
importProgress.value = progress.status === 'pending' ? '等待导入任务开始…' : '导入处理中…'
await sleep(1200)
}
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
importRunning.value = false
}
}
function doExport(): void {
const anchor = document.createElement('a')
anchor.href = '/api/admin/skip-price-asins/export'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
ElMessage.success('导出文件已开始下载')
}
async function loadGroups() {
try {
groups.value = await fetchShopManageGroups()
} catch {
groups.value = []
}
}
async function load() {
loading.value = true
try {
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize.value))
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
void load()
}
function reset() {
Object.assign(filter, createSkipPriceFilterState())
page.value = 1
void load()
}
onMounted(() => {
void loadGroups()
void load()
})
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>最低价 ASIN / 跳过跟价</h2>
<p>每店铺在 5 个站点设定的最低价 ASIN 清单行内可逐站配置 ASIN 与最低价</p>
</div>
</div>
<el-card shadow="never">
<div class="filter-grid">
<div class="f-item">
<label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<div class="f-item">
<label>店铺</label>
<el-input v-model="filter.shopName" placeholder="店铺名模糊" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>ASIN</label>
<el-input v-model="filter.asin" placeholder="ASIN 搜索" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable>
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</div>
<div class="f-item price-range">
<label>最低价</label>
<div class="price-range-row">
<el-input v-model="filter.minimumPriceFrom" placeholder="≥ 最低值" clearable @keyup.enter="apply" />
<span class="range-sep"></span>
<el-input v-model="filter.minimumPriceTo" placeholder="≤ 最高值" clearable @keyup.enter="apply" />
</div>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
<el-button @click="openImportAdd">导入添加</el-button>
<el-button @click="openImportDelete">导入删除</el-button>
<el-button @click="doExport">导出</el-button>
</div>
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
<el-table-column label="分组" min-width="130">
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
</el-table-column>
<el-table-column label="店铺" min-width="150">
<template #default="{ row }">{{ row.item.shopName }}</template>
</el-table-column>
<el-table-column label="ASIN" min-width="150">
<template #default="{ row }">
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="国家" min-width="110">
<template #default="{ row }">
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="最低价" min-width="110" align="right">
<template #default="{ row }">
<span v-if="row.minimumPrice !== ''">{{ row.minimumPrice }}</span>
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as SkipPriceItem)">配置</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="sizes, prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="page"
@current-change="(p: number) => { page = p; void load() }"
@size-change="() => { page = 1; void load() }"
/>
</div>
</el-card>
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="店铺名">
<el-select
v-model="createShopName"
:disabled="createGroupId == null"
:loading="shopNamesLoading"
:placeholder="createGroupId == null ? '请先选择分组' : '请选择店铺'"
filterable
style="width: 100%"
>
<el-option v-for="name in shopNames" :key="name" :label="name" :value="name" />
</el-select>
</el-form-item>
<el-form-item label="国家">
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</el-form-item>
<el-form-item label="ASIN">
<el-input ref="createAsinInputRef" v-model="createAsin" placeholder="请输入 ASIN" @input="onCreateAsinInput" />
</el-form-item>
<el-form-item label="最低价">
<el-input v-model="createMinimumPrice" type="number" min="0" step="0.01" placeholder="选填" />
</el-form-item>
<el-alert v-if="createMsg" :title="createMsg" :type="createMsgOk ? 'success' : 'error'" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" :title="drawerItem ? `${drawerItem.shopName} · 最低价 ASIN 配置` : '最低价 ASIN 配置'" size="min(560px, 94%)">
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与最低价。</p>
<el-form label-position="top">
<el-form-item v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)">
<div class="country-cell">
<el-input
v-model="draftAsin[code]"
placeholder="ASIN留空删除该站点"
@input="normalizeAsin(code)"
/>
<el-input v-model="draftPrice[code]" placeholder="最低价填最低价须填 ASIN" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="drawerSaving" @click="saveConfig">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组必选">
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="Excel 文件">
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickImportFile" />
<p class="import-tip">按 Excel 中的分组、店铺名、国家 ASIN{{ importMode === 'add' ? '与最低价' : '' }}批量{{ importMode === 'add' ? '添加' : '删除' }},仅支持 .xlsx/.xls。</p>
</el-form-item>
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importRunning" @click="submitImport">
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.heading-actions { display: flex; gap: 8px; flex: none; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
/* 最低价范围筛选:≥ 与 ≤ 并排同一行。 */
.f-item.price-range { width: 300px; }
.price-range-row { display: flex; align-items: center; gap: 8px; width: 100%; }
.price-range-row .el-input { flex: 1; }
.range-sep { color: var(--el-text-color-secondary); font-size: 12px; flex: none; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
.dim { color: var(--el-text-color-placeholder); }
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
.country-cell { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
</style>
@@ -0,0 +1,23 @@
/** ASIN 数据中心共享国家字典(任务 75):支持国家码与中文标签,纯逻辑。 */
export const ASIN_COUNTRY_CODES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
export type AsinCountryCode = (typeof ASIN_COUNTRY_CODES)[number]
export const ASIN_COUNTRY_LABELS: Record<AsinCountryCode, string> = {
DE: '德国',
UK: '英国',
FR: '法国',
IT: '意大利',
ES: '西班牙',
}
/** 国家码 → 中文标签;不属白名单原样返回。 */
export function asinCountryLabel(country: string): string {
return ASIN_COUNTRY_LABELS[country as AsinCountryCode] || country
}
/** 国家码归一:大小写不敏感;不属白名单时返回空串。 */
export function normalizeAsinCountry(value: string): string {
const upper = (value || '').trim().toUpperCase()
return ASIN_COUNTRY_CODES.includes(upper as AsinCountryCode) ? upper : ''
}
@@ -0,0 +1,81 @@
/** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */
export const ASIN_PAGE_DEFAULT_SIZE = 15
export const ASIN_PAGE_MIN_PAGE = 1
export const ASIN_PAGE_MAX_SIZE = 200
/** ISO 日期(yyyy-MM-dd)或空。 */
export function validIsoDateOrUndefined(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const text = value.trim()
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : undefined
}
/** ASIN 中心列表页共享筛选/分页状态。 */
export interface AsinListParams {
page: number
pageSize: number
/** 值/商品模糊关键字。 */
keyword?: string
/** 上传用户名模糊搜索。 */
username?: string
startDate?: string
endDate?: string
groupId?: number | null
/** 国家代码(如 DE、UK)。 */
country?: string
}
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */
export interface AsinPageQuery {
page: number
page_size: number
keyword?: string
username?: string
start_date?: string
end_date?: string
group_id?: number
country?: string
}
function finiteInt(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 归一化共享分页/筛选:页码下限 1、页大小 1..上限,空条件视为无筛选。 */
export function normalizeAsinPageParams(raw: Partial<AsinListParams>): AsinListParams {
const page = Math.max(finiteInt(raw.page) ?? ASIN_PAGE_MIN_PAGE, ASIN_PAGE_MIN_PAGE)
const rawSize = finiteInt(raw.pageSize)
const pageSize =
rawSize === null || rawSize < ASIN_PAGE_MIN_PAGE
? ASIN_PAGE_DEFAULT_SIZE
: Math.min(rawSize, ASIN_PAGE_MAX_SIZE)
const textOrUndefined = (value: unknown) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined
const keyword = textOrUndefined(raw.keyword)
const username = textOrUndefined(raw.username)
const country = textOrUndefined(raw.country)
const groupId = finiteInt(raw.groupId)
return {
page,
pageSize,
keyword,
username,
startDate: validIsoDateOrUndefined(raw.startDate),
endDate: validIsoDateOrUndefined(raw.endDate),
groupId: groupId === null || groupId < 1 ? null : groupId,
country,
}
}
/** 前端状态 → Java 查询参数;仅下发非空字段。 */
export function toAsinPageQuery(params: AsinListParams): AsinPageQuery {
const query: AsinPageQuery = { page: params.page, page_size: params.pageSize }
if (params.keyword) query.keyword = params.keyword
if (params.username) query.username = params.username
if (params.startDate) query.start_date = params.startDate
if (params.endDate) query.end_date = params.endDate
if (params.groupId != null) query.group_id = params.groupId
if (params.country) query.country = params.country
return query
}
@@ -0,0 +1,35 @@
/** 去重汇总导入启动适配(任务 65):multipart POST /import(file+groupId) → importId。 */
import { http } from '@/api/http'
import { parseDedupeImportProgress, parseDedupeImportStart, type DedupeImportProgress } from './dedupe-import-model'
export const DEDUPE_IMPORT_ENDPOINT = '/api/admin/dedupe-total-data/import'
export const DEDUPE_DELETE_IMPORT_ENDPOINT = '/api/admin/dedupe-total-data/delete-import'
export async function startDedupeImport(file: File, groupId: number): Promise<string> {
const form = new FormData()
form.append('file', file, file.name)
form.append('groupId', String(groupId))
const { data } = await http.post<unknown>(DEDUPE_IMPORT_ENDPOINT, form)
return parseDedupeImportStart(data)
}
/** 查询导入进度:GET /import/{importId}。 */
export async function fetchDedupeImportProgress(importId: string): Promise<DedupeImportProgress> {
const { data } = await http.get<unknown>(`${DEDUPE_IMPORT_ENDPOINT}/${importId}`)
return parseDedupeImportProgress(data)
}
/** 删除导入启动:multipart POST /delete-import(file+groupId) → importId。 */
export async function startDedupeDeleteImport(file: File, groupId: number): Promise<string> {
const form = new FormData()
form.append('file', file, file.name)
form.append('groupId', String(groupId))
const { data } = await http.post<unknown>(DEDUPE_DELETE_IMPORT_ENDPOINT, form)
return parseDedupeImportStart(data)
}
/** 查询删除导入进度:GET /delete-import/{importId}。 */
export async function fetchDedupeDeleteImportProgress(importId: string): Promise<DedupeImportProgress> {
const { data } = await http.get<unknown>(`${DEDUPE_DELETE_IMPORT_ENDPOINT}/${importId}`)
return parseDedupeImportProgress(data)
}
@@ -0,0 +1,84 @@
/** 去重汇总导入模型(任务 65):启动结果解析 + xlsx 文件校验,纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
export const DEDUPE_IMPORT_ALLOWED_EXT = /\.(xlsx|xls)$/i
/** 是否为允许导入的 Excel 文件。 */
export function isAllowedImportFile(fileName: unknown): boolean {
return typeof fileName === 'string' && DEDUPE_IMPORT_ALLOWED_EXT.test(fileName.trim())
}
/** 解包导入启动结果并取回 importId;缺省抛可读错误。 */
export function parseDedupeImportStart(payload: unknown): string {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
const importId = typeof record?.importId === 'string' ? record.importId.trim() : ''
if (!importId) throw new Error('导入任务响应异常:缺少任务 ID')
return importId
}
export type DedupeImportStatus = 'pending' | 'running' | 'success' | 'failed'
export interface DedupeImportProgress {
status: DedupeImportStatus
totalRows: number
processedRows: number
asinCount: number
insertedCount: number
skippedCount: number
errorMessage?: string
}
function intOrZero(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
}
/** 归一化导入进度负载;缺省按未开始(pending)处理,未知状态不改。 */
export function parseDedupeImportProgress(payload: unknown): DedupeImportProgress {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
const rawStatus = typeof record?.status === 'string' ? record.status.trim().toLowerCase() : ''
const status: DedupeImportStatus =
rawStatus === 'running' || rawStatus === 'success' || rawStatus === 'failed' || rawStatus === 'pending'
? rawStatus
: 'pending'
const progress: DedupeImportProgress = {
status,
totalRows: intOrZero(record?.totalRows),
processedRows: intOrZero(record?.processedRows),
asinCount: intOrZero(record?.asinCount),
insertedCount: intOrZero(record?.insertedCount),
skippedCount: intOrZero(record?.skippedCount),
}
const errorMessage =
typeof record?.errorMessage === 'string' && record.errorMessage.trim()
? record.errorMessage.trim()
: undefined
if (errorMessage) progress.errorMessage = errorMessage
return progress
}
/** 是否到达终态(success/failed)。 */
export function importTaskFinished(progress: Pick<DedupeImportProgress, 'status'>): boolean {
return progress.status === 'success' || progress.status === 'failed'
}
/** 是否成功完成。 */
export function importTaskSucceeded(progress: Pick<DedupeImportProgress, 'status'>): boolean {
return progress.status === 'success'
}
/** 结果文案:pending/running/success/failed 分别给等待、进度、完成/失败可读行。 */
export function importOutcomeText(progress: DedupeImportProgress): string {
switch (progress.status) {
case 'running':
return `导入处理中:已处理 ${progress.processedRows}/${progress.totalRows}`
case 'success':
return `导入完成:处理 ${progress.insertedCount} 条,跳过 ${progress.skippedCount}`
case 'failed':
return progress.errorMessage ? `导入失败:${progress.errorMessage}` : '导入失败,请重试'
case 'pending':
default:
return '等待导入任务开始…'
}
}
@@ -0,0 +1,38 @@
/** 去重汇总列表查询适配(任务 62):GET /api/admin/dedupe-total-data + 共享筛选归一。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import { normalizeAsinPageParams, toAsinPageQuery, type AsinListParams } from './asin-filter'
import { parseDedupeTotalPage, parseDedupeGroupOptionList, type DedupeGroupOption, type DedupeTotalPageResult } from './dedupe-total-model'
export const DEDUPE_TOTAL_ENDPOINT = '/api/admin/dedupe-total-data'
/** 数据权限分组选项源(与店铺/去重共用同一分组集)。 */
export const DEDUPE_GROUPS_ENDPOINT = '/api/admin/shop-manages/groups'
export async function fetchDedupeTotalList(params: Partial<AsinListParams> = {}): Promise<DedupeTotalPageResult> {
const normalized = normalizeAsinPageParams(params)
const { data } = await http.get<unknown>(DEDUPE_TOTAL_ENDPOINT, { params: toAsinPageQuery(normalized) })
return parseDedupeTotalPage(data)
}
export { type DedupeGroupOption }
/** 编辑单行去重总数据(ASIN 值 + 分组):PUT /dedupe-total-data/{id}。 */
export async function updateDedupeTotal(id: number, dataValue: string, groupId: number): Promise<void> {
const { data } = await http.put<unknown>(`${DEDUPE_TOTAL_ENDPOINT}/${id}`, {
data_value: dataValue,
group_id: groupId,
})
unwrap<unknown>(data)
}
/** 删除单行去重总数据:DELETE /dedupe-total-data/{id}。 */
export async function deleteDedupeTotal(id: number): Promise<void> {
const { data } = await http.delete<unknown>(`${DEDUPE_TOTAL_ENDPOINT}/${id}`)
unwrap<unknown>(data)
}
/** 加载数据权限分组选项:GET /shop-manages/groups → {id,name,leaderUserId}[]。 */
export async function fetchDedupeTotalGroups(): Promise<DedupeGroupOption[]> {
const { data } = await http.get<unknown>(DEDUPE_GROUPS_ENDPOINT)
return parseDedupeGroupOptionList(data)
}
@@ -0,0 +1,42 @@
/** 去重汇总导出等待模型(任务 64):导出 URL 构建 + 运行等待态提示,纯逻辑。 */
import { validIsoDateOrUndefined } from './asin-filter.ts'
import type { DedupeTotalFilterState } from './dedupe-total-filter.ts'
export const DEDUPE_TOTAL_EXPORT_ENDPOINT = '/api/admin/dedupe-total-data/export'
export type DedupeExportStage = 'idle' | 'running' | 'done' | 'error'
/** 运行中/完成等待提示;idle 无提示。 */
export function exportStageHint(stage: DedupeExportStage): string | undefined {
switch (stage) {
case 'running':
return '正在生成并下载导出文件,请稍候…'
case 'done':
return '导出完成'
case 'error':
return '导出失败,请重试'
default:
return undefined
}
}
/** 按导出可用筛选(用户名/日期区间/分组/国家)构建查询串;空白/非法条件不下发。 */
export function buildDedupeExportQuery(state: DedupeTotalFilterState): string {
const params = new URLSearchParams()
const username = (state.username || '').trim()
const country = (state.country || '').trim()
if (username) params.set('username', username)
if (country) params.set('country', country)
const startDate = validIsoDateOrUndefined(state.startDate)
const endDate = validIsoDateOrUndefined(state.endDate)
if (startDate) params.set('start_date', startDate)
if (endDate) params.set('end_date', endDate)
if (typeof state.groupId === 'number' && state.groupId >= 1) params.set('group_id', String(state.groupId))
return params.toString()
}
/** 导出下载地址:浏览器直接访问该 URL 触发流式下载。 */
export function toExportUrl(state: DedupeTotalFilterState): string {
const query = buildDedupeExportQuery(state)
return query ? `${DEDUPE_TOTAL_EXPORT_ENDPOINT}?${query}` : DEDUPE_TOTAL_EXPORT_ENDPOINT
}
@@ -0,0 +1,47 @@
/** 去重汇总页面局部筛选状态(任务 63):日期区间/国家/分组/关键字,纯逻辑复用共享筛选类型。 */
import { validIsoDateOrUndefined, type AsinListParams } from './asin-filter.ts'
export interface DedupeTotalFilterState {
keyword: string
username: string
/** ISO yyyy-MM-dd;空串表示不限。 */
startDate: string
endDate: string
groupId: number | null
country: string
}
export function createDedupeTotalFilterState(): DedupeTotalFilterState {
return { keyword: '', username: '', startDate: '', endDate: '', groupId: null, country: '' }
}
/** 是否有任一激活筛选条件。 */
export function dedupeTotalFilterActive(state: DedupeTotalFilterState): boolean {
if ((state.keyword || '').trim()) return true
if ((state.username || '').trim()) return true
if ((state.country || '').trim()) return true
if (typeof state.groupId === 'number' && state.groupId >= 1) return true
if (validIsoDateOrUndefined(state.startDate) || validIsoDateOrUndefined(state.endDate)) return true
return false
}
/** 过滤态 + 页码 → 列表查询参数;空白/非法日期不下发。 */
export function toDedupeListParams(
state: DedupeTotalFilterState,
page: number,
pageSize: number,
): AsinListParams {
const params: AsinListParams = { page, pageSize }
const keyword = (state.keyword || '').trim()
const username = (state.username || '').trim()
const country = (state.country || '').trim()
if (keyword) params.keyword = keyword
if (username) params.username = username
if (country) params.country = country
const startDate = validIsoDateOrUndefined(state.startDate)
const endDate = validIsoDateOrUndefined(state.endDate)
if (startDate) params.startDate = startDate
if (endDate) params.endDate = endDate
if (typeof state.groupId === 'number' && state.groupId >= 1) params.groupId = state.groupId
return params
}
@@ -0,0 +1,133 @@
/** 去重汇总列表查询模型(任务 62):解析 DedupeTotalDataPageVo(camel),纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
import { ASIN_PAGE_DEFAULT_SIZE, type AsinListParams } from './asin-filter.ts'
export interface DedupeTotalItem {
id: number
dataValue: string
country: string
groupId: number | null
groupName: string
uploaderUserId: number | null
username: string
createdAt?: string
}
export interface DedupeTotalPageResult {
items: DedupeTotalItem[]
total: number
page: number
pageSize: number
}
export function emptyDedupeTotalPage(): DedupeTotalPageResult {
return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 解析单条去重总数据;缺 id 视为无效。 */
export function toDedupeTotalItem(raw: unknown): DedupeTotalItem | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const item: DedupeTotalItem = {
id,
dataValue: text(record.dataValue ?? record.data_value),
country: text(record.country),
groupId: numberOrNull(record.groupId ?? record.group_id),
groupName: text(record.groupName ?? record.group_name),
uploaderUserId: numberOrNull(record.uploaderUserId ?? record.uploader_user_id),
username: text(record.username),
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
return item
}
/** 归一化分页负载(信封或已解包 VO)为前端结果;缺省字段回默认。 */
export function parseDedupeTotalPage(payload: unknown): DedupeTotalPageResult {
const out = emptyDedupeTotalPage()
const core = unwrap<unknown>(payload)
if (!core || typeof core !== 'object') return out
const record = core as Record<string, unknown>
if (Array.isArray(record.items)) {
out.items = record.items
.map((raw) => toDedupeTotalItem(raw))
.filter((item): item is DedupeTotalItem => item !== null)
}
if (typeof record.total === 'number') out.total = Math.floor(record.total)
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
const rawSize = record.pageSize ?? record.page_size
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
return out
}
/** 构造缺省分页查询参数(供初始加载使用)。 */
export function emptyDedupePageParams(): Partial<AsinListParams> {
return { page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
}
/** 数据权限分组选项(GET /api/admin/shop-manages/groups items)。 */
export interface DedupeGroupOption {
id: number
name: string
leaderUserId?: number | null
}
/** 归一化分组选项负载(信封 items 或裸数组),缺 id 行丢弃;解析组长 id 供汇总卡判定组长/组员。 */
export function parseDedupeGroupOptionList(payload: unknown): DedupeGroupOption[] {
const core = unwrap<unknown>(payload)
const rawList = Array.isArray(core)
? core
: core && typeof core === 'object'
? (core as Record<string, unknown>).items
: []
if (!Array.isArray(rawList)) return []
const options: DedupeGroupOption[] = []
for (const raw of rawList) {
if (!raw || typeof raw !== 'object') continue
const record = raw as Record<string, unknown>
const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id)
if (!Number.isFinite(id) || id <= 0) continue
const nameRaw = record.group_name ?? record.name
const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''
const option: DedupeGroupOption = { id, name: name || `分组${id}` }
const leaderRaw = record.leader_user_id ?? record.leaderUserId
const leaderUserId = typeof leaderRaw === 'number' ? Math.floor(leaderRaw) : Number(leaderRaw)
if (Number.isFinite(leaderUserId) && leaderUserId > 0) option.leaderUserId = leaderUserId
options.push(option)
}
return options
}
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
export type DedupeGroupSummary =
| { kind: 'all'; count: number }
| { kind: 'none' }
| { kind: 'chips'; chips: Array<{ name: string; relation: string }>; extra: number }
export function dedupeGroupSummaryOf(
groups: DedupeGroupOption[],
currentUserId: number | null,
operatorRole: string,
): DedupeGroupSummary {
if ((operatorRole || '').toLowerCase() === 'super_admin') {
return { kind: 'all', count: groups.length }
}
if (!groups.length) return { kind: 'none' }
const visible = groups.slice(0, 4)
const currentKey = currentUserId != null ? String(currentUserId) : ''
const chips = visible.map((group) => ({
name: group.name,
relation: String(group.leaderUserId ?? '') === currentKey ? '组长' : '组员',
}))
return { kind: 'chips', chips, extra: Math.max(groups.length - visible.length, 0) }
}
@@ -0,0 +1,101 @@
/** ASIN 中心导入/轮询通用模型(任务 76):启动结果、Excel 校验与导入进度归一(含 deletedCount),纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
export const EXCEL_IMPORT_ALLOWED_EXT = /\.(xlsx|xls)$/i
/** 是否为允许导入的 Excel 文件。 */
export function isAllowedExcelImportFile(fileName: unknown): boolean {
return typeof fileName === 'string' && EXCEL_IMPORT_ALLOWED_EXT.test(fileName.trim())
}
/** 解包导入启动结果并取回 importId;缺省抛可读错误。 */
export function parseImportStart(payload: unknown): string {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
const importId = typeof record?.importId === 'string' ? record.importId.trim() : ''
if (!importId) throw new Error('导入任务响应异常:缺少任务 ID')
return importId
}
export type ImportStatus = 'pending' | 'running' | 'success' | 'failed'
export interface ImportProgress {
status: ImportStatus
totalRows: number
processedRows: number
asinCount: number
insertedCount: number
deletedCount: number
skippedCount: number
errorMessage?: string
}
function intOrZero(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
}
/** 归一化导入进度负载;缺省按未开始(pending)处理,未知状态不改。 */
export function parseImportProgress(payload: unknown): ImportProgress {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
const rawStatus = typeof record?.status === 'string' ? record.status.trim().toLowerCase() : ''
const status: ImportStatus =
rawStatus === 'running' || rawStatus === 'success' || rawStatus === 'failed' || rawStatus === 'pending'
? rawStatus
: 'pending'
const progress: ImportProgress = {
status,
totalRows: intOrZero(record?.totalRows),
processedRows: intOrZero(record?.processedRows),
asinCount: intOrZero(record?.asinCount),
insertedCount: intOrZero(record?.insertedCount),
deletedCount: intOrZero(record?.deletedCount),
skippedCount: intOrZero(record?.skippedCount),
}
const errorMessage =
typeof record?.errorMessage === 'string' && record.errorMessage.trim()
? record.errorMessage.trim()
: undefined
if (errorMessage) progress.errorMessage = errorMessage
return progress
}
/** 是否到达终态(success/failed)。 */
export function importFinished(progress: Pick<ImportProgress, 'status'>): boolean {
return progress.status === 'success' || progress.status === 'failed'
}
/** 是否成功完成。 */
export function importSucceeded(progress: Pick<ImportProgress, 'status'>): boolean {
return progress.status === 'success'
}
/** 导入添加结果文案。 */
export function importOutcomeText(progress: ImportProgress): string {
switch (progress.status) {
case 'running':
return `导入处理中:已处理 ${progress.processedRows}/${progress.totalRows}`
case 'success':
return `导入完成:处理 ${progress.insertedCount} 条,跳过 ${progress.skippedCount}`
case 'failed':
return progress.errorMessage ? `导入失败:${progress.errorMessage}` : '导入失败,请重试'
case 'pending':
default:
return '等待导入任务开始…'
}
}
/** 导入删除结果文案。 */
export function deleteOutcomeText(progress: ImportProgress): string {
switch (progress.status) {
case 'running':
return `删除处理中:已处理 ${progress.processedRows}/${progress.totalRows}`
case 'success':
return `删除完成:删除 ${progress.deletedCount} 条,跳过 ${progress.skippedCount}`
case 'failed':
return progress.errorMessage ? `删除失败:${progress.errorMessage}` : '删除失败,请重试'
case 'pending':
default:
return '等待删除任务开始…'
}
}
@@ -0,0 +1,54 @@
/** 商品类目树/搜索适配(任务 78):GET /api/admin/product-categories(+keyword) 与 /product-categories/search。 */
import { http } from '@/api/http'
import {
parseProductCategoryList,
parseProductCategorySearchPage,
type ProductCategoryListResult,
type ProductCategoryNode,
type ProductCategorySearchPage,
} from './product-category-model'
export const PRODUCT_CATEGORY_ENDPOINT = '/api/admin/product-categories'
/** 拉取类目树(keyword 非空时由服务端过滤并保留父路径)。 */
export async function fetchProductCategoryTree(keyword = ''): Promise<ProductCategoryListResult> {
const trimmed = keyword.trim()
const { data } = await http.get<unknown>(PRODUCT_CATEGORY_ENDPOINT, {
params: trimmed ? { keyword: trimmed } : {},
})
return parseProductCategoryList(data)
}
/** 分页搜索类目(keyword 空时返回空页)。 */
export async function searchProductCategories(
keyword: string,
page: number,
pageSize: number,
): Promise<ProductCategorySearchPage> {
const params: Record<string, string | number> = { page, pageSize }
const trimmed = keyword.trim()
if (trimmed) params.keyword = trimmed
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
return parseProductCategorySearchPage(data)
}
/** 子级类目分页页(对齐 admin.js loadProductCategoryChildren 的 /children 端点语义)。 */
export interface ProductCategoryChildrenPage {
items: ProductCategoryNode[]
total: number
page: number
hasMore: boolean
}
/** 懒加载某父级的子类目页(parentId 为空表示根级),解析 page/total/hasMore。 */
export async function fetchProductCategoryChildren(
parentId: number | null,
page: number,
pageSize: number,
): Promise<ProductCategoryChildrenPage> {
const params: Record<string, string | number> = { page, page_size: pageSize }
if (parentId != null) params.parent_id = parentId
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/children`, { params })
const parsed = parseProductCategorySearchPage(data)
return { items: parsed.items, total: parsed.total, page: parsed.page, hasMore: parsed.hasMore }
}
@@ -0,0 +1,20 @@
/** 商品类目新增/编辑适配(任务 79):POST /api/admin/product-category + PUT /api/admin/product-category/{id}。 */
import { http } from '@/api/http'
import type { ProductCategorySaveRequest } from './product-category-editor-model'
export const PRODUCT_CATEGORY_ITEM_ENDPOINT = '/api/admin/product-category'
/** 新增类目。 */
export async function createProductCategory(body: ProductCategorySaveRequest): Promise<void> {
await http.post<unknown>(PRODUCT_CATEGORY_ITEM_ENDPOINT, body)
}
/** 更新类目。 */
export async function updateProductCategory(id: number, body: ProductCategorySaveRequest): Promise<void> {
await http.put<unknown>(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`, body)
}
/** 删除类目:DELETE /api/admin/product-category/{id}。 */
export async function deleteProductCategory(id: number): Promise<void> {
await http.delete<unknown>(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`)
}
@@ -0,0 +1,54 @@
/** 商品类目新增/编辑表单模型(任务 79):保存草稿校验与序列化(名称必填、排序可选非负整数),纯逻辑。 */
export interface ProductCategoryDraft {
parentId: number | null
name: string
sortOrder: string
description: string
}
export function createProductCategoryDraft(): ProductCategoryDraft {
return { parentId: null, name: '', sortOrder: '', description: '' }
}
export interface ProductCategorySaveRequest {
parentId?: number
name: string
sortOrder?: number
description?: string
}
function parseSortOrder(text: string): number | null {
const trimmed = (text || '').trim()
if (!trimmed) return null
if (!/^\d+$/.test(trimmed)) return null
const num = Number(trimmed)
return Number.isInteger(num) ? num : null
}
/** 校验保存草稿;合法返回 null,否则返回可读错误。 */
export function categoryDraftError(draft: ProductCategoryDraft): string | null {
if (!draft || typeof draft !== 'object') return '类目表单数据无效'
const name = typeof draft.name === 'string' ? draft.name.trim() : ''
if (!name) return '类目名称不能为空'
const rawSort = typeof draft.sortOrder === 'string' ? draft.sortOrder : ''
if (rawSort.trim() !== '' && parseSortOrder(rawSort) === null) {
return '排序需为非负整数'
}
return null
}
/** 草稿 → 保存请求;仅提交非空/合法字段,名称 trim。 */
export function toCategorySaveRequest(draft: ProductCategoryDraft): ProductCategorySaveRequest {
const error = categoryDraftError(draft)
if (error) throw new Error(error)
const request: ProductCategorySaveRequest = { name: draft.name.trim() }
if (typeof draft.parentId === 'number' && Number.isFinite(draft.parentId) && draft.parentId >= 1) {
request.parentId = Math.floor(draft.parentId)
}
const sortOrder = parseSortOrder(draft.sortOrder)
if (sortOrder !== null) request.sortOrder = sortOrder
const description = typeof draft.description === 'string' ? draft.description.trim() : ''
if (description) request.description = description
return request
}
@@ -0,0 +1,10 @@
/** 商品类目导出 URL 构建(任务 79):keyword 非空时带查询参数,纯逻辑。 */
export const PRODUCT_CATEGORY_EXPORT_ENDPOINT = '/api/admin/product-categories/export'
/** 导出下载地址:浏览器直接访问该 URL 触发流式下载。 */
export function buildProductCategoryExportUrl(keyword: string): string {
const trimmed = (keyword || '').trim()
if (!trimmed) return PRODUCT_CATEGORY_EXPORT_ENDPOINT
return `${PRODUCT_CATEGORY_EXPORT_ENDPOINT}?keyword=${encodeURIComponent(trimmed)}`
}
@@ -0,0 +1,172 @@
/** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 20
export interface ProductCategoryNode {
id: number
parentId: number | null
name: string
categoryKey: string
sortOrder: number | null
description: string
isBuiltin: boolean
childCount: number
level: number | null
path: string
children: ProductCategoryNode[]
}
export interface ProductCategoryListResult {
tree: ProductCategoryNode[]
items: ProductCategoryNode[]
}
export interface ProductCategorySearchPage {
items: ProductCategoryNode[]
total: number
page: number
pageSize: number
hasMore: boolean
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function int(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value)
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
return Math.floor(Number(value))
}
return null
}
function asBoolean(value: unknown): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value !== 0
if (typeof value === 'string') {
const v = value.trim().toLowerCase()
return v === 'true' || v === '1'
}
return false
}
/** 解析单条类目节点(递归 children);缺 id 视为无效。 */
export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = int(record.id)
if (id === null) return null
const childCount = int(record.childCount ?? record.child_count) ?? 0
const node: ProductCategoryNode = {
id,
parentId: int(record.parentId ?? record.parent_id),
name: text(record.name),
categoryKey: text(record.categoryKey ?? record.category_key),
sortOrder: int(record.sortOrder ?? record.sort_order),
description: text(record.description),
isBuiltin: asBoolean(record.isBuiltin ?? record.builtin),
childCount,
level: int(record.level),
path: text(record.path),
children: [],
}
if (Array.isArray(record.children)) {
node.children = record.children
.map((child) => toProductCategoryNode(child))
.filter((child): child is ProductCategoryNode => child !== null)
}
return node
}
function nodesOf(value: unknown): ProductCategoryNode[] {
if (!Array.isArray(value)) return []
return value
.map((raw) => toProductCategoryNode(raw))
.filter((node): node is ProductCategoryNode => node !== null)
}
/** 解析类目 VO:树加载(list) 取嵌套 tree 与扁平 items。 */
export function parseProductCategoryList(payload: unknown): ProductCategoryListResult {
const core = unwrap<unknown>(payload)
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : null
return {
tree: nodesOf(record?.tree),
items: nodesOf(record?.items),
}
}
/** 解析类目搜索分页 VO;缺省字段回默认。 */
export function parseProductCategorySearchPage(payload: unknown): ProductCategorySearchPage {
const core = unwrap<unknown>(payload)
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : null
const items = nodesOf(record?.items)
const total = int(record?.total) ?? 0
const page = int(record?.page) ?? 1
const pageSize = int(record?.pageSize ?? record?.page_size) ?? PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE
const hasMore = typeof record?.hasMore === 'boolean' ? record.hasMore : page * pageSize < total
return { items, total, page, pageSize, hasMore }
}
/** 来源展示标签(对齐 admin.js:6004 内置/自定义 tag)。 */
export function productCategorySourceLabel(node: Pick<ProductCategoryNode, 'isBuiltin'> | null | undefined): string {
return node?.isBuiltin ? '内置' : '自定义'
}
/** 节点元信息文案:来源 + 排序(对齐参考 来源列/排序列)。 */
export function productCategoryNodeMeta(node: Pick<ProductCategoryNode, 'isBuiltin' | 'sortOrder'>): string {
return `${productCategorySourceLabel(node)} · 排序 ${node.sortOrder ?? 0}`
}
/** 合成"加载更多"节点:挂在同级 children 末尾,点击翻下一页(对齐 admin.js 的 load-more 行)。 */
export interface CategoryLoadMoreNode extends ProductCategoryNode {
isLoadMore: true
loadedCount: number
totalCount: number
}
export function isCategoryLoadMoreNode(node: ProductCategoryNode | null | undefined): node is CategoryLoadMoreNode {
return !!node && (node as Partial<CategoryLoadMoreNode>).isLoadMore === true
}
export function makeCategoryLoadMoreNode(
parentId: number | null,
loadedCount: number,
totalCount: number,
): CategoryLoadMoreNode {
// 合成 id 独立负数域(-2_000_000 以下):父级 id=1 时 -parentId=-1 会与根级(-1)撞 key
// el-tree node-key 重复会致节点状态错位(子级挂错父级)。placeholder 域(-1_000_000)与此错开。
const syntheticId = -(parentId ?? 0) - 2_000_000
return {
id: syntheticId,
parentId,
name: '',
categoryKey: '',
sortOrder: null,
description: '',
isBuiltin: false,
childCount: 0,
level: null,
path: '',
children: [],
isLoadMore: true,
loadedCount,
totalCount,
}
}
/** 把新一页并入同级 children:去掉旧"加载更多"节点,追加新条目;仍有下一页时补新"加载更多"节点。 */
export function appendCategoryChildren(
current: ProductCategoryNode[],
nextItems: ProductCategoryNode[],
parentId: number | null,
loadedCount: number,
totalCount: number,
hasMore: boolean,
): ProductCategoryNode[] {
const base = current.filter((node) => !isCategoryLoadMoreNode(node))
const merged = [...base, ...nextItems]
if (hasMore) merged.push(makeCategoryLoadMoreNode(parentId, loadedCount, totalCount))
return merged
}
@@ -0,0 +1,53 @@
/** 查询 ASIN 列表查询适配(任务 71):GET /api/admin/query-asins + 分页/筛选归一与 camel 序列化。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import { fetchShopManageList } from '../shop/shop-manage-api.ts'
import { parseQueryAsinPage, type QueryAsinPageResult } from './query-asin-model'
import {
normalizeQueryAsinParams,
toQueryAsinQuery,
type QueryAsinListParams,
} from './query-asin-filter'
export const QUERY_ASIN_ENDPOINT = '/api/admin/query-asins'
export async function fetchQueryAsinList(params: Partial<QueryAsinListParams> = {}): Promise<QueryAsinPageResult> {
const normalized = normalizeQueryAsinParams(params)
const { data } = await http.get<unknown>(QUERY_ASIN_ENDPOINT, { params: toQueryAsinQuery(normalized) })
return parseQueryAsinPage(data)
}
/** 新增/覆盖查询 ASIN 载荷(对齐 Java QueryAsinCreateRequest camelCase)。 */
export interface QueryAsinCreatePayload {
groupId: number
shopName: string
countries: string[]
asin: string
asinMappings: Record<string, string>
}
/** 新增或覆盖查询 ASINPOST /api/admin/query-asins,返回后端成功消息。 */
export async function createQueryAsin(payload: QueryAsinCreatePayload): Promise<string> {
const { data } = await http.post<unknown>(QUERY_ASIN_ENDPOINT, payload)
const record = unwrap<unknown>(data) as Record<string, unknown> | null | undefined
const message = record && typeof record.message === 'string' ? record.message : ''
return message || '保存成功'
}
/** 按分组加载店铺名列表(对齐 admin.js loadAsinShopNameOptions100/页翻页聚合去重,上限 20 页)。 */
export async function fetchShopNamesByGroup(groupId: number): Promise<string[]> {
const names: string[] = []
const seen = new Set<string>()
for (let page = 1; page <= 20; page += 1) {
const result = await fetchShopManageList({ page, pageSize: 100, groupId })
for (const shop of result.items) {
const name = (shop.shopName || '').trim()
if (name && !seen.has(name)) {
seen.add(name)
names.push(name)
}
}
if (page * result.pageSize >= result.total) break
}
return names
}
@@ -0,0 +1,26 @@
/** 查询 ASIN 导入删除适配(任务 73):multipart POST /delete-import(file[+groupId]) → importId,及进度轮询。 */
import { http } from '@/api/http'
import {
parseQueryAsinImportProgress,
parseQueryAsinImportStart,
type QueryAsinImportProgress,
} from './query-asin-import-model'
export const QUERY_ASIN_DELETE_IMPORT_ENDPOINT = '/api/admin/query-asins/delete-import'
/** 启动导入删除;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */
export async function startQueryAsinDeleteImport(file: File, groupId?: number): Promise<string> {
const form = new FormData()
form.append('file', file, file.name)
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
form.append('groupId', String(Math.floor(groupId)))
}
const { data } = await http.post<unknown>(QUERY_ASIN_DELETE_IMPORT_ENDPOINT, form)
return parseQueryAsinImportStart(data)
}
/** 查询导入删除进度:GET /delete-import/{importId}。 */
export async function fetchQueryAsinDeleteImportProgress(importId: string): Promise<QueryAsinImportProgress> {
const { data } = await http.get<unknown>(`${QUERY_ASIN_DELETE_IMPORT_ENDPOINT}/${importId}`)
return parseQueryAsinImportProgress(data)
}
@@ -0,0 +1,18 @@
/** 查询 ASIN 详情抽屉保存适配(任务 74):PUT/DELETE /api/admin/query-asins/{id}/countries/{country}。 */
import { http } from '@/api/http'
export const QUERY_ASIN_DETAIL_ENDPOINT = '/api/admin/query-asins'
function countryUrl(id: number, country: string): string {
return `${QUERY_ASIN_DETAIL_ENDPOINT}/${id}/countries/${country}`
}
/** 保存某国家 ASINbody { asin });返回后由列表刷新回读最新行。 */
export async function updateQueryAsinCountryAsin(id: number, country: string, asin: string): Promise<void> {
await http.put<unknown>(countryUrl(id, country), { asin })
}
/** 删除某国家 ASIN(清空该站点 ASIN)。 */
export async function deleteQueryAsinCountryAsin(id: number, country: string): Promise<void> {
await http.delete<unknown>(countryUrl(id, country))
}
@@ -0,0 +1,76 @@
/** 查询 ASIN 详情抽屉模型(任务 74):副标题、5 国家草稿与变更差异(空→删除),纯逻辑。 */
import { QUERY_ASIN_COUNTRIES, asinForCountry, type QueryAsinCountry, type QueryAsinItem } from './query-asin-model.ts'
export const QUERY_ASIN_COUNTRY_LABELS: Record<QueryAsinCountry, string> = {
DE: '德国',
UK: '英国',
FR: '法国',
IT: '意大利',
ES: '西班牙',
}
/** 国家码 → 中文标签;不属白名单原样返回。 */
export function queryAsinCountryLabel(country: string): string {
return QUERY_ASIN_COUNTRY_LABELS[country as QueryAsinCountry] || country
}
/** 抽屉副标题:`分组 / 店铺名`;无分组时仅店铺名。 */
export function queryAsinDetailSubtitle(item: Pick<QueryAsinItem, 'groupId' | 'groupName' | 'shopName'>): string {
const group = (item.groupName || '').trim()
const shop = (item.shopName || '').trim()
return group ? `${group} / ${shop}` : shop
}
/** 抽屉单国家输入草稿(编辑前的初值)。 */
export interface QueryAsinCountryDraft {
code: string
value: string
}
/** 由列表行生成 5 国家草稿(当前值 trimmed,保持支持国家序)。 */
export function queryAsinCountryDraftsOf(item: QueryAsinItem): QueryAsinCountryDraft[] {
return QUERY_ASIN_COUNTRIES.map((code) => ({
code,
value: asinForCountry(item, code).trim(),
}))
}
export type QueryAsinCountryChangeKind = 'update' | 'delete'
export interface QueryAsinCountryChange {
code: string
label: string
kind: QueryAsinCountryChangeKind
asin?: string
}
/**
* 与当前行比较草稿产出变更:
* - 归一(trim + 大写)后与当前相同 → 跳过;
* - 草稿留空(当前有值) → delete;
* - 新值非空 → update。
* 未知国家码或缺失草稿不产生变更,避免误删;结果按支持国家序稳定输出。
*/
export function diffQueryAsinCountryChanges(
item: QueryAsinItem,
drafts: QueryAsinCountryDraft[],
): QueryAsinCountryChange[] {
const byCode = new Map<string, string>()
for (const draft of drafts || []) {
if (!draft || typeof draft.code !== 'string') continue
byCode.set(draft.code.trim().toUpperCase(), typeof draft.value === 'string' ? draft.value : '')
}
const changes: QueryAsinCountryChange[] = []
for (const code of QUERY_ASIN_COUNTRIES) {
if (!byCode.has(code)) continue
const next = (byCode.get(code) || '').trim().toUpperCase()
const current = asinForCountry(item, code).trim().toUpperCase()
if (next === current) continue
if (!next) {
changes.push({ code, label: queryAsinCountryLabel(code), kind: 'delete' })
} else {
changes.push({ code, label: queryAsinCountryLabel(code), kind: 'update', asin: next })
}
}
return changes
}
@@ -0,0 +1,103 @@
/** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;Java 参数名 camelCase。 */
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts'
/** 后端 QueryAsinService 对 pageSize 的上限;超发会被服务端静默截断,前端先行对齐。 */
export const QUERY_ASIN_MAX_PAGE_SIZE = 100
export interface QueryAsinFilterState {
groupId: number | null
shopName: string
asin: string
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
country: string
}
export interface QueryAsinListParams {
page: number
pageSize: number
groupId?: number | null
shopName?: string
asin?: string
country?: string
}
export interface QueryAsinQuery {
page: number
pageSize: number
groupId?: number
shopName?: string
asin?: string
country?: string
}
export function createQueryAsinFilterState(): QueryAsinFilterState {
return { groupId: null, shopName: '', asin: '', country: '' }
}
/** 国家码归一:大小写不敏感;不属 DE/UK/FR/IT/ES 白名单时返回空串(不发起后端不支持的筛选)。 */
export function normalizeQueryAsinCountry(value: string): string {
const upper = (value || '').trim().toUpperCase()
return QUERY_ASIN_COUNTRIES.includes(upper as (typeof QUERY_ASIN_COUNTRIES)[number]) ? upper : ''
}
/** 是否有任一激活筛选条件。 */
export function queryAsinFilterActive(state: QueryAsinFilterState): boolean {
if (typeof state.groupId === 'number' && state.groupId >= 1) return true
if ((state.shopName || '').trim()) return true
if ((state.asin || '').trim()) return true
return normalizeQueryAsinCountry(state.country) !== ''
}
/** 归一化列表查询参数:页码下限 1、页大小 1..100、空白与非法条件不下发。 */
export function normalizeQueryAsinParams(raw: Partial<QueryAsinListParams>): QueryAsinListParams {
const page = typeof raw.page === 'number' && Number.isFinite(raw.page)
? Math.max(Math.floor(raw.page), 1)
: 1
const rawSize = typeof raw.pageSize === 'number' && Number.isFinite(raw.pageSize)
? Math.floor(raw.pageSize)
: NaN
const pageSize =
Number.isNaN(rawSize) || rawSize < 1
? ASIN_PAGE_DEFAULT_SIZE
: Math.min(rawSize, QUERY_ASIN_MAX_PAGE_SIZE)
const groupId =
typeof raw.groupId === 'number' && Number.isFinite(raw.groupId) && raw.groupId >= 1
? Math.floor(raw.groupId)
: null
const shopName = typeof raw.shopName === 'string' ? raw.shopName.trim() : ''
const asin = typeof raw.asin === 'string' ? raw.asin.trim() : ''
const country = normalizeQueryAsinCountry(typeof raw.country === 'string' ? raw.country : '')
const params: QueryAsinListParams = { page, pageSize }
if (groupId !== null) params.groupId = groupId
if (shopName) params.shopName = shopName
if (asin) params.asin = asin
if (country) params.country = country
return params
}
/** 筛选态 + 页码 → 列表查询参数(已归一,可幂等重入)。 */
export function toQueryAsinParams(
state: QueryAsinFilterState,
page: number,
pageSize: number,
): QueryAsinListParams {
return normalizeQueryAsinParams({
page,
pageSize,
groupId: state.groupId,
shopName: state.shopName,
asin: state.asin,
country: state.country,
})
}
/** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(camel) 对齐,仅下发非空字段。 */
export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery {
const query: QueryAsinQuery = { page: params.page, pageSize: params.pageSize }
if (typeof params.groupId === 'number' && params.groupId >= 1) query.groupId = params.groupId
if (params.shopName) query.shopName = params.shopName
if (params.asin) query.asin = params.asin
if (params.country) query.country = params.country
return query
}
@@ -0,0 +1,26 @@
/** 查询 ASIN 导入添加适配(任务 72):multipart POST /import(file[+groupId]) → importId,及进度轮询。 */
import { http } from '@/api/http'
import {
parseQueryAsinImportProgress,
parseQueryAsinImportStart,
type QueryAsinImportProgress,
} from './query-asin-import-model'
export const QUERY_ASIN_IMPORT_ENDPOINT = '/api/admin/query-asins/import'
/** 启动导入添加;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */
export async function startQueryAsinImport(file: File, groupId?: number): Promise<string> {
const form = new FormData()
form.append('file', file, file.name)
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
form.append('groupId', String(Math.floor(groupId)))
}
const { data } = await http.post<unknown>(QUERY_ASIN_IMPORT_ENDPOINT, form)
return parseQueryAsinImportStart(data)
}
/** 查询导入添加进度:GET /import/{importId}。 */
export async function fetchQueryAsinImportProgress(importId: string): Promise<QueryAsinImportProgress> {
const { data } = await http.get<unknown>(`${QUERY_ASIN_IMPORT_ENDPOINT}/${importId}`)
return parseQueryAsinImportProgress(data)
}
@@ -0,0 +1,101 @@
/** 查询 ASIN 导入模型(任务 72):启动结果解析 + xlsx 校验 + 进度归一(含 deletedCount),纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
export const QUERY_ASIN_IMPORT_ALLOWED_EXT = /\.(xlsx|xls)$/i
/** 是否为允许导入的 Excel 文件。 */
export function isAllowedQueryAsinImportFile(fileName: unknown): boolean {
return typeof fileName === 'string' && QUERY_ASIN_IMPORT_ALLOWED_EXT.test(fileName.trim())
}
/** 解包导入启动结果并取回 importId;缺省抛可读错误。 */
export function parseQueryAsinImportStart(payload: unknown): string {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
const importId = typeof record?.importId === 'string' ? record.importId.trim() : ''
if (!importId) throw new Error('导入任务响应异常:缺少任务 ID')
return importId
}
export type QueryAsinImportStatus = 'pending' | 'running' | 'success' | 'failed'
export interface QueryAsinImportProgress {
status: QueryAsinImportStatus
totalRows: number
processedRows: number
asinCount: number
insertedCount: number
deletedCount: number
skippedCount: number
errorMessage?: string
}
function intOrZero(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
}
/** 归一化导入进度负载;缺省按未开始(pending)处理,未知状态不改。 */
export function parseQueryAsinImportProgress(payload: unknown): QueryAsinImportProgress {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
const rawStatus = typeof record?.status === 'string' ? record.status.trim().toLowerCase() : ''
const status: QueryAsinImportStatus =
rawStatus === 'running' || rawStatus === 'success' || rawStatus === 'failed' || rawStatus === 'pending'
? rawStatus
: 'pending'
const progress: QueryAsinImportProgress = {
status,
totalRows: intOrZero(record?.totalRows),
processedRows: intOrZero(record?.processedRows),
asinCount: intOrZero(record?.asinCount),
insertedCount: intOrZero(record?.insertedCount),
deletedCount: intOrZero(record?.deletedCount),
skippedCount: intOrZero(record?.skippedCount),
}
const errorMessage =
typeof record?.errorMessage === 'string' && record.errorMessage.trim()
? record.errorMessage.trim()
: undefined
if (errorMessage) progress.errorMessage = errorMessage
return progress
}
/** 是否到达终态(success/failed)。 */
export function queryAsinImportFinished(progress: Pick<QueryAsinImportProgress, 'status'>): boolean {
return progress.status === 'success' || progress.status === 'failed'
}
/** 是否成功完成。 */
export function queryAsinImportSucceeded(progress: Pick<QueryAsinImportProgress, 'status'>): boolean {
return progress.status === 'success'
}
/** 结果文案:pending/running/success/failed 分别给等待、进度、完成/失败可读行。 */
export function queryAsinImportOutcomeText(progress: QueryAsinImportProgress): string {
switch (progress.status) {
case 'running':
return `导入处理中:已处理 ${progress.processedRows}/${progress.totalRows}`
case 'success':
return `导入完成:处理 ${progress.insertedCount} 条,跳过 ${progress.skippedCount}`
case 'failed':
return progress.errorMessage ? `导入失败:${progress.errorMessage}` : '导入失败,请重试'
case 'pending':
default:
return '等待导入任务开始…'
}
}
/** 导入删除结果文案(任务 73):突出删除条数,成功/失败/等待各有可读行。 */
export function queryAsinDeleteOutcomeText(progress: QueryAsinImportProgress): string {
switch (progress.status) {
case 'running':
return `删除处理中:已处理 ${progress.processedRows}/${progress.totalRows}`
case 'success':
return `删除完成:删除 ${progress.deletedCount} 条,跳过 ${progress.skippedCount}`
case 'failed':
return progress.errorMessage ? `删除失败:${progress.errorMessage}` : '删除失败,请重试'
case 'pending':
default:
return '等待删除任务开始…'
}
}
@@ -0,0 +1,148 @@
/** 查询 ASIN 列表查询模型(任务 71):解析 QueryAsinPageVo(camel) 与每店铺 5 国家 ASIN 宽行,纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
export const QUERY_ASIN_COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
export type QueryAsinCountry = (typeof QUERY_ASIN_COUNTRIES)[number]
export interface QueryAsinItem {
id: number
groupId: number | null
groupName: string
shopName: string
asinDe: string
asinUk: string
asinFr: string
asinIt: string
asinEs: string
createdAt?: string
updatedAt?: string
}
export interface QueryAsinPageResult {
items: QueryAsinItem[]
total: number
page: number
pageSize: number
}
export function emptyQueryAsinPage(): QueryAsinPageResult {
return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
const COUNTRY_COLUMN: Record<string, keyof Pick<QueryAsinItem, 'asinDe' | 'asinUk' | 'asinFr' | 'asinIt' | 'asinEs'>> = {
DE: 'asinDe',
UK: 'asinUk',
FR: 'asinFr',
IT: 'asinIt',
ES: 'asinEs',
}
/** 解析单条查询 ASIN 店铺行;缺 id 视为无效。 */
export function toQueryAsinItem(raw: unknown): QueryAsinItem | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const item: QueryAsinItem = {
id,
groupId: numberOrNull(record.groupId ?? record.group_id),
groupName: text(record.groupName ?? record.group_name),
shopName: text(record.shopName ?? record.shop_name),
asinDe: text(record.asinDe ?? record.asin_de),
asinUk: text(record.asinUk ?? record.asin_uk),
asinFr: text(record.asinFr ?? record.asin_fr),
asinIt: text(record.asinIt ?? record.asin_it),
asinEs: text(record.asinEs ?? record.asin_es),
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item
}
/** 归一化分页负载(信封或已解包 VO)为前端结果;缺省字段回默认。 */
export function parseQueryAsinPage(payload: unknown): QueryAsinPageResult {
const out = emptyQueryAsinPage()
const core = unwrap<unknown>(payload)
if (!core || typeof core !== 'object') return out
const record = core as Record<string, unknown>
if (Array.isArray(record.items)) {
out.items = record.items
.map((raw) => toQueryAsinItem(raw))
.filter((item): item is QueryAsinItem => item !== null)
}
if (typeof record.total === 'number') out.total = Math.floor(record.total)
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
const rawSize = record.pageSize ?? record.page_size
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
return out
}
/** 取某国家列 ASIN;国家码不属白名单时返回空串。 */
export function asinForCountry(item: QueryAsinItem, country: string): string {
const column = COUNTRY_COLUMN[country]
return column ? item[column] : ''
}
/** 该店铺行是否存在任一国家 ASIN。 */
export function queryAsinItemHasAnyAsin(item: QueryAsinItem): boolean {
return QUERY_ASIN_COUNTRIES.some((code) => asinForCountry(item, code) !== '')
}
/** 纵向行条目(对齐 admin.js renderQueryAsinEntries):按 DE/UK/FR/IT/ES 顺序收集有值国家;全空补一条空条目。 */
export interface QueryAsinRowEntry {
country: QueryAsinCountry | null
asin: string
}
export function queryAsinRowEntries(item: QueryAsinItem): QueryAsinRowEntry[] {
const entries: QueryAsinRowEntry[] = []
for (const code of QUERY_ASIN_COUNTRIES) {
const asin = asinForCountry(item, code)
if (asin) entries.push({ country: code, asin })
}
if (!entries.length) entries.push({ country: null, asin: '' })
return entries
}
/** 纵向展示行(对齐 admin.js renderQueryAsinRows):序号/分组/店铺/操作 按 rowspan 合并,逐国一行。 */
export interface QueryAsinDisplayRow {
key: string
rowNo: number
rowspan: number
isFirst: boolean
country: QueryAsinCountry | null
asin: string
item: QueryAsinItem
}
export function queryAsinDisplayRows(items: QueryAsinItem[], startRowNo: number): QueryAsinDisplayRow[] {
const out: QueryAsinDisplayRow[] = []
let rowNo = Math.max(Math.floor(startRowNo || 1), 1)
for (const item of items) {
const entries = queryAsinRowEntries(item)
entries.forEach((entry, index) => {
out.push({
key: `${item.id}:${index}`,
rowNo,
rowspan: entries.length,
isFirst: index === 0,
country: entry.country,
asin: entry.asin,
item,
})
})
rowNo += 1
}
return out
}
@@ -0,0 +1,35 @@
/** 最低价 ASIN 列表查询适配(任务 75):GET /api/admin/skip-price-asins + snake 分页/筛选序列化。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import { parseSkipPricePage, type SkipPricePageResult } from './skip-price-model'
import {
normalizeSkipPriceParams,
toSkipPriceQuery,
type SkipPriceListParams,
} from './skip-price-filter'
export const SKIP_PRICE_ENDPOINT = '/api/admin/skip-price-asins'
export async function fetchSkipPriceList(params: Partial<SkipPriceListParams> = {}): Promise<SkipPricePageResult> {
const normalized = normalizeSkipPriceParams(params)
const { data } = await http.get<unknown>(SKIP_PRICE_ENDPOINT, { params: toSkipPriceQuery(normalized) })
return parseSkipPricePage(data)
}
/** 新增/覆盖最低价 ASIN 载荷(对齐 Java SkipPriceAsinCreateRequest camelCase)。 */
export interface SkipPriceCreatePayload {
groupId: number
shopName: string
countries: string[]
asin: string
asinMappings: Record<string, string>
minimumPriceMappings?: Record<string, number>
}
/** 新增或覆盖最低价 ASINPOST /api/admin/skip-price-asins,返回后端成功消息。 */
export async function createSkipPriceAsin(payload: SkipPriceCreatePayload): Promise<string> {
const { data } = await http.post<unknown>(SKIP_PRICE_ENDPOINT, payload)
const record = unwrap<unknown>(data) as Record<string, unknown> | null | undefined
const message = record && typeof record.message === 'string' ? record.message : ''
return message || '保存成功'
}
@@ -0,0 +1,23 @@
/** 最低价 ASIN 详情编辑抽屉保存适配(任务 77):PUT/DELETE /api/admin/skip-price-asins/{id}/countries/{country}。 */
import { http } from '@/api/http'
export const SKIP_PRICE_DETAIL_ENDPOINT = '/api/admin/skip-price-asins'
function countryUrl(id: number, country: string): string {
return `${SKIP_PRICE_DETAIL_ENDPOINT}/${id}/countries/${country}`
}
/** 保存某国家 ASIN 与最低价(body { asin, minimumPrice });返回后由列表刷新回读最新行。 */
export async function updateSkipPriceCountry(
id: number,
country: string,
asin: string,
minimumPrice: number | null,
): Promise<void> {
await http.put<unknown>(countryUrl(id, country), { asin, minimumPrice })
}
/** 删除某国家最低价 ASIN(清空该站点)。 */
export async function deleteSkipPriceCountry(id: number, country: string): Promise<void> {
await http.delete<unknown>(countryUrl(id, country))
}
@@ -0,0 +1,90 @@
/** 最低价 ASIN 详情编辑抽屉模型(任务 77):副标题、5 国家草稿(ASIN+最低价)与变更差异,纯逻辑。 */
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
import { skipPriceAsin, skipPriceMinimum, type SkipPriceItem } from './skip-price-model.ts'
/** 抽屉副标题:`分组 / 店铺名`;无分组时仅店铺名。 */
export function skipPriceDetailSubtitle(item: Pick<SkipPriceItem, 'groupName' | 'shopName'>): string {
const group = (item.groupName || '').trim()
const shop = (item.shopName || '').trim()
return group ? `${group} / ${shop}` : shop
}
/** 抽屉单国家草稿(编辑前初值)。 */
export interface SkipPriceCountryDraft {
code: string
label: string
asin: string
/** 最低价输入原文;空串表示清除。 */
price: string
}
/** 由列表行生成 5 国家草稿,保持支持国家序。 */
export function skipPriceCountryDraftsOf(item: SkipPriceItem): SkipPriceCountryDraft[] {
return ASIN_COUNTRY_CODES.map((code) => {
const price = skipPriceMinimum(item, code)
return {
code,
label: asinCountryLabel(code),
asin: skipPriceAsin(item, code),
price: price === null ? '' : String(price),
}
})
}
/** 非负可解析价格 → number;空/非法/负数返回 null。 */
export function normalizeSkipPriceCellPrice(value: string): number | null {
const trimmed = (value || '').trim()
if (!trimmed) return null
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
const num = Number(trimmed)
return Number.isFinite(num) && num >= 0 ? num : null
}
function priceEquals(a: number | null, b: number | null): boolean {
if (a === null || b === null) return a === b
return a === b
}
export type SkipPriceCountryChangeKind = 'update' | 'delete'
export interface SkipPriceCountryChange {
code: string
label: string
kind: SkipPriceCountryChangeKind
asin?: string
minimumPrice?: number | null
}
/**
* 与当前行比较草稿产出变更:
* - ASIN 与最低价均未变(归一后相等) → 跳过;
* - ASIN 留空(当前有值) → delete
* - 否则 update(ASIN 大写 + 最低价随行,可为空以清价)。
* 未知国家码或缺失草稿不产生变更,避免误删;结果按支持国家序稳定输出。
*/
export function diffSkipPriceCountryChanges(
item: SkipPriceItem,
drafts: SkipPriceCountryDraft[],
): SkipPriceCountryChange[] {
const byCode = new Map<string, SkipPriceCountryDraft>()
for (const draft of drafts || []) {
if (!draft || typeof draft.code !== 'string') continue
byCode.set(draft.code.trim().toUpperCase(), draft)
}
const changes: SkipPriceCountryChange[] = []
for (const code of ASIN_COUNTRY_CODES) {
const draft = byCode.get(code)
if (!draft) continue
const nextAsin = (draft.asin || '').trim().toUpperCase()
const nextPrice = normalizeSkipPriceCellPrice(draft.price)
const currentAsin = skipPriceAsin(item, code).trim().toUpperCase()
const currentPrice = skipPriceMinimum(item, code)
if (nextAsin === currentAsin && priceEquals(currentPrice, nextPrice)) continue
if (!nextAsin) {
changes.push({ code, label: asinCountryLabel(code), kind: 'delete' })
} else {
changes.push({ code, label: asinCountryLabel(code), kind: 'update', asin: nextAsin, minimumPrice: nextPrice })
}
}
return changes
}
@@ -0,0 +1,121 @@
/** 最低价 ASIN 页面筛选/分页状态(任务 75):分组/店铺名/ASIN/国家 + 最低价区间;Java 参数名 snake。 */
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
import { normalizeAsinCountry } from './asin-country.ts'
/** 后端 SkipPriceAsinService 对 pageSize 的上限;超发会被服务端静默截断,前端先行对齐。 */
export const SKIP_PRICE_MAX_PAGE_SIZE = 100
export interface SkipPriceFilterState {
groupId: number | null
shopName: string
asin: string
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
country: string
/** 最低价区间输入原文;空串不限。 */
minimumPriceFrom: string
minimumPriceTo: string
}
export interface SkipPriceListParams {
page: number
pageSize: number
groupId?: number | null
shopName?: string
asin?: string
country?: string
minimumPriceFrom?: number | null
minimumPriceTo?: number | null
}
export interface SkipPriceQuery {
page: number
page_size: number
group_id?: number
shop_name?: string
asin?: string
country?: string
minimum_price_from?: number
minimum_price_to?: number
}
export function createSkipPriceFilterState(): SkipPriceFilterState {
return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' }
}
/** 非负可解析价格 → number;空/非法/负数返回 null。 */
export function normalizePriceInput(value: string): number | null {
const trimmed = (value || '').trim()
if (!trimmed) return null
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
const num = Number(trimmed)
return Number.isFinite(num) && num >= 0 ? num : null
}
/** 是否有任一激活筛选条件。 */
export function skipPriceFilterActive(state: SkipPriceFilterState): boolean {
if (typeof state.groupId === 'number' && state.groupId >= 1) return true
if ((state.shopName || '').trim()) return true
if ((state.asin || '').trim()) return true
if (normalizeAsinCountry(state.country) !== '') return true
return normalizePriceInput(state.minimumPriceFrom) !== null || normalizePriceInput(state.minimumPriceTo) !== null
}
/** 归一化列表查询参数:页码下限 1、页大小 1..100、空白与非法条件不下发。 */
export function normalizeSkipPriceParams(raw: Partial<SkipPriceListParams>): SkipPriceListParams {
const page = typeof raw.page === 'number' && Number.isFinite(raw.page) ? Math.max(Math.floor(raw.page), 1) : 1
const rawSize = typeof raw.pageSize === 'number' && Number.isFinite(raw.pageSize) ? Math.floor(raw.pageSize) : NaN
const pageSize = Number.isNaN(rawSize) || rawSize < 1 ? ASIN_PAGE_DEFAULT_SIZE : Math.min(rawSize, SKIP_PRICE_MAX_PAGE_SIZE)
const groupId =
typeof raw.groupId === 'number' && Number.isFinite(raw.groupId) && raw.groupId >= 1 ? Math.floor(raw.groupId) : null
const shopName = typeof raw.shopName === 'string' ? raw.shopName.trim() : ''
const asin = typeof raw.asin === 'string' ? raw.asin.trim() : ''
const country = normalizeAsinCountry(typeof raw.country === 'string' ? raw.country : '')
const minimumPriceFrom = typeof raw.minimumPriceFrom === 'number' && Number.isFinite(raw.minimumPriceFrom)
? Math.max(raw.minimumPriceFrom, 0)
: null
const minimumPriceTo = typeof raw.minimumPriceTo === 'number' && Number.isFinite(raw.minimumPriceTo)
? Math.max(raw.minimumPriceTo, 0)
: null
const params: SkipPriceListParams = { page, pageSize }
if (groupId !== null) params.groupId = groupId
if (shopName) params.shopName = shopName
if (asin) params.asin = asin
if (country) params.country = country
if (minimumPriceFrom !== null) params.minimumPriceFrom = minimumPriceFrom
if (minimumPriceTo !== null) params.minimumPriceTo = minimumPriceTo
return params
}
/** 筛选态 + 页码 → 列表查询参数(已归一,可幂等重入)。 */
export function toSkipPriceParams(
state: SkipPriceFilterState,
page: number,
pageSize: number,
): SkipPriceListParams {
return normalizeSkipPriceParams({
page,
pageSize,
groupId: state.groupId,
shopName: state.shopName,
asin: state.asin,
country: state.country,
minimumPriceFrom: normalizePriceInput(state.minimumPriceFrom),
minimumPriceTo: normalizePriceInput(state.minimumPriceTo),
})
}
/** 前端查询参数 → Java 查询参数;与 SkipPriceAsinController @RequestParam(snake) 对齐,仅下发非空字段。 */
export function toSkipPriceQuery(params: SkipPriceListParams): SkipPriceQuery {
const query: SkipPriceQuery = { page: params.page, page_size: params.pageSize }
if (typeof params.groupId === 'number' && params.groupId >= 1) query.group_id = params.groupId
if (params.shopName) query.shop_name = params.shopName
if (params.asin) query.asin = params.asin
if (params.country) query.country = params.country
if (typeof params.minimumPriceFrom === 'number' && Number.isFinite(params.minimumPriceFrom)) {
query.minimum_price_from = params.minimumPriceFrom
}
if (typeof params.minimumPriceTo === 'number' && Number.isFinite(params.minimumPriceTo)) {
query.minimum_price_to = params.minimumPriceTo
}
return query
}
@@ -0,0 +1,44 @@
/** 最低价 ASIN 导入添加/删除适配(任务 76):multipart POST /import、/delete-import(file+group_id 必填) 及进度轮询。 */
import { http } from '@/api/http'
import { parseImportProgress, parseImportStart, type ImportProgress } from './import-progress-model'
export const SKIP_PRICE_IMPORT_ENDPOINT = '/api/admin/skip-price-asins/import'
export const SKIP_PRICE_DELETE_IMPORT_ENDPOINT = '/api/admin/skip-price-asins/delete-import'
function requireGroupId(groupId: number): number {
if (typeof groupId !== 'number' || !Number.isFinite(groupId) || groupId < 1) {
throw new Error('请选择分组后再导入')
}
return Math.floor(groupId)
}
function importForm(file: File, groupId: number): FormData {
const form = new FormData()
form.append('file', file, file.name)
form.append('group_id', String(requireGroupId(groupId)))
return form
}
/** 启动导入添加(后端 group_id 必填)。 */
export async function startSkipPriceImport(file: File, groupId: number): Promise<string> {
const { data } = await http.post<unknown>(SKIP_PRICE_IMPORT_ENDPOINT, importForm(file, groupId))
return parseImportStart(data)
}
/** 查询导入添加进度:GET /import/{importId}。 */
export async function fetchSkipPriceImportProgress(importId: string): Promise<ImportProgress> {
const { data } = await http.get<unknown>(`${SKIP_PRICE_IMPORT_ENDPOINT}/${importId}`)
return parseImportProgress(data)
}
/** 启动导入删除(后端 group_id 必填)。 */
export async function startSkipPriceDeleteImport(file: File, groupId: number): Promise<string> {
const { data } = await http.post<unknown>(SKIP_PRICE_DELETE_IMPORT_ENDPOINT, importForm(file, groupId))
return parseImportStart(data)
}
/** 查询导入删除进度:GET /delete-import/{importId}。 */
export async function fetchSkipPriceDeleteImportProgress(importId: string): Promise<ImportProgress> {
const { data } = await http.get<unknown>(`${SKIP_PRICE_DELETE_IMPORT_ENDPOINT}/${importId}`)
return parseImportProgress(data)
}
@@ -0,0 +1,193 @@
/** 最低价 ASIN 列表模型(任务 75):解析 SkipPriceAsinPageVo(camel) 宽行(每店铺 5 国家 ASIN+最低价),纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
import { ASIN_COUNTRY_CODES, type AsinCountryCode } from './asin-country.ts'
export interface SkipPriceItem {
id: number
groupId: number | null
groupName: string
shopName: string
asinDe: string
minimumPriceDe: number | null
asinUk: string
minimumPriceUk: number | null
asinFr: string
minimumPriceFr: number | null
asinIt: string
minimumPriceIt: number | null
asinEs: string
minimumPriceEs: number | null
createdAt?: string
updatedAt?: string
}
export interface SkipPricePageResult {
items: SkipPriceItem[]
total: number
page: number
pageSize: number
}
export function emptySkipPricePage(): SkipPricePageResult {
return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/** 数字或可解析数字串 → number;否则 nullBigDecimal 可能以字符串下发)。 */
function numberOrNull(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed && Number.isFinite(Number(trimmed))) return Number(trimmed)
}
return null
}
function idOrNull(value: unknown): number | null {
const num = numberOrNull(value)
return num === null ? null : Math.floor(num)
}
function lowerCode(code: AsinCountryCode): string {
return code.toLowerCase()
}
/** 'DE' → 'De',拼出 VO 驼峰字段后缀(asinDe/minimumPriceDe)。 */
function countrySuffix(code: string): string {
return code.length > 1 ? code[0] + code.slice(1).toLowerCase() : code.toUpperCase()
}
/** 解析单条最低价 ASIN 店铺行;缺 id 视为无效。 */
export function toSkipPriceItem(raw: unknown): SkipPriceItem | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = idOrNull(record.id)
if (id === null) return null
const item: SkipPriceItem = {
id,
groupId: idOrNull(record.groupId ?? record.group_id),
groupName: text(record.groupName ?? record.group_name),
shopName: text(record.shopName ?? record.shop_name),
asinDe: '',
minimumPriceDe: null,
asinUk: '',
minimumPriceUk: null,
asinFr: '',
minimumPriceFr: null,
asinIt: '',
minimumPriceIt: null,
asinEs: '',
minimumPriceEs: null,
}
const writable = item as unknown as Record<string, unknown>
for (const code of ASIN_COUNTRY_CODES) {
const suffix = countrySuffix(code)
const camelAsin = `asin${suffix}`
const snakeAsin = `asin_${lowerCode(code)}`
writable[camelAsin] = text(record[camelAsin] ?? record[snakeAsin])
const camelPrice = `minimumPrice${suffix}`
const snakePrice = `minimum_price_${lowerCode(code)}`
writable[camelPrice] = numberOrNull(record[camelPrice] ?? record[snakePrice])
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item
}
/** 归一化分页负载(信封或已解包 VO)为前端结果;缺省字段回默认。 */
export function parseSkipPricePage(payload: unknown): SkipPricePageResult {
const out = emptySkipPricePage()
const core = unwrap<unknown>(payload)
if (!core || typeof core !== 'object') return out
const record = core as Record<string, unknown>
if (Array.isArray(record.items)) {
out.items = record.items
.map((raw) => toSkipPriceItem(raw))
.filter((item): item is SkipPriceItem => item !== null)
}
if (typeof record.total === 'number') out.total = Math.floor(record.total)
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
const rawSize = record.pageSize ?? record.page_size
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
return out
}
/** 取某国家列 ASIN;国家码不属白名单返回空串。 */
export function skipPriceAsin(item: SkipPriceItem, country: string): string {
if (!ASIN_COUNTRY_CODES.includes(country as AsinCountryCode)) return ''
return item[`asin${countrySuffix(country)}` as keyof SkipPriceItem] as string
}
/** 取某国家最低价;国家码不属白名单返回 null。 */
export function skipPriceMinimum(item: SkipPriceItem, country: string): number | null {
if (!ASIN_COUNTRY_CODES.includes(country as AsinCountryCode)) return null
return item[`minimumPrice${countrySuffix(country)}` as keyof SkipPriceItem] as number | null
}
/** 最低价展示文本(对齐 admin.js formatSkipPriceMinimumPrice:数字→两位小数,空→'',非法→原串)。 */
export function formatSkipPriceText(value: number | null | undefined): string {
if (value === null || value === undefined) return ''
const num = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(num)) return String(value)
return num.toFixed(2)
}
/** 纵向行条目(对齐 admin.js buildSkipPriceAsinEntries):有 ASIN 或最低价的国家一行;全空补一条空条目。 */
export interface SkipPriceRowEntry {
country: AsinCountryCode | null
asin: string
minimumPrice: string
}
export function skipPriceRowEntries(item: SkipPriceItem): SkipPriceRowEntry[] {
const entries: SkipPriceRowEntry[] = []
for (const code of ASIN_COUNTRY_CODES) {
const asin = skipPriceAsin(item, code)
const minimumPrice = skipPriceMinimum(item, code)
if (asin || minimumPrice != null) {
entries.push({ country: code, asin, minimumPrice: formatSkipPriceText(minimumPrice) })
}
}
if (!entries.length) entries.push({ country: null, asin: '', minimumPrice: '' })
return entries
}
/** 纵向展示行(对齐 admin.js renderSkipPriceAsinRows):序号/分组/店铺/操作 按 rowspan 合并,逐国一行。 */
export interface SkipPriceDisplayRow {
key: string
rowNo: number
rowspan: number
isFirst: boolean
country: AsinCountryCode | null
asin: string
minimumPrice: string
item: SkipPriceItem
}
export function skipPriceDisplayRows(items: SkipPriceItem[], startRowNo: number): SkipPriceDisplayRow[] {
const out: SkipPriceDisplayRow[] = []
let rowNo = Math.max(Math.floor(startRowNo || 1), 1)
for (const item of items) {
const entries = skipPriceRowEntries(item)
entries.forEach((entry, index) => {
out.push({
key: `${item.id}:${index}`,
rowNo,
rowspan: entries.length,
isFirst: index === 0,
country: entry.country,
asin: entry.asin,
minimumPrice: entry.minimumPrice,
item,
})
})
rowNo += 1
}
return out
}
@@ -0,0 +1,52 @@
/** 品牌数据库(不符合ASIN)列表加载适配(任务 68):GET /api/admin/invalid-asin-data。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import { toInvalidAsinCreateRequest, toInvalidAsinUpdateRequest, type InvalidAsinForm } from './invalid-asin-form-model'
import { parseInvalidAsinPage, type InvalidAsinPageResult } from './invalid-asin-model'
export const INVALID_ASIN_ENDPOINT = '/api/admin/invalid-asin-data'
export interface InvalidAsinQuery {
page: number
pageSize: number
dataValue?: string
brand?: string
groupId?: number | null
}
function trimOrUndefined(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function toQuery(query: InvalidAsinQuery): Record<string, string | number> {
const params: Record<string, string | number> = { page: query.page, page_size: query.pageSize }
const dataValue = trimOrUndefined(query.dataValue)
const brand = trimOrUndefined(query.brand)
if (dataValue) params.data_value = dataValue
if (brand) params.brand = brand
if (typeof query.groupId === 'number' && query.groupId >= 1) params.group_id = query.groupId
return params
}
export async function fetchInvalidAsinPage(query: InvalidAsinQuery): Promise<InvalidAsinPageResult> {
const { data } = await http.get<unknown>(INVALID_ASIN_ENDPOINT, { params: toQuery(query) })
return parseInvalidAsinPage(data)
}
/** 新增品牌数据库记录:POST /api/admin/invalid-asin-data。 */
export async function createInvalidAsin(form: InvalidAsinForm): Promise<void> {
const { data } = await http.post<unknown>(INVALID_ASIN_ENDPOINT, toInvalidAsinCreateRequest(form))
unwrap<unknown>(data)
}
/** 编辑品牌数据库记录:PUT /api/admin/invalid-asin-data/{id}。 */
export async function updateInvalidAsin(id: number, form: InvalidAsinForm, includeGroup = false): Promise<void> {
const { data } = await http.put<unknown>(`${INVALID_ASIN_ENDPOINT}/${id}`, toInvalidAsinUpdateRequest(form, { includeGroup }))
unwrap<unknown>(data)
}
/** 删除品牌数据库记录:DELETE /api/admin/invalid-asin-data/{id}。 */
export async function deleteInvalidAsin(id: number): Promise<void> {
const { data } = await http.delete<unknown>(`${INVALID_ASIN_ENDPOINT}/${id}`)
unwrap<unknown>(data)
}
@@ -0,0 +1,88 @@
/** 品牌数据库(不符合ASIN)表单模型(任务 69):新建/编辑表单校验与序列化,纯逻辑。 */
import type { InvalidAsinItem } from './invalid-asin-model.ts'
export const INVALID_ASIN_FIELD_MAX_LEN = 128
export interface InvalidAsinForm {
dataValue: string
brand: string
groupId: number | null
}
export interface InvalidAsinFormErrors {
dataValue?: string
brand?: string
groupId?: string
}
export interface InvalidAsinCreatePayload {
dataValue: string
brand?: string
groupId: number
}
export interface InvalidAsinUpdatePayload {
dataValue: string
brand?: string
groupId?: number
}
export function createEmptyInvalidAsinForm(): InvalidAsinForm {
return { dataValue: '', brand: '', groupId: null }
}
/** 从列表项回填表单。 */
export function invalidAsinFormFromItem(item: InvalidAsinItem): InvalidAsinForm {
return { dataValue: item.dataValue, brand: item.brand, groupId: item.groupId }
}
function text(value: string): string {
return (value || '').trim()
}
/** 校验表单(镜像后端 NotBlank/@Size/NotNull 文案);manual 新增必须选分组。 */
export function validateInvalidAsinForm(
form: InvalidAsinForm,
options: { requireGroup: boolean },
): { valid: boolean; errors: InvalidAsinFormErrors } {
const errors: InvalidAsinFormErrors = {}
const dataValue = text(form.dataValue)
if (!dataValue) {
errors.dataValue = 'ASIN 不能为空'
} else if (dataValue.length > INVALID_ASIN_FIELD_MAX_LEN) {
errors.dataValue = `ASIN 长度不能超过${INVALID_ASIN_FIELD_MAX_LEN}个字符`
}
const brand = text(form.brand)
if (brand.length > INVALID_ASIN_FIELD_MAX_LEN) {
errors.brand = `品牌长度不能超过${INVALID_ASIN_FIELD_MAX_LEN}个字符`
}
if (options.requireGroup && (typeof form.groupId !== 'number' || form.groupId < 1)) {
errors.groupId = '请选择分组'
}
return { valid: Object.keys(errors).length === 0, errors }
}
/** 表单 → POST /api/admin/invalid-asin-data 请求体(camel)。 */
export function toInvalidAsinCreateRequest(form: InvalidAsinForm): InvalidAsinCreatePayload {
const payload: InvalidAsinCreatePayload = {
dataValue: text(form.dataValue),
groupId: typeof form.groupId === 'number' ? form.groupId : 0,
}
const brand = text(form.brand)
if (brand) payload.brand = brand
return payload
}
/** 表单 → PUT /api/admin/invalid-asin-data/{id} 请求体;自动导入记录默认不迁组。 */
export function toInvalidAsinUpdateRequest(
form: InvalidAsinForm,
options: { includeGroup?: boolean } = {},
): InvalidAsinUpdatePayload {
const payload: InvalidAsinUpdatePayload = { dataValue: text(form.dataValue) }
const brand = text(form.brand)
if (brand) payload.brand = brand
if (options.includeGroup && typeof form.groupId === 'number' && form.groupId >= 1) {
payload.groupId = form.groupId
}
return payload
}
@@ -0,0 +1,96 @@
/** 品牌数据库(不符合ASIN)列表模型(任务 68):解析 InvalidAsinDataPageVo(camel),纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
import { ASIN_PAGE_DEFAULT_SIZE } from '../asin/asin-filter.ts'
export type InvalidAsinSource = 'MANUAL' | 'AUTO'
export interface InvalidAsinItem {
id: number
dataValue: string
brand: string
groupId: number | null
groupName: string
recordSource: string
createdAt?: string
}
export interface InvalidAsinPageResult {
items: InvalidAsinItem[]
total: number
page: number
pageSize: number
}
export function emptyInvalidAsinPage(): InvalidAsinPageResult {
return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 来源展示标签:MANUAL 手动新增,其余(含空)自动导入。 */
export function recordSourceLabel(source: string | null | undefined): string {
return (source || '').trim().toUpperCase() === 'MANUAL' ? '手动新增' : '自动导入'
}
/** 删除二次确认文案(对齐 admin.js:3775 原文)。 */
export function invalidAsinDeleteText(item: Pick<InvalidAsinItem, 'dataValue'> | null | undefined): string {
const value = item?.dataValue || '该 ASIN'
return `确定删除“${value}”吗?`
}
/**
* 非超管分组锁定(对齐 admin.js getInvalidAsinDataLockedGroupId:超管不锁;
* 非超管锁 lockedGroupId,缺失时退化为第一个分组;无分组返回 null)。
*/
export function invalidAsinLockedGroupId(
groups: Array<{ id: number }>,
lockedGroupId: number | null | undefined,
operatorRole: string,
): number | null {
if ((operatorRole || '').toLowerCase() === 'super_admin') return null
if (lockedGroupId != null && groups.some((group) => group.id === lockedGroupId)) return lockedGroupId
return groups.length ? groups[0].id : null
}
/** 解析单条品牌数据库项;缺 id 视为无效。 */
export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const item: InvalidAsinItem = {
id,
dataValue: text(record.dataValue ?? record.data_value),
brand: text(record.brand),
groupId: numberOrNull(record.groupId ?? record.group_id),
groupName: text(record.groupName ?? record.group_name),
recordSource: (text(record.recordSource ?? record.record_source)).toUpperCase(),
}
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
return item
}
/** 归一化分页负载(信封或已解包 VO)为前端结果。 */
export function parseInvalidAsinPage(payload: unknown): InvalidAsinPageResult {
const out = emptyInvalidAsinPage()
const core = unwrap<unknown>(payload)
if (!core || typeof core !== 'object') return out
const record = core as Record<string, unknown>
if (Array.isArray(record.items)) {
out.items = record.items
.map((raw) => toInvalidAsinItem(raw))
.filter((item): item is InvalidAsinItem => item !== null)
}
if (typeof record.total === 'number') out.total = Math.floor(record.total)
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
const rawSize = record.pageSize ?? record.page_size
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
return out
}
@@ -0,0 +1,333 @@
<script setup lang="ts">
/** 数富AI 后台 Vue 登录页:复刻旧登录页「品牌视觉 + 登录卡」蓝白设计;POST /login 建会话后进入后台。 */
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { http } from '@/api/http'
import { joinAdminPath } from '@/config/app'
import { useAdminSessionStore } from '@/stores/admin-session'
const route = useRoute()
const router = useRouter()
const session = useAdminSessionStore()
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const logoUrl = joinAdminPath('assets', 'logo.jpg')
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
function deviceId(): string {
try {
const key = 'aiimage_console_device_id'
let value = localStorage.getItem(key)
if (!value) {
value = `web-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`
localStorage.setItem(key, value)
}
return value
} catch {
return `web-${Date.now()}`
}
}
function safeRedirect(value: unknown): string {
if (typeof value !== 'string') return '/'
let target = value
try {
target = decodeURIComponent(target)
} catch {
// 忽略非法编码
}
if (!target.startsWith('/') || target.startsWith('//')) return '/'
if (target.startsWith('/admin-vue')) target = target.slice('/admin-vue'.length) || '/'
return target
}
async function submit() {
const user = username.value.trim()
if (!user || !password.value) {
errorMessage.value = '请输入用户名和密码'
return
}
if (loading.value) return
errorMessage.value = ''
loading.value = true
try {
const did = deviceId()
await http.post<unknown>('/login', { username: user, password: password.value, deviceId: did })
session.$reset()
await session.initialize()
const target = safeRedirect(route.query.redirect)
await router.replace(target)
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '用户名或密码错误'
loading.value = false
}
}
onMounted(() => {
if (session.initialized && session.user) {
router.replace('/')
}
})
</script>
<template>
<div class="auth-page">
<aside class="auth-visual" aria-label="数富AI 产品信息">
<div class="auth-visual-content">
<div class="auth-brand">
<span class="auth-brand-mark" aria-hidden="true"><img :src="logoUrl" alt=""></span>
<span class="auth-brand-copy">
<span class="auth-brand-name">数富AI</span>
<span class="auth-brand-sub">电商运营管理后台</span>
</span>
</div>
<div class="auth-visual-copy">
<span class="auth-kicker">数富AI · 运营工作台</span>
<h2 class="auth-visual-title">让每一次运营动作都有清晰的工作流</h2>
<p class="auth-visual-description">统一管理数据店铺任务与版本让团队在一个可靠的运营工作台中快速协作</p>
<ul class="auth-feature-list" aria-label="工作台能力">
<li class="auth-feature-item">
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg></span>
<span>权限隔离操作边界清晰可控</span>
</li>
<li class="auth-feature-item">
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 2"></path><circle cx="12" cy="12" r="9"></circle><path d="M3 4v5h5"></path><path d="M3.5 9A9 9 0 0 1 19 5.5"></path></svg></span>
<span>任务状态进度反馈及时透明</span>
</li>
<li class="auth-feature-item">
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="m7 15 3-4 3 2 4-6"></path><path d="M17 7h3v3"></path></svg></span>
<span>数据工具支撑日常电商运营</span>
</li>
</ul>
</div>
</div>
<div class="auth-visual-footer">
<span>数富AI · 管理控制台</span>
<span class="auth-system-status">服务已就绪</span>
</div>
</aside>
<main class="auth-content">
<section class="login-card" aria-labelledby="loginTitle">
<header class="login-card-header">
<p class="login-card-eyebrow">欢迎回来</p>
<h1 class="login-title" id="loginTitle">登录工作台</h1>
<p class="login-subtitle">使用管理员账号进入数富AI运营后台</p>
</header>
<form novalidate @submit.prevent="submit">
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
<div class="form-group">
<label for="loginUsername">用户名</label>
<div class="input-shell">
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"></circle><path d="M4 21a8 8 0 0 1 16 0"></path></svg></span>
<input id="loginUsername" v-model="username" type="text" autocomplete="username" placeholder="请输入用户名" autofocus>
</div>
</div>
<div class="form-group">
<label for="loginPassword">密码</label>
<div class="input-shell">
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="10" x="5" y="11" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path></svg></span>
<input id="loginPassword" v-model="password" :type="inputType" autocomplete="current-password" placeholder="请输入密码" @keyup.enter="submit">
<button type="button" class="password-toggle" :aria-label="showPassword ? '隐藏密码' : '显示密码'" @click="showPassword = !showPassword">
<svg v-if="!showPassword" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 3 18 18"></path><path d="M10.6 5.1A9.7 9.7 0 0 1 12 5c6 0 9.5 7 9.5 7a17 17 0 0 1-2.3 3"></path><path d="M6.6 6.6C3.7 8.6 2.5 12 2.5 12s3.5 7 9.5 7a9.7 9.7 0 0 0 4.5-1.1"></path></svg>
</button>
</div>
</div>
<button type="submit" class="btn-login" :class="{ 'is-loading': loading }" :disabled="loading" :aria-busy="loading">
<span class="btn-login-label">{{ loading ? '登录中...' : '登录' }}</span>
<span class="btn-login-spinner" aria-hidden="true"></span>
</button>
</form>
<p class="login-security-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg>
<span>请勿在公共设备保存账号凭据登录状态由本地安全会话管理</span>
</p>
<footer class="login-card-footer">数富AI · 电商运营管理工作台</footer>
</section>
</main>
</div>
</template>
<style>
.auth-page {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(420px, 1.1fr);
color: #24384d;
background:
radial-gradient(circle at 8% 0%, rgba(178, 205, 229, 0.32), transparent 34rem),
radial-gradient(circle at 96% 100%, rgba(214, 226, 239, 0.28), transparent 28rem),
#f4f7fb;
}
button, input { font: inherit; }
.auth-visual {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100vh;
padding: 36px clamp(32px, 5vw, 84px) 34px;
overflow: hidden;
border-right: 1px solid #d4e0eb;
background:
linear-gradient(160deg, rgba(232, 240, 248, 0.97), rgba(248, 251, 254, 0.99)),
radial-gradient(circle at 20% 8%, rgba(112, 148, 186, 0.16), transparent 28rem);
}
.auth-visual::before,
.auth-visual::after {
content: "";
position: absolute;
pointer-events: none;
}
.auth-visual::before {
inset: 0;
opacity: 0.3;
background-image: linear-gradient(rgba(79, 120, 165, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(79, 120, 165, 0.1) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: linear-gradient(to bottom, black, transparent 78%);
}
.auth-visual::after {
width: 360px;
height: 360px;
right: -150px;
bottom: -160px;
border: 1px solid rgba(79, 120, 165, 0.22);
border-radius: 50%;
box-shadow: 0 0 0 32px rgba(79, 120, 165, 0.055), 0 0 0 64px rgba(79, 120, 165, 0.035);
}
.auth-visual-content, .auth-visual-footer { position: relative; z-index: 1; }
.auth-brand { display: inline-flex; align-items: center; gap: 12px; width: fit-content; }
.auth-brand-mark {
display: inline-flex; align-items: center; justify-content: center;
width: 46px; height: 46px; border-radius: 13px; overflow: hidden;
background: #fff; box-shadow: 0 10px 24px rgba(79, 120, 165, 0.18);
}
.auth-brand-mark img { display: block; width: 100%; height: 100%; object-fit: cover; }
.auth-brand-copy { display: grid; gap: 1px; }
.auth-brand-name { font-size: 22px; font-weight: 700; letter-spacing: 0.2px; }
.auth-brand-sub { color: #77899b; font-size: 11px; }
.auth-visual-content { max-width: 540px; margin: auto 0; padding: 72px 0 96px; }
.auth-visual-copy { padding-top: 72px; }
.auth-kicker {
display: inline-flex; align-items: center; gap: 8px;
color: #2f5d8b; font-size: 11px; font-weight: 700; letter-spacing: 1.8px;
}
.auth-kicker::before { content: ""; width: 24px; height: 1px; background: #4f78a5; }
.auth-visual-title {
max-width: 560px; margin: 18px 0;
font-size: clamp(30px, 4vw, 52px); font-weight: 700; letter-spacing: -1.8px; line-height: 1.12;
}
.auth-visual-description { max-width: 470px; margin: 0; color: #60748a; font-size: 16px; line-height: 1.75; }
.auth-feature-list { display: grid; gap: 12px; margin: 34px 0 0; padding: 0; list-style: none; }
.auth-feature-item { display: flex; align-items: center; gap: 12px; color: #465d73; }
.auth-feature-icon {
display: inline-flex; align-items: center; justify-content: center;
flex: 0 0 30px; width: 30px; height: 30px;
border: 1px solid #c2d3e3; border-radius: 9px; background: #edf5fb; color: #2f5d8b;
}
.auth-feature-icon svg { width: 16px; height: 16px; }
.auth-visual-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #778b9f; font-size: 12px; }
.auth-system-status { display: inline-flex; align-items: center; gap: 7px; color: #3d7158; }
.auth-system-status::before {
content: ""; width: 7px; height: 7px; border-radius: 50%;
background: #4e806d; box-shadow: 0 0 0 4px rgba(78, 128, 108, 0.13);
}
.auth-content {
display: flex; align-items: center; justify-content: center;
min-width: 0; padding: 40px clamp(24px, 6vw, 96px);
background: rgba(249, 251, 253, 0.5);
}
.login-card {
width: min(100%, 452px);
padding: clamp(28px, 4vw, 48px);
border: 1px solid #d8e3ee;
border-radius: 22px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 26px 70px -38px rgba(39, 67, 94, 0.34);
}
.login-card-header { margin-bottom: 30px; }
.login-card-eyebrow { margin: 0 0 8px; color: #71859a; font-size: 15px; font-weight: 700; letter-spacing: 1.2px; }
.login-title { margin: 0; font-size: 30px; font-weight: 700; letter-spacing: -0.8px; line-height: 1.2; }
.login-subtitle { margin: 10px 0 0; color: #5b6f83; line-height: 1.65; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 8px; color: #5b6f83; font-size: 13px; font-weight: 600; }
.input-shell { position: relative; }
.input-icon { position: absolute; top: 50%; left: 14px; display: inline-flex; color: #8298ad; pointer-events: none; transform: translateY(-50%); }
.input-icon svg { width: 18px; height: 18px; }
.form-group input {
width: 100%; min-height: 48px; padding: 12px 52px 12px 44px;
border: 1px solid #cbd9e6; border-radius: 12px; outline: none;
background: #f8fbfd; color: #24384d; font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover { border-color: #9fb7cd; }
.form-group input:focus { border-color: #5f85ad; background: #fff; box-shadow: 0 0 0 4px rgba(95, 133, 173, 0.16); }
.form-group input::placeholder { color: #8293a5; }
.password-toggle {
position: absolute; top: 50%; right: 4px;
display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border: 0; border-radius: 9px;
background: transparent; color: #8298ad; cursor: pointer; transform: translateY(-50%);
transition: background 160ms ease, color 160ms ease;
}
.password-toggle:hover { background: #edf5fb; color: #2f5d8b; }
.password-toggle svg { width: 18px; height: 18px; }
.error-msg {
display: flex; align-items: flex-start; gap: 9px;
margin: -4px 0 18px; padding: 11px 12px;
border: 1px solid #e4c2c5; border-radius: 11px; background: #f8ebeb; color: #91474f;
font-size: 13px; line-height: 1.55;
}
.error-msg::before {
content: "!";
display: inline-flex; align-items: center; justify-content: center;
flex: 0 0 18px; width: 18px; height: 18px;
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
}
.btn-login {
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
width: 100%; min-height: 48px; padding: 12px 18px;
border: 1px solid #7196ba; border-radius: 12px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #fff; cursor: pointer; font-size: 15px; font-weight: 800;
box-shadow: 0 12px 24px -16px rgba(79, 120, 165, 0.82);
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease, opacity 160ms ease;
}
.btn-login:hover:not(:disabled) { background: linear-gradient(135deg, #7094ba, #5d83ac); color: #fff; box-shadow: 0 16px 28px -15px rgba(79, 120, 165, 0.86); transform: translateY(-1px); }
.btn-login:active:not(:disabled) { transform: translateY(1px) scale(0.99); }
.btn-login:disabled { cursor: wait; opacity: 0.7; }
.btn-login-spinner { display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.35); border-top-color: #fff; border-radius: 50%; animation: login-spin 0.8s linear infinite; }
.btn-login.is-loading .btn-login-spinner { display: inline-block; }
@keyframes login-spin { to { transform: rotate(360deg); } }
.login-security-note { display: flex; align-items: flex-start; gap: 9px; margin: 22px 0 0; color: #71859a; font-size: 12px; line-height: 1.55; }
.login-security-note svg { flex: 0 0 auto; width: 16px; height: 16px; margin-top: 1px; color: #4e806d; }
.login-card-footer { margin-top: 34px; padding-top: 18px; border-top: 1px solid #dce5ee; color: #778b9f; font-size: 12px; text-align: center; }
@media (max-width: 900px) {
.auth-page { display: block; }
.auth-visual { min-height: auto; padding: 22px 24px; border-right: 0; border-bottom: 1px solid #d4e0eb; }
.auth-visual-content { display: block; max-width: none; margin: 0; padding: 0; }
.auth-visual-copy { display: none; }
.auth-visual-footer { display: none; }
.auth-content { min-height: calc(100vh - 87px); padding: 32px 24px 44px; }
}
@media (max-width: 520px) {
.auth-visual { padding: 18px 16px; }
.auth-content { padding: 24px 14px 32px; }
.login-card { padding: 26px 20px; border-radius: 18px; }
.login-title { font-size: 27px; }
}
</style>
@@ -0,0 +1,280 @@
<script setup lang="ts">
/** 数字人版本管理页(module 13 task 261 对齐 admin panel-digital-human-version;本轮补齐分页/三态/上传进度/下载语义)。 */
import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
deleteDigitalHumanVersion,
fetchDigitalHumanDownloadUrl,
fetchDigitalHumanVersions,
releaseDigitalHumanVersion,
setLatestDigitalHumanVersion,
uploadDigitalHumanVersion,
} from './digitalhuman-api.ts'
import type { DigitalHumanVersion } from './digitalhuman-dto.ts'
import {
digitalHumanChangelogShort,
digitalHumanDeleteConfirmText,
digitalHumanVersionFileSizeText,
} from './digitalhuman-dto.ts'
import { validateDigitalHumanUpload } from './digitalhuman-upload-model.ts'
import { canReleaseDigitalHumanVersion, releaseDigitalHumanConfirmText } from './digitalhuman-release.ts'
import { canSetLatestDigitalHumanVersion, setLatestDigitalHumanConfirmText } from './digitalhuman-latest.ts'
import { digitalHumanStatusDisplay } from './digitalhuman-status-display.ts'
const loading = ref(false)
const items = ref<DigitalHumanVersion[]>([])
/** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */
const pageSize = 20
const page = ref(1)
const pagedItems = computed(() => {
const start = (page.value - 1) * pageSize
return items.value.slice(start, start + pageSize)
})
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadProgress = ref<number | null>(null)
const form = ref({ version: '', file: null as { name?: string; size?: number } | null, changelog: '', minClientVersion: '' })
const confirming = ref<string | null>(null)
const downloadingVersion = ref<string | null>(null)
async function load() {
loading.value = true
try {
items.value = (await fetchDigitalHumanVersions()).items
// 数据收缩后页码越界回钳。
const last = Math.max(Math.ceil(items.value.length / pageSize), 1)
if (page.value > last) page.value = last
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
} finally {
loading.value = false
}
}
function onFileChange(file: File) {
form.value.file = file
}
async function submitUpload() {
const { valid, errors } = validateDigitalHumanUpload({ ...form.value, file: form.value.file })
if (!valid) {
ElMessage.warning(errors.version || errors.file || '请完善上传信息')
return
}
uploading.value = true
uploadProgress.value = 0
const version = (form.value.version || '').trim()
try {
await uploadDigitalHumanVersion(
{ version, file: form.value.file, changelog: form.value.changelog, minClientVersion: form.value.minClientVersion },
form.value.file?.name,
(percent) => {
uploadProgress.value = percent
},
)
ElMessage.success(`上传成功!版本:${version}(状态:草稿,请在列表中点击“发布”按钮)`)
uploadVisible.value = false
uploadProgress.value = null
form.value = { version: '', file: null, changelog: '', minClientVersion: '' }
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '上传失败')
} finally {
uploading.value = false
}
}
async function release(row: DigitalHumanVersion) {
const text = releaseDigitalHumanConfirmText(row.version)
if (!text) return
confirming.value = row.version
try {
await ElMessageBox.confirm(text, '发布版本', { type: 'warning', confirmButtonText: '发布', cancelButtonText: '取消' })
await releaseDigitalHumanVersion(row.version)
ElMessage.success('已发布')
load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '发布失败')
} finally {
confirming.value = null
}
}
async function setLatest(row: DigitalHumanVersion) {
const text = setLatestDigitalHumanConfirmText(row.version)
if (!text) return
confirming.value = row.version
try {
await ElMessageBox.confirm(text, '设为最新', { type: 'warning', confirmButtonText: '设为最新', cancelButtonText: '取消' })
await setLatestDigitalHumanVersion(row.version)
ElMessage.success('已设为最新')
load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '操作失败')
} finally {
confirming.value = null
}
}
/** 下载(对齐 admin.js downloadDigitalHumanVersionRELEASED 恒显示,点击取链接并触发下载)。 */
async function download(row: DigitalHumanVersion) {
if (!row.version) return
downloadingVersion.value = row.version
try {
const url = await fetchDigitalHumanDownloadUrl(row.version)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `ShuFuDigitalHuman-${row.version}.zip`
anchor.target = '_blank'
anchor.rel = 'noopener'
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败')
} finally {
downloadingVersion.value = null
}
}
async function remove(row: DigitalHumanVersion) {
const text = digitalHumanDeleteConfirmText(row.version)
if (!text) return
confirming.value = row.version
try {
await ElMessageBox.confirm(text, '删除版本', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await deleteDigitalHumanVersion(row.version)
ElMessage.success('删除成功')
load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '删除失败')
} finally {
confirming.value = null
}
}
function canDelete(row: DigitalHumanVersion): boolean {
return row.isLatest !== true
}
function statusOf(row: DigitalHumanVersion): { text: string; tag: 'info' | 'success' | 'danger' } {
return digitalHumanStatusDisplay(row.status)
}
onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>数字人版本管理</h2>
<p>管理数字人客户端的草稿/已发布/已废弃版本</p>
</div>
<div class="actions">
<el-button type="primary" @click="uploadVisible = true">上传草稿状态</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无数字人版本">
<el-table-column prop="version" label="版本号" min-width="150" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<el-tag :type="statusOf(row as DigitalHumanVersion).tag" size="small">
{{ statusOf(row as DigitalHumanVersion).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最新" min-width="60" align="center">
<template #default="{ row }">
<span v-if="(row as DigitalHumanVersion).isLatest" class="star" title="最新版本"></span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="文件大小" min-width="100">
<template #default="{ row }">{{ digitalHumanVersionFileSizeText((row as DigitalHumanVersion).fileSize) }}</template>
</el-table-column>
<el-table-column label="MD5" min-width="130">
<template #default="{ row }">
<span :title="(row as DigitalHumanVersion).md5 || ''">{{ (row as DigitalHumanVersion).md5 ? `${((row as DigitalHumanVersion).md5 || '').substring(0, 12)}...` : '—' }}</span>
</template>
</el-table-column>
<el-table-column label="更新说明" min-width="180">
<template #default="{ row }">
<span :title="(row as DigitalHumanVersion).changelog || ''">{{ digitalHumanChangelogShort((row as DigitalHumanVersion).changelog) }}</span>
</template>
</el-table-column>
<el-table-column label="发布时间" min-width="170">
<template #default="{ row }">{{ formatDateTime((row as DigitalHumanVersion).releasedAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="240" fixed="right">
<template #default="{ row }">
<el-button v-if="canReleaseDigitalHumanVersion((row as DigitalHumanVersion).status)" text type="warning" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="release(row as DigitalHumanVersion)">发布</el-button>
<el-button v-if="!canReleaseDigitalHumanVersion((row as DigitalHumanVersion).status) && canSetLatestDigitalHumanVersion((row as DigitalHumanVersion).status) && !(row as DigitalHumanVersion).isLatest" text type="primary" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="setLatest(row as DigitalHumanVersion)">设为最新</el-button>
<el-button v-if="canSetLatestDigitalHumanVersion((row as DigitalHumanVersion).status)" text type="success" size="small" :loading="downloadingVersion === (row as DigitalHumanVersion).version" @click="download(row as DigitalHumanVersion)">下载</el-button>
<el-button v-if="canDelete(row as DigitalHumanVersion)" text type="danger" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="remove(row as DigitalHumanVersion)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ items.length.toLocaleString() }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
:total="items.length"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p }"
/>
</div>
</el-card>
<el-dialog v-model="uploadVisible" title="上传数字人版本" width="560px">
<p class="upload-desc">上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布。</p>
<el-form label-width="110px">
<el-form-item label="版本号" required>
<el-input v-model="form.version" placeholder=" 1.0.2" />
</el-form-item>
<el-form-item label="压缩包" required>
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (form.file = null)">
<el-button>选择文件</el-button>
</el-upload>
<div v-if="form.file" class="dim">{{ form.file.name }}</div>
</el-form-item>
<el-form-item label="最低客户端">
<el-input v-model="form.minClientVersion" placeholder="可选客户端最低版本要求" />
</el-form-item>
<el-form-item label="更新说明">
<el-input v-model="form.changelog" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
<el-alert v-if="uploading && uploadProgress != null" :title="`正在上传:${uploadProgress}%`" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">上传草稿状态</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
.actions { display: flex; align-items: center; gap: 10px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.star { color: #e6a23c; font-weight: 700; }
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
</style>
@@ -0,0 +1,274 @@
<script setup lang="ts">
/** 历史生成记录页(module 13 task 259 对齐 admin panel-history):指定用户下拉 + 起止时间筛选、类型中文、≤3 缩略图、大图预览、空态。 */
import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchHistoryList, fetchHistoryUserOptions, clearHistory } from './history-api.ts'
import type { HistoryUserOption } from './history-user-option.ts'
import type { HistoryRecordItem } from './history-dto.ts'
import { historyEmptyText, historyErrorText } from './history-feedback.ts'
import { historyPanelTypeLabel } from './history-type.ts'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const userLoading = ref(false)
const rows = ref<HistoryRecordItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const userOptions = ref<HistoryUserOption[]>([])
const errorText = ref('')
const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
const previewVisible = ref(false)
const previewItem = ref<HistoryRecordItem | null>(null)
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value }))
/** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */
function previewUrls(item: HistoryRecordItem): string[] {
const out: string[] = []
const seen = new Set<string>()
const push = (url: string) => {
const clean = typeof url === 'string' ? url.trim() : ''
if (!clean || seen.has(clean)) return
seen.add(clean)
out.push(clean)
}
if (item.longImageUrl) push(item.longImageUrl)
for (const url of item.resultUrls || []) push(url)
return out
}
async function load() {
loading.value = true
errorText.value = ''
try {
const result = await fetchHistoryList({
page: page.value,
pageSize,
userId: filter.userId ? Number(filter.userId) : undefined,
timeStart: filter.timeStart || undefined,
timeEnd: filter.timeEnd || undefined,
})
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
errorText.value = historyErrorText(error)
} finally {
loading.value = false
}
}
async function loadUserOptions() {
userLoading.value = true
try {
userOptions.value = await fetchHistoryUserOptions()
} catch {
userOptions.value = []
} finally {
userLoading.value = false
}
}
function apply() {
page.value = 1
load()
}
function reset() {
filter.userId = ''
filter.timeStart = ''
filter.timeEnd = ''
page.value = 1
load()
}
function retry() {
load()
}
function changePage(next: number) {
page.value = next
load()
}
function openPreview(item: HistoryRecordItem) {
previewItem.value = item
previewVisible.value = true
}
const clearing = ref(false)
/** 清空全部生成记录:二次确认后调后端 DELETE /api/admin/history。 */
async function clearAll() {
try {
await ElMessageBox.confirm('确定清空全部生成记录吗?该操作会删除所有历史数据,无法恢复。', '清理生成记录', {
confirmButtonText: '确认清空',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
clearing.value = true
try {
const removed = await clearHistory()
ElMessage.success(`已清空 ${removed} 条生成记录`)
page.value = 1
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清理失败')
} finally {
clearing.value = false
}
}
onMounted(() => {
load()
loadUserOptions()
})
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>生成记录</h2>
<p>查看各用户在后台生成的历史记录与结果图</p>
</div>
<div class="heading-actions">
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
</div>
</div>
<el-card shadow="never" class="filter-card">
<div class="filter-grid">
<div class="f-item">
<label>指定用户</label>
<el-select v-model="filter.userId" clearable filterable placeholder="全部用户" style="width: 100%" :loading="userLoading">
<el-option label="全部用户" value="" />
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
</el-select>
</div>
<div class="f-item">
<label>开始时间</label>
<el-date-picker
v-model="filter.timeStart"
type="datetime"
placeholder="开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
<div class="f-item">
<label>结束时间</label>
<el-date-picker
v-model="filter.timeEnd"
type="datetime"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<el-alert
v-if="errorText"
:title="errorText"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
>
<template #default>
<el-button link type="primary" @click="retry">重试</el-button>
</template>
</el-alert>
<el-table v-loading="loading" :data="rows" stripe :empty-text="emptyText">
<el-table-column prop="id" label="ID" min-width="90" />
<el-table-column prop="username" label="用户" min-width="140" />
<el-table-column label="类型" min-width="150">
<template #default="{ row }">
{{ historyPanelTypeLabel((row as HistoryRecordItem).panelType) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime((row as HistoryRecordItem).createdAt) }}</template>
</el-table-column>
<el-table-column label="结果预览" min-width="220">
<template #default="{ row }">
<div v-if="previewUrls(row as HistoryRecordItem).length" class="thumbs">
<el-image
v-for="(url, index) in previewUrls(row as HistoryRecordItem).slice(0, 3)"
:key="url"
:src="url"
:preview-src-list="previewUrls(row as HistoryRecordItem)"
:initial-index="index"
preview-teleported
fit="cover"
class="thumb"
/>
<span v-if="previewUrls(row as HistoryRecordItem).length > 3" class="dim">+{{ previewUrls(row as HistoryRecordItem).length - 3 }}</span>
</div>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="110" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openPreview(row as HistoryRecordItem)">查看大图</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> <b>{{ total.toLocaleString() }}</b> </span>
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
</div>
</el-card>
<el-dialog v-model="previewVisible" :title="previewItem ? `记录 #${previewItem.id} 结果图` : '结果图'" width="820px" top="6vh">
<div v-if="previewItem" class="preview-meta">
<el-tag size="small">{{ previewItem.username }}</el-tag>
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
</div>
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
<el-image
v-for="(url, index) in previewUrls(previewItem)"
:key="url"
:src="url"
:preview-src-list="previewUrls(previewItem)"
:initial-index="index"
preview-teleported
fit="contain"
class="preview-img"
/>
</div>
<el-empty v-else description="无结果图片" />
</el-dialog>
</div>
</template>
<style scoped>
.filter-card { margin-bottom: 14px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.thumbs { display: flex; align-items: center; gap: 6px; }
.thumb { width: 56px; height: 56px; border-radius: 4px; border: 1px solid var(--el-border-color-lighter); flex: none; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.table-footer b { color: var(--el-text-color-primary); }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.preview-meta { display: flex; align-items: center; gap: 12px; color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 12px; }
.preview-grid { display: flex; flex-direction: column; gap: 12px; }
.preview-img { width: 100%; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
</style>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 软件版本管理页:客户端版本列表 + 上传新版本(成功留驻弹窗含链接,对齐 admin panel-version)。 */
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts'
import type { SoftwareVersionItem } from './version-dto.ts'
import { isNonEmptyVersion } from './version-dto.ts'
const loading = ref(false)
const items = ref<SoftwareVersionItem[]>([])
// 客户端分页:全量拉取后按页切片展示。
const page = ref(1)
const pageSize = 10
const total = computed(() => items.value.length)
const pagedItems = computed(() => items.value.slice((page.value - 1) * pageSize, page.value * pageSize))
function changePage(p: number) {
page.value = p
}
const uploadVisible = ref(false)
const uploading = ref(false)
const newVersion = ref('')
const pickedFile = ref<File | null>(null)
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
const uploadMsg = ref('')
const uploadMsgOk = ref(false)
async function load() {
loading.value = true
try {
items.value = (await fetchSoftwareVersions()).items
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
} finally {
loading.value = false
}
}
function onFileChange(file: File) {
pickedFile.value = file
}
function openUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
newVersion.value = ''
pickedFile.value = null
uploadVisible.value = true
}
async function submitUpload() {
uploadMsg.value = ''
uploadMsgOk.value = false
if (!isNonEmptyVersion(newVersion.value)) {
uploadMsg.value = '请填写版本号'
return
}
if (!pickedFile.value) {
uploadMsg.value = '请选择 zip 压缩包'
return
}
if (!/\.zip$/i.test(pickedFile.value.name)) {
uploadMsg.value = '仅支持 .zip 格式'
return
}
uploading.value = true
try {
const created = await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name)
const version = created?.version || newVersion.value.trim()
const link = created?.fileUrl
// 对齐 admin.js:6484-6488:弹窗保持打开,成功文案(含链接)留驻可复制,仅清空输入并刷新列表。
uploadMsg.value = link ? `发布成功。版本:${version},链接:${link}` : `发布成功。版本:${version}`
uploadMsgOk.value = true
newVersion.value = ''
pickedFile.value = null
load()
} catch (error) {
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
} finally {
uploading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>软件版本管理</h2>
<p>管理客户端更新包版本用于 /api/version/latest 下发</p>
</div>
<div class="actions">
<el-button type="primary" @click="openUpload">上传新版本</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无版本记录">
<el-table-column prop="version" label="版本号" min-width="180" />
<el-table-column label="下载链接" min-width="240">
<template #default="{ row }">
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" target="_blank" rel="noopener">{{ (row as SoftwareVersionItem).fileUrl }}</a>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" download>下载</a>
<span v-else class="dim"></span>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
</div>
</el-card>
<el-dialog v-model="uploadVisible" title="上传新版本" width="520px">
<p class="upload-desc">上传软件 ZIP 系统会自动计算 MD5 并存储到 OSS</p>
<el-form label-width="110px">
<el-form-item label="版本号" required>
<el-input v-model="newVersion" placeholder="例如:1.0.0" />
</el-form-item>
<el-form-item label="ZIP 压缩包" required>
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (pickedFile = null)">
<el-button>选择文件</el-button>
</el-upload>
<p class="zip-hint">仅支持 .zip 格式</p>
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
</el-form-item>
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">上传软件版本</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
.actions { display: flex; align-items: center; gap: 10px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
.zip-hint { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
.upload-msg { margin-bottom: 8px; }
.upload-msg :deep(.el-alert__title) { word-break: break-all; }
</style>
@@ -0,0 +1,62 @@
/** 数字人版本适配(任务 132-136):/api/digital-human/versions 系列端点。 */
import { http } from '@/api/http'
import { parseDigitalHumanVersions } from './digitalhuman-model.ts'
import { buildDigitalHumanUploadFields, type DigitalHumanUploadForm } from './digitalhuman-upload-model.ts'
import type { DigitalHumanVersionList } from './digitalhuman-dto.ts'
export const DIGITAL_HUMAN_VERSIONS_ENDPOINT = '/api/digital-human/versions'
export async function fetchDigitalHumanVersions(): Promise<DigitalHumanVersionList> {
const { data } = await http.get<unknown>(DIGITAL_HUMAN_VERSIONS_ENDPOINT)
return parseDigitalHumanVersions(data)
}
/** 上传数字人版本(multipart:字段 + file)onProgress 接收 0-100 进度(对齐 admin.js:6543-6550 正在上传 N%)。 */
export async function uploadDigitalHumanVersion(
form: DigitalHumanUploadForm,
filename = '',
onProgress?: (percent: number) => void,
): Promise<void> {
const body = new FormData()
const fields = buildDigitalHumanUploadFields(form)
Object.entries(fields).forEach(([key, value]) => body.append(key, value))
if (form.file) body.append('file', form.file as unknown as Blob, filename || (form.file.name || 'dh.zip'))
await http.post<unknown>(`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/upload`, body, {
onUploadProgress: (event) => {
if (!onProgress) return
const total = event.total || 0
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
},
})
}
/** 获取数字人版本下载链接(对齐 admin.js downloadDigitalHumanVersion:点击才取链接)。 */
export async function fetchDigitalHumanDownloadUrl(version: string): Promise<string> {
const { data } = await http.get<unknown>(
`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/${encodeURIComponent(version)}/download-url`,
)
const core = data as { data?: unknown } | null | undefined
const payload = (core && core.data) || data
if (typeof payload === 'string') return payload
if (payload && typeof payload === 'object') {
const record = payload as Record<string, unknown>
const url = record.downloadUrl ?? record.url ?? record.download_url
if (typeof url === 'string' && url) return url
}
throw new Error('Java 后端未返回下载链接')
}
/** 发布数字人版本:POST /{version}/release。 */
export async function releaseDigitalHumanVersion(version: string): Promise<void> {
await http.post<unknown>(`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/${encodeURIComponent(version)}/release`)
}
/** 设为最新版本:POST /{version}/set-latest。 */
export async function setLatestDigitalHumanVersion(version: string): Promise<void> {
await http.post<unknown>(`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/${encodeURIComponent(version)}/set-latest`)
}
/** 删除数字人版本:DELETE /{version}。 */
export async function deleteDigitalHumanVersion(version: string): Promise<void> {
await http.delete<unknown>(`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/${encodeURIComponent(version)}`)
}
@@ -0,0 +1,55 @@
/** 数字人版本 DTO(任务 131):数字人版本行/状态类型;与软件版本 DTO 分离,纯逻辑。 */
export type DigitalHumanVersionStatus = 'DRAFT' | 'RELEASED' | 'DEPRECATED'
/** 数字人版本行(GET /api/digital-human/versionsJava DigitalHumanVersionVo camel)。 */
export interface DigitalHumanVersion {
id: number
version: string
fileSize?: number
md5?: string
changelog?: string
minClientVersion?: string
isLatest?: boolean
status: DigitalHumanVersionStatus
downloadUrl?: string
createdBy?: string
createdAt?: string
releasedAt?: string
}
export interface DigitalHumanVersionList {
items: DigitalHumanVersion[]
}
export function emptyDigitalHumanVersionList(): DigitalHumanVersionList {
return { items: [] }
}
/** 状态归一(对齐 admin.js:6337 三态:DRAFT 草稿 / RELEASED 已发布 / 其余一律已废弃)。 */
export function normalizeDigitalHumanStatus(value: unknown): DigitalHumanVersionStatus {
const text = typeof value === 'string' ? value.trim().toUpperCase() : ''
if (text === 'RELEASED') return 'RELEASED'
if (text === 'DRAFT') return 'DRAFT'
return 'DEPRECATED'
}
/** 文件大小展示(对齐 admin.js:6340 固定两位小数 MB;空/0 显示 -)。 */
export function digitalHumanVersionFileSizeText(fileSize: number | null | undefined): string {
if (typeof fileSize !== 'number' || !Number.isFinite(fileSize) || fileSize <= 0) return '-'
return `${(fileSize / 1024 / 1024).toFixed(2)} MB`
}
/** 更新说明 50 字符截断(对齐 admin.js:6342-6343;空显示 -)。 */
export function digitalHumanChangelogShort(changelog: string | null | undefined): string {
const text = (changelog || '').trim() || '-'
if (text === '-') return text
if (text.length > 50) return `${text.substring(0, 50)}...`
return text
}
/** 删除数字人版本确认文案(对齐 admin.js:6356)。 */
export function digitalHumanDeleteConfirmText(version: string): string {
const v = (version || '').trim()
return v ? `确认删除版本 ${v} 吗?该操作会删除对应文件,无法恢复。` : ''
}
@@ -0,0 +1,9 @@
/** 数字人版本展示格式化(module 13 task 261):MD5 截断展示;纯逻辑。 */
/** MD5 前 len 位(默认 12,对齐 admin)展示;空值回空串。 */
export function md5Short(md5: string | null | undefined, len = 12): string {
const text = typeof md5 === 'string' ? md5.trim() : ''
if (!text) return ''
const max = typeof len === 'number' && Number.isFinite(len) && len >= 1 ? Math.floor(len) : 12
return text.length <= max ? text : `${text.slice(0, max)}`
}
@@ -0,0 +1,11 @@
/** 数字人版本设为最新操作(任务 136):仅已发布版本可设为最新;纯逻辑。 */
export function canSetLatestDigitalHumanVersion(status: unknown): boolean {
return typeof status === 'string' && status.trim().toUpperCase() === 'RELEASED'
}
/** 设为最新确认文案(对齐 admin.js:6350);空版本返回 null。 */
export function setLatestDigitalHumanConfirmText(version: string): string | null {
const v = (version || '').trim()
return v ? `确认将版本 ${v} 设为最新吗?客户端会自动检测更新。` : null
}
@@ -0,0 +1,50 @@
/** 数字人版本列表加载模型(任务 132):解析 /api/digital-human/versions 数组负载(camel);纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
import { normalizeDigitalHumanStatus, type DigitalHumanVersion, type DigitalHumanVersionList } from './digitalhuman-dto.ts'
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 解析单条数字人版本;缺 id 视为无效。 */
export function toDigitalHumanVersion(raw: unknown): DigitalHumanVersion | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const id = numberOrNull(r.id)
if (id === null) return null
const version: DigitalHumanVersion = { id, version: text(r.version), status: normalizeDigitalHumanStatus(r.status) }
const fileSize = numberOrNull(r.fileSize)
if (fileSize !== null) version.fileSize = fileSize
const md5 = text(r.md5)
if (md5) version.md5 = md5
const changelog = text(r.changelog)
if (changelog) version.changelog = changelog
const minClientVersion = text(r.minClientVersion)
if (minClientVersion) version.minClientVersion = minClientVersion
const downloadUrl = text(r.downloadUrl)
if (downloadUrl) version.downloadUrl = downloadUrl
const createdBy = text(r.createdBy)
if (createdBy) version.createdBy = createdBy
const createdAt = text(r.createdAt)
if (createdAt) version.createdAt = createdAt
const releasedAt = text(r.releasedAt)
if (releasedAt) version.releasedAt = releasedAt
if (typeof r.isLatest === 'boolean') version.isLatest = r.isLatest
return version
}
/** 归一化版本列表负载(data 数组)为前端列表;缺省回空。 */
export function parseDigitalHumanVersions(payload: unknown): DigitalHumanVersionList {
const data = unwrap<unknown>(payload)
const list = Array.isArray(data) ? data : data && typeof data === 'object' ? (data as Record<string, unknown>).items : null
if (!Array.isArray(list)) return { items: [] }
return {
items: list
.map((raw) => toDigitalHumanVersion(raw))
.filter((version): version is DigitalHumanVersion => version !== null),
}
}
@@ -0,0 +1,12 @@
/** 数字人版本发布操作(任务 135):可发布判定与确认文案;纯逻辑。 */
/** 仅明确为草稿(DRAFT)的版本可发布;未知/已发布不可。 */
export function canReleaseDigitalHumanVersion(status: unknown): boolean {
return typeof status === 'string' && status.trim().toUpperCase() === 'DRAFT'
}
/** 发布确认文案(对齐 admin.js:6347);空版本返回 null。 */
export function releaseDigitalHumanConfirmText(version: string): string | null {
const v = (version || '').trim()
return v ? `确认发布版本 ${v} 吗?` : null
}

Some files were not shown because too many files have changed in this diff Show More