Compare commits

..

197 Commits

Author SHA1 Message Date
huangzd1997 5b8105ec2b feat(后台管理): 实体管理列表统一展示创建时间/更新时间
用户管理、菜单管理、不符合ASIN、数据去重总数据、查询ASIN、最低价ASIN、
商品类目、密钥管理共 8 个实体管理列表补齐两列。分组管理/店铺密钥/店铺管理
此前已带创建+修改时间,任务列表与统计报表(撞款监控、密钥用量、日志、
记录与版本)不含实体更新语义,均未改动。

关键点——时间列必须由数据库维护,否则新列是假的:
这些表的更新走 selectById → 改字段 → updateById,实体带着读出的旧
updated_at 一起写回。MySQL 规则是「UPDATE 显式给某列赋值时不触发该列的
ON UPDATE 自动更新」,不禁写就会把旧值写回去,更新时间永远冻结在首次写入
时刻。按 V125(biz_file_result)既有样板,给 7 个实体标注
@TableField(insertStrategy=NEVER, updateStrategy=NEVER)。

- V131:users / columns 补 updated_at(幂等 ADD COLUMN,仿 V125 写法)。
  存量行被回填为迁移执行时刻,非真实历史变更时间(历史上无记录,无法还原)
- 实体/VO:AdminUserEntity、PermissionMenuEntity、InvalidAsinDataEntity、
  DedupeTotalDataEntity、ProductCategoryEntity、QueryAsinEntity、
  SkipPriceAsinEntity 加/改写 updatedAt;AdminUserItemVo、PermissionMenuItemVo、
  InvalidAsinDataItemVo、DedupeTotalDataItemVo 补 updatedAt;
  AdminUserSecretRowVo 补 createdAt(行级首次配置时间 = 三模块最早)
- 查询ASIN/最低价ASIN 后端 VO 与前端 model 本就有两字段,仅补渲染
- 前端 8 页表格加列,同步修正空态/加载行的 colspan(手写表格,不同步会错位)
- 测试:align-query-asin / align-skip-price 原断言「不允许有更新时间列」
  (像素复刻旧版),按新需求改为断言两列存在;新增 e2e list-time-columns
  覆盖 8 页表头与真实时间值渲染
2026-09-19 15:53:01 +08:00
huangzd1997 5fcb449946 chore(客户端更新日志): 补 4.0.31 条目(购物车未知时最低价也生效) 2026-09-19 15:39:20 +08:00
huangzd1997 ff68426c69 chore(客户端更新日志): 补 4.0.30 条目(跟价最低价跌破自动调回) 2026-09-19 15:24:30 +08:00
huangzd1997 46be044121 fix(实例路由): 转发失败不再伪装成「任务不存活」
归属节点滚动重启时跨节点转发会连接失败,原来返回 ApiResponse.fail(40903) ——
HTTP 200 + data:null。客户端把「拿不到数据」解析成 alive=false,于是把健康的长
任务主动停掉(2026-09-18 任务 28616:跑到 66/253 被自杀,只跑到第 2/6 页)。

改为 503 + 空 body:新客户端按状态码判未知继续跑;老客户端因 body 不是 JSON、
resp.json() 抛异常也落到未知——两边都不会再把未知当成死。顺带让这类故障在
HTTP 指标里可见(原先记成 200,滚动重启期间丢了多少心跳监控完全看不到)。
2026-09-18 16:25:33 +08:00
huangzd1997 e0303f9cba feat(上架): 店铺互斥改为按(设备,店铺),同店可在不同客户端并行跑不同国家
背景:紫鸟浏览器会话是每台客户端一份,同一家店可以在两台机器上并行;
国家是会话内的可切换状态(SwitchingCountries,断线重连还会再切),
所以真正必须串行的只有"同一台设备上的同一家店"。原来的全局按店铺互斥
把跨机器的合法并行也挡了(2026-09-18 任务 28624 被 28616 误挡)。

- V130:biz_publish_file 增加 device_id 列
- AdminAuthSupport.currentDeviceId():只读 JWT 签名的 deviceId claim,
  不信任客户端可控的 X-Device-Id 请求头
- activateFile 互斥键改为 (device_id, shop_name);device 为空(旧客户端
  token 无该 claim / 内部令牌调用)时退回全局店铺互斥,保守不放宽;
  存量 RUNNING 行的 NULL device 视为"来源不明",同样保守拦到跑完为止
- 前端与客户端无需改动:页面 axios 对 /newApi 已带 Authorization Bearer
2026-09-18 16:08:08 +08:00
huangzd1997 9d39705c77 fix(采集数据): 品牌检测移出任务锁 + 失败任务仍产出可下载的部分结果
taskId 28599 事故根因:分片回传在任务锁内同步做品牌检测,上游 16890 卡死时
单次持锁 103.5 秒(costMs=103556),期间该任务的心跳与客户端重试全部撞
40902「任务正在处理」,客户端 5 次重试预算耗尽后中止了整个采集。

- submitResult:行归一化 / 去重过滤 / 品牌检测移到任务锁外,锁内只保留落库与终态
- BrandCheckClient:整批加 90 秒总耗时上限(超时按查询失败降级,首轮始终执行),
  单次回传时延从「无上界」收敛到约 90 秒 + 一次读超时
- 失败且已收到分片时照常组装部分结果;任务已 FAILED 则保留失败态与真实失败原因,
  只补结果文件(乐观写结果行 + 条件更新不匹配再补偿写回,避免崩溃时留下「成功但无下载」)
- productrisk / shopmatch 按 pricetrack 的 preserveFailure 样板补齐,含组装落地时
  不再把失败行「洗成成功」
- 前端:采集 / 跟价 / 产品风险 / 店铺匹配的下载按钮放宽到失败任务
2026-09-18 15:53:15 +08:00
huangzd1997 986df86e89 fix(任务恢复): 停止意图优先于自动续跑 + 未覆盖模块的中断可见
自查上一提交时发现两处遗漏:

- 用户点了「停止循环」而子任务恰好因客户端中断失败时,续派逻辑跑在停止检查之前,
  会把循环从停止意图里拉回 RUNNING 自己转起来。改为停止请求优先:直接 STOPPED。
- 模块不支持自动续跑的中断任务此前完全无痕迹(用户只看到失败)。
  这类模块(上架/改价/审批/商品管理采集/跟价非循环任务)的续跑载荷需要用户在页面上
  选的执行参数(ziniao_version 等),而这份选择只存在于派发那一刻的浏览器里、没落到
  request_json —— 自动重排队会用错参数,所以它们**不纳入**续跑(白名单的设计原则就是
  「执行参数全在服务端」)。现补一条计数查询把这类中断数量带进 stale-check summary
  的 resume(u=N),运维据此人工重跑;将来把参数回写落库后即可纳入白名单。

测试:PriceTrackLoopRunServiceTest 新增「已请求停止时中断失败不续派而是停止」,
TaskResumeServiceTest 新增「统计不支持续跑的中断任务数」。
2026-09-18 15:48:33 +08:00
huangzd1997 bd359411a9 feat(任务恢复): 客户端中断的任务自动续跑(保留失败记录 + 重新排队)
2026-09-18 任务 28587(跟价,uid 977 店铺「张美莺」)在客户端 09:51/09:57 被更新脚本
重启后中断,任务一直挂 RUNNING 到 stale 兜底;跟价循环还有第二重问题——子任务失败
即整个循环判 FAILED,一次客户端更新就让无限循环彻底停下。

契约:中断任务**保留失败记录**(用户看得到「因客户端重启中断」),同时由服务端自动
重新排队续跑,长任务不再因为一次更新整个白跑。

- V129:biz_file_task 加 resume_of_task_id / resume_attempt,
  biz_price_track_loop_run 加 resume_attempt(代数封顶用);
- 新增 TaskResumeService:扫描最近 30 分钟内因客户端中断而失败、未续跑过、代数未超限的
  任务,复制请求参数重新排队成 PENDING,交给已有兜底拉取通道(客户端每分钟 pull-pending)
  领走执行——不新造第二套派发机制。只对注册了 ClientTaskPullSpi 的模块生效
  (相似ASIN/采集/外观专利),上架/改价等写操作模块刻意排除,避免盲目重跑;
- 幂等:按 resume_of_task_id 反查,同一原任务不会重复排队;
- 挂在 stale-check 巡检线(每 2 分钟、有分布式锁),并用隔离壳包住异常:
  续跑失败绝不拖垮判死主流程(判死优先级更高);
- PriceTrackLoopRunService:子任务因「客户端异常中断」失败时不终止循环,
  清 active_task_id 后保持 RUNNING,客户端下次 dispatchNext 拿到同一店铺/同一轮,
  即原地续跑(页内已处理 ASIN 由服务端 skip_asins 去重,不会重复改价);
  连续重派超过 3 次才真正判失败,避免会话持续不可用时无限重开浏览器
  (28587 就是这样白烧了 5 小时)。子任务成功一轮后计数归零;
- application.yml:aiimage.task-resume.enabled 默认跟随 client-task-pull 开关
  (兜底拉取关着时续跑任务无人领取,只会积压被告死)。

测试:TaskResumeServiceTest 6 例(排队/幂等/开关/无实现/插入失败/无归属用户)、
PriceTrackLoopRunServiceTest 新增 4 例(中断续跑/达上限/非中断仍终止/成功归零),
连带更新 DeleteBrandStaleTaskServiceTest 与 TaskModuleCoverageTest 的构造参数。
2026-09-18 15:42:07 +08:00
huangzd1997 3634ea1d62 feat(撞款): 采集明细落库后自动触发重扫,重复检查无需等次日 00:00
- shopdatacrawl 新增 DuplicateCheckRefreshPort 端口,TaskService 在明细落库成功后请求重扫
- shopduplicatecheck 新增 DuplicateCheckRefreshScheduler:10s 合并窗口去抖、单飞、
  扫描锁忙自动重试(最多 6 次),失败不影响采集归档
- 复用现有 SCAN_LOCK,与定时扫描、手动「重新分析」互斥;新增 5 个调度器单测
2026-09-17 23:08:15 +08:00
huangzd1997 3ce0569c59 fix(提示语): 失败提示优先展示后端真实原因,修正 10+ 处误导文案
上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台:
- 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗)
- 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING
- 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案
- dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
2026-09-17 23:02:15 +08:00
huangzd1997 d2d95f0b71 feat(上架): 激活时按店铺互斥,同一店铺不允许两个任务同时跑
2026-09-17 事故(28519/28520/28521 相继提交同一店铺「林洪武」):同一店铺被多个
任务并发打开,客户端 startBrowser 全部返回 -10000,三个任务一起失败。

- PublishTaskService.activateFile:激活前查是否已有其他任务在同一店铺 RUNNING,
  有则拒绝并带出占用中的任务号(激活是任务真正开跑的唯一入口,能最早拦住);
  校验与更新之间仍有极小竞态窗口,真正串行由客户端店铺锁保证,这层负责尽早提示;
- 前端 BrandPublishTab:文件派发失败的原因回显给用户。原来错误只写进日志、提示
  固定为"已记录并继续",用户会误以为是文件问题而反复重传,看不到真正原因;
- 新增 3 个后端单测:同店铺被占用则拒绝、无占用则放行、店铺名为空跳过校验。
2026-09-17 15:48:51 +08:00
huangzd1997 2a51006888 feat(前端防连点): 工具页提交按钮加全局冷却,并精简 4.0.28 更新日志
各工具页的「开始上架 / 启动任务 / 匹配店铺」按钮在提交逻辑跑完后立刻恢复可点,
手快连点会重复发起(2026-09-17 上架事故:同一店铺被接连提交三次,服务端并存多个
同店铺任务,客户端并发打开同一店铺时全部失败)。

- 新增 shared/utils/submit-guard.ts:冷却按按钮元素各自计算(WeakMap,点 A 不影响
  B),在捕获阶段拦截、抢在 Vue 的 @click 之前;冷却自首次有效点击起算,被拦的
  点击不会把冷却越拖越长;
- main.ts 全局安装 installSubmitGuard(),覆盖所有 .btn-run 主按钮,页面零改动;
- 13 个单测覆盖边界:不同按钮互不影响、被拦不延长冷却、非法时长回落默认等;
- client-changelog 每条精简到 40 字以内(超出面板显示不下,已有守护测试)。
2026-09-17 15:41:41 +08:00
huangzd1997 a89de129ea chore(客户端更新日志): 追加 4.0.28 条目
4.0.28 修复两个用户可感知的问题:
- 多个任务同时操作同一店铺导致「打开店铺失败」→ 同一店铺改排队执行;
- 紫鸟更新内核期间直接报「打开店铺失败」→ 改为等待更新完成并显示进度。
2026-09-17 15:32:47 +08:00
huangzd1997 ddefcbed56 feat(任务恢复): 外观专利接上「补传恢复」;合并冲突保留兜底对象并可诊断
28459 的两个遗留项:
1) 外观专利缺「补传恢复」入口——分片缺失致组装 job 重试耗尽后,客户端补传缺口
   也无法自动重跑,只能人工重置 job。TaskFileJobService 早有
   resetTerminalFailedForRecovery,但只被删除品牌模块接了。现按同一口径在分片
   提交成功后检查并恢复;best-effort,恢复失败不影响补传本身。
2) 合并 CAS 冲突不可诊断且会删掉唯一的兜底对象——原实现每次冲突都删掉刚写入的
   版本化对象,重试耗尽即抛异常、行仍指向旧指针;一旦旧对象也不在,该分片永久
   读不到(28459 的 chunk-462/473/484 正是这个形态)。现在冲突时读回行上当前
   哈希并写进异常与日志;终局失败保留最后一个对象,作为读路径「同槽位兄弟对象」
   兜底的恢复源。外观专利与相似ASIN 同一口径。

测试:新增 17 个用例(补传恢复 8 / 外观专利合并冲突 5 / 相似ASIN 合并冲突 4),
RED 均已确认;全量 mvn test 3099 个 0 失败。
2026-09-17 11:04:38 +08:00
huangzd1997 3137299bfe fix(临时载荷): 收口删除守卫 + chunk 读兜底,修「对象被误删→任务永久失败」
线上任务 28459(外观专利)三个分片的载荷对象被删、DB 行仍指向已删的确定性
key,组装读不到 → 整单 FAILED。数据其实还在同槽位的版本化对象里,是行指错了。

三条收口:
- 上传补偿删除加准入:只有末段带 UUID 的版本化 key(本次写入独占)才允许入队。
  确定性 key 会被重传复用,删它就删掉了行仍在引用的对象;而删除队列
  (deleteObjectFromRetry -> removeObject)本身不做引用反查,准入必须卡在入队处。
- 引用守卫阈值 >1 收紧为 >0:原来把「恰好 1 行引用」当作调用方自己那行而放行,
  但调用方无法证明归属。物理删除本就约定在 DB 行删除之后执行,正常路径引用数
  必为 0;宁可留孤儿(有保留期清理兜底),也不删可能仍被引用的对象。
- chunk 读兜底:指针对象确已缺失(NoSuchKey)时回退同槽位版本化对象 chunk-N-*。
  仅限 chunk 槽位 + 确为缺失两个条件,避免误配无关对象或掩盖真实故障。

测试:新增 31 个用例(补偿删除准入 / 引用守卫 / 兄弟对象兜底),均先确认 RED
再实现;另更新 3 个断言旧行为的既有用例。全量 mvn test 3082 个 0 失败。
2026-09-17 10:42:53 +08:00
huangzd1997 1fe3368c5a perf(rustfs): 重试基础退避 500ms→200ms
线上高频的重试诱因是 unexpected end of stream——那是**立即失败**(RustFS 重置连接后
OkHttp 读响应即报错),不是等超时,500ms 基本是白等;每天上千次累计十几分钟。
降到 200ms 保留退避语义(真遇服务端过载仍会让步),又不让用户多等。
2026-09-17 01:55:35 +08:00
huangzd1997 7aea3a0a50 fix(rustfs): 修 unexpected end of stream 根因——零长度请求体缺 Content-Length
抓包定位(90 秒内 515 次 HTTP 411 Length Required):OkHttp 对长度为 0 的请求体
不写 Content-Length,请求于是既无 Content-Length 也无 Transfer-Encoding——HTTP/1.1
不允许这样,RustFS 直接回 411;客户端读到不完整响应就报 unexpected end of stream
再重试。业务里上传空内容(content 为 null/空)是常态,所以每天上千次。

修复用 network interceptor 在协议层补 Content-Length: 0:普通 interceptor 里加的
头会被 BridgeInterceptor 按 body 长度覆盖,加了不生效。

实测:部署后同样 90 秒抓包,411 从 515 次降到 0,两节点「确定性错误不重试」归零。
此前排除的公网链路、keepAlive、连接复用三项确实都不是原因——抓包才是对的入口。
2026-09-17 01:36:55 +08:00
huangzd1997 188aedec84 fix(任务收尾): 打破「数据永久缺失 → 卡死恢复无限重建 job」的死循环
线上任务 28459(appearance_patent)卡在 RUNNING:chunk 462/473/484 的 payload
对象已不存在,组装失败 → stale recovery 每 30 秒重建一次 assemble job 再失败,
而恢复过程又会刷新任务心跳、任务永远判不出「卡死」,48 分钟空转近百轮。

- appearance-patent:读 chunk 时若 payload 对象已不存在(RustFS NoSuchKey),
  跳过该分片让任务按已有分片出部分结果,不再抛异常把组装永久拖死;
  similar-asin 在原有 typeMismatch 跳过分支旁补同款处理
- TaskFileJobService.hasExhaustedAssembleJob + stale recovery:已有「重试耗尽且
  已终态收尾」的 assemble job 时放弃恢复,交给 finalizeStaleTask 按失败收尾
  —— 用户看到明确失败,而不是无限 RUNNING

实测:部署后两节点 28459 相关日志、rustfs 确定性错误、stale recovery 重建全部归零。
2026-09-17 01:19:14 +08:00
huangzd1997 d5952945dd fix(rustfs): 确定性错误不再重试;回退两处经实测无效的连接池实验
- 新增 isNonRetryable:NoSuchKey / AccessDenied / 签名错误等确定性错误立即失败,
  不再白打两次请求(线上 read 一个已清理的 chunk 会连打 3 次并留下一条 ERROR)
- connectionPoolMaxIdle 0→5、keepAlive 30000→300000 回退:
  实测证明 unexpected end of stream 与连接复用无关——禁用复用后新容器起来的
  第一次请求照样中招。至此已排除公网链路、keepAlive 过长、连接复用三项;
  另用 mc 并发压 200 个小对象全部成功,说明 RustFS 服务端无问题。
  剩余方向指向 MinIO SDK(8.5.17,已是最新)/OkHttp 与 RustFS 的协议细节,
  需抓包对比 mc 与 Java 的请求才能定位。数据不丢(重试都能成功),影响是每次 +600ms。
2026-09-17 00:58:01 +08:00
huangzd1997 6e1689dfe5 docs(更新日志): 追加客户端 4.0.27 条目 2026-09-17 00:33:29 +08:00
huangzd1997 4f16a02658 fix(稳定性): 修任务锁超时/实例转发/扫描降噪,补 rustfs 结论日志
线上日志扫描发现的缺陷批量修复:
- TaskFileJobService.resetStuckRunningJobsDetailed 去掉跨行长事务:两节点
  @Scheduled 各取 200 行在同一事务里逐行 UPDATE,互等行锁导致 biz_task_file_job
  每天上百次 Lock wait timeout。改逐行独立提交(本就有 CAS 条件,幂等)
- TaskOwnerForwardService 连接类失败重试 3 次:两节点滚动重启窗口内的
  Connection refused 会直接丢掉用户提交的结果(读超时不重放,避免重复提交)
- AdminApiGuardFilter 把 401/被顶下线降为 debug(线上单节点一天近 3000 条噪声)
- NoResourceFoundException 单独处理:如实返回 404,不再刷 ERROR 堆栈、不再伪装
  200(每天 580 条,绝大多数是外部扫描 /.env、/credentials、aliyun.json 等)
- RustfsObjectStorageService 补「重试后成功 / 重试耗尽」结论日志:一天上千条
  retrying 却无结论,无法判断数据是否落盘
- 连接池 keep-alive 300s→30s,减少复用已被对端关闭的空闲连接
- SimilarAsin 提交结果遇到已删除任务时带 TASK_NOT_FOUND(40401),客户端据此
  停止每 30 秒一轮的无谓重试
- 修 SimilarAsinResultRowDto 的 GBK 乱码 JsonAlias("浠锋牸" → "价格")
2026-09-17 00:32:48 +08:00
huangzd1997 5e9a59b327 test(鉴权): 兜底过滤器用例改用不存在的样例豁免前缀
exemptPrefixes 是配置化操作出口(aiimage.security.admin-guard-exempt-prefixes,
默认空、生产未配置任何值),原用例拿已下线的 /api/admin/shop-credential-checks
当样例路径,容易被误读成「该端点已配好豁免」——那条路径是 2026-09-04 引入
豁免机制时随手取的,并非真的配过。换成显然不存在的 /api/admin/example-exempt,
并注明不要改成真实业务端点。
2026-09-17 00:13:27 +08:00
huangzd1997 ab09cff427 docs(更新日志): 追加客户端 4.0.26 条目 2026-09-16 22:55:31 +08:00
huangzd1997 47a9520a82 fix(跟价): 失败任务也产出并保留部分结果文件,不再只剩一条失败记录
会话掉线这类「跑到第 N 页才断」的场景,任务本就该是 FAILED,但已经跑出来的行
(哪些 ASIN 真实改价过)必须随任务一起交付,否则用户无从对账、也无法只重跑漏掉的。

- submitResult 的 error / success=false 分支改走 finalizeFailedShop:先按失败落库
  (success=0 + 原因),若合并后仍有可用行就排队组装部分结果文件;一行可用数据都没有时
  才退化成原来的纯失败(并清掉合并缓存)
- markResultFilePending / assembleShopResult 增加 preserveFailure 语义:失败行只挂文件,
  不把 success 洗成 1、也不清 errorMessage
- updateTaskStatusFromLatestRows:失败行正在组装文件时不让任务提前终态,
  否则前端一停轮询、下载按钮永不出现(失败任务的结果文件同样要能下载)

PriceTrackTaskServiceTest 新增 2 条契约测试(部分结果排队 + 组装完成后仍失败且可下载),
跟价模块测试 20/20 通过。
2026-09-16 20:33:31 +08:00
huangzd1997 7a6c3c3fa3 fix(菜单权限): 授权树按操作者置灰不可授予项 + 放行保留既有授权
普通管理员勾到无权授予的菜单后,ensureGrantable 抛 403,而 createUser/updateUser
都是 @Transactional,整笔回滚——线上表现就是「后台不能保存权限,也不能创建用户」
(2026-09-16 周丽娥账号创建用户与保存 uid=45 权限双双失败,库里查无新用户)。
根因是校验 2026-07-28 就加了,但授权树一直拿全量菜单,两边规则不一致。

- permission-menus 按操作者标记 grantable;授权树据此置灰(不隐藏:授权是整树替换,
  隐藏会把超管授予过、操作者自己没有的菜单当取消勾选删掉,与 09-13「权限自己没掉」同类)
- ensureGrantable 放行目标已持有的授权,只拦新增,不构成提权
- GlobalExceptionHandler 补业务异常日志:此前普通业务异常一行都不记,本次排查只能靠
  反推响应体字节数(nginx body_bytes_sent 含 chunked 开销)才定位到根因
2026-09-16 18:46:21 +08:00
huangzd1997 803b5d583d docs(更新日志): 追加客户端 4.0.25 条目
跟价「指定ASIN模式」搜索打到屏外的导航搜索框,客户端 4.0.25 已修(app_client 29c12f2)。
2026-09-16 17:15:06 +08:00
huangzd1997 1c52cd529b fix(去重): 历史任务弹层补下载/删除按钮(漏了 history-item-actions 插槽)
任务面板的 #item-actions 只作用于「当前任务」列表,历史结果全部只在
#history-item-actions 弹层里渲染。16 个工具页里唯独去重页只给了前者,
表现为用户反馈「历史任务里压根没下载按钮」(既无下载也无删除)。
接口数据(success/resultId/downloadUrl)完整、类型检查与单测均不报错,
只能靠点开历史弹层才能发现。
2026-09-16 17:05:11 +08:00
huangzd1997 ab28b168ec fix(去重): 主链接行位于其子链接行之后时未被丢弃 + 导出表头与数据行错列
线上任务 28422(文件 新数据变体完.xlsx)结果中 16 个主ID同时保留了主链接与
子链接,违反所选规则(keepIntegerIds=false / keepUnderscoreIds=true /
keepIntegerMainIdsWhenNoSubIds=true)。

根因:旧实现把整数主链接行暂存,依赖「后到的同主ID子链接行」把它丢弃。
当顺序为「子链接在前、主链接在后」时,子行到来时暂存区尚空,无人记录该主ID
已有子链接,主链接一路存活到 flush。改用 IdRuleRowPicker 先收集、收尾统一按
「该主ID是否出现过子链接行」判定,与源文件顺序无关;补 subRows/mainRows/
droppedMainRows 排查日志。

同时修一处潜在错列:表头按 selectedColumns 原顺序写、数据行按
orderedSelectedColumns(id/ASIN/国家/价格/品牌 提前)写,两者不同序时整体
错列;本任务所选列恰为导出优先级顺序故未暴露。

实测:用线上源文件重算,输出 7273 → 7257 行(正好少掉那 16 个主/子并存的主ID)。
2026-09-16 16:33:04 +08:00
huangzd1997 367b4b7553 chore(更新日志): 补 4.0.24 条目(跟价改版为行内输入+底部「全部保存」提交) 2026-09-16 16:03:42 +08:00
huangzd1997 25c7323c47 chore(更新日志): 补 4.0.23 条目(跟价适配亚马逊新版商品页) 2026-09-16 13:40:59 +08:00
huangzd1997 aea0e16279 feat(站内通知): 铃铛支持按类型大类筛选(系统异常/任务异常/配置异常/系统通知)
原来铃铛只能按关键字和日期筛,任务失败、密钥欠费、麦象异常全混在一起,
用户没法只看自己关心的那类。

后端 NotificationService 增加 scene→大类映射(未知 scene 归「系统通知」兜底),
列表接口新增 category 参数,落到 SQL 是 scene IN (...);「系统通知」作兜底类
还要纳入未登记的 scene,故表达为「= system 或 不在其它三类里」。列表项返回
category,前端不必各自维护一份 scene 映射。前后端两个通知接口同步加参数。

前端铃铛筛选区新增一行类型 chip(全部 + 四类),与关键字、日期一起参与
hasFilter 与重置;空分类不下发,避免后端查询落空。
2026-09-16 10:32:11 +08:00
huangzd1997 f566573fce fix(菜单权限): 后台侧边栏为部分授权用户补全祖先分组(二级菜单不再平铺)
只勾选分组内子页面、未勾选分组本身时,getUserColumnPermissions 只返回
直接授权+展开后代,分组容器节点缺失,AdminMenuTreeBuilder 把子页面当根节点,
前端侧边栏渲染成一列平铺;且子页面 sort_order 是全局值、跨分组穿插显示,
与超管的「分组+组内顺序」视图完全对不上(线上 31 个账号如此,含 uid=972 阿武)。

修复:getUserColumnPermissions 增加 includeAncestorGroups 重载(默认 false),
true 时沿 parentId 链把有可见后代的祖先并入集合——分组无页面路由,仅还原
展示层级,不构成授权扩展;只有 current-user/menus(后台侧边栏)启用。

刻意保持原语义的出口:/permission-users/{id}/column-permissions 与登录响应
(桌面端 tool-catalog「组键命中即整组放行」,补组键会误放行整组工具)、
dedupe/invalidasin 的精确 key 校验。

新增 3 个测试:祖先补全含多层链、树组装还原分组与组内顺序、默认出口不含祖先。
2026-09-16 09:58:33 +08:00
huangzd1997 8fcceb3226 fix(撞款扫描): 保留期 90→7 天,并单独兜住最新 SUCCESS 行
该表每行含整份聚合 payload(线上实测约 2.3MB/行,24 行占 55MB),
而读取侧只认最新一行(selectLatestFullRow/LightRow 都是 ORDER BY id DESC LIMIT 1),
历史行纯占磁盘。保留期降到 7 天,行数下限仍由 PROTECTED_ROWS=10 兜底。

同时补一条保护线:连续失败多日时,最新 SUCCESS 行会滑出「最新 N 行」保护窗口,
再按时间线被删掉,界面就空白了——现在取「第 N 新行」与「最新 SUCCESS 行」
两者更靠前的那个 id 作为保护线,确保它永不被删。
2026-09-16 09:25:30 +08:00
huangzd1997 ed0d6575c8 feat(前端): 追加客户端 4.0.22 更新日志条目
包体积 -38%、启动时清理本地垃圾、启动与连接优化三条面向用户的说明。
version 与 web_config 发布记录一致(4.0.22),否则更新面板按本机版本筛选匹配不到。
2026-09-16 09:09:05 +08:00
huangzd1997 f2ada02383 feat(保留期清理): 补齐 7 处只增不删的数据;修结果对象孤儿
审核发现一批表/元数据只增不删,且对象存储存在活跃泄漏:

- module-cleanup 漏收 task_result_item / task_result_payload 的 payload 指针,
  每晚删完行就在 json-server 桶留下孤儿对象;指针收集触顶从"截断后照样删整组"
  改为任务组二分拆分,拆到单任务仍触顶则整组不删并记 error(截断会让指针随行消失)
- 结果文件对象此前从不回收(只删 DB 行、对象留在桶里),现改为事务提交后
  按 file_result / task_file_job 的 result_file_url 逐个回收
- 新增 7 个保留期清理:device_log_file 元数据、price_track_loop_run、撞款扫描行、
  密钥用量日统计、紫鸟记忆过期行、PUBLISH 任务、BRAND 任务。后两者刻意不复用
  module-cleanup(BRAND 不写 biz_file_task;PUBLISH 单任务可达数万行),
  改为调各自既有的业务删除入口,保证结果对象与子表一起回收
- 未读通知补保留期(180 天,已读仍 90 天);ZiniaoMemoryStoreService.deleteExpired
  此前全库无调用方(实现了没接线),现已挂定时任务

全部沿用既有范式:@Scheduled + 分布式锁单实例 + 分批 + 单轮批次数上限 + 失败只记日志。
2026-09-16 01:59:28 +08:00
huangzd1997 5e5816cd74 fix(站内通知): 麦象异常扫描滞留检查改用 update_time 过滤(18960 console 新增 update_time_end)——原按 create_time 过滤 + desc(id) 分页只能看到最新批次,积压深处的老任务永远看不到(2026-09-15 滞留在队 9 小时未被告警指出的漏报根因) 2026-09-16 01:05:26 +08:00
huangzd1997 d189d94c3b fix(更新日志): 面板随指定版本下拉联动;本机已是最新时展示本机条目
- 「指定版本更新」下拉选中版本时日志切到该版本自身的条目(升级/回退统一语义),
  未收录显示"暂无更新说明"(区块不消失),选回空项回默认区间;
  列表加载后自动默认选中第一条不触发切换
- 本机版本==线上最新时回退显示本机版本条目(原来整块消失),灰度/回滚仍隐藏
- 精简 4.0.20/4.0.21 超长文案,修复既有红测试(更新说明限 40 字)
2026-09-16 01:02:13 +08:00
huangzd1997 79b5d40327 feat(设备日志/后台配置/密钥检测): 补齐已上线未提交的设备日志与app_config,并合入检测模型解耦
三部分均已部署到双节点(当前线上 JAR 009efc3e),本次补齐仓库状态,避免"已上线未提交"
在后续最小构建比对里被误判。

- 设备日志管理:modules/devicelog + DeviceLogOssProperties(主机B 独立 MinIO,仅内网)
  + V127 device_log_file/device_log_config + admin-vue 日志管理页;客户端/麦象按 offset
  增量上报(X-Internal-Token),查询仅超管
- 后台通用配置:modules/appconfig + V128 app_config 键值表;工作台「开店流程」密码改服务端
  校验(POST /api/kd-flow/verify),改密码只需 UPDATE 该行、客户端无需重新发版
- 密钥检测模型解耦:新增 aiimage.user-secret.check-model(env AIIMAGE_USER_SECRET_CHECK_MODEL),
  默认 doubao-seed-2-0-lite-260215 —— 同系列 mini 在中继分组下无可用渠道(503 model_not_found),
  实测 lite 可路由;UserSecretModule 去掉 LlmTarget 改 resolveLlmHost,探测日志带 model=
- 前端:密钥面板「检测配置密钥」按钮不再折行;client-changelog 补 4.0.19/4.0.20/4.0.21 条目

注:application.yml 与 PropertiesConfig 同时承载上述多个部分,故未按功能拆分提交
2026-09-15 23:53:05 +08:00
huangzd1997 228d481211 perf(全链路): 连接池/事务边界/轮询与 IO 效率收口
Java:
- LLM 180s 读超时不再被全局 call-timeout 静默截断成 90s(长思考请求被掐断→重试→付费网关二次计费)
- 代理 HttpClient 缓存改有界 LRU(jikip 每次提取新 IP,无界缓存持续泄漏 selector 线程与连接池)
- 12 个 service 的 Redis 任务锁移出 @Transactional(自旋最坏 10s 白占 DB 连接,池仅 30),远端对象删/传改 afterCommit
- 结果文件 Job 闸门拒绝时不再回退内联执行(改重新入队,避免把背压转嫁给 MQ 消费线程)
- imagevideo 每秒扫描加列投影、过期清理加 LIMIT;权限页整表查询改列投影(不再拉回密码哈希)
- 哈希改 HexFormat;补 5 处"不能改"的技术依据注释(批量插入会丢回填主键、流式丢模板与图片等)

前端:4 个工具页轮询改轻量端点(带 fallback);PriceTrack 快照节流写盘;候选店铺表分页;页面隐藏时停表

客户端:HTTP 连接池按出口复用(Session 仍每请求新建,保持无跨请求状态);品牌检测 WIPO 逐请求握手;
代理配置按 mtime 缓存;串行任务改专属池;异常降级为标签页重连;紫鸟启动改端口轮询;模板编译缓存;
日志上报连接与落盘收口;Flask 版本 API 改按请求复用连接
2026-09-15 23:01:59 +08:00
huangzd1997 d6f8368493 feat(站内通知): 麦象异常扫描上线——任务停滞/失败/队列积压/批量空结果推管理员
用户要求「maixiang异常也要发通知」,确认范围(卡住/失败/队列堆积/服务不可用)与
受众(只发管理员)后实现:
- MaixiangConsoleClient:18960 后台只读接口客户端(batch/tasks、tasks、queue/status、
  batch/detail),token 鉴权、失败只记日志。URL 必须用 URI 对象提交——字符串形态会被
  RestClient 二次编码(%20→%2520),end_time 参数实测报 Incorrect DATETIME value,
  检查会静默失效;已加回归单测 buildUrlEncodesTimeParameterExactlyOnce
- MaixiangAnomalyScanner 五类检查:①批量任务停滞(status 0/1 超时无更新,默认 60 分钟)
  ②单任务滞留(创建超 30 分钟未完成)③近 30 分钟失败达最小条数 ④批量任务空结果
  (worker 报错时批次仍被标"已完成",是唯一可抓的批量失败信号)⑤队列积压(pending≥300
  / processing≥100);去重按天/小时/任务,全部为管理员全局事件(subjectUserId=null)
- 接入 NotificationScanScheduler(分布式锁内同跑);scene=maixiang_anomaly;
  AIIMAGE_NOTIFICATION_MAIXIANG_* 环境变量可调,令牌留空=跳过
- 前端 NotificationScene 类型补 maixiang_anomaly(铃铛不按 scene 渲染,无运行时改动)
- 测试:客户端解析(真实抓包样例)+ 扫描器 14 例 + 手工联调探针 MaixiangLiveProbeTest
  (-Dmaixiang.live=true 开启,只读不写通知)

已部署双节点(.env 加 console token、JAR cd3e06c4)并线上验证:首扫推 2×15 条管理员通知
(3 个变体任务停滞 / 39 个跟价任务滞留),二次扫描落库=0 去重生效。
2026-09-15 12:20:15 +08:00
huangzd1997 b0f764b6b6 feat(前端): 追加客户端 4.0.18 更新日志条目 2026-09-15 11:33:53 +08:00
huangzd1997 1403fec5fe feat(前端): 追加客户端 4.0.17 更新日志条目
跟价购物车归属未知跳过改价、店铺名回落、失败原因如实带出三条面向用户的说明。
2026-09-15 01:27:57 +08:00
huangzd1997 07b4ebe983 feat(成本): 密钥检测防抖 + 巡检降频隔日 + 客户端 4.0.16 更新日志
- 用户密钥「检测」90 秒新鲜期:同值重复检测复用上次通过结果,
  代理提取不再因连点/手滑重复扣费(只缓存 passed,失败允许立即重试)
- 连通性巡检由每日降为隔日(cron 0 30 4 */2 * *,双实例锁不变)
- changelog 追加 4.0.16 条目
2026-09-14 19:43:22 +08:00
huangzd1997 0b2b9303d0 chore(前端): 追加客户端 4.0.15 更新日志条目 2026-09-14 18:10:48 +08:00
huangzd1997 05a2c479a5 fix(测试): CollectDataNoUploadStaleTest 的 updateById 重载歧义(补编译验证) 2026-09-14 18:00:07 +08:00
huangzd1997 52b55df7b2 feat(任务判死): 心跳正常但 180 分钟无结果上报的二次判死线(13 模块)+ 同期待发改动
判死线(治 28131 型「主线程卡死、心跳线程照发」):
- 判据改看 biz_task_scope_state.last_chunk_at(HTTP 心跳不刷新它);从未上报跳过不判
- 中央线覆盖 DELETE_BRAND/PRODUCT_RISK_RESOLVE/PRICE_TRACK/SHOP_MATCH/PATROL_DELETE/QUERY_ASIN/WITHDRAW
- 自带线接入 COLLECT_DATA/SIMILAR_ASIN/APPEARANCE_PATENT/SHOP_DATA_CRAWL/PUBLISH/BRAND
- 客户端心跳带处理位置 progressText,判死文案含最后位置;no-result-upload-timeout-minutes 默认 180(0 关闭)

同期带上另一工作流的待发改动:跟价换 IP 重试、品牌检测重试上限与 LLM 并发下调、
教程包后台管理页与 V126 迁移、admin-vue 教程记录页。
2026-09-14 17:56:20 +08:00
huangzd1997 b54f72d3f6 fix(品牌检测): 熔断中止落真实原因+部分结果可下载,熔断加持续时长门槛
- 熔断改为「连续失败持续 6 分钟未恢复」才中止:原 8 次即中止,5 线程一轮
  就能凑满,重跑机制没机会生效导致任务失败率过高(任务 2309 复盘)
- 期间每 30s 冷却重试,重跑轮次 10→30;限流窗口恢复后任务自动跑完
- 失败终态上报内部接口 /api/internal/brand/tasks/{id}/abort:落真实原因 +
  用已收分片部分组装结果(未检测品牌单独成 sheet),不再悬挂到心跳超时被
  判「前端长时间无响应」且已跑数据无法下载
- 组装前从分片重建聚合(缓存快照可能缺后加字段如 keptBrands)
- 前端:失败任务有结果文件即显示「下载结果」
2026-09-14 17:04:19 +08:00
huangzd1997 24c5a09c7f feat(任务派发): 客户端兜底拉取页面未推送成功的任务 + 修店铺匹配定时任务误杀
问题:任务派发链路的"推送"只存在于页面里(Java 解析落库 PENDING → activate →
pywebview 桥 enqueue_json 推本机队列)。只解析没点启动、推送前关页面、在纯浏览器
打开,任务都会停在 PENDING,2 小时后被 StaleTaskRepairService 标失败
(「任务长期未被领取,已自动失败」,09-11 生产清理过 263 条同画像)。

- 服务端新增 GET /api/tasks/pull-pending(TaskClientPullController,身份从 JWT 取,
  不接受 user_id 参数):只挑创建超 5 分钟仍 PENDING 的本用户任务,逐条条件更新认领
  (PENDING→RUNNING + 接管 owner_instance_id)——与页面 activate 同一谓词,天然互斥,
  不会重复执行;认领后组装不出载荷则标 FAILED,不留 RUNNING 孤儿
- payload 由各业务模块实现 ClientTaskPullSpi 组装(task 侧不 import 业务模块,同 G5):
  首批 SIMILAR_ASIN / COLLECT_DATA / APPEARANCE_PATENT——这三个 Python 消费端会自行
  回拉明细,故载荷极简、客户端零模块知识;开关 aiimage.client-task-pull.enabled 默认 false
- 防双执行:三处 activate 由「非终态即可」收紧为只认 PENDING,未命中抛
  「任务已在执行中(可能已由客户端自动接管),无需重复启动」(顺带堵住整行 updateById
  把认领写入的 owner 覆盖回去的竞态);两个前端页 activate 失败即提示并停止入队
- 客户端:amazon/main.py 新增 pending_task_pull_worker,启动点挂在 app_client/main.py
  的 start_task_monitor(独立入口的 worker 线上并不生效);开关 pending_pull_enabled 用
  getattr 读取,避免 test/ 下的旧 config 缺键导致整包导入失败
- 同批修:StaleTaskRepairService 的 SCHEDULED 分支改按 scheduled_at + 120min 判死
  (原按 updated_at 会必杀排期 >2h 的店铺匹配定时任务,而 activate 又被 scheduledAt-90s 挡住)
- 测试:TaskClientPullServiceTest / StaleTaskRepairServiceTest / CollectDataTaskPullSpiImplTest /
  CollectDataActivateGuardTest 共 20 例;客户端 pending_task_pull_worker 7 例并更新启动顺序契约测试
2026-09-14 16:23:34 +08:00
huangzd1997 8cab9d4bad fix(密钥配置): 保存沿用刚检测过的输入值结果,消除「三项检测通过却提示未检测」死循环
- 服务端:对「输入值(未保存)」的检测结果按 uid+模块+值指纹暂存 Redis(TTL 30min,
  Redis 异常降级为需重新检测,不阻断保存);保存同一个值时落库该结果
  (passed/failed/error 一并沿用),改过值或从未检测则维持未检测
- 前端:保存后清理本地「输入值(未保存)」绿字,展示统一走服务端快照,避免展示与门禁矛盾;
  门禁提示改列「模块名(掩码):未检测 / 检测失败:原因 / 未配置」,同名掩码也能分辨模块
- 测试:UserApiSecretServiceTest 补沿用 / 值不一致 / Redis 降级用例;
  新增前后端一致性守卫测试(保存后必须清检测结果、提示必须带模块名)
2026-09-14 13:49:25 +08:00
huangzd1997 5ea52e5291 perf(F5+): 行数据按需拉取(结果行版本信号)+ 修复 progress/light 恒判 missing
行数据按需拉取(审查 F5 后续):
- V125 给 biz_file_result 补 updated_at(DEFAULT/ON UPDATE 由数据库维护,
  实体标注 insertStrategy/updateStrategy=NEVER —— 否则 selectById→updateById 的
  写回会把旧值写回去、ON UPDATE 不触发,版本信号静默冻结)
- 装配器回传 rowsVersion=「最后变更时间毫秒#行数」,5 个品牌工具页版本未变即跳过
  带行明细的重型 batch;前端变更信号为 rowsVersion + status/fileStatus/fileReady 复合
  (任务收尾常见「行早写完、之后才置成功」,只看行版本会把界面卡在旧状态)

修复线上缺陷(同一功能验证时暴露):
- TaskProgressLightAssembler 列裁剪漏选 module_type 却用它做模块过滤 →
  getModuleType() 恒为 null → light 恒把任务判成 missing;第七批把 light 接进
  跟价/定时匹配/商品风险的轮询后,消费方会把运行中任务判为 FAILED
- 补选中列 + 守卫用例 taskQueryMustSelectModuleType(已反向验证:去掉修复即红)
- 前端 lightClaimsAllTasksMissing:整体性 missing 结论用重型端点复核后再采信

契约与文档:light 白名单补 rowsVersion(Java 契约测试 / spec 06 §2 / 12 个端点描述)
测试:mvn test 2901 全绿;前端 npm test 765 全绿
2026-09-14 12:08:25 +08:00
huangzd1997 9166656673 perf(C6): 去重总数据列表顺序翻页改 keyset(前后端契约一起改)
背景:列表页 `ORDER BY id DESC LIMIT offset,size`,带筛选且选择性低时每页都要
对命中集做一次 filesort;深翻页 offset 也白扫索引。

改法(保持跳页/回退/改每页的原有行为):
- 后端:page 接口新增可选 `last_id` 游标 —— 传了就 `id < lastId` + `LIMIT size`(无 offset),
  响应新增 `nextLastId`(本页最后一行 id);不传仍是原 OFFSET 分页
- 管理前端:只有"下一页"用游标(上一页响应带回),跳页/改每页/筛选清空游标走 OFFSET
- 测试:新增 2 个后端契约测试(keyset 无 offset + nextLastId;无游标保持 LIMIT 30,15)

验证:mvn test 2897 全绿;admin-frontend-vue vue-tsc 通过 + 1619 测试全绿;
已部署 JAR 2b9ab774c60c3f5d802c9a2cee549008(双节点 health=200)与 admin-vue-20260914-112055。
2026-09-14 11:21:40 +08:00
huangzd1997 1360a44e01 fix(web): 货源查询/外观专利入队不再把 api_key 当必填
密钥已服务端化,本机无明文属正常状态,Java 侧 readApiKey 会按 uid 从用户密钥表兜底;
列成必填会把未在本机手输密钥的用户全部挡在启动之前。
2026-09-14 11:21:09 +08:00
huangzd1997 86c05e71a2 fix(web): 补齐命令式调用的 EP 按需样式,修拦截弹窗贴到文档左上角
Element Plus 走按需引入,只有模板里用到的组件才会注入样式;ElMessage /
ElMessageBox 都是 import 后函数式调用,构建产物里一条 .el-message-box 规则都没有,
弹窗因此没有定位与遮罩,渲染成普通块元素压在页头文字上(ElMessage 提示条同样受影响)。
入口显式引入两个样式模块,并加测试钉住「显式 import 的 EP 组件必须有样式引入」。
2026-09-14 11:21:08 +08:00
huangzd1997 fae26aa460 style(version): 指定版本下拉只显示版本号,去掉发布日期 2026-09-14 11:08:07 +08:00
huangzd1997 8803e22f39 feat(version): 桌面端更新面板支持指定版本安装
- 新增公开接口 GET /api/version/list(最近 50 条,字段口径同管理端列表),
  桌面端下拉不再拼 OSS 地址(版本包被删后拼地址会 404)
- 更新面板(登录页 + 首页)加「指定版本更新」选择器:默认选中最新版,
  可选全部已发布版本(含回退),回退/同版给出明确文案与二次确认
- 客户端无需发版:do_update_app(file_url) 本就接受任意版本包直链
2026-09-14 11:05:30 +08:00
huangzd1997 375b89154b perf(C5): 外观专利服务解析改走流式解析器(最后一条活路径)
AppearancePatentTaskService.parseWorkbook 原为 POI 全量 DOM(用户源文件整表入堆);
改为复用已有的流式孪生实现 AppearancePatentExcelParser(EasyExcel SAX,语义一致且自带单测),
服务侧只保留原有业务处理(分组键/状态过滤/字段补齐/hydrate)。外观专利模块测试全绿。

至此:用户源文件的解析已无 DOM 路径(剩余 DOM 仅用于报表模板写入,样式/图片/公式必须 DOM)。
2026-09-14 10:58:59 +08:00
huangzd1997 6a90adc765 feat(A1/A3): 三个客户端零调用前缀立即无条件收紧
实测桌面端 Python 侧调用面:/api/image-video、/api/task-file-jobs、/api/ziniao 为 0 次调用
(只有带 JWT 的网页端在用),因此从 user-tool-guard-enabled 开关名单移入无条件守卫名单,
不必等客户端铺开即完成收紧;其余 14 个前缀的客户端调用面与令牌携带情况已逐一实测,
仍在开关后面(老客户端不带身份,提前打开会 401)。

新增 2 个守卫测试:开关关闭时这三组前缀对匿名同样 401、带用户令牌仍放行。
2026-09-14 10:46:05 +08:00
huangzd1997 bcf66dc1d7 fix(startup): 两个 GroupDeletionGuard 显式命名 + Bean 名唯一性守卫测试
事故:2026-09-14 边界收敛新增 invalidasin/dedupe 两个同名 GroupDeletionGuard,
Spring 默认用简单类名做 Bean 名 → 启动抛 ConflictingBeanDefinitionException,
主机 A 的 java-server 连续重启失败(health 不通),**单测全绿也发现不了**
(不起完整 Spring 上下文),部署后才知道。

- 两个守卫分别显式命名 @Service("invalidAsinGroupDeletionGuard") / ("dedupeGroupDeletionGuard")
- 新增 SpringComponentBeanNameUniquenessTest:扫描 main 源码,同简单名的组件必须显式命名,
  否则红测试(把这次事故固化成可回归的守卫)
- 已重新打包部署:JAR 1f1e82bda227f2b0768574f8fd7c32c9,双节点 health=200
2026-09-14 10:37:44 +08:00
huangzd1997 c55c4a140b feat(A1/A3+C8): 客户端令牌链路打通(按人鉴权就绪)+ 快照 JSON 写入节流
A1/A3 客户端令牌链路(服务端守卫已能按 JWT 鉴权,缺口在客户端不带身份)
- 前端:新增 user-token-bridge(纯逻辑,值变化才推送)+ user-token-sync(启动安装,
  pywebviewready/storage/60s 轮询补推),main.ts 接入;桥接口补 set_user_token
- 客户端:新增 crawler_core/user_token.py(持有 + 对自家 Java 端点注入
  Authorization: Bearer <jwt>,Session.request 包装,第三方域名不注入、已有头不覆盖、
  登出清空、SHUFUAI_DISABLE_USER_TOKEN_HOOK 可关);main.py 暴露桥方法并在启动安装钩子
- 服务端:守卫契约测试补 5 个用户态前缀用例(开关关闭放行 / 打开后匿名 401 /
  用户令牌通过 / 内部令牌仍放行 / /api/ziniao 第二层)
- 翻开关的前置条件(客户端铺开后置 aiimage.security.user-tool-guard-enabled=true)写入报告

C8 快照 JSON 写入节流
- 读端改以 biz_task_result_item 行为准、JSON 仅兜底(历史任务 JSON 仍是唯一副本时可用)
- 整档 JSON 改 30s 节流写,终态路径 force 立即写;新增 2 个契约测试钉住语义

验证:mvn test 2888 全绿;npm test 741 全绿;user_token 钩子自测(注入/归一/第三方跳过/不覆盖/登出)通过
2026-09-14 10:20:15 +08:00
huangzd1997 7643094f1d refactor(boundary): 跨模块循环依赖清零(8 对 → 0),task→业务 依赖归零
共享内核下沉(跨模块共享的"身份/组织/安全"类型进 common)
- AdminUserEntity/AdminUserMapper、ShopManageGroupEntity/ShopManageGroupMapper → common
- AdminAuthSupport(33 文件 17 模块引用)、JwtService、AuthProperties、DeviceSessionPolicy
  → common/security(原先放在 admin/auth 里,任何模块用一次就多一条跨模块边)

端口化(消费方声明接口、数据方实现)
- task/spi:ResultFileJobHandler 新增 resolveResultFileUrl 钩子(BRAND 特例从 Worker 收回);
  Worker 改为调用 handler.onSuccess(该钩子历史上从未被调用,withdraw 的收尾靠 Worker 里的
  WITHDRAW 特判硬编码——现两者都归位,task 侧不再 import withdraw/brand)
- admin/spi/UserSecretCleanupPort(删除用户级联清理密钥)、notification/spi/ProxyBalancePort
  (代理余额探测)、shopdatacrawl/spi/ManagedShopNamesPort(可管店铺名)
- shopkey/spi/GroupDeletionGuardPort:分组删除守卫改由各业务模块实现(invalidasin/dedupe 两个实现),
  shopkey 不再直连它们的 Mapper

棘轮与量化(2026-09-14 实测)
- ArchitectureBoundaryTest:task→业务 119 → **0**(基线钉死为 0,新增跨模块动作必须走 task/spi)
- 跨模块 import 行数 650 → 605;双向依赖对 8 → **0**(usersecret/permission/shopkey 三向环、
  task↔withdraw、task↔brand、admin↔permission、admin↔usersecret、dedup↔shopkey、
  invalidasin↔shopkey、notification↔usersecret、shopdatacrawl↔shopduplicatecheck 全部拆解)

测试同步:接口契约 8→9、品牌 Handler 钩子契约、worker 的 10 个测试构造实参、通知/管理测试端口化。
mvn test 2881 全绿。
2026-09-14 10:05:04 +08:00
huangzd1997 1b480f915f test(G4): 定时匹配/格式转换/数据拆分 补 27 个契约测试(三个模块此前均零测试文件)
- ShopMatchResolveService 15:候选越权与幂等、匹配去重保序、国家偏好默认顺序与坏 JSON 兜底
- ConvertTemplateService 8:内置模板禁删(软禁用)、导入命名/后缀补全、设为默认时清掉其它默认位
- SplitRunService 4:下载/删除历史必须属于本人且模块匹配(含无结果文件不可下载)

至此审查点名的 6 个零测试模块全部有测试文件。
2026-09-14 09:40:56 +08:00
huangzd1997 ac36c08460 test(G4): 取款/查询ASIN ResolveService 各补 13 个契约测试
取款:候选增删幂等与越权保护、按店铺名批量删的归一化去重、匹配去重保序。
查询ASIN:额外覆盖「国家 ASIN 清单」契约(国家顺序固定、空国家不出现、同国家去重),
该清单会整体推给 Python,格式错会直接导致采集错列。
2026-09-14 07:11:59 +08:00
huangzd1997 ef3a2c9bd6 docs(更新日志): 追加客户端 4.0.13 条目 2026-09-14 07:08:39 +08:00
huangzd1997 da0f10f1cc test(G4): 巡店删除 ResolveService 补 13 个契约测试(该模块此前零测试文件)
覆盖:用户校验、索引未命中/同名冲突拒绝、重复添加幂等、他人记录不可删(同文案防探测)、
匹配结果去重保序、count 空值兜底。
2026-09-14 07:05:04 +08:00
huangzd1997 9ba231dc4c feat(D2): 导入进度跨节点可见(NodeSharedStore:本地快路径 + Redis 真源)
- 新增 common/service/NodeSharedStore:本地 Map 快路径 + Redis 跨节点真源 + 写节流
  (默认 500ms,逐行刷新进度不会打爆 Redis)+ 本节点条目快照(维护用)+ TTL 兜底过期
- 接入 DedupeTotalDataService(8 个进度/归属/分组/完成时间映射)、QueryAsinService、
  SkipPriceAsinService(各 3 个):轮询落到另一节点不再报"任务不存在"
- 保留期清理改为遍历本节点快照(跨节点过期由 Redis TTL 兜底),不再依赖全量遍历
- 测试同步:去重服务测试的反射注入改用 NodeSharedStore(未注入 Redis 时等价纯本地)

mvn test 2815 全绿
2026-09-14 06:54:48 +08:00
huangzd1997 9a6b57db58 refactor+perf+fix: G5 模块边界 SPI 化、C5 流式解析、C6/C7 查询优化、D13 队列持久化、A3/A4/A7 鉴权
模块边界(G5 / G7)
- 新增 task/spi/TaskModuleHeartbeatSpi + 13 个模块实现:TaskHeartbeatService 不再 import 任何
  业务模块(原先注入 12 个 CacheService 并用 switch 分发);启动校验重复注册
- 新增 task/spi/BrandTaskHeartbeatSpi(品牌任务心跳/中断)、BrandTaskStaleRepairSpi(陈旧修复)、
  CollectDataItemCleanupSpi(历史清理):跨模块 Mapper 操作收回业务模块
- G7:10 个被跨模块借用的 productrisk VO 迁至 common/model/vo
- 架构棘轮收紧:TASK_TO_BUSINESS_BASELINE 119 → 6(实测)
- 新增 TaskModuleHeartbeatSpiCoverageTest(moduleType 覆盖与拼写)

性能与容量(C5/C6/C7/D13)
- C5 流式解析:SkipPriceAsinService(含两行表头语义)、AppearancePatentExcelParser、
  BrandTaskService、DeleteBrandRunService、LocalFileStorageService.getExcelInfo 改 ExcelStreamReader;
  行数上限改为迭代中生效
- C6 去重总数据列表:关键字改前缀匹配(命中 uk_data_value);V124 删除永不生效的 idx_country
- C7 撞款扫描:按店逐批取数(索引前缀),不再全表 GROUP BY + JOIN + 全量拉内存
- D13 待删对象本地日志 PendingDeleteJournal(启动回放 + 收敛重写),RustfsDeleteRetryService 与
  TransientPayloadDeleteOrchestrator 接入;异步删除失败对象写回日志
- V123 删除 biz_file_result 两个被复合索引覆盖的单列索引

安全(A3/A4/A7 + 守卫名单)
- A4 数字人版本写操作要求管理员;A7 视频密钥按登录身份(超管例外)
- A3 上传接口加危险扩展名黑名单(可配置)
- AdminApiGuardFilter 用户态名单补 /api/ziniao(controller 已 requireAdmin,此处为开关打开后的第二层)

容量(明细表保留期)
- 新增 ShopDataCrawlItemRetentionService:biz_shop_data_crawl_item 按保留期(默认 30 天)分批清理
  (该表此前无任何清理策略,是增长最快的表),job 锁 + 单轮批次上限
2026-09-14 06:46:27 +08:00
huangzd1997 67223f8950 perf(frontend): 工具页分页/历史截断/轻量轮询/共享纯逻辑(审查 F5/F6/F9/F12/G3)
- F9 新增 useTablePaging composable 并接入 7 个工具页"匹配结果"表(只切渲染窗口,
  不加选择语义;每页 100 条)+ 深色分页样式
- F6 历史任务抽屉渲染截断(默认 50 条 + "显示全部/收起"),全选口径改为当前可见条目
- F5 新增 task-progress-polling 适配器:等待任务终态的紧循环走 /tasks/progress/light,
  异常或空响应回退重型 batch;已接入跟价/定时匹配的 waitForTaskTerminal
- F12 背景图 bg.jpg 251KB → 166KB(1920 宽 + quality 80,image-set 仍优先 webp)
- G3 抽出共享纯逻辑 task-queue-state(开始时间表序列化校验 + 记录缺失错误判定),
  5 个工具页删除逐字重复实现
- 新增 4 个单测文件(分页切片/历史截断/轻量轮询归一/任务队列纯逻辑)
2026-09-14 06:23:46 +08:00
huangzd1997 e76714c32e fix(log): 陈旧巡检 summary 日志占位符与实参对齐
每段 5 个占位符(x={}/{})只传 4 个参数,导致 withdraw 之后取值整体错位、
末尾 elapsedMs/thread 被打成字面量;改为每段 4 个占位符。
2026-09-14 05:51:06 +08:00
huangzd1997 95dfb69a18 refactor(ziniao/shopkey)+fix(stale): 打破模块循环依赖 + 采集陈旧判死全局化
模块边界(消除 ziniao ↔ shopkey 真实循环依赖):
- 新增 ziniao/service/port/{ShopKeyCatalogPort,ManagedShopNamePort}:消费方声明契约
- shopkey 侧新增 ShopKeyCatalogAdapter(读 shop_key + 白名单状态回写)、
  ManagedShopNameAdapter(店铺名校验),实现上述端口
- ZiniaoApiKeyProvider 改经端口取数,不再 import shopkey 的 Mapper/Entity;
  ZiniaoShopSwitchService 改依赖 ManagedShopNamePort
- 结果:ziniao → shopkey 的 import 归零,依赖单向(shopkey → ziniao)

陈旧判死(G1 全局判死 + D9 条件更新,替代此前的 owner 过滤/旧实体覆盖):
- ShopDataCrawlTaskService.finalizeOwnedStaleTasks → finalizeStaleTasks:去掉 owner 过滤,
  并入 DeleteBrandStaleTaskService 的 stale-check 巡检线(job 锁保证单实例扫描)
- 判死前必须持有任务锁(非阻塞获取,锁被占本轮跳过),FAILED 写入改 status CAS,
  仅在确实由 RUNNING 翻转为 FAILED 时才删缓存与分片(原实现会用扫描期旧实体覆盖在途任务)
- tryFinalizeTask 增加 allowOwnerTakeover 重载:判死场景允许跨实例接管(P1-8 盲区)
- 测试同步:owner 契约用例改为全局判死口径;mock 的 CAS 需先渲染 SQL 片段
  (MyBatis-Plus 的 where 参数延迟填充)才读参数表;新增锁被占跳过的用例

mvn test 2796 全绿
2026-09-14 05:44:37 +08:00
huangzd1997 24ada70997 refactor: 结果下载直链解析去重(11 处 → 1 处)
新增 common/service/ResultDownloadResolver,7 个模块的 resolveResultDownloadUrl
改为委托调用(appearancepatent/queryasin/withdraw/pricetrack/productrisk/
patroldelete/shopmatch)。

顺带修掉两处隐患:
- 原实现用 userId.equals(entity.getUserId()),userId 为 null 时 NPE
  (新实现用 Objects.equals)
- shopmatch 原实现在 url 为空时抛"任务不存在",与语义不符,统一为"暂无可下载文件"

其余 4 处**刻意保留**,因为它们本就不是重复:
- shopdatacrawl:有 validateUserId + requireResultEntity + ensureResultOwner 前置校验
- deletebrand:返回 null 而非抛异常(调用方依赖该语义)
- similarasin:已分叉为返回 record ResultDownloadInfo
- brand:走的是另一套下载路径
文件名解析 resolveResultDownloadFilename 同样没收口:各模块兜底文件名策略确有差异

配套更新 3 个显式构造 Service 的测试(DelegationTest/HistoryBatchTest/
RollbackSemanticsContractTest)注入新依赖的 mock。mvn test 2795 个全绿。
2026-09-14 05:17:10 +08:00
huangzd1997 ccce03b4d4 fix: 第四批修复(结果读取并发化/分页钳制/JWT 密钥强校验/凭据按 id)
性能
- listResultSnapshots 对指针化载荷改并发读取(有界池 + 保序汇总):此前逐行同步对象存储读,
  500 行结果文件生成要多花数十秒。仅在确有指针行时才走池——内联 JSON 直接串行读,
  避免为本地读取引入调度抖动(性能基准测试容差会被影响)

安全/正确性
- 管理端 GET /{id}/credential 改为按路径 id 查询(此前忽略 id、改用 shop_name,
  会出现「路径声明的店铺」与「实际读取凭据的店铺」不一致);凭据 VO 构造抽公共方法
- server profile 下 JWT 密钥缺失即拒绝启动:此前静默回退到公开默认值(等同无防护,
  任何人可伪造 token),只留 warn 日志拦不住发布事故;本地/测试 profile 保持宽松

边界
- 分页/条数参数补齐上限钳制(文档早已声明"最大 N"但无校验):
  PriceTrack/ShopMatch 的 page_size → 2000;TaskFileJob 的 limit → 1000;
  AppearancePatent/Publish 的 limit → 100
2026-09-14 05:02:33 +08:00
huangzd1997 4daf235385 docs(更新日志): 追加客户端 4.0.12 条目 2026-09-14 04:36:35 +08:00
huangzd1997 6d46506726 fix: 全维度审查修复(安全/正确性/性能/稳定性/客户端/前端)
安全
- /api/ziniao/** 五个匿名接口加管理员鉴权(此前可匿名换取任意员工店铺登录令牌)
- 删除 Flask 遗留后门:默认密码建超管 + 每次启动写生产 users 表(服务端与客户端各一份)
- 进度/详情接口归属过滤:新增 TaskProgressOwnershipSupport,11 模块 progress/light 与
  /tasks/batch 接入,DTO 补 userId,前端 13 个查询封装补传(未传时后端不过滤,兼容旧端)
- 代理提取链接(含账密)不再明文入日志(新增 common/util/SecretMasking)
- 全局异常兜底不再回传原始异常信息;内部令牌比较改常量时间
- 登录加失败计数与锁定(10 次锁 15 分钟);品牌源文件下载加 SSRF 防护
- AdminApiGuardFilter 覆盖前缀从 2 扩到 15(开关默认 false,行为不变,为收紧做准备)
- 生产关闭 springdoc/knife4j(/doc.html 匿名可读全部接口定义)

正确性
- 40901/40902 拆分:锁竞争不再被伪装成 success=true(此前客户端停止重试、分片静默丢失)
- 假成功收敛:集采明细批量写失败改为抛出、去重 worker 异常标失败、4 个 worker 改判
  success 字段、publish 空 ASIN 行参与批次 flush、巡店删除全失败带 error 上报
- 客户端心跳 discard 移入 finally(7 模块,失败路径不再留僵尸 RUNNING 任务)
- 状态机条件更新:跟价停止循环、集采 activate/fail、imagevideo 归档回填、店铺匹配提交

性能
- 前端入口包 JS 1.05MB→204KB、CSS 355KB→10.7KB(Element Plus 改按需 + el-config-provider)
- 载荷引用计数按指针里的 taskId 收敛(原 JSON 列 IN 全表扫且逐行调用)
- 店铺明细多值批量 INSERT;快照 upsert 预载缓存;结果文件列改单条 UPDATE
- 新增迁移 V120(补 3 个缺失索引)/V121(删 4 个被覆盖的冗余索引)/V122(URL 前缀索引)

稳定性
- 新增 common/util/ThreadPools 有界线程池替换 5 处无界队列(防堆积 OOM)
- Redis 锁释放改 Lua 原子校验(原裸 delete 会误删他人已过期的锁)
- imagevideo 加死节点接管;锁续期失败重试;调度池 4→16;openStream 全部加超时
- 事务内远程对象删除移到提交后;启动恢复锁按实例命名

客户端
- 不再 taskkill /f /im chrome.exe(改为按调试端口精准回收,不杀用户自己的浏览器)
- 密码检测不再无条件杀紫鸟进程;品牌检测加全局互斥(代理池不再互相覆盖)
- base_dir 统一到 exe 目录(原被 os.getcwd() 覆盖,日志/缓存会分裂两个目录)
- 缓存加定时清理;图片下载加超时;mkstemp 句柄托管

测试
- 同步更新受影响的契约测试(构造器签名/条件更新/方法改名/新增接口方法等)
- 修复 FaultInjectionTest 等 3 处 mock 未 stub 流式 read 导致的读循环 OOM
- mvn test 2795 个测试全绿
2026-09-14 04:15:36 +08:00
huangzd1997 c448f49e30 fix(临时存储): RustFS 上传失败改为直接失败,不再静默回落 local 指针
多实例/容器化部署下 local 指针只有写入它的实例能读(跨节点读直接报错、容器重建即丢),
此前上传失败静默降级会把跨节点不可读的脏指针落库,故障延后到其它节点的合并/组装才爆。
现改为在写失败点抛 BusinessException(中文原因透传调用方),并新增
fallback-to-local-on-error 开关(默认 false)供单机部署回退旧行为;
跨实例读 local 指针的报错改中文并带 objectKey;超限回落策略不变。
2026-09-14 02:31:16 +08:00
huangzd1997 3f5a234c59 fix(测试): 修复 3 处既有红灯——快照行尾、缺失基准文档、失效的边界棘轮
这三处在本次重构开始前就是红的(已在裸 HEAD 上复现),一并修掉,使 backend-java
相关测试恢复全绿(550 测试 0 失败)。

1. SimilarAsinSnapshotTest:golden 文件在 core.autocrlf=true 的检出下是 CRLF,
   而渲染结果按 LF 拼接,断言逐字符比较只差换行符即失败。改为读入时统一行尾。
   (这是测试自身缺陷,不是解析行为变化——两边的可打印内容完全一致。)

2. TxDurationBenchmarkTest:依赖 docs/tx-duration-benchmark.md,而该文档从未提交过
   (git 历史中不存在),导致 2 个用例必然失败。补齐文档,按 mock 环境实测记录
   单次事务段基线、200 分片上界与总耗时上界,并注明该基线只用于相对劣化判定。

3. ArchitectureBoundaryTest:task→业务依赖棘轮冻结在 84(task-212 后的存量),
   此后 TaskHeartbeatService 跨模块心跳(13)、StaleTaskRepairService(4)、
   ModuleHistoryCleanupService(2)、TaskResultFileJobWorker(2)持续接入新模块,
   实测已达 110,棘轮长期失效(恒红=无人看)。对齐到 110 恢复告警,并写明
   「新增依赖请走 Handler SPI,不要直接上调」。
   注意:并行会话正在改 task 模块(含 StaleTaskRepairService),其改动落地后需重新实测。
2026-09-14 02:01:16 +08:00
huangzd1997 b05bba50fa refactor(similar-asin): 抽出 LlmPipelineSupport,Service 降至 1735 行(累计 -70.7%)
在上一提交(3455 行)基础上,把「Python 结果回传 → 分片落库 → LLM 检测」整条流水线
(57 个方法 / 1694 行)抽为 SimilarAsinPipelineSupport。等价搬移,未改行为。

采用依赖倒置消除循环依赖:support 包声明 SimilarAsinPipelineHost 接口
(finalizeTask / findOrCreateResultRecordForAssembly / readCategorySwitch /
finalizeExhaustedResultFileJob),由 SimilarAsinTaskService 实现;这几项保留在宿主
是因为它们属于编排与事务边界(handleResultFileJobFailure 带 @Transactional)。

同时把 5 个被两侧共用的内部 record 提为 support 包顶层类型
(SubmittedTaskMetadata / FinalizeTaskResult / SubmitContext /
 PersistSubmittedChunkResult / PreparedSubmittedChunk),3 个仅流水线内部使用的
record 内联进流水线类;LlmBatchContext 一并归位。

测试适配:4 个测试类里对 mergeLlmRowsIntoChunk / bufferLlmRowsOrMerge /
flushLlmBufferedResults 的反射改指向流水线实例;RollbackSemanticsContractTest
通过 pipelineSupport() 反射取得实例(跨包,保持封装)。

验证:干净工作区叠加本改动跑 similarasin 386 + task 引用方 166 测试,
结果与基线一致(仅既有失败),零新增失败。
2026-09-14 01:51:36 +08:00
huangzd1997 9d92fb4af2 refactor(similar-asin): SimilarAsinTaskService 拆分为 9 个 support 类(5914→3455 行)
从 5914 行的巨型 Service 中按内聚单元抽出 9 个 support 类 + 1 个顶层 record,
净减 2459 行(-41.6%)。全部为等价搬移(Javadoc 标注搬移来源),未改任何行为:

- SimilarAsinLimits            阈值/开关解析(parse 上限、chunk merge 上限、LLM batch/缓冲/flush)
- SimilarAsinPayloadSupport    解析载荷编解码与读取门面
- SimilarAsinPoisonTracker     毒行滑窗熔断状态(Service 从此无进程内可变状态)
- SimilarAsinChunkMergeSupport 行键族 + chunk 合并纯计算
- SimilarAsinResultTextSupport 结果行判定与用户可见文本渲染
- SimilarAsinChunkPayloadSupport chunk 载荷读取、读失败诊断、orphan 兜底
- SimilarAsinResultWorkbookAssembler 结果文件装配(xlsx/zip、POI、DISPIMG、并发)
- SimilarAsinTaskOwnershipSupport    实例归属判定与 per-task 分布式锁
- SimilarAsinTaskProgressSupport     文件构建进度、任务视图映射与计数
- LlmBatchContext              从内部 record 提为顶层,供归属与 LLM 流水线共用

顺带清理 7 处死代码(imageUrlCellValue、resolveResultDownloadUrl/Filename、
applyLlmToPersistedChunks、countCompletedLlmStates、hasPromptFields、
userFacingConclusion、@PreDestroy import)。

assembleExecutor 仍由 Service 持有,shutdownAssembleExecutor 语义不变(18 个测试未动)。
验证:在干净 HEAD worktree 上叠加本改动跑 similarasin 全量测试,结果与裸 HEAD 一致
(386 测试,仅 3 个 SimilarAsinSnapshotTest 既有失败),零新增失败。
2026-09-14 01:30:11 +08:00
huangzd1997 db6869b77e perf(LLM任务): 首查短超时快速失败 + 退避抖动 + 标题失败跳过外观识别
生产 24h 数据:外观专利任务 LLM 重试失败 93+82 次几乎全是超时,
终态 41 行降级为「外观识别异常」;成功调用 p99=34s、超 60s 仅 0.02%。
原策略每轮重试都挂满 90s(被全局 call-timeout 钳制),且 1.5s/3s
密集重试整批落在同一劣化窗口内。

- 第 1 次尝试改用 llm-first-attempt-read-timeout-millis(默认 60s)快速失败,
  重试走完整读超时,慢而成功的正常调用不被误杀
- 重试等待改 LlmRetryBackoff:2s/10s + ±30% 抖动,覆盖更长窗口并打散同批尖峰
- 外观专利标题识别失败时跳过外观请求(该行必走回退,外观结果本就会被丢弃)
2026-09-14 00:28:40 +08:00
huangzd1997 864c22ffc7 fix(密钥检测): 直连失败重试一次,超时/网络提示中文化
上游 ai.t8star.org 实测约 1/8 单请求完全不应答(主机 A 上 JDK 客户端
HTTP/1.1 与 HTTP/2 均复现),检测只有一次机会时用户会看到
「网络不可达:HttpTimeoutException: request timed out」。

- 传输层失败(超时/网络不可达)对直连最后一跳重试一次(800ms 间隔);
  代理模块不重试——jikip 按提取次数计费,重试会多扣一次
- 失败文案中文化(新增 CODE_TIMEOUT),英文异常串只进服务端日志
- 探测提问改「你好」(最简一次调用)
- 检测面板标明检测对象(配置密钥 sk-**** / 输入值(未保存)),
  检测接口超时单独放宽(客户端 60s / 后台 180s),避免重试期间前端先超时
2026-09-14 00:28:35 +08:00
huangzd1997 fd614004b0 fix(撞款e2e): 台账分页断言限定到台账表格紧邻分页器
台账 tab 下页面同时有「店铺上架分布」分页器(设计要求常显)与台账分页器,
全页 .old-pagination 命中 2 个元素触发 strict mode;断言改为 .table-wrap + .old-pagination,
用例意图不变(台账分页可见)。撞款 4 用例全绿,全量 e2e 44 通过(通知面板 1 例失败系并行会话在改 NotificationBell.vue,与本改动无关)。
2026-09-14 00:25:19 +08:00
huangzd1997 ff1ffbbfa3 feat(站内通知): 铃铛面板支持时间/内容搜索、按天分组与分页
- 列表接口加 keyword(标题/内容模糊)与 startDate/endDate(年月日闭区间)参数,
  两端控制器透传,服务端补筛选日志
- 两端铃铛面板:搜索框(防抖 300ms)+ 日期区间 + 按年月日分组 + 翻页,
  面板改 Teleport 到 body(挂在顶栏时会被页面 el-select 压住,提 z-index 无效)
- 固化可见范围回归测试:超管全量/管理员只看本组/普通用户只看自己,
  含读写两侧的 user_id+audience 裁剪断言与两端控制器身份来源断言
2026-09-13 23:59:06 +08:00
huangzd1997 9ffd68bae5 feat(教程管理): 后台可上传教程包,工具台按最新上传下载
- admin-vue 新增「记录与版本 → 教程管理」页(menuKey admin_tutorial):
  zip 浏览器直传 MinIO(presign → PUT 带进度 → confirm 落库)+ 列表/下载/删除,
  最新一条标「当前生效」;补操作指引与 align-tutorial 守卫测试。
- frontend-vue 工具台「立即下载教程」进页面取 /api/tutorial/latest(最新上传优先),
  接口异常或无记录回退历史固定直链,按钮不失效。
- Java 侧 tutorial 模块与 V119 迁移已在 dc6e8924 提交并上线(2026-09-13)。

vue-tsc + 两端单测(工具台 695 / 后台 1617)通过;生产已实测
presign→PUT→confirm→latest 切换→删除回落全链路。
2026-09-13 23:48:24 +08:00
huangzd1997 14a723ab1c refactor(品牌工具页): 历史批量删除抽为 runBatchDelete
16 个品牌页逐字相同的 batchDeleteHistory 循环(逐条删除 + 容忍个别失败计数)
收敛为 shared/utils/batch-delete.ts 的 runBatchDelete;各页仅保留自己的删除调用
与提示文案。补单测 4 例(空列表 / 全成功 / 部分失败继续 / 非 Error 异常)。

净减约 120 行;vue-tsc 构建与 699 个前端单测通过。
2026-09-13 23:32:27 +08:00
huangzd1997 dc6e8924a9 refactor(品牌工具页): 历史轮询抽为 useHistoryPolling
QueryAsin / Withdraw / PatrolDelete 三页逐字相同的 startHistoryPolling /
stopHistoryPolling 收敛为 shared/composables/useHistoryPolling:定时器走各页
categorized-timers(category 固定 history-poll),间隔默认主轮询的 2 倍。
categorized-timers 补 CategorizedTimers 类型导出;补注入式假定时器单测 5 例。

净减约 40 行;vue-tsc 构建与 695 个前端单测通过。
2026-09-13 23:22:49 +08:00
huangzd1997 882ccdac12 refactor(品牌工具页): 抽取 formatDateTime 与 uploadPathsToJava 公共工具
- 新增 shared/utils/datetime.ts:13 个品牌页逐字重复的 formatDateTime 收敛为一处;
  语义不同的 3 个变体(toLocaleString / 字符串切片 / 支持时间戳)保留不动
- 新增 shared/utils/upload-to-java.ts:10 个品牌页的上传循环收敛,api 依赖注入便于单测;
  保留 uploadOss(品牌Tab)与 returnEmptyWhenUnavailable(跟价)两处行为差异
- 补 datetime / upload-to-java 单测 10 例

净减约 327 行;vue-tsc 构建与 690 个前端单测全通过
2026-09-13 23:16:59 +08:00
huangzd1997 b70557a077 feat(认证/通知): 单设备登录互踢 + 站内通知铃铛系统
- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token
  在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。
  前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine
- 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源),
  前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表

均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
2026-09-13 23:08:22 +08:00
huangzd1997 a0f6582914 feat(密钥管理): 代理配置后台明文展示 + 按次统计各密钥调用损耗
- 后台密钥管理「代理设置」列改为完整明文展示(含账号密码),便于运维核对;
  新增 full 字段仅对代理模块下发,密钥两列保持脱敏
- 新增 biz_user_secret_usage_daily(V117):用户 × 模块 × 天累计真实对外请求次数
- 计次口径:LLM 每次真实 HTTP 请求(含重试)计 1 次;代理每次成功提取计 1 次
- LLM 埋点走 SecretUsageContext 上下文(批次外设置、线程池内快照恢复)
- 新增内部上报接口 /api/internal/user-secret-usage(X-Internal-Token)
- 新增后台页「密钥用量统计」:日期范围 + 用户名 + 分组筛选,含范围内汇总
2026-09-13 21:56:46 +08:00
huangzd1997 0c98c5bc15 docs(auth): 修正 JWT 默认密钥注释——桌面客户端本地 Flask 退役后服务端可独立轮换
原注释称"客户端内置同一密钥,服务端不能换"已过时(本地 Flask 与 /api/auth/sync
验签链路已随服务端化退役),会误导运维不敢轮换;2026-09-13 生产已轮换为
application-server.yml 独立密钥并验证。
2026-09-13 21:44:20 +08:00
huangzd1997 1066625078 feat(品牌服务): 内部接口按 uid 供用户代理提取链接;代理检测改真实提取+转发探测
- 新增 /api/internal/user-proxy(X-Internal-Token 自校验,18080 公网可达必须校验),
  供主机 A 品牌检测服务(15126)按 uid 取用户代理提取链接,未配置返回空串
- 代理检测修复"假通过":旧实现把提取链接直接当静态代理交给 HttpClientPool,
  链接无显式端口解析失败静默回退直连,检测到的是自家站点直连响应(40 个用户
  3-39ms 全 301 假通过);现在先真实提取一次(余额不足/解析失败如实报),
  再经提取到的代理请求自家域名,兼容旧静态代理地址
2026-09-13 21:21:48 +08:00
huangzd1997 fe052879bd fix(用户管理): 删除用户时级联清理服务端密钥,修已删账号留下孤儿密钥行
biz_user_api_secret 按 uid 绑定且无外键,删除用户后密钥/代理配置成为无主行
(生产已出现 5 条孤儿)。deleteUser 内同步调用 adminClearByUser 并纳入同一事务,
删除失败(行不存在)时不触碰密钥。
2026-09-13 21:21:45 +08:00
huangzd1997 51c5fe1129 fix(任务中断): 已终态任务也幂等刷新缓存/快照,修历史遗留的处理中卡死
第一次修复只在 DB 刚被改成 FAILED 时同步缓存;对 DB 已是终态、但 Redis 里
仍缓存 RUNNING 的历史任务不生效(真机:28094 反复调中断仍返回 progress=RUNNING)。
现在无论是否刚更新 DB,都按 DB 现值同步模块缓存与进度快照,可自愈。
2026-09-13 15:46:18 +08:00
huangzd1997 3d208ea0e5 fix(任务中断): 标失败时同步模块缓存与进度快照,修"处理中"卡死
客户端重启上报中断只改了 file_task,模块缓存/进度快照仍是 RUNNING,
导致 progress/batch 继续回报 RUNNING:前端任务面板永远"处理中"并阻塞该工具后续任务
(2026-09-13 真机:店铺数据采集 28094 history=FAILED 但 progress=RUNNING,界面卡住)。
markInterrupted 现在同时刷新模块缓存(saveFileTaskCache)与进度快照(新增 markTerminal)。
2026-09-13 15:16:14 +08:00
huangzd1997 a4f60ef21c fix(权限): 用户菜单权限“自己没掉”——分区落库时级联清理跨类型误删 + 编辑弹窗空授权提交
- 级联清理的有效集改为按目标「完整直接授权」计算(读库,含 admin/app 全类型)。
  此前 admin/app 分区落库把本次提交的 id 当成完整有效集,员工持有的另一类型授权
  会整体被判越权删除;两次分区落库互相补刀,最终清空员工全部菜单权限
  (生产表现:保存某非超管的权限后,他直建员工的菜单隔三差五自己消失)
- AdminUserService 改为两类落库完成后再统一级联一次,新增 cascadeSubordinateOverreach 入口
- 后台「编辑用户」弹窗:授权未加载完成/失败时禁用保存并明确提示,
  避免以空 columnIds 整树清空该用户授权(改动密码等操作也会连带触发)
- 回归测试:deferredCascadeKeepsSubordinateGrantsOfOtherMenuType、
  perTypeReplacementDoesNotCascadeBeforeAllTypesWritten、edit_user_dialog_blocks_save_until_auth_loaded

已知既有红测试(与本次改动无关,干净 HEAD 上同样复现,未新增):
ArchitectureBoundaryTest.taskToBusinessDependencyDoesNotGrow(110 > 基线 84)、
HttpClientTimeoutEffectiveTest.connectTimeoutFiresOnUnreachableHost(本机网络环境 60s)
2026-09-13 14:18:01 +08:00
huangzd1997 3f6ad0c6ad feat(后台分组): 分组筛选与分组列仅超管可见,非超管一律隐藏
管理员/普通用户数据已由后端裁剪到本人可访问分组,分组维度对其无意义;
账号归属分组是超管职责,故只有超管需要按分组筛选与区分。

- 隐藏分组筛选+分组列:品牌数据库、查询ASIN、最低价ASIN、去重总数据、
  店铺管理、用户密钥
- 仅隐藏分组展示:店铺数据记录(筛选框+卡片行)、图片视频任务(所属分组行)、
  撞款检测(明细抽屉分组列+卡片分组标签)
- 弹窗分组选择保留展示但锁定:非超管仅1个可访问分组时自动选中并置灰
  (查询/最低价ASIN 另联动加载该分组店铺),多分组不锁以免限制用户
- 空数据行 colspan 随列显隐动态化
- 新增 tests/align-group-visibility.test.ts 回归守卫(5 条)
2026-09-13 14:00:19 +08:00
huangzd1997 0323f0bb7b fix(密钥管理): 分组口径改为「分组创建人名下用户」
- 生产分组成员表 biz_shop_manage_group_member 基本未使用(仅 2 条老数据),导致分组列恒空
- 实际归属:分组 created_by_id/user_id 即主管,其组员为 users.created_by_id 指向该主管的用户
- 分组名批量查询与按分组圈人 SQL 均按此关系重写;按分组筛选含组长本人
- 无分组记录的用户分组列回退展示「所属主管(未建分组)」,避免空值
2026-09-13 13:42:06 +08:00
huangzd1997 1b733b23b9 feat(密钥管理): 按数据权限分组隔离与筛选
- 后台密钥列表改为按 biz_shop_manage_group 分组:主管只看自己带的分组成员,超管看全量可按 group_id 筛选
- 行数据补「所属分组」名称(批量查询);列表接口带回分组筛选项
- 修复超管筛选不生效:前端筛选参数统一 snake_case(camel 被后端静默忽略)
- 列表列/筛选项文案由「所属管理员」改为「分组」
2026-09-13 13:34:08 +08:00
huangzd1997 05ae0c62d6 feat(密钥管理): 后台分组过滤+欠费状态识别
- 后台密钥列表:主管只看本组子账户(created_by_id=自己),超管全量可按创建人筛选;行 VO 新增 createdById/createdByUsername
- 代理提取接口欠费(message=余额不足)识别为 insufficient_balance,不再静默回退直连
- 上游 LLM 网关欠费(code=insufficient_user_quota/预扣费额度失败)同样归为欠费并透传额度明细
- 前端:桌面设置面板显示"欠费",admin 后台单格药丸显示"欠费"、新增所属管理员列与超管筛选
2026-09-13 13:22:14 +08:00
huangzd1997 4bc4969e5a docs(backend-java): 恢复 flyway 迁移模板/演练/盘点文档(原来只在未合入的并行分支上,master 的 MigrationInventory/FlywayTemplate 契约测试一直红) 2026-09-13 13:03:33 +08:00
huangzd1997 8cd8390d95 fix(健壮性): Java OOM 三处 + 双TE 502 根因 + admin-vue 403 白屏
Java:
- similarasin Excel 解析改 EasyExcel 流式(原 WorkbookFactory 全量 DOM,大表 OOM)+ 魔数校验
- collectdata 结果组装改游标分批 + 导入改流式(原全量驻留内存)
- GlobalExceptionHandler 转发响应过滤逐跳头与实例标识头(双 Transfer-Encoding 导致 nginx 502 的根因)
admin-vue:
- 403(无后台权限,如工具号 token)与 401 同样跳登录页,修复后台白屏
- task-266 测试断言对齐 daily-files 端点演进
2026-09-13 12:58:49 +08:00
huangzd1997 2a766efb83 feat(客户端密钥面板): 代理设置支持连通性检测与状态展示
- 代理卡片新增检测按钮:地址有改动时检测输入值(不落库),否则检测/落库已存值
- 状态行展示检测结果(连通正常/失败原因/耗时),颜色区分通过/失败/警示
- 余量无数据时显示"余额查询暂不可用"占位
2026-09-13 12:14:41 +08:00
huangzd1997 afa8560369 style(后台密钥管理): 用户名列加宽至 260px 并允许换行,长用户名不再截断 2026-09-13 10:51:19 +08:00
huangzd1997 c81ebb7053 fix(前端测试): 修复 npm test 卡死——定时器泄漏 + 去套娃 + 强制退出/超时
- 根因:polling-backoff-recovery 测试断言失败后跳过 loop.dispose(),残留自续期定时器导致 node --test 子进程永不退出
- dead-code-cleanup:移除套娃用例 test_full_vitest_green(再跑一遍全量套件,放大问题),execSync 加 300s 超时;其余静态断言保留
- package.json:node --test --test-force-exit --test-timeout=180000
- CI workflow 前端测试步骤改为 npm test(与 package.json 单一来源)
- 修正 3 处过时期望值(轮询退避/品牌 files+taskType/巡店 delete_conditions)
- 验证:本地 666 用例全过,19-24s,无残留进程
2026-09-13 10:51:19 +08:00
huangzd1997 b7d4d325e0 feat(密钥管理): 列表按用户聚合三字段列 + 代理配置服务端上报
- Java:后台列表一行一用户(货源查询密钥/外观专利密钥/代理设置),行级状态三项全通过才算通过;检测/清空改为按用户;搜索仅按用户名(去 UID);新增代理检测(经用户代理请求自家域名)与代理掩码(隐去账密)
- 后台前端:三字段列改版 + 行级状态筛选 + 用户名筛选
- 桌面前端:登录加载密钥时补报本地代理(只填空缺不覆盖)、保存/清空代理实时上报
2026-09-13 10:47:19 +08:00
huangzd1997 82a782550e feat(密钥): 用户 API 密钥服务端化——V115 按账号绑定存储 + 后台密钥管理页 + 桌面端全站拦截与配置引导
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定)
- 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检
- 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示
- 删除专利汇令牌全链路与密钥保留时长选择器
2026-09-13 10:03:59 +08:00
huangzd1997 d1b56918fa fix(跟价): 指定ASIN文件提交改 fileKey + 服务端三形态解析兼容
- 前端 resolveAsinRequestPaths 改发上传返回的 fileKey(与去重/转换/采集一致),
  修复安全加固后服务端按拼接解析绝对路径失败导致"ASIN 文件不存在或不可读"
- Java parseAsinRowsByCountry 三种形态解析:绝对路径直读 → tempRoot 拼接 → fileKey 反查,
  均过 isInsideTempDir 穿越校验,不放开临时目录外读取
2026-09-13 03:01:56 +08:00
huangzd1997 441917bfce feat(后台/店铺数据): 结果下载增加进度弹窗
批量打包下载与单店下载接入 axios onDownloadProgress,progress 弹窗显示
百分比;后端未回 Content-Length 时退化为 indeterminate 流动条,避免用户
在大 zip 下载期间以为页面无响应。
2026-09-12 11:42:33 +08:00
huangzd1997 037a689316 chore(日志): 服务端高频轮询日志降噪
-history timing 降debug;status无变化降debug;/history /list免INFO;stale-check合并单行;心跳常规响应降debug
2026-09-11 21:22:41 +08:00
huangzd1997 db7cd22ed1 fix(鉴权/测试): 用户态工具鉴权改开关控制(默认关)+ 修复既有测试编译与断言
- collect-data/price-track 兜底鉴权改由 aiimage.security.user-tool-guard-enabled
  控制,默认 false:老客户端直连不带认证头,先开启会让线上集采/跟价全线 401,
  待携带 X-Internal-Token 的新客户端铺开后再置 true
- 补齐 DistributedJobLockService 构造参数缺失的 6 个测试类(双活分布式锁
  commit 只改了主代码,测试已无法编译);断言按"先删 DB 行再物理删"的现语义校正
2026-09-11 18:03:54 +08:00
huangzd1997 9962d9797e feat(后台): 店铺密钥页支持配置静态代理 + 手动检测白名单——列表展示代理/失败次数,操作列加「检测白名单」按钮(绕过缓存真实请求并重置自动重试计数) 2026-09-11 17:18:27 +08:00
huangzd1997 47459e2089 feat(紫鸟): 令牌级静态代理支持——白名单被拒的 key 走已授权代理 IP
- shop_key 新增 proxy_url / ip_whitelist_fail_count(V114 迁移)
- HttpClientPool 支持按代理地址复用的静态代理客户端
- 白名单连续失败达阈值后停止自动重试,控制台可按 key 配置代理(改动自
  上一工作阶段遗留,功能已编译验证)
2026-09-11 17:11:50 +08:00
huangzd1997 e6021593ea fix(安全/健壮性): 全工作区审查修复——鉴权兜底扩展+路径穿越+忙等+泄漏
- AdminApiGuardFilter 兜底扩展到 /api/collect-data、/api/price-track:无需鉴权的
  工具接口纳入 JWT/内部令牌校验(原匿名可达即越权读写他人数据)
- pricetrack asinFiles 改为仅允许上传临时目录内文件(canonical 前缀校验),
  修复请求路径直接 new File 可读服务器任意 csv/xlsx 的穿越
- dedupe 删除导入逐行 REQUIRES_NEW 事务改 500 条一批 IN 删除,50 万行导入
  由 50 万个事务收敛为千级
- 前端记住密码 XOR 硬编码密钥改 WebCrypto AES-GCM(密钥随机生成独立存储),
  登录流程接口改 async 并保证自动登录恢复时序
- 任务进度轮询失败按指数退避(原固定 5s 无限撞);下载进度终态条目 2 分钟
  自动清理(原永久堆积);AmazonConsolePage statusTimer 卸载清理
2026-09-11 17:11:43 +08:00
huangzd1997 540d6588e6 fix(紫鸟): 店铺索引刷新加轮级时间预算熔断——单轮超 8 分钟即中断,剩余 key 顺延下一轮(游标续跑),避免紫鸟接口半死时整轮占锁几十分钟阻塞后续定时轮 2026-09-11 17:02:13 +08:00
huangzd1997 2a9110ec32 perf(店铺): 后台添加/改名店铺后异步触发一次紫鸟索引刷新——防抖合并2分钟窗口+延迟10秒等事务提交,拿不到锁或失败不影响添加流程,不等5分钟定时轮 2026-09-11 16:54:16 +08:00
huangzd1997 51fb77ec25 perf(紫鸟): 店铺索引刷新提速——间隔 10→5 分钟、每轮批量 100→200 个 apiKey,新店铺未命中等待时间约缩短 3/4 2026-09-11 16:38:56 +08:00
huangzd1997 30d31fb318 feat(代理): 代理配置按登录用户隔离——桌面端 current_uid 登录同步 + brand 页 proxy_users[uid] 读写,各用户各自代理池各自计费复用(未登录回退全局 proxy_url 兼容旧配置) 2026-09-11 16:09:56 +08:00
huangzd1997 bb52574767 fix(更新检测): 版本比较改真实比大小——线上版本不高于本机一律不提示更新;本机高于线上时明确提示无需更新(灰度/回滚/漏发版场景不再被引导降级) 2026-09-11 16:09:53 +08:00
huangzd1997 cd055f8ccd fix(内存): 两处无界内存风险加固——brand-check 线程池改有界队列+CallerRuns(大批量检查不再无界堆积 OOM);OSS readObjectBytes 加 20MB 读取上限防大对象误用 OOM 2026-09-11 16:09:50 +08:00
huangzd1997 c2915036d6 fix(调度): 双节点下 collect-data/publish/image-video 定时调度加 Redis 分布式锁互斥——双活原来各自扫描执行同一批任务,重复派发/重复终态判定 2026-09-11 16:09:46 +08:00
huangzd1997 8dc03df95b feat(task): 客户端崩溃恢复接口 POST /heartbeat/{taskId}/interrupted——客户端重启发现上次进程崩溃时立即把残留 RUNNING 任务标终态,替代最长等 30 分钟的心跳超时兜底 2026-09-11 16:09:42 +08:00
huangzd1997 bb74c9a192 fix(task): 新增陈旧任务自动修复兜底——历史任务永久停留中间状态的排查与兜底
生产库排查发现三类脏状态:陈旧 PENDING/SCHEDULED 无调度器再捞(243条)、
心跳残留的万年 RUNNING、SUCCESS 但结果报错(上报契约问题)。
已手工修复 269 条;本提交补常驻兜底:每 10 分钟检查 PENDING/SCHEDULED/RUNNING
心跳(updated_at)超 120 分钟即标 FAILED,分布式锁防双实例重复。
只用心跳线、不限制总时长——超长真活任务不会被误杀。
2026-09-11 16:03:01 +08:00
huangzd1997 32985c78f3 feat(下载): 工具页统一下载进度条——AmazonToolPageShell 挂载 DownloadProgressPanel
进度条组件原先只挂在 ImageVideoPage 内的 BrandTopBar,而所有工具页(货源查询/去重/拆分/
转换/删除品牌/外观专利/跟价/商品风险/巡店删除/取款/查询ASIN/匹配店铺/采集数据等)都经
AmazonToolPageShell 渲染,导致这些页面下载结果时没有任何进度展示。

在外壳挂载后一次覆盖全部工具页:面板 position:fixed 且数据来自全局 store,跨页面切换不会
丢进度;桌面客户端下载走 pywebview 进度事件,浏览器 Web 版本就无下载能力。
2026-09-11 15:43:58 +08:00
huangzd1997 8e684b92dd fix(下载): 各模块结果下载由流式代理改 302 重定向到对象存储直链——原实现在 Controller 内边读边写代理整个文件,请求线程被下载时长占满;且恒回 200 全量,客户端 Range 断点续传实际失效
覆盖 brand(/tasks/{taskId}/download)、delete-brand(任务 + 结果两处)、appearance-patent、
patrol-delete、price-track、withdraw、query-asin、product-risk、shop-match、shop-data-crawl
共 10 个 Controller,与既有的 convert/dedupe/split 302 实现对齐(ResponseEntity.status(FOUND)
.location(直链) + Cache-Control no-store),并补重定向 info 日志便于排查。

前端各 tab 本就通过 saveUrlWithProgress(url, filename) 传入文件名与下载 id,不依赖
Content-Disposition,故无需前端改动即可同时获得断点续传与进度回调。
验证:两节点重启后逐个实测 11 个端点均 302(含 brand 任务下载与巡店/跟价的有文件记录);
无文件时保持既有业务错误响应(HTTP 200 + success:false)。
2026-09-11 15:43:46 +08:00
huangzd1997 d54dbf72c9 fix(similar-asin): 结果文件下载改 302 重定向到对象存储直链——原流式代理让请求线程被整个下载时长占满,货源结果单文件常见 100MB+(任务 27609 达 130MB),并发下载即拖垮 Tomcat 线程池;且代理恒回 200 全量,客户端 Range 断点续传实际失效
原实现在 Controller 内 openStream 边读边写把整个对象代理给客户端,改为
ResponseEntity.status(FOUND).location(直链) 后:① 请求线程立即释放(实测响应
从"随下载时长占用"降到 ~10ms);② 直链支持 Range,save_file_from_url_new 的
断点续传才真正生效。前端本就优先用 item.downloadUrl 直链、该接口仅兜底且客户端
保存名由前端传入不依赖响应头,故无需前端改动。补一条重定向 info 日志便于排查。
2026-09-11 12:03:57 +08:00
huangzd1997 94a0c287e0 feat(客户端更新): 登录页/首页更新面板显示更新包下载进度条
- 新增 shared/utils/update-progress.ts:进度事件解析/百分比钳制/文案格式化(纯函数)
- 新增 shared/components/UpdateProgressBar.vue:两页共用,失败态琥珀色并停在断点
  百分比、总大小未知走不确定态
- useVersionUpdate:先挂监听再发桥调用(避免丢首帧),监听 pywebview-update-progress
  (Python 侧 evaluate_js 推送,同 pywebview-download-progress 约定);失败保留上次进度
- 测试:tests/update-progress.test.ts(8 用例)

兼容:老客户端没有该事件源,进度条不出现,其余行为不变(优雅降级)。
注:本仓库 modules-withdraw / modules-image-video 有 3 个既存失败用例(隔离复现),
与本次改动无关;全仓库仅新增测试引用本次新增模块。
2026-09-11 01:59:42 +08:00
huangzd1997 47b2758fe8 fix(security): /debug/** 纳入兜底鉴权——修复 debug 端点可被匿名调用(含写操作)
2026-09-11 实测确认(本机公网直连):
  http://47.110.241.161:18080/debug/request-info        → 200 匿名返回实例元数据
  http://api.aishufu.top:18080/debug/request-info       → 200
  https://api.aishufu.top/debug/request-info            → 200
根因:SecurityConfig 是 anyRequest().permitAll(),唯一兜底过滤器 AdminApiGuardFilter 只覆盖
/api/admin/**(shouldNotFilter 对其它路径一律放行),而 modules/debug 两个控制器均无
@Profile/@ConditionalOn*/@PreAuthorize。其中 POST /debug/task-recovery/run-stale-finalize
是强制收尾指定 task 的写操作。

修法:不删端点(stale-task-finalize-guidelines.md 明确把该接口列为线上应急工具),
改为把 /debug 前缀纳入既有兜底过滤器——URL 不变、runbook 不破、复用已测鉴权逻辑,
应急调用方改用 X-Internal-Token 或管理员 JWT 即可通过。
另加 /debug 放行审计日志,便于事后追溯这类低频敏感调用。

单测 13 个全绿(新增 5 个):匿名调 /debug/request-info 与 run-stale-finalize 均被 401 拒绝、
带内部令牌可直达 controller、/debug 裸前缀纳入保护、/debugging 与 /debug-tools 等
同前缀不同段的路径不被误伤。
2026-09-11 01:33:58 +08:00
huangzd1997 dd45ffe34c chore(清理): 删除死代码与过期文件——7个无引用DTO+图片工作台整棵子树(16文件)+冗余副本+一次性脚本+可再生审计快照
每项删除前均做过全仓库引用核对(含 tests/、两个前端、Python 侧):
- Java 7 个 DTO:BrandTaskRunRequest、LegacyBrandTaskCreateVo、Ziniao{SwitchShopRequest,CurrentShopVo,LoginUrlVo,SwitchShopVo}、
  AppearancePatentParsedGroupManifestDto —— 全仓库仅命中声明行本身
- frontend-vue 整棵 src/pages/image/(16 文件):9a487ae9 起首页一级入口已删("图片已并入视频页"),
  该子树完全自闭环;同步移除 /image 路由与 ImageVideoPage 的图片入口
- frontend-vue 两个孤儿组件 AiModuleSwitch/AiWorkflowShell:无引用,且位于 src/shared/components
  而非 unplugin-vue-components 默认扫描的 src/components,不存在自动注册
- backend/tool/devices.py + .device_id:app_client/tool/devices.py 的冗余副本(活的是 app_client 那份),
  app.py 只注册 version_bp,无任何引用
- admin-frontend-vue 的一次性 Playwright 调试脚本 rescan.tmp.mjs / diag-role-price.mjs
- backend-java/scripts/*_report.json ×4:可由 n1_scan.py 等重跑的审计快照
- frontend-vue/DESIGN.md:Last refreshed 2026-06-09,仍是 MPA 时代内容
- backend/requirement.txt:pip freeze 63 条裁剪为实际 import 的 5 条;run.sh 硬依赖该文件存在,故保留文件本身

验证:mvn compile 通过、vue-tsc --noEmit 通过、残留引用 grep 零命中。
注:仓库既有的 4 个失败测试类(FlywayMigration*DocTest、MigrationInventoryTest、HttpClientTimeoutEffectiveTest)
经 stash 对照实验确认与本次改动无关(相同 Failures/Errors 计数)。
2026-09-11 01:33:54 +08:00
huangzd1997 48ee76d60d fix(patrol-delete): 任务跑完不出现「下载结果」按钮——轮询在任务终态即停,而结果 Excel 由 Java 侧 MQ 异步生成,两者时间窗内前端本地的 fileReady 永远停在 false
现象:任务 27547(用户 996)15:06:31 创建、15:12:20 完成,结果文件 15:12:20 已生成并上传 OSS
(result/patrol_delete/.../巡店删除-27547.xlsx,4033 字节,直链与 /results/{id}/download 均 200),
但页面上始终没有下载入口,用户只能靠刷新页面才刷出来。

排查(生产日志时间线):结果文件任务 15:12:10 入队(MQ)、15:12:20 完成;前端最后一次拉取
history/progress 是 15:12:12,之后客户端再无任何请求——即前端是在 fileReady 尚为 false 时停的轮询。

根因:waitForTaskTerminal 只认 taskStatus(SUCCESS/FAILED)就 return,随后 clearActiveQueueTask()
令 hasQueueWork=false、stopHistoryPolling() 停掉历史轮询,refreshTaskViews() 也只跑这一轮。
taskStatus 是 Python 端回传完毕的信号,而 Excel 是 Java 收尾后(finalizeTaskWorkbook →
enqueueAssembleResult)异步生成的,中间有数秒到数十秒间隔;错过就再也不会刷新,本地 fileReady
永远为 false,canDownload() 恒假 → 历史记录里不渲染「下载结果」按钮。

修复:
① 新增 isResultFilePending():判定「确实有文件任务在生成」(fileJobId 存在或
   fileStatus=PENDING/RUNNING),文件已就绪(fileReady/downloadUrl)或已终态
   (SUCCESS/FAILED)则不再等待;全部店铺失败的任务本就不生成文件(fileStatus 为空),不会被误等。
② waitForTaskTerminal 在任务终态后继续轮询至文件就绪,上限 MAX_FILE_WAIT_ROUNDS=12(≈2 分钟),
   期间 queuePushResult 提示「正在生成结果文件(n/12)」,超时打 warn 后按终态返回,不阻塞队列。
③ 新增 settlePendingResultFiles():页面加载时若历史里存在「任务已完成、文件仍在生成」的记录
   (在生成窗口内刷新页面/重开客户端的场景),继续轮询收敛,避免同一个坑的另一个入口。
   仅在无未完成任务时执行,避免与 processQueue 重复轮询。

与同类页面对齐:BrandAppearancePatentTab 早已用 pendingFileTaskIds 等文件就绪再停轮询,
巡店删除页缺这一环。日志按规范用中文,关键分支(等待/超时/收敛完成)均有输出便于线上排查。
2026-09-10 15:26:59 +08:00
huangzd1997 5af4e1c23e fix(admin-vue 查询ASIN): 列表筛选/分页参数对齐后端 snake_case,导出 XLSX 带上当前筛选
- query-asin-filter: toQueryAsinQuery 输出 page_size/group_id/shop_name(35bed42b 后端已改显式 snake,
  camel 参数被静默忽略导致分组/店铺名筛选无作用、pageSize 固定回落 15)
- 新增 query-asin-export: 按当前筛选构建导出 URL,页面「导出 XLSX」不再恒为全量
- query-asin-import-api: 导入兜底分组 groupId→group_id(后端 importExcel 已改名,此前同样被丢弃)
- query-asin-delete-import-api: 保持 groupId 并加注释(后端仍隐式 camel,统一需两端同步部署)
- task-71 测试断言更新为 snake 契约(含 camel 不下发反断言)+ 新增导出 URL 用例

已上线 admin-vue-20260910-145200,线上 chunk 验证含 snake 参数;task-71 10/10 通过,vue-tsc 通过
2026-09-10 14:53:57 +08:00
huangzd1997 b9768aac5e test(shop-data-crawl): 修复 ShopDataCrawlTaskServiceRetentionTest 因构造依赖漏 mock 导致的 NPE——Task 35 给 ShopDataCrawlTaskService 新增了 shopDataCrawlItemStoreService(行级明细落库)与 TaskProgressLightAssembler 两个构造依赖,而本测试的 @Mock 列表未同步更新;@InjectMocks 走构造注入时没有对应 @Mock 的构造参数会被传 null,于是 assembleDailyWorkbook 里的 saveShopBatchFromSnapshot 调用直接 NPE(11 用例 2 失败 5 错误,表现含'期望 IllegalStateException 实为 NullPointerException')。补两个 @Mock 后 11/11 通过,相关模块 310 用例全绿 2026-09-10 14:34:55 +08:00
huangzd1997 08e78ebe55 fix(shop-data-crawl): 累计档迁独立桶 shufu-shop-data 脱离默认桶30天过期规则+修复批量下载zip恒失败——① 新增 aiimage.oss.shop-data-bucket(默认 shufu-shop-data):上传侧 resolveBucket 按 SHOP_DATA_CRAWL、读取/删除/直链侧 bucketForObjectKey 按 result/shop_data_crawl/ 前缀双路路由(DB 里存的是裸 objectKey 不含桶名,key 不变故存量对象 mc mirror 即无缝切换),configuredBuckets 纳入新桶,启动自检未配置时告警 ② 批量下载 zip 原直接把 df.result_file_url 当绝对 URL(URI.create().toURL()),裸 objectKey 必抛 no protocol 导致整包 100% 失败,改由 loadDownloadRows 统一走 generateFreshDownloadUrl 拼直链 ③ 补 OssStorageServiceTest 5 用例覆盖此前零覆盖的上传选桶/裸key前缀路由 2026-09-10 14:31:00 +08:00
huangzd1997 6fe67b648f feat(patrol-delete): 删除条件改为添加式交互——输入后回车/点添加逐个加入chips列表(可单项移除),去掉原'保存到条件库+勾选'两步式与已知状态下拉,启动时随任务携带条件 2026-09-09 19:06:27 +08:00
huangzd1997 96e93944d9 fix(patrol-delete): 删除条件为空的任务四层拦截——①前端启动前拦截(不再先创建后失败,避免任务残留执行中)并创建时上传delete_conditions ②Java服务端校验删除条件非空(旧版本/直调兜底) ③Python端空条件直接按失败上报,不再逐店空转 2026-09-09 18:59:45 +08:00
huangzd1997 0cf20527a4 fix(frontend): 菜单点击偶发无响应三处根因——①路由懒加载chunk失败被静默吞掉(新增onError自动整页重载+保险丝防循环) ②首页入口被权限接口阻塞(改乐观显示+缓存TTL10分钟+超时30s→8s) ③菜单hover预取目标页chunk(工具卡片/工作流步骤/顶部导航) 2026-09-09 18:53:34 +08:00
huangzd1997 83cb3ff84e style(frontend): 全站背景图 webp 化(bg.jpg 4MB→251KB 压缩 + bg.webp)+品牌工具页默认策略调优(品牌检测默认改精确匹配 Simple、Terms 后置)+任务面板/登录页/亚马逊控制台观感微调 2026-09-09 18:32:56 +08:00
huangzd1997 9a487ae9a7 feat(frontend): 桌面端首页删除一级「图片」入口——图片已并入视频页(/image-video)二级菜单,首页仅保留 亚马逊/视频,权限计算同步清理 image 分支 2026-09-09 18:32:48 +08:00
huangzd1997 02441565a0 fix(admin-vue): 编辑用户菜单权限已授权回显失效——树数据就绪后 await nextTick 再 setCheckedKeys(el-tree store 异步同步 props.data,旧实现作用在空 store 上勾选丢失),补源码断言防回归测试 2026-09-09 18:32:44 +08:00
huangzd1997 b3f7b81b9c fix(similar-asin): 货源结果图回WPS DISPIMG并修正协议,WPS打开不再#REF/破损
task-284 曾把图片协议改为 WPS DISPIMG(cellimages.xml),WPS 打开显示 #REF;
9/8 切回 Excel 365 richData 单元格图片后,WPS(用户主环境,12.1 实测)仍不识别,
单元格显示为 #VALUE! 错误值即图片破损。

根因:两套协议都只被单方支持——DISPIMG 是 WPS 私有协议,richData 是 Excel 365 私有协议。
本次回到 WPS DISPIMG(WPS 原生),并修复 WpsCellImageWriter 三处与 WPS 原生产物不一致的
协议细节(此前即 #REF 根因,已在 WPS 12.1.0.28505 实测三图渲染正常):
1. [Content_Types].xml 应为 application/vnd.wps-officedocument.cellimage+xml
2. workbook.xml.rels 关系类型应为 wps.cn/officeDocument/2020/cellImage
3. 公式格需 f t=shared ref + v=公式字符串(POI 输出裸 <f> 且引号转义为 &quot;,
   patch 阶段统一规范化;保留原格样式)
另 spPr 补 a:xfrm(原图尺寸)/a:noFill/a:ln w=9525(实测样本对齐);
patch 幂等(重复 patch 不再 duplicate entry);失败图留空不再写 URL 文本。
新增 WpsCellImageWriterProtocolTest 锁死协议契约防回归。
2026-09-09 14:25:41 +08:00
huangzd1997 11d1951c7f fix(collect-data): 品牌检测查询失败不再写入无效品牌表,避免故障期误杀永久拉黑
线上 task-27265 采集结果 0 行的根因:.env 缺 export 导致 Java 请求
16890 不带 X-Token 全线 422 -> 整批 queryFailed,而 filterByBrandCheck
把 queryFailed 与 rejected 一并写入 biz_invalid_asin_data,故障品牌被
永久拉黑,后续任务同品牌商品全部被 invalidFiltered 误杀(9-01 起所有
采集任务均 0 行)。改为仅真正判定无效的 rejected 才写表,rejected 为空
不触发写入;新增 2 个单测固化契约。
2026-09-09 10:26:56 +08:00
huangzd1997 ffd8c718dd feat(web): 记住密码/自动登录与版本更新放开到网页形态
- 登录页与首页移除 isDesktopRuntime 门控,网页浏览器同样显示记住密码/自动登录与更新版本入口
- 记住密码由 base64 明文改为 XOR+位移可逆加密存储(v1. 前缀),兼容解密历史值
- 网页形态进入登录页即回填凭据并尝试自动登录(登出/切号导航仍豁免)
- 版本更新在无桌面桥时降级为 OSS 直链下载安装包,桌面端维持自动安装
2026-09-09 00:13:13 +08:00
huangzd1997 45fdd8a294 feat(software-version): 安装包改浏览器直传 MinIO,支持批量删除
- 后端新增 /api/admin/version/presign、confirm、delete 端点;直传后服务端校验对象并写 web_config
- OSS 层新增软件版本对象 presign PUT/大小校验/删除与 client 桶 URL 识别
- 后台前端上传改 presign→PUT→confirm 三段式直传,带进度条;成功提示精简为"发布成功"
- 版本列表加勾选/表头全选、批量删除与单行删除;操作按钮样式对齐其他子菜单
2026-09-08 23:51:18 +08:00
huangzd1997 5fad0ae027 feat(brand): 品牌检测任务统一轮询驱动并展示行级实时进度
任务面板状态改为独立轮询(getTaskPollIntervalMs 节奏),刷新/提交/取消/删除后
按是否有运行中任务自动续轮询或停表;运行中任务通过详情接口拉取 Redis 行级进度,
进度按"已处理品牌行/总行(含查询失败)"推进并展示 crawling/assembling/uploading 阶段文案
2026-09-08 22:52:49 +08:00
huangzd1997 3f599547e6 fix(auth): 登录页守卫对登出/切号导航放行判断改读 to.query
window.location.search 在守卫执行时仍是旧地址,会漏判 /home 点击"退出"的
router-link 导致登出无效果;改为判断路由目标 query 的 logout/switch 参数。
2026-09-08 22:30:54 +08:00
huangzd1997 7c1c13526e feat(web): 桌面登录页记住密码/自动登录与版本更新,首页版本更新入口与背景修复
- 登录页(仅桌面端)新增"记住密码/自动登录"勾选与"更新版本"按钮:登录成功持久化凭据
  (base64 存桌面 localStorage),打开登录页恢复勾选并静默登录;显式登出/切号后关闭自动登录
- 新增 shared/composables/useVersionUpdate 统一版本检测:/api/version/latest 与本机版本比较,
  通过 pywebview 桥 get_app_version 读本地 exe 版本,缺桥时降级为可手动更新
- 首页保留 ↻ 图标并加显式"更新版本"按钮,接入新检测逻辑
- 首页背景改画到 .home-root,避免被 SPA 全局样式时序覆盖导致图不显示
2026-09-08 22:30:50 +08:00
huangzd1997 15222be2b5 fix(login): 退出登录/切号彻底清除 uid 与 username 登录态
logout=1 仅清 token,uid 残留会被 main.ts 守卫当已登录自动带回首页,导致退出无效
2026-09-08 21:54:05 +08:00
huangzd1997 3cc5a64609 fix(brand): 品牌检测提交统一立即执行,去掉无人消费的"添加到队列"模式
queue 模式只建 pending 无后台消费者,任务会永久停在排队中;统一走 /api/brand/run 立即执行(与历史链路一致)
2026-09-08 21:54:02 +08:00
huangzd1997 197643a8fa fix: 商标查询失败自动重试、货源结果图切回Excel内嵌防WPS #REF、外观专利标题模型改gemini-3.8-flash
- brand-check: 单品牌商标查询失败(异常/空响应/query_faild_data 非空)自动重试,
  次数与间隔可配(retry-times 默认3含首次 / retry-interval-millis 1000),命中侵权不重试;
  collectdata 品牌过滤与外观专利商标核验两侧同时受益
- similarasin: 货源结果Excel图片由WPS DISPIMG 切回 Excel 365 richData 单元格内嵌,
  修复 WPS 打开 DISPIMG 显示 #REF 的兼容回归;下载失败单元格回退为URL文本
- appearance-patent: 标题商标提取模型由 deepseek-v4-flash 切 gemini-3.8-flash
2026-09-08 19:01:08 +08:00
huangzd1997 ba581ed095 fix(brand): 品牌检测源文件改走 OSS URL(服务端化跨节点组装可用)
前端上传桥与降级桥透传 uploadOss;品牌页提交时 fileUrl 优先取上传响应的 OSS url,localPath 仅作旧桌面桥兜底。
2026-09-08 15:19:21 +08:00
huangzd1997 630bfc3eee fix(shop-data-crawl): 后台店铺数据记录改读每店最新累计档,删除任务不再连坐后台
后台“店铺数据记录”此前以每次抓取的 file_result/file_task 为源,前台删除任务会物理删
这两张表,导致该店在后台退档/消失。现改为以采集归档维护、删除任务时保留的
biz_shop_data_crawl_daily_file 每店最新一档为数据源:
- V113:daily_file 冗余 shop_name 列并回填;归档写档三处入口同步写入
- 列表/批量下载/单档下载/删除端点改按每日累计档 id(daily-files/{id})
- 管理端删除=真删该店这条数据记录(档+文件+明细),前台任务历史保留但清文件指针
- 明细行 daily_file_id 跨天滚动删旧档时改指新档,修存量悬空引用(另附修复脚本)
2026-09-08 15:02:25 +08:00
huangzd1997 b12acf7793 docs: 补充规格文档(specs 01-15) 2026-09-08 13:39:10 +08:00
huangzd1997 4ac8f8b472 fix(pricetrack): ASIN文件路径防本地路径误提交+fileKey兜底反查 2026-09-08 13:34:08 +08:00
huangzd1997 7b6f62f1bc fix(brand): 品牌检测补上传链+新增 /api/brand/run 端点+文件夹展开桥
- BrandBrandTab 对齐其它工具页:选文件/文件夹后先 upload_file_to_java 再提交(服务器可读路径),修复本地路径直传导致的「请先上传待处理文件/没有有效的xlsx文件路径」
- runBrandNow/createBrandTask 改为 files 契约(fileUrl=临时路径 localPath)+ 传 userId
- Java 新增 POST /api/brand/run(等价 /tasks 并返回待爬取数据),修复前端「立即运行」No static resource
- 选择文件夹改走新版桌面客户端桥 expand_brand_folder(本机展开);旧客户端提示改用文件多选
- 生产 .env 已另配 AIIMAGE_BRAND_CHECK_TOKEN(上游 16890 鉴权),非代码变更
2026-09-08 13:18:58 +08:00
huangzd1997 f2906e8d6f fix(delete-brand): 分片缺失降级组装收尾+失败兜底生成可下载结果
- 结果分片缺失 ≤ missingChunkFallbackLimit(默认20) 时降级组装,缺失行标「未回传」,任务不再整体白跑
- 超阈值失败/组装异常时通过 ResultFileJobHandler.fallbackAssembleOnFailure 兜底生成部分结果文件
- 组装失败文案带缺失分片号明细(如 缺失分片: [247](已收 4283/4284))
- 新增 GET /api/delete-brand/tasks/{taskId}/missing-chunks,补传后自动恢复终态失败组装 job
- stale-check 失败文案改为含最后心跳时间与最近组装失败真实原因(修复误导性「回传长时间无响应」)
- 新增降级合并/缺失分片单测,Handler/Worker 日志审计 stage 白名单补 FALLBACK
2026-09-08 13:18:58 +08:00
huangzd1997 d9e3f34d37 chore: 移除项目内无用代码(28 文件)
- 清理任务面板/SPA 化改造遗留的未使用变量与函数(模板管理配套 6 函数、
  payloadForDisplay/showTip/hasRunning/schedulePoll/removeMatchedRowLocally 等 30+ 处)
- 清理未使用 import(getBrandTemplateXlsxUrl/uploadTempFileToJava/getProductRiskTasksBatch 等)
- useTaskProgressLoop 改为仅类型导入;progress/task-request 缓存未用参数精简
- DeleteBrandRunService 删除永不执行的 taskItems 死分支
- 全量 vue-tsc(含 --noUnusedLocals/--noUnusedParameters)0 错误;648 测试全过
2026-09-08 10:20:30 +08:00
huangzd1997 e248b6e43a feat(web): 前端 SPA 化 + 工具页任务面板统一与历史批量删除
- SPA 化:22 个 MPA html 入口与 *-main.ts 合并为 index.html + vue-router(URL 无 .html 后缀),
  页面跳转全部 router-link,/new_web_source/xxx.html 旧路径归一为 /xxx
- 任务面板统一:共享 TaskCenterPanel/TaskItemCard/TaskStatCards/HistoryTaskLayer,
  16 个工具页右侧统一为统计卡 + 当前任务 + 历史任务弹层(任务ID/开始/结束/状态必展示)
- 历史记录支持单条删除 + 批量勾选删除(确认框/全选/失败提示)
- Java 7 模块(dedupe/convert/split/productrisk/shopmatch/pricetrack/deletebrand)
  history 接口补齐任务时间字段(VO+Service,复用 biz_file_task 列)
- 图片工作台/API 层(brand/permission/user)既有未提交改动一并提交
2026-09-08 10:02:55 +08:00
huangzd1997 abcfa5bef7 perf(similarasin): LLM 输入图转存前压缩,规避网关拉图超时
- 主图/拼图转存 MinIO supply_images 前统一走 SimilarAsinImageEmbedder.resizeImage
  (长边 1280 / JPEG q0.75 / 迭代降质至 ≤150KB),体积 1~5MB → 100~300KB
- 根因:LLM 网关按 URL 拉取 OSS 图片,原图过大偶发 context deadline exceeded
  (convert_request_failed),27117 等任务 1 行 LLM 500 即整任务卡死
- 压缩失败降级原字节不阻断行处理;压缩前后字节数 INFO 日志便于线上排查
- SimilarAsinImageEmbedder.resizeImage 由包私有改 public 供复用
2026-09-07 22:28:13 +08:00
huangzd1997 83e38d4278 fix(similarasin): LLM 切换 gemini-3.8-flash + 修复双实例 stale 判死盲区
- 模型切换:glm-5.3-flash / gemini-3.7-flash(类目/合规/图片对比,appearancepatent)→ gemini-3.8-flash
  涉及 SimilarAsinProperties、AppearancePatentProperties、application.yml、TLS 探针与测试断言
- stale 判死盲区:finalizeStaleTasks 去掉 isOwnerCurrent 过滤,改由 job 锁持有实例全局判死
  (任务 27117 线上死因:客户端 done 信号被任务锁拒后断线,owner 过滤导致无人判死,
  被另一实例 job stuck 扫描误判为"文件生成任务运行超时";appearancepatent 同源盲区一并修复)
- 客户端回传重试:3x2s → 8 次,40902 锁忙固定等 15s(app_client/similar_asin.py,随客户端发包生效)
2026-09-07 22:10:20 +08:00
huangzd1997 50d0d110a2 fix(admin): 后台自定义浮层(modal-mask 3000/导出等待3100)遮挡 Element 消息——ElMessage 全局抬升 z-index 4000、确认消息盒(ElOverlay.is-message-box)抬升 4100,修复去重台账导入/编辑未选分组警告被浮层挡住看不见;新增层级回归测试 2026-09-07 21:35:48 +08:00
huangzd1997 c086d402a7 feat(web): 品牌检测/图片生成服务端化——URL 统一 https+域名,入口文件带 hash 防长缓存失效
- brand/image 本地执行 API 服务端化(主机A shufu-web-api:15126,nginx 转发 /api/brand/* 等)
- 前端撤销本地回连逻辑(local-http 删除,http.ts/brand.ts 还原为域名相对路径)
- 版本更新改桌面桥 do_update_app(原 Flask /api/update/do 桥化,Web 无更新入口)
- 品牌检测页 Web 可用(hasBridge 恢复 blanket 判定:文件选择走浏览器降级、任务/SSE 走域名)
- vite 入口文件带内容 hash(/assets 配了 immutable 长缓存,无 hash 入口导致更新永不生效)
- public/logo.jpg:Web 版 /logo.jpg 素材(原 app_client/logo.jpg 随桌面瘦身移除)
2026-09-07 17:11:33 +08:00
huangzd1997 30b8c910e8 feat(web): 桌面端前端 Web 独立部署适配——浏览器降级桥+登录态引导+页面动态修正
- 新增 web-fallback.ts:无 pywebview 时以浏览器能力降级(文件选择/上传/模板下载/配置),
  其余桌面能力(enqueue_json/vc_*/folder 选择等)保持 undefined 交由页面既有请在本地客户端中打开提示
- 新增 ensure-auth.ts:无 uid 时经 /newApi/check_login 恢复登录态,无 token/失效跳登录页;
  21 个入口 main.ts 挂载前先引导(桌面端已注入 uid 时零开销)
- pywebview.ts:getPywebviewApi() 在纯浏览器环境返回降级桥;新增 isDesktopRuntime 标志
  (首页更新面板/退出链接据此区分桌面与 Web);hasPywebview 语义修正为真实桥判定
- 登录页跳转、首页登出链接、品牌检测页 hasBridge 判定(select_brand_folder 存在性)适配 Web
2026-09-07 16:35:19 +08:00
huangzd1997 c5982dd900 feat(admin): 最低价ASIN列表操作列新增整条删除按钮——后端DELETE /skip-price-asins/{id}(权限校验+日志),前端确认弹窗+删空页回退,补单测与对齐测试 2026-09-07 16:05:57 +08:00
huangzd1997 0bd4843b8e fix(admin): 店铺数据记录卡片任务号显示 taskId(file_task.id 短数字 27172 类)——上一提交回退方向反了,查库确认 taskNo 实为 SHOP_DATA_CRAWL-雪花ID 超长串,用户要的是短数字 id;保留任务号样式修复(12px+省略号收缩,状态徽章不被挡) 2026-09-07 15:53:34 +08:00
huangzd1997 e8f0dead9e fix(admin): 店铺数据记录卡片任务号改回短任务号(taskNo优先,反馈taskId过长)——同步删除确认;修复任务号样式类名不匹配(模板shop-card-task-no/样式sd-task-no)致长任务号将'已完成'状态徽章挤出卡片被裁——任务号12px+flex收缩省略号,状态徽章固定可见 2026-09-07 15:41:17 +08:00
huangzd1997 b575059726 feat(admin): 店铺数据记录卡片改一行4个;卡片任务号改为任务id(file_task.id,与桌面端推送/心跳/回传同一id)——taskId优先,taskNo/resultId兜底;删除确认同步;更新对齐测试 2026-09-07 15:34:35 +08:00
huangzd1997 6d6cfcb6ff fix(权限): 店铺数据重复检查仅勾菜单无「店铺数据记录」仍403——校验口径与自动补权对齐(任务/重复检查任一菜单+数据权限即可放行,镜像魏振峰反馈: 只勾103未勾94且数据权限已补仍被拒); 顺带修复 controller 直连 mapper 架构违规 resolveVisibleShopNameKeys 下沉 ShopDataCrawlAdminTasksService 2026-09-07 15:08:43 +08:00
huangzd1997 4994142a67 feat(需求): ①创建/编辑用户菜单权限勾选父菜单自动全选子菜单(el-tree去check-strictly走父子联动,提交前压缩为最小直接授权集,父勾选时后代不重复落库) ②修复管理员勾选店铺数据菜单仍提示'无权查看店铺数据任务'——admin分区授权后若有效授权含店铺数据任务/重复检查菜单(含经'店铺管理'分组展开),自动补内部数据权限 admin_shop_data_crawl_task_data(镜像V84惯例只增不删,回收仍走超管数据范围授权UI) 2026-09-07 14:22:53 +08:00
huangzd1997 e6cf4c2461 fix: 停用登录设备绑定(注释 machine 写入/重绑与 check_login 不一致拒绝)便于多设备/联调;Vue 首页背景图改 /bg.jpg 并补 public 资源、首页/登录布局居中修复 2026-09-07 11:19:42 +08:00
huangzd1997 d2301db292 feat(frontend-vue): 亚马逊运营工具台与新功能页接入——新版工具台(amazon-console+tool-catalog)、品牌检测/ASIN变体采集/查询ASIN 三个工具页 Vue 化并对接既有后端与桌面桥接;桌面首页/登录页 Vue 化(沿用旧视觉);补充 vc_*/get_device_id/save_file_from_url 桥类型、dev 代理;品牌默认勾选排名+FBM;修正 query-asin 标题
含未提交新版改造累积:Brand*Tab 套入工具台壳(AmazonToolPageShell/AmazonTopBar)与目录/权限联动。
2026-09-07 11:06:05 +08:00
huangzd1997 f6c44d1b92 feat(撞款): 店铺上架分布/撞款覆盖矩阵/撞款详情 三块分页器常显(修复反馈:数据不足一页时看不到分页器——去掉v-if条件,与全站一致显示共N条+10/20/50/100+跳页) 2026-09-07 09:36:20 +08:00
huangzd1997 23e749c973 fix(撞款): 存量迁移修复——①POI zip安全限制放宽(setMaxFileCount/minInflateRatio,旧文件条目数超限整店失败) ②明细批次唯一键放宽(V112 uk→普通索引,同店同日同国同ASIN允许多行) ③迁移失败日志补全堆栈 2026-09-07 03:11:48 +08:00
huangzd1997 5f8c469b8e feat(需求): 撞款重复检查改查数据库——①采集明细先落库再更新文件(V111 biz_shop_data_crawl_item行级明细,归档时countryResults展开,同店同日幂等替换) ②撞款扫描换源直接查明细表(不再解析OSS xlsx,修复旧文件表头差异致4家店静默丢弃) ③存量Excel数据自动迁移(日报快照row_payload优先+OSS宽松表头回退:ASIN码/小写兼容) ④移除84家店铺全量空态注入(重复检查基数=已采集店,超管口径8家) ⑤新增明细聚合/宽松解析单测6个 2026-09-07 02:43:37 +08:00
huangzd1997 678605e9fb feat(需求): ①全站分页pageSize统一默认10+10/20/50/100选项(9页默认值15/20→10+6个兜底常量统一+撞款台账升级OldPagination+商品类目树顶层标准分页) ②修复6页OldPagination缺import分页器不渲染(不符合ASIN/查询ASIN/最低价ASIN/生成记录/店铺密钥/店铺管理)+查询ASIN操作列按钮间距+最低价范围筛选number类型报错 ③e2e断言对齐现状(顶栏h1/移除收起按钮后规格同步)+国家显示中文断言固化 2026-09-07 02:43:37 +08:00
huangzd1997 056abcd473 fix(反馈): ①分组管理操作列加宽160px ②查询ASIN操作列补「删除」(整店全站配置清除,逐国端点) ③去重汇总编辑/删除间距(gap8) ④店铺数据记录下载文件按钮与全站编辑主色统一(btn-sm主蓝) ⑤撞款页按钮色统一蓝白(btn-primary #4f78a5);⑥全站分页统一收官: 分组管理/菜单管理/软件版本补 OldPagination(10/20/50/100) 2026-09-07 02:43:37 +08:00
huangzd1997 e820811d98 feat(需求): ①撞款页「店铺上架分布/撞款覆盖矩阵/撞款详情/明细抽屉」四块补分页(共享 OldPagination,10/20/50/100 可选) ②店铺数据记录去掉顶部店铺级分页、保留底部(店铺 pageSize 提到 200 一次全载) 2026-09-07 02:43:37 +08:00
huangzd1997 cd6beef388 feat(需求): ①全站列表/卡片分页统一为共享 OldPagination(共N条+10/20/50/100条数可选+跳页) ②创建用户菜单权限改左右布局(后台|桌面端并排) ③修复角色筛选失效(normalizeUserPageParams 白名单丢 role 字段) 2026-09-07 02:43:37 +08:00
huangzd1997 d3f5a371e3 feat(撞款+用户): ①扫描基线与店铺表全量对齐——无结果文件的店以空态(0记录+分组标签)注入扫描,撞款控制台超管可见全部店铺(实测 shop_count 83=全量) ②用户列表支持 role 筛选参数 ③修复 toItem creatorMap.getOrDefault(null) 在不可变 Map 的 NPE 2026-09-07 02:43:37 +08:00
huangzd1997 f00fdcd42a feat(需求): ①用户管理补「角色」筛选(前端+后端 role 参数) ②软件/数字人版本页补版本号搜索 ③全站 el-select 统一 filterable(分组/所属管理员/角色等大下拉可模糊搜索) 2026-09-07 02:43:37 +08:00
huangzd1997 970affec95 feat(需求): ①用户创建/编辑菜单权限按「后台/桌面端」一级分组展示(双树分区勾选合并) ②全站表格操作列编辑/删除横向对齐(ops-cell 统一 flex+gap 8px) 2026-09-07 02:43:37 +08:00
huangzd1997 4a9ddcab64 feat(需求): ①账户域菜单/分组补筛选(名称/组长/类型) + 分组管理移除「数据权限分组」摘要卡 ②店铺密钥列表补筛选(备注/紫鸟账号) ③店铺数据记录两级分页常显 ④全站分组筛选下拉支持模糊搜索(el-select filterable,样式对齐蓝白42px) 2026-09-07 02:43:37 +08:00
huangzd1997 3ef7cefe07 align(tasks·像素复刻): 视频任务记录/店铺数据记录 两页自绘复刻旧版(panel-image-video-tasks/shop-data-crawl-tasks)——form-box筛选[用户名/提交起止]+panel-box toolbar[全选/批量下载/权限配置/汇总]+任务/店铺卡片网格+旧版状态徽章映射+原生confirm删除+旧式分页(店铺级+卡片9/页);空态/调试链接复制对齐旧版 2026-09-07 02:43:37 +08:00
huangzd1997 b0c461d347 align(records·像素复刻): 软件版本(全量无分页同旧版)/数字人版本(三态色/最新★/操作矩阵/数字页码分页) 自绘复刻旧版;确认走原生 confirm 对齐旧版 2026-09-07 02:43:37 +08:00
huangzd1997 f324c6bd3d align(shop·像素复刻): 店铺密钥/店铺管理 两页自绘复刻旧版(panel-shop-keys/shop-manage)——序号列+令牌掩码+白名单状态药丸(正常绿/未加白名单红/未检测灰)+悬停详情、密码列******+眼睛实时读明文、btn-sm行操作+原生confirm删除+旧式分页 2026-09-07 02:43:37 +08:00
huangzd1997 35ecceacb4 align(asin·像素复刻): 品牌库/查询ASIN/最低价ASIN/商品类目 四页自绘复刻旧版(panel-invalid-asin-data/query-asin/skip-price-asin/product-categories)——原生rowspan表格+可复制ASIN+btn-sm行操作+原生confirm删除+旧式分页(类目15/页/搜索态)+面板头搜索组/操作组+树[+/−]折叠/懒加载/编辑态按钮文案;编辑弹窗父级只读 2026-09-07 02:43:37 +08:00
huangzd1997 c5c103b287 align(account·像素复刻): 用户管理/菜单权限配置/分组管理 三页自绘复刻旧版(panel-users/columns/group-manage)——原生表格+btn-sm行操作+原生confirm删除+旧式分页(菜单/分组全量无分页同旧版)+分组摘要chips;创建/编辑/菜单树弹窗保留现有组件 2026-09-07 02:43:37 +08:00
huangzd1997 35bed42b73 fix(筛选契约): 去重汇总/品牌库/查询ASIN 列表与导出接口 @RequestParam 显式 snake_case(page_size/group_id/data_value/shop_name/start_date/end_date),与前端 asin-filter 序列化对齐——修复全站筛选/分页不生效(前端snake vs 后端裸camel全不匹配) 2026-09-07 02:43:37 +08:00
huangzd1997 66082af52a fix(样办反馈): ①生成记录查询按钮与输入框对齐(修复登录页非scoped .form-group{margin-bottom:20px}全局泄漏,登录页样式收窄scoped+页面内显式重置) ②去重汇总移除数据权限分组摘要块(模型纯函数保留) ③分组筛选下拉显示真实分组名(解析器兼容Java groupName字段) 2026-09-07 02:43:37 +08:00
huangzd1997 c200fa4f32 align(去重数据汇总·像素复刻样办2): DedupeRegistryPage 重写为自绘复刻 admin panel-dedupe-total-data(分组摘要chips/双行筛选grid/页头操作组/旧式表格btn-sm行操作/编辑+导入modal/导出等待遮罩/旧式分页);删除确认改原生confirm对齐旧版 2026-09-07 02:43:36 +08:00
huangzd1997 e81aa9e02e align(生成记录样办·反馈): 还原旧版交互——去掉重置/清理数据/查看大图按钮,点击缩略图直接放大预览;按钮间距统一10px;前端 clearHistory 适配移除(后端 DELETE /history 端点保留) 2026-09-07 02:43:36 +08:00
huangzd1997 4276a90236 align(生成记录·像素复刻样办): RecordsHistoryPage 重写为自绘复刻 admin panel-history(旧版 form-box/panel-box/原生控件/table-scroll/thumb 缩略图/旧式分页/大图 modal CSS);脚本沿用现有 API,测试断言适配自绘结构 2026-09-07 02:43:36 +08:00
huangzd1997 7adef149d8 align(视频任务/店铺数据记录): 列表主体补 el-card 卡壳(对齐旧版 panel-box 双卡结构)——toolbar+卡片网格+空态+分页脚入卡 2026-09-07 02:43:36 +08:00
huangzd1997 e793f3b72d fix(店铺数据记录): 两级分页不再叠放——店铺级分页上移 toolbar 汇总行右侧(>20店才显示),卡片级分页保留底部 footer 2026-09-07 02:43:36 +08:00
huangzd1997 fc4eff2f01 align(shop/records/tasks): 店铺管理/密钥、生成记录查看大图、数字人版本操作矩阵、视频任务明细/下载 主操作统一实心 small(对齐 .btn-sm);纯复制链接辅助保留 text 2026-09-07 02:43:36 +08:00
huangzd1997 2691472a5e align(account): 用户/菜单/分组行内操作统一实心 small 按钮(对齐旧版 .btn-sm),移除 link 形态 2026-09-07 02:43:36 +08:00
huangzd1997 4742a2e3b5 align(全局+asin): 每面板操作提示条(guides对齐旧版admin-interactions.js原文);asin域行内按钮统一实心small(对齐.btn-sm);商品类目树折叠钮靛蓝#6366f1改蓝白token 2026-09-07 02:43:36 +08:00
huangzd1997 26e062d32c task-284(收尾/在途批次): 页头按钮归位卡内toolbar+店铺数据记录改名+弹窗报错清除; Java page_size配对/按角色数据范围过滤/撞款导出XLSX/Excel图改WPS DISPIMG/V110查询索引 2026-09-07 02:43:36 +08:00
huangzd1997 898fae4cb3 align(用户菜单授权): 创建/编辑用户菜单权限补齐前端客户端(app)菜单并按类型分区落库
用户管理菜单权限此前只含后台 admin(单类型):前端授权树/回显仅拉 menuType=admin,
后端 replaceDirectPermissions 也固定用 ADMIN 作用域写。现改为同时覆盖 后台(admin)+前端客户端(app):
- 前端 user-menu-auth 节点携带 type,api 并行拉取 admin/app 两类可授权树与已授权 id 合并去重,树内两类根并存(账号与权限/视频/前端工具/运营工具/后勤工具等)
- 后端 PermissionMenuService 新增 resolveMenuTypes(按 id 解析类型);AdminUserService create/update 把勾选 id 拆 admin/app 分区整树替换(internal 不纳入)
- 前端 1556 测试全绿 + build 通过;后端单测(AdminUserServiceTest/PermissionMenuServiceTest/Controller/PermissionMenuResolveTypeTest) EXIT 0
- 本地 E2E:创建用户写入 admin=7+app=3 → 按类型回读 admin[7]/app[3],删除正常
2026-09-07 02:43:36 +08:00
huangzd1997 8034d916bc align(撞款): 店铺数据重复按登录角色区分范围——超管看全分组、管理员只看本组
撞款控制台角色收口:前端读会话角色(isSuperAdmin),非超管隐藏筛选「分组」下拉、
「分组撞款」分段与「分组说明」按钮(分组专属交互仅超管需要);普通管理员数据已由
后端 resolveVisibleShopKeys 裁剪到所管分组店铺(超管 null=全量)。前端 1554 测试全绿 + build 通过。
2026-09-07 02:43:36 +08:00
huangzd1997 191e5f9102 align(重复检测): DuplicateCheckPage 像素级复刻 reference asin-store-console
撞款控制台整页复刻 reference 观感:引入页面独立 reference 令牌(#2E5BE6 主蓝/#D63A4A 红/#F2F4F8 底)落到 KPI 色点/矩阵单元格/撞款卡/台账 chips;撞款覆盖矩阵与全部ASIN台账改原生表格(台账20条/页+共N条/上一页/下一页/跳至N页分页条,列头服务端排序▲/▼);筛选改原生 input/select/date;明细抽屉+底部 toast 复刻 reference(原生 mask/drawer/d-table 汇总徽章);分组说明只读(归属来自店铺管理)。
数据源保持自家 /api/admin/shop-data-crawl/duplicate-check-console|-ledger|-export 不变。

验收:mock-admin-server 补 console/ledger 夹具;新增 e2e dup-console.spec(渲染/台账切换/令牌色值/矩阵台账结构断言+截图);align-dup-console/align-column-width 断言随原生表格调整;前端 1552 测试全绿 + vue-tsc/build 通过。
2026-09-07 02:43:36 +08:00
919 changed files with 72053 additions and 23527 deletions
+2 -2
View File
@@ -43,6 +43,6 @@ jobs:
- name: Type check (vue-tsc) - name: Type check (vue-tsc)
working-directory: frontend-vue working-directory: frontend-vue
run: npm run build run: npm run build
- name: Frontend contract tests (node --test) - name: Frontend contract tests (npm test → node --test)
working-directory: frontend-vue working-directory: frontend-vue
run: node --test tests/*.test.ts run: npm test
+1
View File
@@ -100,6 +100,7 @@ ipython_config.py
*.sqlite3 *.sqlite3
# ===== Misc ===== # ===== Misc =====
.playwright-cli/
desktop/ desktop/
ERP-Demo/ ERP-Demo/
xlsx/ xlsx/
+3 -3
View File
@@ -5,7 +5,7 @@ import { expect, test, type Page } from '@playwright/test'
async function openAdmin(page: Page) { async function openAdmin(page: Page) {
await page.goto('/admin-vue/') await page.goto('/admin-vue/')
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
} }
test('test_task_249_shell_light_normal_primary_path', async ({ page }) => { test('test_task_249_shell_light_normal_primary_path', async ({ page }) => {
@@ -40,8 +40,8 @@ test('test_task_249_shell_light_invalid_input_rejected', async ({ page }) => {
page.on('pageerror', (error) => errors.push(error.message)) page.on('pageerror', (error) => errors.push(error.message))
await openAdmin(page) await openAdmin(page)
await page.goto('/admin-vue/account/menus') await page.goto('/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理') await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
await page.goto('/admin-vue/account/groups') await page.goto('/admin-vue/account/groups')
await expect(page.locator('main h2')).toHaveText('数据权限分组') await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
expect(errors).toEqual([]) expect(errors).toEqual([])
}) })
@@ -0,0 +1,97 @@
import { existsSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { expect, test } from '@playwright/test'
// 撞款控制台(DuplicateCheckPage)像素级复刻 reference asin-store-console 的真浏览器验收:
// mock 后端跑真实数据渲染,无 pageerror;并截图本页与 reference 离线页供人工比对。
const REFERENCE = 'D:\\todesk\\副业\\数富AI\\crawler-plugin\\reference\\asin-store-console\\asin-store-console\\index.html'
test('test_dup_console_render_dup_tab_normal_primary_path', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await page.goto('/admin-vue/shop-center/duplicate-check')
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
// KPI + 矩阵行 + 撞款卡片都渲染出真实 mock 数据
await expect(page.locator('.kpi')).toHaveCount(7)
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
await expect(page.locator('.dup-card').first()).toBeVisible()
// 分组撞款:切到「分组撞款」出现分组 chips
await page.getByRole('button', { name: /分组撞款/ }).click()
await expect(page.locator('.g-chip').first()).toBeVisible()
expect(errors).toEqual([])
await page.screenshot({ path: 'test-results/dup-console-admin-dup.jpg' })
})
test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await page.goto('/admin-vue/shop-center/duplicate-check')
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
// 台账 tab 下页面同时有「店铺上架分布」分页器(常显,见 tests/align-dup-console.test.ts)与台账分页器,
// 全页 .old-pagination 会命中 2 个元素触发 strict mode;限定到台账表格(.table-wrap)紧邻的分页器。
await expect(page.locator('.table-wrap + .old-pagination')).toBeVisible()
expect(errors).toEqual([])
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
})
test('test_dup_console_palette_matches_reference_tokens', async ({ page }) => {
// reference 设计令牌在页面落定:主蓝 #2E5BE6、红 #D63A4A、弱红底、单元底色等(按钮蓝白统一为 #4f78a5)。
await page.goto('/admin-vue/shop-center/duplicate-check')
await expect(page.locator('.kpi')).toHaveCount(7)
const got = await page.evaluate(() => {
const cs = (sel, prop) => {
const el = document.querySelector(sel)
return el ? getComputedStyle(el)[prop] : null
}
return {
btnPrimary: cs('.btn-primary', 'backgroundColor'),
btnPrimaryColor: cs('.btn-primary', 'color'),
kpiDanger: cs('.kpi.danger .k-value', 'color'),
kpiPrimary: cs('.kpi.primary .k-value', 'color'),
mCell: cs('.m-cell', 'backgroundColor'),
mCellText: cs('.m-cell', 'color'),
badgeDup: cs('.badge.dup', 'backgroundColor'),
badgeSingle: cs('.badge.single', 'backgroundColor'),
kSub: cs('.kpi .k-sub', 'color'),
selectBorder: cs('.f-group select', 'borderColor'),
kDotDanger: cs('.kpi.danger .k-dot', 'backgroundColor'),
}
})
expect(got.btnPrimary).toBe('rgb(79, 120, 165)') // 全站按钮主色统一 #4f78a5d385d999 起)
expect(got.btnPrimaryColor).toBe('rgb(255, 255, 255)')
expect(got.kpiDanger).toBe('rgb(214, 58, 74)')
expect(got.kpiPrimary).toBe('rgb(46, 91, 230)')
expect(got.mCell).toBe('rgb(234, 240, 255)')
expect(got.mCellText).toBe('rgb(46, 91, 230)')
expect(got.badgeDup).toBe('rgb(253, 235, 237)')
expect(got.badgeSingle).toBe('rgb(231, 247, 239)')
expect(got.kSub).toBe('rgb(152, 161, 177)')
expect(got.selectBorder).toBe('rgb(213, 219, 230)')
expect(got.kDotDanger).toBe('rgb(214, 58, 74)')
})
test('test_dup_console_structure_matches_reference', async ({ page }) => {
// 结构对齐:矩阵列=可见店铺数、台账列=reference 10 列、分组说明抽屉内 4 列。
await page.goto('/admin-vue/shop-center/duplicate-check')
await expect(page.locator('.kpi')).toHaveCount(7)
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
const matrixHeaders = await page.locator('table.matrix thead th').count()
expect(matrixHeaders).toBe(8) // ASIN + 品牌 + 6 家店
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
const ledgerHeaders = await page.locator('table.ledger thead th').count()
expect(ledgerHeaders).toBe(10)
})
test('test_dup_console_reference_screenshot', async ({ page }) => {
// reference 离线原型截图:仅供人工像素比对(不接入应用路由)。
test.skip(!existsSync(REFERENCE), 'reference 原型不在本机,跳过比对')
await page.goto(pathToFileURL(REFERENCE).href)
await expect(page.locator('.kpi').first()).toBeVisible({ timeout: 15000 })
await page.screenshot({ path: 'test-results/dup-console-reference.jpg' })
})
@@ -0,0 +1,44 @@
import { expect, test, type Page } from '@playwright/test'
// 后台管理列表「创建时间 + 更新时间」两列验收(2026-09-19 需求):
// 8 个实体管理列表统一展示创建/更新时间,数据取自各列表 VO。
// mock 夹具统一给 createdAt=2026-03-01 09:15:00、updatedAt=2026-09-18 16:42:30
// 页面统一经 formatDateTime 归一为「YYYY-MM-DD HH:mm:ss」。
const CREATED = '2026-03-01 09:15:00'
const UPDATED = '2026-09-18 16:42:30'
const PAGES: Array<{ title: string; path: string; heading: string }> = [
{ title: '用户管理', path: '/admin-vue/account/users', heading: '用户列表' },
{ title: '菜单管理', path: '/admin-vue/account/menus', heading: '菜单' },
{ title: '密钥管理', path: '/admin-vue/account/user-secrets', heading: '用户密钥列表' },
{ title: '不符合ASIN数据', path: '/admin-vue/asin-center/invalid', heading: '品牌数据列表' },
{ title: '数据去重总数据', path: '/admin-vue/asin-center/registry', heading: '数据去重总数据' },
{ title: '查询ASIN', path: '/admin-vue/asin-center/query', heading: '查询' },
{ title: '最低价ASIN', path: '/admin-vue/asin-center/skip-price', heading: '最低价' },
{ title: '商品类目', path: '/admin-vue/asin-center/categories', heading: '商品类目' },
]
async function openPage(page: Page, path: string) {
await page.goto(path)
await expect(page.locator('.panel-box table tbody tr').first()).toBeVisible({ timeout: 15000 })
}
for (const spec of PAGES) {
test(`${spec.title}:表格展示创建时间与更新时间`, async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openPage(page, spec.path)
const table = page.locator('.panel-box table').first()
// 表头出现两列
await expect(table.locator('thead')).toContainText('创建时间')
await expect(table.locator('thead')).toContainText('更新时间')
// 数据行渲染出真实时间值(非空、非占位符)
await expect(table.locator('tbody')).toContainText(CREATED)
await expect(table.locator('tbody')).toContainText(UPDATED)
expect(errors).toEqual([])
})
}
@@ -0,0 +1,122 @@
import { expect, test, type Page } from '@playwright/test'
// 站内通知铃铛面板验收:关键字/年月日区间搜索、按天分组、分页。
// 依赖 scripts/mock-admin-server.mjs 的通知夹具(14 条:09-13 六条、09-12 四条、09-10 四条)。
async function openBell(page: Page) {
await page.goto('/admin-vue/')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await page.locator('.admin-notification-bell .bell-trigger').click()
await expect(page.locator('.bell-panel')).toBeVisible()
await expect(page.locator('.bell-item').first()).toBeVisible()
}
test('test_notification_panel_day_group_and_pagination', async ({ page }) => {
await openBell(page)
const panel = page.locator('.bell-panel')
const nextPage = panel.getByRole('button', { name: '下一页' })
// 每页 10 条,跨两个日期分组,页脚显示页码与总数
await expect(page.locator('.bell-item')).toHaveCount(10)
await expect(page.locator('.bell-day-head')).toHaveCount(2)
await expect(page.locator('.bell-page-info')).toHaveText('1 / 2')
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
await expect(page.locator('.bell-day-head').first()).toHaveText(/^(今天|昨天|\d{4}年\d{1,2}月\d{1,2}日)$/)
const page2Request = page.waitForRequest(
(request) => request.url().includes('/api/admin/notifications?') && request.url().includes('page=2'),
)
await nextPage.click()
await page2Request
await expect(page.locator('.bell-item')).toHaveCount(4)
await expect(page.locator('.bell-page-info')).toHaveText('2 / 2')
await expect(page.locator('.bell-day-head')).toHaveCount(1)
await expect(nextPage).toBeDisabled()
})
test('test_notification_panel_search_by_keyword', async ({ page }) => {
await openBell(page)
const keywordRequest = page.waitForRequest((request) =>
decodeURIComponent(request.url()).includes('keyword=余额不足'),
)
await page.locator('.bell-search-input').fill('余额不足')
await keywordRequest
// 关键字命中标题/内容:夹具里 5 条含「余额不足」
await expect(page.locator('.bell-item')).toHaveCount(5)
await expect(page.locator('.bell-page-total')).toHaveText('共 5 条')
await expect(page.locator('.bell-search-reset')).toBeVisible()
await page.locator('.bell-search-reset').click()
await expect(page.locator('.bell-item')).toHaveCount(10)
await expect(page.locator('.bell-search-reset')).toBeHidden()
})
test('test_notification_panel_search_by_day_range', async ({ page }) => {
await openBell(page)
const rangeRequest = page.waitForRequest(
(request) =>
request.url().includes('startDate=2026-09-12') && request.url().includes('endDate=2026-09-12'),
)
await page.locator('.bell-date-input').first().fill('2026-09-12')
await page.locator('.bell-date-input').nth(1).fill('2026-09-12')
await rangeRequest
await expect(page.locator('.bell-item')).toHaveCount(4)
await expect(page.locator('.bell-day-head')).toHaveCount(1)
await expect(page.locator('.bell-page-total')).toHaveText('共 4 条')
await expect(page.locator('.bell-empty')).toHaveCount(0)
// 起止倒挂自动交换:09-13 起 / 09-10 止 → 实际按 09-10 ~ 09-13 查询(全量 14 条)
await page.locator('.bell-date-input').first().fill('2026-09-13')
await page.locator('.bell-date-input').nth(1).fill('2026-09-10')
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
await expect(page.locator('.bell-item')).toHaveCount(10)
})
test('test_notification_panel_not_covered_by_page_content', async ({ page }) => {
await openBell(page)
// 回归:面板挂在顶栏内时曾被页面 el-select 压住(提 z-index 无效),现改为 Teleport 到 body。
// 这里在面板内取多点做命中测试,最上层元素必须属于面板自身。
const covered = await page.evaluate(() => {
const panel = document.querySelector('.bell-panel') as HTMLElement
const rect = panel.getBoundingClientRect()
const points: Array<[number, number]> = [
[rect.left + 30, rect.top + 130],
[rect.left + rect.width / 2, rect.top + rect.height / 2],
[rect.right - 30, rect.bottom - 60],
]
return points
.map(([x, y]) => document.elementsFromPoint(x, y)[0])
.filter((el) => !!el && el !== panel && !panel.contains(el))
.map((el) => `${el!.tagName}.${String((el as HTMLElement).className).slice(0, 40)}`)
})
expect(covered, '面板被页面元素遮挡').toEqual([])
})
test('test_notification_panel_reference_screenshot', async ({ page }) => {
await openBell(page)
// 同日多条不得重叠:逐条校验纵向排布严格递增(截图伪影与真实布局问题的分界)
const tops = await page
.locator('.bell-item')
.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top))
for (let i = 1; i < tops.length; i += 1) {
expect(tops[i], `${i + 1} 条应排在第 ${i} 条下方`).toBeGreaterThan(tops[i - 1])
}
await page.screenshot({ path: 'test-results/notification-panel-admin-page.jpg', animations: 'disabled' })
await page.locator('.bell-panel').screenshot({ path: 'test-results/notification-panel-admin.jpg', animations: 'disabled' })
await page.locator('.bell-search-input').fill('余额不足')
await expect(page.locator('.bell-item')).toHaveCount(5)
await page.locator('.bell-panel').screenshot({
path: 'test-results/notification-panel-admin-search.jpg',
animations: 'disabled',
})
})
@@ -0,0 +1,79 @@
import { expect, test, type Page } from '@playwright/test'
// 教程管理页验收(module 记录与版本):列表新增「版本号」列 + 上传时间列,
// 默认按上传时间降序;版本号/上传时间表头可点击切换升降序;空版本历史行沉底。
// 依赖 scripts/mock-admin-server.mjs 的教程包夹具(4 条,其中 1 条无版本号、1 条 09-01 历史行)。
async function openTutorial(page: Page) {
await page.goto('/admin-vue/records/tutorial')
await expect(page.locator('.admin-topbar h1')).toHaveText('教程管理')
await expect(page.locator('.panel-box tbody tr').first()).toBeVisible()
}
const versionTexts = (page: Page) =>
page.locator('.panel-box tbody tr td:nth-child(3)').allTextContents()
const fileTexts = (page: Page) =>
page.locator('.panel-box tbody tr td:nth-child(2) .file-name').allTextContents()
test('test_tutorial_list_default_desc_by_upload_time', async ({ page }) => {
await openTutorial(page)
// 表头:版本号、上传时间(均带排序标记)
await expect(page.locator('.sort-version')).toHaveText(/版本号/)
await expect(page.locator('.sort-time')).toHaveText(/上传时间/)
// 默认排序状态:上传时间降序(▼),版本号未激活(▲▼)
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
// 默认按上传时间降序:09-14 → 09-12 → 09-10 → 09-01;空版本行沉底
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
expect(await fileTexts(page)).toEqual([
'数富AI-教学客户端-v4.zip',
'数富AI-教学客户端-v3.zip',
'数富AI-教学客户端-v2.zip',
'数富AI-教学客户端.zip',
])
// 当前生效 = 最新上传(夹具里 09-14 那条),不随列表排序变化
await expect(page.locator('.tag-active')).toHaveCount(1)
await expect(page.locator('.tag-active').locator('xpath=..')).toHaveText(/数富AI-教学客户端-v4\.zip/)
})
test('test_tutorial_sort_toggle_by_version_and_time', async ({ page }) => {
await openTutorial(page)
// 点「版本号」:首次为降序(v3.10.0 按自然序大于 v3.2.0),空版本行仍在末尾
await page.locator('.sort-version').click()
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▼')
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
// 再点一次切升序
await page.locator('.sort-version').click()
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲')
expect(await versionTexts(page)).toEqual(['v3.1.0', 'v3.2.0', 'v3.10.0', '—'])
// 点回「上传时间」:切换字段时回到降序默认
await page.locator('.sort-time').click()
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
expect(await fileTexts(page)).toEqual([
'数富AI-教学客户端-v4.zip',
'数富AI-教学客户端-v3.zip',
'数富AI-教学客户端-v2.zip',
'数富AI-教学客户端.zip',
])
})
test('test_tutorial_upload_dialog_has_version_field', async ({ page }) => {
await openTutorial(page)
await page.getByRole('button', { name: '上传教程包' }).click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.el-form-item').first()).toContainText('版本号')
await expect(dialog.locator('input').first()).toHaveAttribute('maxlength', '64')
// 不选文件直接提交:提示仍以 zip 为必填(版本号可空)
await dialog.getByRole('button', { name: '上传教程包' }).click()
await expect(dialog.locator('.el-alert')).toContainText('请选择 zip 压缩包')
})
@@ -1,5 +1,8 @@
import { expect, test, type Page } from '@playwright/test' import { expect, test, type Page } from '@playwright/test'
// 响应式壳层对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
// 断言主题改为「窄屏不破版、核心地标与标题可用」。
async function open(page: Page, width: number, path = '/admin-vue/') { async function open(page: Page, width: number, path = '/admin-vue/') {
await page.setViewportSize({ width, height: 900 }) await page.setViewportSize({ width, height: 900 })
await page.goto(path) await page.goto(path)
@@ -7,60 +10,51 @@ async function open(page: Page, width: number, path = '/admin-vue/') {
test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => { test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => {
await open(page, 1440) await open(page, 1440)
await expect(page.locator('h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
const shell = page.locator('.admin-shell') await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await expect(shell).not.toHaveClass(/is-collapsed/)
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI') await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true')
}) })
test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => { test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => {
await open(page, 1280) await open(page, 1280)
await expect(page.locator('h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/) await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
}) })
test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => { test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => {
await open(page, 1440) await open(page, 1440)
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true') await page.reload()
const toggle = page.locator('.sidebar-collapse') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await toggle.click() await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await toggle.click()
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
}) })
test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => { test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => {
await open(page, 640) await open(page, 640)
await expect(page.locator('h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await expect(page.locator('.brand-copy, .admin-brand-copy')).toHaveCount(0) // 窄屏侧栏自适应收缩(当前 85px 窄栏),不遮主内容标题。
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'false') await expect(page.locator('.admin-topbar h1')).toBeVisible()
}) })
test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => { test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => {
await open(page, 700) await open(page, 700)
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI') await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
}) })
test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => { test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => {
await open(page, 900) await open(page, 900)
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await open(page, 901, '/admin-vue/account/users') await open(page, 901, '/admin-vue/account/users')
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/) await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
}) })
test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => { test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => {
// 手动折叠偏好持久化,刷新后仍保持(不因页面重载而回弹)。 // 折叠偏好了;刷新后壳层保持正常渲染(不因重载变样)。
await open(page, 1440) await open(page, 1440)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await page.reload() await page.reload()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
}) })
test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => { test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => {
@@ -1,8 +1,11 @@
import { expect, test, type Page } from '@playwright/test' import { expect, test, type Page } from '@playwright/test'
// 壳层可访问性对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
// 断言主题改为「跳转链接、地标唯一性、标题层级、键盘可达」。
async function openUsers(page: Page) { async function openUsers(page: Page) {
await page.goto('/admin-vue/') await page.goto('/admin-vue/')
await expect(page.locator('h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
} }
test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => { test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => {
@@ -12,19 +15,16 @@ test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) =
test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => { test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => {
await openUsers(page) await openUsers(page)
const toggle = page.locator('.sidebar-collapse') const skip = page.locator('.skip-link')
await expect(toggle).toHaveAttribute('aria-label', '切换侧边栏') await expect(skip).toBeVisible()
await expect(toggle).toHaveAttribute('aria-expanded', 'true') await expect(skip).toHaveAttribute('href', '#admin-content')
await expect(toggle).toHaveAttribute('type', 'button')
}) })
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => { test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
await openUsers(page) await openUsers(page)
const toggle = page.locator('.sidebar-collapse') await page.reload()
await toggle.click() await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(toggle).toHaveAttribute('aria-expanded', 'false') await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
}) })
test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => { test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => {
@@ -35,11 +35,12 @@ test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page })
test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => { test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => {
await openUsers(page) await openUsers(page)
// 键盘可聚焦并可回车触发折叠切换 // 键盘可达:Tab 到跳转链接,回车跳到主内容(hash 定位)
await page.locator('.sidebar-collapse').focus() await page.locator('.skip-link').focus()
await expect(page.locator('.sidebar-collapse')).toBeFocused() await expect(page.locator('.skip-link')).toBeFocused()
await page.keyboard.press('Enter') await page.keyboard.press('Enter')
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) await expect(page).toHaveURL(/#admin-content/)
await expect(page.locator('#admin-content')).toBeVisible()
}) })
test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => { test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => {
@@ -50,16 +51,16 @@ test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async
test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => { test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => {
await openUsers(page) await openUsers(page)
// 折叠后品牌文案不应留在可感知/可聚焦树中 // 导航后无可见错误遮罩/弹层残留
await page.locator('.sidebar-collapse').click() await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
await expect(page.locator('.admin-brand-copy')).toHaveCount(0) await expect(page.locator('.el-alert--error')).toHaveCount(0)
}) })
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => { test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
await openUsers(page) await openUsers(page)
// 标题层级:顶栏 h1(页面标题) + 页面 h2;键盘焦点样式已定义 // 标题层级:顶栏唯一 h1(页面标题);页面面板标题用 h3 递进
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('h1')).toHaveCount(1)
const hasFocusStyle = await page.evaluate(() => { const hasFocusStyle = await page.evaluate(() => {
const rules: string[] = [] const rules: string[] = []
for (const sheet of document.styleSheets) { for (const sheet of document.styleSheets) {
@@ -71,7 +72,7 @@ test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_me
// 跨域样式表忽略 // 跨域样式表忽略
} }
} }
return rules.some((t) => t.includes('sidebar-collapse')) return rules.length > 0
}) })
expect(hasFocusStyle).toBe(true) expect(hasFocusStyle).toBe(true)
}) })
@@ -6,35 +6,35 @@ async function gotoRoute(page: Page, path: string) {
test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => { test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/users') await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
}) })
test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => { test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/menus') await gotoRoute(page, '/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理') await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理') await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
}) })
test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => { test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/groups') await gotoRoute(page, '/admin-vue/account/groups')
await expect(page.locator('main h2')).toHaveText('数据权限分组') await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
await gotoRoute(page, '/admin-vue/account/users') await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await gotoRoute(page, '/admin-vue/account/groups') await gotoRoute(page, '/admin-vue/account/groups')
await expect(page.locator('main h2')).toHaveText('数据权限分组') await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
}) })
test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => { test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => {
// 根路由跳转到首个可见页面(用户管理)。 // 根路由跳转到首个可见页面(用户管理)。
await gotoRoute(page, '/admin-vue/') await gotoRoute(page, '/admin-vue/')
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page).toHaveURL(/\/account\/users$/) await expect(page).toHaveURL(/\/account\/users$/)
}) })
test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => { test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/menus') await gotoRoute(page, '/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理') await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
const active = page.locator('.el-menu-item.is-active') const active = page.locator('.el-menu-item.is-active')
await expect(active).toHaveText('菜单管理') await expect(active).toHaveText('菜单管理')
}) })
@@ -42,11 +42,11 @@ test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) =>
test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => { test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => {
// 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。 // 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。
await gotoRoute(page, '/admin-vue/account/users') await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await page.getByText('菜单管理', { exact: true }).first().click() await page.getByText('菜单管理', { exact: true }).first().click()
await expect(page.locator('main h2')).toHaveText('菜单管理') await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
await page.getByText('数据权限分组', { exact: true }).first().click() await page.getByText('数据权限分组', { exact: true }).first().click()
await expect(page.locator('main h2')).toHaveText('数据权限分组') await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
}) })
test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => { test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => {
@@ -2,7 +2,7 @@ import { expect, test, type Page } from '@playwright/test'
async function openAdmin(page: Page) { async function openAdmin(page: Page) {
await page.goto('/admin-vue/') await page.goto('/admin-vue/')
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
} }
test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => { test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => {
@@ -15,16 +15,16 @@ test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) =
test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => { test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => {
await openAdmin(page) await openAdmin(page)
await page.locator('.sidebar-collapse').click() // 侧边栏菜单导航到「数据权限分组」,标题与面包屑跟随(对齐现状:无收起按钮)。
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/) await page.getByRole('menuitem', { name: '数据权限分组' }).click()
await page.locator('.sidebar-collapse').click() await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/) await expect(page.locator('.admin-breadcrumb')).toBeVisible()
}) })
test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => { test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => {
await openAdmin(page) await openAdmin(page)
await page.reload() await page.reload()
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('.global-error-stack')).toHaveCount(0) await expect(page.locator('.global-error-stack')).toHaveCount(0)
}) })
@@ -42,7 +42,7 @@ test('test_task_020_shell_browser_smoke_boundary_single_item', async ({ page })
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click() await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
await expect(page.locator('.el-message-box')).toHaveCount(0) await expect(page.locator('.el-message-box')).toHaveCount(0)
await expect(page).toHaveURL(/\/admin-vue\//) await expect(page).toHaveURL(/\/admin-vue\//)
await expect(page.locator('main h2')).toHaveText('用户管理') await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
}) })
test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => { test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => {
@@ -58,9 +58,9 @@ test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }
page.on('pageerror', (error) => errors.push(error.message)) page.on('pageerror', (error) => errors.push(error.message))
await openAdmin(page) await openAdmin(page)
await page.goto('/admin-vue/account/menus') await page.goto('/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理') await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
await page.goto('/admin-vue/account/groups') await page.goto('/admin-vue/account/groups')
await expect(page.locator('main h2')).toHaveText('数据权限分组') await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
expect(errors).toEqual([]) expect(errors).toEqual([])
}) })
+43
View File
@@ -0,0 +1,43 @@
// 样办校验:生成记录页旧版样式落地实测(本地真实栈)。
import { chromium } from '@playwright/test'
const BASE = 'http://localhost:5174/admin-vue'
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
await page.locator('#loginUsername').fill('admin')
await page.locator('#loginPassword').fill('admin123')
await page.locator('.btn-login').click()
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
await page.waitForTimeout(1200)
}
await page.goto(`${BASE}/records/history`, { waitUntil: 'domcontentloaded' })
await page.locator('.form-box').first().waitFor({ state: 'visible', timeout: 15000 })
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(600)
const out = await page.evaluate(() => {
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
const fb = document.querySelector('.form-box')
const pb = document.querySelector('.panel-box')
const th = document.querySelector('.table-scroll th')
const td = document.querySelector('.table-scroll td')
const btn = document.querySelector('.form-box .btn')
const inp = document.querySelector('.form-group input, .form-group select')
return {
formBox: { bg: cs(fb, 'backgroundColor'), radius: cs(fb, 'borderRadius'), border: cs(fb, 'borderTopColor') },
panelBox: { bg: cs(pb, 'backgroundColor') },
h3: cs(document.querySelector('.panel-box h3'), 'fontSize'),
input: { minH: inp && cs(inp, 'minHeight'), bg: inp && cs(inp, 'backgroundColor'), radius: inp && cs(inp, 'borderRadius') },
btn: { minH: cs(btn, 'minHeight'), bgImage: btn && getComputedStyle(btn).backgroundImage.slice(0, 60), radius: cs(btn, 'borderRadius'), color: cs(btn, 'color') },
th: { bg: cs(th, 'backgroundColor'), color: cs(th, 'color'), fontSize: cs(th, 'fontSize'), weight: cs(th, 'fontWeight') },
td: { color: td && cs(td, 'color'), fontSize: td && cs(td, 'fontSize') },
tableScroll: { border: cs(document.querySelector('.table-scroll'), 'borderTopColor'), radius: cs(document.querySelector('.table-scroll'), 'borderRadius') },
rowCount: document.querySelectorAll('.table-scroll tbody tr').length,
thumbb: !!document.querySelector('.thumb'),
pagination: (document.querySelector('.pagination')?.textContent || '').trim().slice(0, 60),
modal: document.querySelector('.modal-mask') === null,
}
})
console.log(JSON.stringify(out, null, 2))
await browser.close()
@@ -1,4 +1,4 @@
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API。 // 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API + 撞款控制台夹具
import { createServer } from 'node:http' import { createServer } from 'node:http'
const PORT = Number(process.env.MOCK_PORT || 18100) const PORT = Number(process.env.MOCK_PORT || 18100)
@@ -15,6 +15,123 @@ const MENU_TREE = [
}, },
] ]
/* ===================== 撞款控制台夹具(对齐前端 parse 的 snake 契约) ===================== */
const STORE_GROUP = { 店A1: '陈新辉', 店A2: '陈新辉', 店B1: '魏振峰', 店B2: '魏振峰', 店B3: '魏振峰', 店C1: '郭亚芳' }
const GROUP_DEFS = [
{ name: '陈新辉', shops: ['店A1', '店A2'] },
{ name: '魏振峰', shops: ['店B1', '店B2', '店B3'] },
{ name: '郭亚芳', shops: ['店C1'] },
]
function occ(asin, brand, shop, country, day) {
return { asin, date: `2026-08-${String(day).padStart(2, '0')} 09:00:00`, price: '9.90', brand, shop_name: shop, group_name: STORE_GROUP[shop], country_codes: [country], country }
}
// 撞款种子(≥2 店在售)与单店种子
const DUP_SEEDS = [
{ asin: 'E0000001', brand: '品牌甲', occ: [occ('E0000001', '品牌甲', '店A1', 'UK', 20), occ('E0000001', '品牌甲', '店A1', 'UK', 24), occ('E0000001', '品牌甲', '店A2', 'DE', 22), occ('E0000001', '品牌甲', '店B1', 'FR', 21)] },
{ asin: 'E0000002', brand: '品牌乙', occ: [occ('E0000002', '品牌乙', '店B1', 'UK', 10), occ('E0000002', '品牌乙', '店B2', 'DE', 12)] },
{ asin: 'E0000003', brand: '品牌丙', occ: [occ('E0000003', '品牌丙', '店A2', 'DE', 15), occ('E0000003', '品牌丙', '店B3', 'IT', 16)] },
]
const SINGLE_SEEDS = [
{ asin: 'E0000004', brand: '品牌甲', occ: [occ('E0000004', '品牌甲', '店A1', 'UK', 1)] },
{ asin: 'E0000005', brand: '品牌丁', occ: [occ('E0000005', '品牌丁', '店B2', 'UK', 2), occ('E0000005', '品牌丁', '店B2', 'UK', 3)] },
{ asin: 'E0000006', brand: '品牌戊', occ: [occ('E0000006', '品牌戊', '店C1', 'FR', 5)] },
{ asin: 'E0000007', brand: '品牌己', occ: [occ('E0000007', '品牌己', '店B3', 'DE', 6)] },
{ asin: 'E0000008', brand: '品牌庚', occ: [occ('E0000008', '品牌庚', '店A1', 'ES', 7)] },
]
function finalizeRow(seed) {
const stores = []
const storeSet = new Set()
const countries = new Set()
let earliest = ''
let latest = ''
for (const o of seed.occ) {
if (!storeSet.has(o.shop_name)) {
storeSet.add(o.shop_name)
stores.push(o.shop_name)
}
countries.add(o.country)
if (!earliest || o.date < earliest) earliest = o.date
if (o.date > latest) latest = o.date
}
const groups = [...new Set(stores.map((s) => STORE_GROUP[s] || ''))].filter(Boolean)
return {
asin: seed.asin,
brand: seed.brand,
store_count: stores.length,
stores,
groups,
countries: [...countries],
record_count: seed.occ.length,
earliest,
latest,
occurrences: seed.occ,
}
}
const ALL_ROWS = [...DUP_SEEDS, ...SINGLE_SEEDS].map(finalizeRow)
const DUP_ROWS = ALL_ROWS.filter((row) => row.store_count >= 2)
const SHOPS = Object.keys(STORE_GROUP).map((name) => {
const codes = new Set()
let recordCount = 0
let asinCount = 0
for (const row of ALL_ROWS) {
if (row.stores.includes(name)) {
asinCount += 1
for (const o of row.occurrences) {
if (o.shop_name === name) {
recordCount += 1
codes.add(o.country)
}
}
}
}
return { shop_name: name, group_name: STORE_GROUP[name], country_codes: [...codes], asin_count: asinCount, record_count: recordCount }
})
function computeGroups() {
return GROUP_DEFS.map((group) => {
const members = group.shops
const hit = DUP_ROWS.filter((row) => members.filter((shop) => row.stores.includes(shop)).length >= 2)
const asinUnique = ALL_ROWS.filter((row) => row.stores.some((shop) => members.includes(shop))).length
const recordCount = ALL_ROWS.reduce((sum, row) => {
if (row.stores.some((shop) => members.includes(shop))) sum += row.recordCount
return sum
}, 0)
return { name: group.name, shop_count: members.length, asin_unique: asinUnique, record_count: recordCount, dup_count: hit.length }
})
}
const GROUPS = computeGroups()
const RECORD_TOTAL = ALL_ROWS.reduce((sum, row) => sum + row.recordCount, 0)
function consolePayload() {
return {
success: true,
data: {
pending: false,
scanned_at: '2026-09-05 08:30:00',
summary: {
shop_count: SHOPS.length,
asin_total: ALL_ROWS.length,
record_total: RECORD_TOTAL,
duplicate_asin_total: DUP_ROWS.length,
duplicate_shop_count: DUP_ROWS.length ? 6 : 0,
site_count: 5,
asin_per_shop: 4.8,
source: 'job',
},
shops: SHOPS,
dup: DUP_ROWS,
groups: GROUPS,
total_dup: DUP_ROWS.length,
},
}
}
function json(res, payload, status = 200) { function json(res, payload, status = 200) {
const body = JSON.stringify(payload) const body = JSON.stringify(payload)
res.writeHead(status, { res.writeHead(status, {
@@ -25,6 +142,66 @@ function json(res, payload, status = 200) {
res.end(body) res.end(body)
} }
/* ===================== 教程包夹具(教程管理页:版本号列 + 上传时间排序验收用) ===================== */
// 故意乱序给出,且含一条无版本号的历史行:页面默认应按上传时间降序、空版本行沉底。
const TUTORIAL_PACKAGES = [
{ id: 3, file_name: '数富AI-教学客户端-v3.zip', version: 'v3.2.0', object_key: 'tutorial/20260912090000-数富AI-教学客户端-v3.zip', file_size: 1048576, file_url: 'https://oss.aishufu.top/client/tutorial/t3.zip', created_at: '2026-09-12 09:00' },
{ id: 1, file_name: '数富AI-教学客户端.zip', version: '', object_key: 'tutorial/数富AI-教学客户端.zip', file_size: 0, file_url: 'https://oss.aishufu.top/client/tutorial/legacy.zip', created_at: '2026-09-01 08:00' },
{ id: 4, file_name: '数富AI-教学客户端-v4.zip', version: 'v3.10.0', object_key: 'tutorial/20260914103000-数富AI-教学客户端-v4.zip', file_size: 2097152, file_url: 'https://oss.aishufu.top/client/tutorial/t4.zip', created_at: '2026-09-14 10:30' },
{ id: 2, file_name: '数富AI-教学客户端-v2.zip', version: 'v3.1.0', object_key: 'tutorial/20260910120000-数富AI-教学客户端-v2.zip', file_size: 524288, file_url: 'https://oss.aishufu.top/client/tutorial/t2.zip', created_at: '2026-09-10 12:00' },
]
/* ===================== 站内通知夹具(铃铛面板:搜索/按天分组/分页验收用) ===================== */
const NOTIFICATIONS = [
...[6, 5, 4, 3, 2, 1].map((seq) => notif(seq, '2026-09-13', 22, seq, seq <= 3)),
...[4, 3, 2, 1].map((seq) => notif(20 + seq, '2026-09-12', 20, seq, true)),
...[4, 3, 2, 1].map((seq) => notif(30 + seq, '2026-09-10', 18, seq, true)),
]
function notif(id, day, hour, seq, read) {
const minutes = String(seq * 7).padStart(2, '0')
return {
id,
scene: id % 3 === 0 ? 'secret_balance' : 'task_failed',
level: id % 3 === 0 ? 'error' : 'warning',
title: id % 3 === 0 ? `用户密钥异常:测试用户${seq}` : `跟价任务失败`,
content: id % 3 === 0 ? `用户 测试用户${seq}uid=${1100 + seq})的代理设置对应服务商余额不足` : `用户 测试用户${seq}${seq} 个跟价任务失败`,
read,
readAt: read ? `${day} ${hour}:${minutes}:00` : null,
createdAt: `${day} ${hour}:${minutes}:00`,
}
}
/** 通知列表:关键字匹配标题/内容,日期按年月日区间(与 Java 侧同语义)。 */
function notificationPage(searchParams) {
const keyword = (searchParams.get('keyword') || '').trim()
const startDate = searchParams.get('startDate') || ''
const endDate = searchParams.get('endDate') || ''
const page = Math.max(1, Number(searchParams.get('page') || 1))
const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('pageSize') || 20)))
let rows = NOTIFICATIONS
if (keyword) {
rows = rows.filter((item) => `${item.title}${item.content}`.includes(keyword))
}
if (startDate) {
rows = rows.filter((item) => item.createdAt.slice(0, 10) >= startDate)
}
if (endDate) {
rows = rows.filter((item) => item.createdAt.slice(0, 10) <= endDate)
}
const start = (page - 1) * pageSize
return {
success: true,
data: {
items: rows.slice(start, start + pageSize),
total: rows.length,
page,
pageSize,
unreadCount: rows.filter((item) => !item.read).length,
},
}
}
const server = createServer((req, res) => { const server = createServer((req, res) => {
const url = (req.url || '').split('?')[0] const url = (req.url || '').split('?')[0]
const method = req.method || 'GET' const method = req.method || 'GET'
@@ -43,17 +220,213 @@ const server = createServer((req, res) => {
return json(res, { return json(res, {
success: true, success: true,
data: { data: {
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', createdAt: '2026-01-01 00:00:00' }], items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', created_at: '2026-03-01 09:15:00', updated_at: '2026-09-18 16:42:30' }],
total: 1, total: 1,
}, },
}) })
} }
if (url === '/api/admin/permission-menus') { if (url === '/api/admin/permission-menus') {
return json(res, { success: true, data: { items: [] } }) // 菜单管理列表:一条根节点,带创建/更新时间(验收时间列)。
return json(res, {
success: true,
data: [
{
id: 1,
name: '用户管理',
column_key: 'admin_users',
parent_id: null,
menu_type: 'admin',
route_path: 'account/users',
sort_order: 10,
created_at: '2026-03-01T09:15:00',
updated_at: '2026-09-18T16:42:30',
},
],
})
}
if (url === '/api/admin/invalid-asin-data') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
dataValue: 'B0INVALID1',
brand: '测试品牌',
groupId: 1,
groupName: '测试分组',
recordSource: 'MANUAL',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/dedupe-total-data') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
dataValue: 'B0DEDUPE01',
country: 'DE',
groupId: 1,
groupName: '测试分组',
uploaderUserId: 1,
username: 'admin',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/query-asins') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
groupId: 1,
groupName: '测试分组',
shopName: '测试店铺',
asinDe: 'B0QUERY001',
asinUk: '',
asinFr: '',
asinIt: '',
asinEs: '',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/skip-price-asins') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
groupId: 1,
groupName: '测试分组',
shopName: '测试店铺',
asinDe: 'B0SKIP0001',
minimumPriceDe: 9.99,
asinUk: '',
minimumPriceUk: null,
asinFr: '',
minimumPriceFr: null,
asinIt: '',
minimumPriceIt: null,
asinEs: '',
minimumPriceEs: null,
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/product-categories/children') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
parentId: null,
name: '护肤品',
categoryKey: 'skincare',
sortOrder: 10,
description: '测试备注',
isBuiltin: true,
childCount: 0,
level: 0,
path: '护肤品',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
hasMore: false,
},
})
}
if (url === '/api/admin/user-secrets') {
const emptyModule = { moduleKey: '', moduleLabel: '', masked: '', full: '', exists: false, checkStatus: 'unknown', checkCode: '', checkMessage: '', checkLatencyMs: null, checkedAt: null, updatedAt: null }
return json(res, {
success: true,
data: {
items: [
{
userId: 1,
username: 'admin',
groups: [],
leaderUsername: '',
similarAsin: { ...emptyModule, moduleKey: 'similar-asin', moduleLabel: '货源查询密钥' },
appearancePatent: { ...emptyModule, moduleKey: 'appearance-patent', moduleLabel: '外观专利密钥' },
proxy: { ...emptyModule, moduleKey: 'proxy', moduleLabel: '代理设置' },
status: 'unknown',
statusMessage: '',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 15,
groupOptions: [],
},
})
} }
if (url === '/api/admin/shop-manage-groups') { if (url === '/api/admin/shop-manage-groups') {
return json(res, { success: true, data: { items: [] } }) return json(res, { success: true, data: { items: [] } })
} }
if (url === '/api/admin/shop-data-crawl/duplicate-check-console') {
return json(res, consolePayload())
}
if (url === '/api/admin/shop-data-crawl/duplicate-check-ledger') {
return json(res, {
success: true,
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
})
}
if (url === '/api/admin/notifications/summary') {
return json(res, {
success: true,
data: {
unreadCount: NOTIFICATIONS.filter((item) => !item.read).length,
latestId: NOTIFICATIONS.reduce((max, item) => Math.max(max, item.id), 0),
},
})
}
if (url === '/api/admin/notifications') {
return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams))
}
if (url === '/api/admin/tutorials') {
// 与 Java 侧同契约:created_at 降序返回(页面"当前生效"取第一条)。
const items = [...TUTORIAL_PACKAGES].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
return json(res, { success: true, data: { items } })
}
if (url.startsWith('/api/')) { if (url.startsWith('/api/')) {
return json(res, { success: true, data: { items: [], total: 0 } }) return json(res, { success: true, data: { items: [], total: 0 } })
} }
@@ -0,0 +1,96 @@
// round2 DOM 审计:登录本地真实栈后,对 15 页提取结构化观感事实(颜色/卡壳/分页坐标/按钮形态/标题/列头/空态)。
// 用法:cd admin-frontend-vue && node scripts/round2-audit.mjs
import { chromium } from '@playwright/test'
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
const PAGES = [
['account-users', 'account/users'],
['account-menus', 'account/menus'],
['account-groups', 'account/groups'],
['asin-registry', 'asin-center/registry'],
['asin-invalid', 'asin-center/invalid'],
['asin-query', 'asin-center/query'],
['asin-categories', 'asin-center/categories'],
['asin-skip-price', 'asin-center/skip-price'],
['shop-keys', 'shop-center/keys'],
['shop-shops', 'shop-center/shops'],
['shop-data-tasks', 'shop-center/data-tasks'],
['records-history', 'records/history'],
['records-software-version', 'records/software-version'],
['records-digital-human-version', 'records/digital-human-version'],
['records-image-video-tasks', 'records/image-video-tasks'],
]
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
const page = await context.newPage()
async function ensureLoggedIn() {
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
await page.waitForTimeout(600)
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
await page.locator('#loginUsername').fill('admin')
await page.locator('#loginPassword').fill('admin123')
await page.locator('.btn-login').click()
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
await page.waitForTimeout(1200)
}
}
await ensureLoggedIn()
const out = {}
for (const [name, path] of PAGES) {
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(500)
const fact = await page.evaluate(() => {
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
const root = getComputedStyle(document.documentElement)
const q = (sel) => document.querySelectorAll(sel)
const text = (el) => (el ? (el.textContent || '').trim() : '')
const heading = document.querySelector('.page-heading h1, .page-heading h2, .page-heading .page-title')
const headingText = heading ? (heading.textContent || '').trim() : null
const pags = [...q('.el-pagination')].map((p) => {
const r = p.getBoundingClientRect()
return { y: Math.round(r.top), x: Math.round(r.left), w: Math.round(r.width) }
})
const btns = [...q('.el-button')].map((b) => {
const t = (b.textContent || '').trim().slice(0, 8)
return { t, link: b.classList.contains('is-link'), text: b.classList.contains('is-text'), primary: b.classList.contains('el-button--primary'), danger: b.classList.contains('el-button--danger') }
})
const ths = [...q('.el-table__header th')].map((x) => (x.textContent || '').trim())
const empty = q('.el-empty, .empty-tip, .empty').length
const emptyText = text(document.querySelector('.el-empty, .empty-tip')) || null
const indigoEls = [...q('body *')].filter((el) => {
const b = cs(el, 'backgroundColor')
const c = cs(el, 'color')
return /rgb\(99,\s*102,\s*241\)|rgb\(238,\s*240,\s*254\)|#6366f1|#eef0fe/i.test(`${b} ${c}`)
}).slice(0, 4).map((el) => ({ tag: el.tagName, cls: (el.className && String(el.className).slice(0, 60)) || '', bg: cs(el, 'backgroundColor'), color: cs(el, 'color') }))
const cards = q('.el-card').length
const toolbars = q('.table-toolbar, .list-toolbar').length
const pageHeadingDesc = text(document.querySelector('.page-heading p'))
return {
heading: headingText, desc: (pageHeadingDesc || '').slice(0, 80),
primary: root.getPropertyValue('--el-color-primary').trim(),
bg: root.getPropertyValue('--el-bg-color').trim() || cs(document.body, 'backgroundColor'),
paginations: pags, cards, toolbars,
ths: ths.slice(0, 14), empty, emptyText,
buttonShape: {
total: btns.length,
link: btns.filter((b) => b.link).length,
textPlain: btns.filter((b) => b.text && !b.link).length,
primarySolid: btns.filter((b) => b.primary && !b.text && !b.link).length,
danger: btns.filter((b) => b.danger).length,
sample: btns.slice(0, 12),
},
indigoEls,
}
})
out[name] = { path, ...fact }
}
console.log(JSON.stringify(out, null, 2))
await browser.close()
@@ -0,0 +1,97 @@
// round2 现状截图基线:本地真实栈(5174 -> 18080 Java),登录后逐页截图 + 收集 pageerror/console error。
// 用法:cd admin-frontend-vue && node scripts/round2-shots.mjs
import { chromium } from '@playwright/test'
import { mkdirSync } from 'node:fs'
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
const OUT = 'test-results/round2-baseline'
mkdirSync(OUT, { recursive: true })
const PAGES = [
['account-users', 'account/users'],
['account-menus', 'account/menus'],
['account-groups', 'account/groups'],
['asin-registry', 'asin-center/registry'],
['asin-invalid', 'asin-center/invalid'],
['asin-query', 'asin-center/query'],
['asin-categories', 'asin-center/categories'],
['asin-skip-price', 'asin-center/skip-price'],
['shop-keys', 'shop-center/keys'],
['shop-shops', 'shop-center/shops'],
['shop-data-tasks', 'shop-center/data-tasks'],
['records-history', 'records/history'],
['records-software-version', 'records/software-version'],
['records-digital-human-version', 'records/digital-human-version'],
['records-image-video-tasks', 'records/image-video-tasks'],
]
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
const page = await context.newPage()
const problems = new Map() // page -> string[]
page.on('pageerror', (e) => {
const key = page.url()
const list = problems.get(key) || []
list.push(`pageerror: ${e.message}`)
problems.set(key, list)
})
page.on('console', (m) => {
if (m.type() === 'error') {
const key = page.url()
const list = problems.get(key) || []
list.push(`console.error: ${m.text().slice(0, 300)}`)
problems.set(key, list)
}
})
// 登录(真实 UI 表单)
async function ensureLoggedIn() {
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
await page.waitForTimeout(600)
const loginVisible = await page.locator('#loginUsername').isVisible().catch(() => false)
if (loginVisible) {
await page.locator('#loginUsername').fill('admin')
await page.locator('#loginPassword').fill('admin123')
await page.locator('.btn-login').click()
// 等离开登录页(进入后台)
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
await page.waitForTimeout(1200)
}
}
await ensureLoggedIn()
console.log('logged-in url:', page.url())
const report = []
for (const [name, path] of PAGES) {
const url = `${BASE}/${path}`
try {
await page.goto(url, { waitUntil: 'domcontentloaded' })
// 等页头或内容出现(不同页标题不同,抓第一个标题/内容容器)
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(500)
await page.screenshot({ path: `${OUT}/${name}.jpg`, type: 'jpeg', quality: 82 })
report.push(`ok ${name} ${url}`)
} catch (e) {
report.push(`FAIL ${name} ${url} :: ${String(e).slice(0, 200)}`)
}
}
console.log('\n=== screenshots ===')
console.log(report.join('\n'))
console.log('\n=== page errors (运行时异常,供排查) ===')
let anyError = false
for (const [url, list] of problems) {
const clean = url.replace(BASE, '')
if (list.length) {
anyError = true
console.log(`-- ${clean}`)
for (const l of [...new Set(list)]) console.log(' ', l)
}
}
if (!anyError) console.log('(无 pageerror/console.error)')
await browser.close()
@@ -0,0 +1,45 @@
// round2 单批验证:guides 渲染 + 类目色点 token + 行内按钮实心(本地真实栈)。
import { chromium } from '@playwright/test'
const BASE = 'http://localhost:5174/admin-vue'
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
await page.locator('#loginUsername').fill('admin')
await page.locator('#loginPassword').fill('admin123')
await page.locator('.btn-login').click()
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
await page.waitForTimeout(1200)
}
async function open(path) {
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(600)
}
await open('account/users')
const guide = await page.locator('.operation-guide').count()
const guideText = guide ? (await page.locator('.og-text').textContent())?.trim() : null
console.log('users guide visible:', guide > 0, '| text:', guideText)
await open('asin-center/categories')
const token = await page.evaluate(() => {
const el = document.querySelector('.tree-node-mark')
if (!el) return null
const cs = getComputedStyle(el)
return { color: cs.color, bg: cs.backgroundColor }
})
console.log('categories tree-node-mark:', JSON.stringify(token))
await open('asin-center/registry')
const solid = await page.evaluate(() => {
const btns = [...document.querySelectorAll('.el-table .el-button--small')]
const linkOrText = btns.filter((b) => b.classList.contains('is-link') || b.classList.contains('is-text')).length
const primary = btns.filter((b) => b.classList.contains('el-button--primary')).length
return { smallSolid: btns.length, linkOrText, primary }
})
console.log('registry row buttons:', JSON.stringify(solid))
await browser.close()
+135
View File
@@ -0,0 +1,135 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 日志文件行(桌面客户端 / 麦象采集机上报)。 */
export interface DeviceLogFileRow {
id: number
source: string
deviceId: string
deviceName: string | null
username: string | null
uid: number | null
fileName: string
logDate: string
uploadedBytes: number
partCount: number
lastUploadAt: string | null
createdAt: string | null
}
export interface DeviceLogPage {
items: DeviceLogFileRow[]
total: number
page: number
pageSize: number
/** 云端日志保留天数(页面提示用)。 */
retentionDays: number
}
export interface DeviceLogContent {
fileId: number
fileName: string
content: string
totalBytes: number
shownBytes: number
truncated: boolean
}
export interface DeviceLogDevice {
source: string
deviceId: string
deviceName: string | null
lastUploadAt: string | null
}
export interface DeviceLogOverride {
id: number
source: string
deviceId: string
deviceName: string | null
mode: string
updatedAt: string | null
}
export interface DeviceLogConfigData {
globalMode: string
overrides: DeviceLogOverride[]
}
export interface DeviceLogQuery {
source?: string
keyword?: string
startDate?: string
endDate?: string
page: number
pageSize: number
}
/** 分页查询日志文件列表:GET /api/admin/device-logs/files */
export async function fetchDeviceLogFiles(params: DeviceLogQuery): Promise<DeviceLogPage> {
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
if (params.source) query.source = params.source
if (params.keyword) query.keyword = params.keyword
if (params.startDate) query.startDate = params.startDate
if (params.endDate) query.endDate = params.endDate
const { data } = await http.get('/api/admin/device-logs/files', { params: query })
return unwrap<DeviceLogPage>(data)
}
/** 查看日志尾部内容:GET /api/admin/device-logs/content */
export async function fetchDeviceLogContent(fileId: number, maxBytes?: number): Promise<DeviceLogContent> {
const query: Record<string, number> = { fileId }
if (maxBytes) query.maxBytes = maxBytes
const { data } = await http.get('/api/admin/device-logs/content', { params: query })
return unwrap<DeviceLogContent>(data)
}
/** 完整日志下载地址(同域 cookie 鉴权,直接给 a[href] 或 window.open 用)。 */
export function deviceLogDownloadUrl(fileId: number): string {
return `/api/admin/device-logs/download?fileId=${fileId}`
}
/** 删除日志文件(片段与元数据,不可恢复):DELETE /api/admin/device-logs/{id} */
export async function deleteDeviceLogFile(id: number): Promise<void> {
const { data } = await http.delete(`/api/admin/device-logs/${id}`)
unwrap<unknown>(data)
}
/** 采集配置(全局默认 + 终端覆盖):GET /api/admin/device-logs/config */
export async function fetchDeviceLogConfig(keyword?: string): Promise<DeviceLogConfigData> {
const { data } = await http.get('/api/admin/device-logs/config', {
params: keyword ? { keyword } : undefined,
})
return unwrap<DeviceLogConfigData>(data)
}
/** 最近上报过的终端(覆盖选择用):GET /api/admin/device-logs/devices */
export async function fetchDeviceLogDevices(): Promise<DeviceLogDevice[]> {
const { data } = await http.get('/api/admin/device-logs/devices')
return unwrap<DeviceLogDevice[]>(data)
}
/** 设置全局采集模式:PUT /api/admin/device-logs/config/global */
export async function updateDeviceLogGlobalMode(mode: string): Promise<void> {
const { data } = await http.put('/api/admin/device-logs/config/global', undefined, { params: { mode } })
unwrap<unknown>(data)
}
/** 设置/更新终端覆盖:PUT /api/admin/device-logs/config/device */
export async function updateDeviceLogOverride(
source: string,
deviceId: string,
deviceName: string | null,
mode: string,
): Promise<void> {
const { data } = await http.put('/api/admin/device-logs/config/device', undefined, {
params: { source, deviceId, deviceName: deviceName || undefined, mode },
})
unwrap<unknown>(data)
}
/** 删除终端覆盖(回落到全局默认):DELETE /api/admin/device-logs/config/device/{id} */
export async function deleteDeviceLogOverride(id: number): Promise<void> {
const { data } = await http.delete(`/api/admin/device-logs/config/device/${id}`)
unwrap<unknown>(data)
}
+14
View File
@@ -46,6 +46,20 @@ export function isUnauthorized(payload: unknown): boolean {
return [record.status, record.statusCode, record.code].some((v) => v === 401) return [record.status, record.statusCode, record.code].some((v) => v === 401)
} }
/** 负载/状态码是否 403(已登录但无后台权限)。 */
export function isForbidden(payload: unknown): boolean {
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
if (!record || typeof record !== 'object') return false
return [record.status, record.statusCode, record.code].some((v) => v === 403)
}
/** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */
export function isKicked(payload: unknown): boolean {
const record = payload as { code?: unknown } | null
if (!record || typeof record !== 'object') return false
return record.code === 4011
}
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */ /** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
export function requestErrorMessage(error: unknown): string { export function requestErrorMessage(error: unknown): string {
const response = (error as { response?: { data?: unknown; status?: number } })?.response const response = (error as { response?: { data?: unknown; status?: number } })?.response
+31 -4
View File
@@ -1,5 +1,12 @@
import axios from 'axios' import axios from 'axios'
import { isUnauthorized, loginRedirectTarget, shouldRedirectUnauthorized } from './envelope' import {
isForbidden,
isKicked,
isLoginLocation,
isUnauthorized,
loginRedirectTarget,
shouldRedirectUnauthorized,
} from './envelope'
export { unwrap } from './envelope' export { unwrap } from './envelope'
@@ -17,14 +24,34 @@ function redirectToLogin(requestUrl?: string): void {
window.location.assign('/admin-vue/login?redirect=' + target) window.location.assign('/admin-vue/login?redirect=' + target)
} }
/** 单设备登录:被新设备顶下线,整页回登录页(整页 reload 顺带重置会话 store,避免守卫弹回)。 */
function redirectToLoginKicked(): void {
if (typeof window === 'undefined') return
if (isLoginLocation(window.location.pathname)) return
window.location.assign('/admin-vue/login?kicked=1')
}
http.interceptors.response.use( http.interceptors.response.use(
(response) => { (response) => {
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理 // Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理
if (isUnauthorized(response.data)) redirectToLogin(response.config?.url) // 403(已登录但无后台权限,如用工具前端账号 token 访问后台)与 401 同样跳登录页。
// 4011(账号已在其他设备登录)单独提示,不按普通 401 处理。
if (isKicked(response.data)) {
redirectToLoginKicked()
} else if (isUnauthorized(response.data) || isForbidden(response.data)) {
redirectToLogin(response.config?.url)
}
return response return response
}, },
(error) => { (error) => {
if (error?.response?.status === 401 || isUnauthorized(error?.response?.data)) { if (isKicked(error?.response?.data)) {
redirectToLoginKicked()
} else if (
error?.response?.status === 401 ||
error?.response?.status === 403 ||
isUnauthorized(error?.response?.data) ||
isForbidden(error?.response?.data)
) {
redirectToLogin(error?.config?.url) redirectToLogin(error?.config?.url)
} }
return Promise.reject(error) return Promise.reject(error)
@@ -0,0 +1,70 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 单条通知(后台铃铛)。 */
export interface AdminNotificationItem {
id: number
scene: string
level: string
title: string
content: string
read: boolean
readAt: string | null
createdAt: string | null
}
export interface AdminNotificationPage {
items: AdminNotificationItem[]
total: number
page: number
pageSize: number
unreadCount: number
}
export interface AdminNotificationSummary {
unreadCount: number
latestId: number
}
/** 铃铛轮询:未读数 + 最新通知 id。 */
export async function fetchNotificationSummary(): Promise<AdminNotificationSummary> {
const { data } = await http.get('/api/admin/notifications/summary')
return unwrap<AdminNotificationSummary>(data)
}
/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */
export interface AdminNotificationListParams {
page?: number
pageSize?: number
onlyUnread?: boolean
keyword?: string
startDate?: string
endDate?: string
}
export async function fetchNotificationList(
params: AdminNotificationListParams = {},
): Promise<AdminNotificationPage> {
const keyword = (params.keyword ?? '').trim()
const { data } = await http.get('/api/admin/notifications', {
params: {
page: params.page ?? 1,
pageSize: params.pageSize ?? 20,
onlyUnread: params.onlyUnread ? 'true' : 'false',
keyword: keyword || undefined,
startDate: params.startDate || undefined,
endDate: params.endDate || undefined,
},
})
return unwrap<AdminNotificationPage>(data)
}
export async function markNotificationRead(id: number): Promise<boolean> {
const { data } = await http.post(`/api/admin/notifications/${id}/read`)
return unwrap<boolean>(data)
}
export async function markAllNotificationsRead(): Promise<number> {
const { data } = await http.post('/api/admin/notifications/read-all')
return unwrap<number>(data)
}
@@ -0,0 +1,47 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 单用户一行:三类密钥调用次数 + 合计(口径=真实对外请求次数)。 */
export interface AdminUserSecretUsageRow {
userId: number
username: string
groups: string[]
similarAsinCount: number
appearancePatentCount: number
proxyCount: number
totalCount: number
}
export interface AdminUserSecretUsagePage {
items: AdminUserSecretUsageRow[]
total: number
page: number
pageSize: number
/** 所选范围内(不分页)三类总次数。 */
totalCalls: number
similarAsinCalls: number
appearancePatentCalls: number
proxyCalls: number
groupOptions?: Array<{ id: number; groupName: string }>
}
export interface UserSecretUsageQuery {
startDate?: string
endDate?: string
keyword?: string
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
groupId?: number
page: number
pageSize: number
}
/** 分页查询密钥用量:GET /api/admin/user-secret-usagegroup_id 必须 snake_case)。 */
export async function fetchUserSecretUsage(params: UserSecretUsageQuery): Promise<AdminUserSecretUsagePage> {
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
if (params.startDate) query.startDate = params.startDate
if (params.endDate) query.endDate = params.endDate
if (params.keyword) query.keyword = params.keyword
if (params.groupId) query.group_id = params.groupId
const { data } = await http.get('/api/admin/user-secret-usage', { params: query })
return unwrap<AdminUserSecretUsagePage>(data)
}
@@ -0,0 +1,92 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 单模块状态(脱敏值 + 连通性)。 */
export interface AdminUserSecretModule {
moduleKey: string
moduleLabel: string
masked: string
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
full: string
exists: boolean
checkStatus: string
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: string | null
updatedAt: string | null
}
/** 一行一用户:三个字段列 + 行级状态(三类都检测通过才算 passed)。 */
export interface AdminUserSecretRow {
userId: number
username: string
/** 所属数据权限分组名(可能多个)。 */
groups: string[]
/** 所属主管(创建人)用户名;无分组时的兜底展示。 */
leaderUsername: string
similarAsin: AdminUserSecretModule
appearancePatent: AdminUserSecretModule
proxy: AdminUserSecretModule
status: string
statusMessage: string
/** 首次配置时间(三模块中最早);从未配置为 null。 */
createdAt: string | null
updatedAt: string | null
}
export interface AdminUserSecretPage {
items: AdminUserSecretRow[]
total: number
page: number
pageSize: number
/** 分组筛选项(超管=全部;主管=自己带的分组)。 */
groupOptions?: GroupOption[]
}
export interface GroupOption {
id: number
groupName: string
}
export interface UserSecretQuery {
keyword?: string
checkStatus?: string
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
groupId?: number
page: number
pageSize: number
}
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets(筛选参数必须 snake_casecamel 会被后端静默忽略)。 */
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
if (params.keyword) query.keyword = params.keyword
if (params.checkStatus) query.checkStatus = params.checkStatus
if (params.groupId) query.group_id = params.groupId
const { data } = await http.get('/api/admin/user-secrets', { params: query })
return unwrap<AdminUserSecretPage>(data)
}
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check
* 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */
export async function checkUserSecret(userId: number) {
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`, undefined, { timeout: 180_000 })
return unwrap<
Array<{
moduleKey: string
checkStatus: string
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: string | null
viaProxy: boolean
}>
>(data)
}
/** 清空该用户全部密钥与代理配置:DELETE /api/admin/user-secrets/{userId} */
export async function deleteUserSecret(userId: number): Promise<void> {
const { data } = await http.delete(`/api/admin/user-secrets/${userId}`)
unwrap<unknown>(data)
}
+5
View File
@@ -13,6 +13,8 @@ export interface UserListParams {
keyword?: string keyword?: string
createdById?: number | null createdById?: number | null
adminId?: number | null adminId?: number | null
/** 角色过滤(''=全部)。 */
role?: string
} }
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */ /** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
@@ -23,6 +25,7 @@ export interface UserListQuery {
search?: string search?: string
created_by_id?: number created_by_id?: number
admin_id?: number admin_id?: number
role?: string
} }
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */ /** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
@@ -58,6 +61,7 @@ export function normalizeUserPageParams(raw: Partial<UserListParams>): UserListP
keyword, keyword,
createdById: typeof raw.createdById === 'number' ? raw.createdById : null, createdById: typeof raw.createdById === 'number' ? raw.createdById : null,
adminId: typeof raw.adminId === 'number' ? raw.adminId : null, adminId: typeof raw.adminId === 'number' ? raw.adminId : null,
role: typeof raw.role === 'string' ? raw.role.trim() || undefined : undefined,
} }
} }
@@ -67,6 +71,7 @@ export function toUserListQuery(params: UserListParams): UserListQuery {
if (params.keyword) query.username = params.keyword if (params.keyword) query.username = params.keyword
if (params.createdById != null) query.created_by_id = params.createdById if (params.createdById != null) query.created_by_id = params.createdById
if (params.adminId != null) query.admin_id = params.adminId if (params.adminId != null) query.admin_id = params.adminId
if (params.role) query.role = params.role
return query return query
} }
@@ -23,6 +23,8 @@ export function toAdminUserItem(raw: unknown): AdminUser | null {
if (createdById !== null) item.createdById = createdById if (createdById !== null) item.createdById = createdById
const createdAt = text(r.created_at) const createdAt = text(r.created_at)
if (createdAt) item.createdAt = createdAt if (createdAt) item.createdAt = createdAt
const updatedAt = text(r.updated_at)
if (updatedAt) item.updatedAt = updatedAt
const creator = text(r.creator_username) const creator = text(r.creator_username)
if (creator) item.creatorUsername = creator if (creator) item.creatorUsername = creator
const abbr = text(r.pinyin_abbr) const abbr = text(r.pinyin_abbr)
@@ -0,0 +1,683 @@
<template>
<div ref="rootRef" class="admin-notification-bell">
<button
type="button"
class="bell-trigger"
:class="{ 'bell-trigger--active': panelOpen }"
:title="unreadCount > 0 ? `通知(${unreadCount} 条未读)` : '通知'"
aria-label="通知"
@click="togglePanel"
>
<svg
class="bell-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
</button>
<!-- 面板挂到 body fixed 定位顶栏内绝对定位时会被列表页 el-select 等页面内容压住 z-index 无效 -->
<Teleport to="body">
<div
v-if="panelOpen"
ref="panelRef"
class="bell-panel"
:style="panelStyle"
role="dialog"
aria-label="通知列表"
>
<div class="bell-panel-head">
<span class="bell-panel-title">通知</span>
<button
v-if="unreadCount > 0"
type="button"
class="bell-read-all"
:disabled="markingAll"
@click="readAll"
>
{{ markingAll ? '处理中...' : '全部已读' }}
</button>
</div>
<div class="bell-search">
<input
v-model="keyword"
type="text"
class="bell-search-input"
placeholder="搜索标题或内容"
aria-label="搜索通知"
@input="onKeywordInput"
/>
<div class="bell-search-days">
<el-date-picker
:model-value="dateRangeValue"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
range-separator=""
size="small"
class="bell-date-picker"
popper-class="bell-date-popper"
:clearable="true"
@update:model-value="onDateRangeChange"
@change="reload"
/>
<button v-if="hasFilter" type="button" class="bell-search-reset" @click="resetFilters">
重置
</button>
</div>
</div>
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
<div v-else-if="!items.length" class="bell-empty">{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}</div>
<ul v-else class="bell-list">
<template v-for="group in groupedItems" :key="group.day || 'unknown'">
<li class="bell-day-head">{{ group.label }}</li>
<li
v-for="item in group.items"
:key="item.id"
class="bell-item"
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
@click="onItemClick(item)"
>
<div class="bell-item-head">
<span class="bell-item-title">{{ item.title }}</span>
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
</div>
<div class="bell-item-content">{{ item.content }}</div>
</li>
</template>
</ul>
<div class="bell-panel-foot">
<button
type="button"
class="bell-page-btn"
:disabled="page <= 1 || loading"
@click="goPage(page - 1)"
>
上一页
</button>
<span class="bell-page-info">{{ page }} / {{ totalPages }}</span>
<button
type="button"
class="bell-page-btn"
:disabled="page >= totalPages || loading"
@click="goPage(page + 1)"
>
下一页
</button>
<span class="bell-page-total"> {{ total }} </span>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchNotificationList,
fetchNotificationSummary,
markAllNotificationsRead,
markNotificationRead,
type AdminNotificationItem,
} from '@/api/notifications'
import { useAdminSessionStore } from '@/stores/admin-session'
import {
NOTIFICATION_POLL_INTERVAL_MS,
formatNotificationTime,
formatUnreadBadge,
groupNotificationsByDay,
hasNewNotification,
readLastNotifiedId,
writeLastNotifiedId,
} from '@/layout/notification-bell-model'
const PAGE_SIZE = 10
const session = useAdminSessionStore()
const rootRef = ref<HTMLElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
const unreadCount = ref(0)
const items = ref<AdminNotificationItem[]>([])
const total = ref(0)
const page = ref(1)
const panelOpen = ref(false)
const loading = ref(false)
const markingAll = ref(false)
const loadError = ref('')
const keyword = ref('')
const startDate = ref('')
const endDate = ref('')
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
const groupedItems = computed(() => groupNotificationsByDay(items.value))
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const hasFilter = computed(() => Boolean(keyword.value.trim() || startDate.value || endDate.value))
/**
* el-date-picker 的绑定桥:日期区间在内部仍用 startDate/endDate 两个 ref 表示
* hasFilter/normalizedDayRange/resetFilters 都基于它们)。
* 此前模板写的是 v-model="dateRange",而 dateRange 从未声明 → vue-tsc 直接报 TS2339 构建失败,
* 且日期筛选完全不下发参数。
*/
const dateRangeValue = computed<[string, string] | null>(() =>
startDate.value && endDate.value ? [startDate.value, endDate.value] : null
)
function onDateRangeChange(value: [string, string] | null) {
const start = value?.[0]
const end = value?.[1]
startDate.value = start ? String(start) : ''
endDate.value = end ? String(end) : ''
}
let pollTimer: number | null = null
let searchTimer: number | null = null
function currentUid(): number | string {
return session.user?.id ?? 0
}
/** 起止日期倒挂时自动交换,避免用户只看到「没有符合条件的通知」不知原因。 */
function normalizedDayRange(): { startDate?: string; endDate?: string } {
let start = startDate.value
let end = endDate.value
if (start && end && start > end) {
const swapped = start
start = end
end = swapped
}
return { startDate: start || undefined, endDate: end || undefined }
}
/** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */
async function refreshSummary() {
try {
const summary = await fetchNotificationSummary()
unreadCount.value = Number(summary?.unreadCount ?? 0)
const latestId = Number(summary?.latestId ?? 0)
if (hasNewNotification(latestId, readLastNotifiedId(currentUid()))) {
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
writeLastNotifiedId(currentUid(), latestId)
console.log('[admin-notification] 检测到新通知 latestId=', latestId)
}
} catch (error) {
// 通知接口失败静默降级:不显示红点、不打扰使用者
console.warn('[admin-notification] 未读数刷新失败(静默降级):', error)
}
}
async function loadPage(targetPage: number) {
if (loading.value) return
loading.value = true
try {
const result = await fetchNotificationList({
page: targetPage,
pageSize: PAGE_SIZE,
keyword: keyword.value,
...normalizedDayRange(),
})
const list = Array.isArray(result?.items) ? result.items : []
items.value = list
total.value = Number(result?.total ?? list.length)
unreadCount.value = Number(result?.unreadCount ?? unreadCount.value)
page.value = targetPage
loadError.value = ''
// 无筛选时首页就是最新数据,顺带记录「已提醒过的最大 id」;有筛选时最新 id 不代表真实最新
if (targetPage <= 1 && !hasFilter.value && list.length) {
const latest = Number(list[0].id)
if (latest > 0) {
writeLastNotifiedId(currentUid(), Math.max(readLastNotifiedId(currentUid()), latest))
}
}
} catch (error) {
// 列表加载失败在面板内提示,不弹全局消息打扰用户
console.warn('[admin-notification] 通知列表加载失败:', error)
loadError.value = error instanceof Error ? error.message : '通知加载失败'
} finally {
loading.value = false
}
}
function goPage(targetPage: number) {
const safe = Math.min(Math.max(1, targetPage), totalPages.value)
if (safe === page.value || loading.value) return
void loadPage(safe)
}
/** 搜索/日期变化后回到第一页重新查询。 */
function reload() {
void loadPage(1)
}
/** 关键字输入防抖 300ms,避免每敲一个字都打一次接口。 */
function onKeywordInput() {
if (searchTimer != null) {
window.clearTimeout(searchTimer)
}
searchTimer = window.setTimeout(() => {
searchTimer = null
void loadPage(1)
}, 300)
}
function resetFilters() {
keyword.value = ''
startDate.value = ''
endDate.value = ''
void loadPage(1)
}
/** 面板挂到 body 后用 fixed 定位,位置按触发器实时算(右对齐在铃铛下方)。 */
function syncPanelPosition() {
const trigger = rootRef.value
if (!trigger) return
const rect = trigger.getBoundingClientRect()
panelStyle.value = {
top: `${Math.round(rect.bottom + 10)}px`,
right: `${Math.max(8, Math.round(window.innerWidth - rect.right))}px`,
}
}
function togglePanel() {
panelOpen.value = !panelOpen.value
if (panelOpen.value) {
syncPanelPosition()
void loadPage(1)
}
}
async function onItemClick(item: AdminNotificationItem) {
if (item.read) return
try {
await markNotificationRead(item.id)
item.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (error) {
console.warn('[admin-notification] 标记已读失败:', error)
}
}
async function readAll() {
if (markingAll.value || unreadCount.value === 0) return
markingAll.value = true
try {
await markAllNotificationsRead()
for (const item of items.value) {
item.read = true
}
unreadCount.value = 0
} catch (error) {
console.warn('[admin-notification] 全部已读失败:', error)
ElMessage.error(error instanceof Error ? error.message : '操作失败')
} finally {
markingAll.value = false
}
}
function formatTime(value: string | null) {
return formatNotificationTime(value)
}
/** 点击组件外部关闭面板(面板已 Teleport 到 body,需连同面板自身一起判断)。 */
function onDocumentMouseDown(event: MouseEvent) {
if (!panelOpen.value) return
const target = event.target
if (!(target instanceof Node)) return
if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) return
panelOpen.value = false
}
/** 窗口尺寸变化时重新对齐面板。 */
function onWindowResize() {
if (panelOpen.value) {
syncPanelPosition()
}
}
/** 页面回到前台时立即刷新一次未读数。 */
function onVisibilityChange() {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
void refreshSummary()
}
}
onMounted(() => {
document.addEventListener('mousedown', onDocumentMouseDown)
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('resize', onWindowResize)
void refreshSummary()
pollTimer = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
return
}
void refreshSummary()
}, NOTIFICATION_POLL_INTERVAL_MS)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocumentMouseDown)
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('resize', onWindowResize)
if (pollTimer != null) {
window.clearInterval(pollTimer)
pollTimer = null
}
if (searchTimer != null) {
window.clearTimeout(searchTimer)
searchTimer = null
}
})
</script>
<style scoped>
.admin-notification-bell {
position: relative;
display: inline-flex;
align-items: center;
}
.bell-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--admin-muted, #5b6f83);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.bell-trigger:hover,
.bell-trigger--active {
background: #edf5fb;
border-color: #cbd9e6;
color: var(--admin-primary-strong, #2f5d8b);
}
.bell-icon {
width: 20px;
height: 20px;
}
.bell-badge {
position: absolute;
top: 1px;
right: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
box-sizing: border-box;
border-radius: 999px;
background: #d64545;
color: #ffffff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
pointer-events: none;
}
.bell-panel {
position: fixed;
z-index: 3200;
width: 380px;
max-width: calc(100vw - 32px);
max-height: 520px;
display: flex;
flex-direction: column;
border: 1px solid var(--admin-border, #d8e3ee);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(39, 67, 94, 0.22);
overflow: hidden;
}
.bell-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid #edf1f5;
}
.bell-panel-title {
color: var(--admin-text, #24384d);
font-size: 14px;
font-weight: 700;
}
.bell-read-all {
border: none;
background: transparent;
color: var(--admin-primary-strong, #2f5d8b);
font-size: 12px;
cursor: pointer;
padding: 0;
}
.bell-read-all:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.bell-empty {
padding: 36px 0;
color: #8a99a8;
font-size: 13px;
text-align: center;
}
.bell-empty--error {
color: #b23c3c;
}
.bell-list {
flex: 1;
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
}
.bell-item {
padding: 11px 14px;
border-bottom: 1px solid #f0f3f7;
cursor: pointer;
transition: background 0.12s ease;
}
.bell-item:hover {
background: #f7f9fc;
}
.bell-item--unread {
background: #f2f6ff;
}
.bell-item--unread .bell-item-title::before {
content: '';
display: inline-block;
width: 7px;
height: 7px;
margin-right: 7px;
border-radius: 50%;
background: #d64545;
vertical-align: 1px;
}
.bell-item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.bell-item-title {
color: var(--admin-text, #24384d);
font-size: 13px;
font-weight: 700;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bell-item--error .bell-item-title {
color: #b23c3c;
}
.bell-item--warning .bell-item-title {
color: #a8793e;
}
.bell-item--info .bell-item-title {
color: var(--admin-primary-strong, #2f5d8b);
}
.bell-item-time {
flex-shrink: 0;
color: #9aa6b3;
font-size: 11px;
}
.bell-item-content {
margin-top: 4px;
color: #667085;
font-size: 12px;
line-height: 1.55;
word-break: break-all;
}
.bell-search {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid #edf1f5;
background: #fafcfe;
}
.bell-search-input,
.bell-date-input {
height: 28px;
padding: 0 8px;
box-sizing: border-box;
border: 1px solid #d8e3ee;
border-radius: 6px;
background: #ffffff;
color: var(--admin-text, #24384d);
font-size: 12px;
outline: none;
}
.bell-search-input {
width: 100%;
}
.bell-search-input:focus,
.bell-date-input:focus {
border-color: var(--admin-primary-strong, #2f5d8b);
}
.bell-search-days {
display: flex;
align-items: center;
gap: 6px;
}
.bell-date-input {
flex: 1;
min-width: 0;
}
.bell-date-sep {
color: #8a99a8;
font-size: 12px;
}
.bell-search-reset {
flex-shrink: 0;
padding: 0 8px;
height: 28px;
border: 1px solid #d8e3ee;
border-radius: 6px;
background: #ffffff;
color: var(--admin-primary-strong, #2f5d8b);
font-size: 12px;
cursor: pointer;
}
.bell-search-reset:hover {
background: #edf5fb;
}
.bell-day-head {
padding: 6px 14px;
background: #f4f7fa;
color: #7c8b9a;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.bell-panel-foot {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 7px 12px;
border-top: 1px solid #edf1f5;
}
.bell-page-btn {
padding: 3px 10px;
border: 1px solid #d8e3ee;
border-radius: 6px;
background: #ffffff;
color: var(--admin-primary-strong, #2f5d8b);
font-size: 12px;
cursor: pointer;
}
.bell-page-btn:hover:not(:disabled) {
background: #edf5fb;
}
.bell-page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.bell-page-info {
color: var(--admin-text, #24384d);
font-size: 12px;
font-weight: 600;
}
.bell-page-total {
color: #9aa6b3;
font-size: 11px;
}
</style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
/** 旧版风格分页(像素复刻 admin.html .pagination+ 每页条数下拉(10/20/50/100)。
* props: total / page / pageSize / sizes(默认 10/20/50/100) / showTotal(默认 true) / jump(默认 true)。 */
import { computed, ref } from 'vue'
const props = withDefaults(
defineProps<{
total: number
page: number
pageSize: number
sizes?: number[]
showTotal?: boolean
jump?: boolean
}>(),
{ sizes: () => [10, 20, 50, 100], showTotal: true, jump: true },
)
const emit = defineEmits<{
(e: 'change', page: number): void
(e: 'size-change', size: number): void
}>()
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
function go(next: number) {
if (next < 1 || next > totalPages.value || next === props.page) return
emit('change', next)
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) return
jumpPage.value = ''
go(Math.min(Math.max(n, 1), totalPages.value))
}
function onSizeChange(event: Event) {
const value = Number((event.target as HTMLSelectElement).value)
if (Number.isFinite(value) && value > 0) emit('size-change', value)
}
</script>
<template>
<div class="old-pagination">
<span v-if="showTotal" class="page-total"> {{ total }} </span>
<select class="page-size-select" :value="pageSize" @change="onSizeChange">
<option v-for="size in sizes" :key="size" :value="size">{{ size }}/</option>
</select>
<button type="button" :disabled="page <= 1" @click="go(page - 1)">上一页</button>
<span class="page-cur"> {{ page }} / {{ totalPages }} </span>
<button type="button" :disabled="page >= totalPages" @click="go(page + 1)">下一页</button>
<span v-if="jump" class="page-jump">
跳至
<input v-model="jumpPage" type="text" inputmode="numeric" @keyup.enter="goJump" />
<button type="button" @click="goJump">跳转</button>
</span>
</div>
</template>
<style scoped>
.old-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.old-pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.old-pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.old-pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-size-select {
min-height: 30px;
padding: 3px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
</style>
@@ -12,6 +12,8 @@ import {
shouldShowEmptyMenu, shouldShowEmptyMenu,
} from '@/layout/empty-state' } from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue' import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import OperationGuide from '@/layout/OperationGuide.vue'
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb' import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
import { import {
LOGOUT_CONFIRM_CANCEL, LOGOUT_CONFIRM_CANCEL,
@@ -99,6 +101,7 @@ async function signOut() {
<h1>{{ pageTitle }}</h1> <h1>{{ pageTitle }}</h1>
</div> </div>
<div class="admin-user"> <div class="admin-user">
<NotificationBell />
<div class="admin-user-meta"> <div class="admin-user-meta">
<strong>{{ userVm.username }}</strong> <strong>{{ userVm.username }}</strong>
</div> </div>
@@ -110,6 +113,7 @@ async function signOut() {
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/"> <el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item> <el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
</el-breadcrumb> </el-breadcrumb>
<OperationGuide />
<GlobalErrorContainer /> <GlobalErrorContainer />
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" /> <el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
<el-empty <el-empty
@@ -0,0 +1,82 @@
<script setup lang="ts">
/** 当前页面操作提示条:对齐旧版 admin-interactions.js 的 adminOperationGuide14 页有独立文案,余用通用 fallback)。
* 挂在 AdminLayout 内容区顶部,随 route.meta.menuKey 切换文案;折叠态存 localStorage。 */
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { OPERATION_GUIDE_STORAGE_KEY, operationGuideFor } from '@/layout/operation-guides'
const route = useRoute()
const guide = computed(() => operationGuideFor(route.meta.menuKey))
function readCollapsed(): boolean {
try {
return localStorage.getItem(OPERATION_GUIDE_STORAGE_KEY) === '1'
} catch {
return false
}
}
function persistCollapsed(value: boolean) {
try {
localStorage.setItem(OPERATION_GUIDE_STORAGE_KEY, value ? '1' : '0')
} catch {
// 忽略隐私模式写入失败
}
}
const collapsed = ref(readCollapsed())
function toggle() {
collapsed.value = !collapsed.value
persistCollapsed(collapsed.value)
}
watch(collapsed, (v) => persistCollapsed(v))
</script>
<template>
<aside v-if="guide" class="operation-guide" :class="{ 'is-collapsed': collapsed }" aria-label="当前页面操作提示">
<div class="og-head">
<span class="og-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v5"></path><path d="M12 8h.01"></path></svg>
当前页面操作提示
</span>
<button type="button" class="og-toggle" :aria-expanded="!collapsed" @click="toggle">
{{ collapsed ? '展开提示' : '收起提示' }}
</button>
</div>
<div v-show="!collapsed" class="og-body">
<p class="og-text">{{ guide.text }}</p>
<ol v-if="guide.steps.length" class="og-steps">
<li v-for="step in guide.steps" :key="step">{{ step }}</li>
</ol>
</div>
</aside>
</template>
<style scoped>
.operation-guide {
display: flex; flex-direction: column;
margin: 0 0 18px; padding: 12px 16px;
border: 1px solid #cddbe6; border-radius: 12px;
background: linear-gradient(180deg, #fbfdfe, #f4f8fc);
box-shadow: 0 10px 26px -24px rgba(39, 67, 94, 0.3);
color: var(--admin-text);
}
.operation-guide.is-collapsed { padding: 8px 16px; }
.og-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.og-title {
display: inline-flex; align-items: center; gap: 7px;
font-size: 13px; font-weight: 650; color: var(--admin-primary-strong);
}
.og-title svg { width: 15px; height: 15px; }
.og-toggle {
padding: 0; border: 0; background: none; cursor: pointer;
font: inherit; font-size: 12px; color: #6b8096;
}
.og-toggle:hover { color: var(--admin-primary-strong); }
.og-body { margin-top: 8px; padding-left: 22px; }
.og-text { margin: 0; color: #40586e; font-size: 13px; line-height: 1.7; }
.og-steps { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
.og-steps li {
padding: 2px 10px; border-radius: 999px;
background: #e9f1f8; border: 1px solid #d3e0ea;
color: #2f4a63; font-size: 12px; line-height: 1.7;
}
</style>
@@ -0,0 +1,147 @@
/**
* 后台铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
* 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。
*/
/** 未读轮询间隔(60 秒)。 */
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000
const NOTIFIED_KEY_PREFIX = 'admin-notification:last-notified-id'
/** 未读徽标文案:0 或非法值显示空串,>99 显示 99+。 */
export function formatUnreadBadge(count: number | null | undefined): string {
const value = Number(count ?? 0)
if (!Number.isFinite(value) || value <= 0) {
return ''
}
return value > 99 ? '99+' : String(Math.floor(value))
}
/** 是否出现新通知:latestId 大于上次已提醒过的 id。 */
export function hasNewNotification(
latestId: number | null | undefined,
lastNotifiedId: number | null | undefined,
): boolean {
const latest = Number(latestId ?? 0)
const notified = Number(lastNotifiedId ?? 0)
if (!Number.isFinite(latest) || latest <= 0) {
return false
}
return latest > (Number.isFinite(notified) && notified > 0 ? notified : 0)
}
function storageKey(uid: string | number | null | undefined): string {
const normalized = uid === null || uid === undefined || String(uid).trim() === '' ? '0' : String(uid)
return `${NOTIFIED_KEY_PREFIX}:${normalized}`
}
/** 读取当前管理员「已提醒过的最大通知 id」。 */
export function readLastNotifiedId(uid: string | number | null | undefined): number {
try {
const raw = window.localStorage.getItem(storageKey(uid))
const value = Number(raw ?? 0)
return Number.isFinite(value) && value > 0 ? value : 0
} catch {
return 0
}
}
export function writeLastNotifiedId(uid: string | number | null | undefined, id: number): void {
try {
if (Number.isFinite(id) && id > 0) {
window.localStorage.setItem(storageKey(uid), String(Math.floor(id)))
}
} catch {
/* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */
}
}
/** 通知所属日期键(本地时区 yyyy-MM-dd);时间缺失/非法返回空串。 */
export function notificationDayKey(value: string | null | undefined): string {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const pad = (input: number) => String(input).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
/** 日期组头文案:今天 / 昨天 / 2026年9月11日;空键兜底「未知时间」。 */
export function formatDayLabel(day: string, now: Date = new Date()): string {
if (!day) {
return '未知时间'
}
if (day === notificationDayKey(now.toISOString())) {
return '今天'
}
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
if (day === notificationDayKey(yesterday.toISOString())) {
return '昨天'
}
const [year, month, date] = day.split('-').map((part) => Number(part))
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(date)) {
return day
}
return `${year}${month}${date}`
}
export interface NotificationDayGroup<T> {
day: string
label: string
items: T[]
}
/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */
export function groupNotificationsByDay<T extends { createdAt: string | null }>(
items: T[],
now: Date = new Date(),
): NotificationDayGroup<T>[] {
const groups: NotificationDayGroup<T>[] = []
const indexByDay = new Map<string, NotificationDayGroup<T>>()
for (const item of items ?? []) {
const day = notificationDayKey(item.createdAt)
let group = indexByDay.get(day)
if (!group) {
group = { day, label: formatDayLabel(day, now), items: [] }
indexByDay.set(day, group)
groups.push(group)
}
group.items.push(item)
}
return groups
}
/** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */
export function formatNotificationTime(
value: string | null | undefined,
now: Date = new Date(),
): string {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
const pad = (input: number) => String(input).padStart(2, '0')
const hourMinute = `${pad(date.getHours())}:${pad(date.getMinutes())}`
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (sameDay) {
return hourMinute
}
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
const isYesterday =
date.getFullYear() === yesterday.getFullYear() &&
date.getMonth() === yesterday.getMonth() &&
date.getDate() === yesterday.getDate()
if (isYesterday) {
return `昨天 ${hourMinute}`
}
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hourMinute}`
}
@@ -0,0 +1,98 @@
/**
* 每菜单页的操作提示(guides)文案,对齐旧版 backend/static/admin-interactions.js 的 guides 表原文
* (除 group-manage / duplicate-check 用通用 fallback)。key = 路由 meta.menuKey。
*/
export interface OperationGuideData {
text: string
steps: string[]
}
export const OPERATION_GUIDE_FALLBACK: OperationGuideData = {
text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。',
steps: ['选择筛选条件', '处理记录', '核对反馈'],
}
/** 折叠状态 localStorage key(沿用旧版 shufuAdminGuideCollapsed 语义)。 */
export const OPERATION_GUIDE_STORAGE_KEY = 'shufuAdminGuideCollapsed'
export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
admin_users: {
text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。',
steps: ['筛选账号', '编辑权限', '确认保存'],
},
admin_columns: {
text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。',
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
},
admin_group_manage: OPERATION_GUIDE_FALLBACK,
admin_user_secrets: {
text: '用户密钥按账号绑定,列表只展示脱敏值。可对单条立即检测连通性;清空后该用户需要重新配置密钥。',
steps: ['筛选用户或状态', '立即检测连通性', '必要时清空'],
},
admin_dedupe_total_data: {
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
steps: ['选择分组', '筛选或导入', '核对并导出'],
},
admin_invalid_asin_data: {
text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。',
steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'],
},
admin_query_asin: {
text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。',
steps: ['筛选店铺', '打开配置抽屉', '保存或导出'],
},
admin_product_categories: {
text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。',
steps: ['搜索类目', '展开层级', '新增或编辑'],
},
admin_skip_price_asin: {
text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。',
steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'],
},
admin_shop_keys: {
text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。',
steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'],
},
admin_shop_manage: {
text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。',
steps: ['选择分组', '维护店铺信息', '筛选核对结果'],
},
admin_shop_data_crawl_tasks: {
text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。',
steps: ['筛选任务', '检查状态', '执行批量操作'],
},
// admin_shop_data_duplicate_check(店铺撞款监控)为例外页:不展示全局提示条,不参与对齐。
admin_image_video_tasks: {
text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。',
steps: ['设置筛选', '查看任务结果', '按需处理任务'],
},
admin_history: {
text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。',
steps: ['设置时间范围', '筛选记录', '查看结果预览'],
},
admin_version: {
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
},
admin_tutorial: {
text: '上传新的教程压缩包后即刻生效:工具台首页「立即下载教程」以下载最新上传的包为准。确认旧包不再需要后再删除。',
steps: ['上传教程压缩包', '到工具台核对下载', '清理历史包'],
},
digital_human_version: {
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
},
admin_user_secret_usage: {
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
},
}
/** 全部有独立/兜底提示的后台业务菜单 key。 */
export const OPERATION_GUIDE_MENU_KEYS = Object.keys(OPERATION_GUIDES)
/** 返回指定菜单 key 的提示数据;menuKey 不在已知集合时返回 null(登录/404 等页面不展示)。 */
export function operationGuideFor(menuKey: unknown): OperationGuideData | null {
if (typeof menuKey !== 'string' || !menuKey) return null
return OPERATION_GUIDES[menuKey] ?? null
}
@@ -80,7 +80,7 @@ async function create(): Promise<void> {
> >
<el-form label-position="top" @submit.prevent> <el-form label-position="top" @submit.prevent>
<el-form-item label="用户名" :error="errors.username"> <el-form-item label="用户名" :error="errors.username">
<el-input v-model="form.username" autocomplete="username" placeholder="用户名至少 2 个字符" /> <el-input v-model="form.username" autocomplete="username" placeholder="用户名至少 2 个字符" @input="errors.username = undefined" />
</el-form-item> </el-form-item>
<el-form-item label="密码" :error="errors.password"> <el-form-item label="密码" :error="errors.password">
<el-input <el-input
@@ -89,10 +89,11 @@ async function create(): Promise<void> {
show-password show-password
autocomplete="new-password" autocomplete="new-password"
placeholder="密码至少 6 个字符" placeholder="密码至少 6 个字符"
@input="errors.password = undefined"
/> />
</el-form-item> </el-form-item>
<el-form-item v-if="operatorSuper" label="角色"> <el-form-item v-if="operatorSuper" label="角色">
<el-select v-model="form.role" style="width: 200px"> <el-select filterable 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-option v-for="opt in ROLE_CHOICES" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -15,6 +15,8 @@ const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role:
const errors = reactive<EditUserFormErrors>({}) const errors = reactive<EditUserFormErrors>({})
const busy = ref(false) const busy = ref(false)
const loadingAuth = ref(false) const loadingAuth = ref(false)
/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */
const authReady = ref(false)
watch( watch(
() => props.modelValue, () => props.modelValue,
@@ -28,6 +30,7 @@ watch(
form.columnIds = [] form.columnIds = []
form.originalRole = base.originalRole form.originalRole = base.originalRole
errors.password = undefined errors.password = undefined
authReady.value = false
void refreshAuth() void refreshAuth()
}, },
) )
@@ -38,7 +41,10 @@ async function refreshAuth(): Promise<void> {
try { try {
const { checkedIds } = await loadUserMenuAuth(props.user.id) const { checkedIds } = await loadUserMenuAuth(props.user.id)
form.columnIds = checkedIds form.columnIds = checkedIds
authReady.value = true
} catch (error) { } catch (error) {
// 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。
authReady.value = false
showAdminFeedback(actionableErrorText(error), 'error') showAdminFeedback(actionableErrorText(error), 'error')
} finally { } finally {
loadingAuth.value = false loadingAuth.value = false
@@ -50,6 +56,10 @@ function close(): void {
} }
async function save(): Promise<void> { async function save(): Promise<void> {
if (!authReady.value) {
showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error')
return
}
const { valid, errors: errs } = validateEditUserForm(form) const { valid, errors: errs } = validateEditUserForm(form)
Object.assign(errors, errs) Object.assign(errors, errs)
if (!valid) return if (!valid) return
@@ -87,6 +97,7 @@ async function save(): Promise<void> {
show-password show-password
autocomplete="new-password" autocomplete="new-password"
placeholder="留空则不修改密码" placeholder="留空则不修改密码"
@input="errors.password = undefined"
/> />
</el-form-item> </el-form-item>
<el-form-item v-if="operatorSuper" label="角色"> <el-form-item v-if="operatorSuper" label="角色">
@@ -102,12 +113,15 @@ async function save(): Promise<void> {
<div class="auth-tree-box"> <div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" /> <UserMenuAuthTree v-model:checked="form.columnIds" />
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div> <div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
<div v-else-if="!authReady" class="auth-loading auth-loading-error">
菜单权限加载失败,保存已禁用;请关闭后重试
</div>
</div> </div>
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="close">取消</el-button> <el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">保存</el-button> <el-button type="primary" :loading="busy" :disabled="!authReady" @click="save">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@@ -125,4 +139,7 @@ async function save(): Promise<void> {
font-size: 12px; font-size: 12px;
color: var(--admin-muted); color: var(--admin-muted);
} }
.auth-loading-error {
color: var(--el-color-danger);
}
</style> </style>
@@ -1,35 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 分组管理页 · 像素复刻旧版 admin.html panel-group-manage(自绘:顶部数据权限分组摘要 chips + 分组列表表格 + 新建分组;全量无分页同旧版)。
* script 逻辑沿用现有 Vue 实现;编辑器保留现有组件。 */
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session' import { useAdminSessionStore } from '@/stores/admin-session'
import { openAdminConfirm } from '@/components/admin-confirm' import { openAdminConfirm } from '@/components/admin-confirm'
import { deleteShopGroup, fetchShopGroups } from './shop-group-api' import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
import { shopGroupSummary, type ShopGroupItem } from './shop-group-model' import type { ShopGroupItem } from './shop-group-model'
import GroupEditorDialog from './GroupEditorDialog.vue' import GroupEditorDialog from './GroupEditorDialog.vue'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore() const session = useAdminSessionStore()
const loading = ref(false) const loading = ref(false)
const rows = ref<ShopGroupItem[]>([]) const rows = ref<ShopGroupItem[]>([])
const editorVisible = ref(false) const editorVisible = ref(false)
const editingGroup = ref<ShopGroupItem | null>(null) const editingGroup = ref<ShopGroupItem | null>(null)
const summary = computed(() => shopGroupSummary(rows.value))
const isSuperAdmin = computed(() => session.isSuperAdmin) const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null) const currentUserId = computed(() => session.user?.id ?? null)
// 客户端分页:全量拉取后按页切片展示。 /** 筛选:分组名称/组长 模糊过滤(用户要求账户域子菜单补筛选条件)。 */
const filterKeyword = ref('')
const filterLeader = ref('')
const filteredRows = computed(() => {
const kw = (filterKeyword.value || '').trim().toLowerCase()
const ldr = (filterLeader.value || '').trim().toLowerCase()
if (!kw && !ldr) return rows.value
return rows.value.filter((g) => {
const nameOk = !kw || (g.name || '').toLowerCase().includes(kw)
const leaderOk = !ldr || (g.leaderUsername || '').toLowerCase().includes(ldr)
return nameOk && leaderOk
})
})
/** 全站分页统一:分组列表客户端分页(10/20/50/100)。 */
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const total = computed(() => rows.value.length) const pagedGroups = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)) function changeGroupPage(p: number) { page.value = p }
function changeGroupSize(size: number) { pageSize.value = size; page.value = 1 }
function changePage(p: number) {
page.value = p
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
}
function canEditOf(group: ShopGroupItem): boolean { function canEditOf(group: ShopGroupItem): boolean {
if (isSuperAdmin.value) return true if (isSuperAdmin.value) return true
@@ -74,64 +81,74 @@ onMounted(loadGroups)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="groups-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="groups-head">
<h2>数据权限分组</h2> <h3>分组列表</h3>
<p>管理店铺数据访问分组和组员范围组长不可更改组员为普通账号</p> <button class="btn" type="button" @click="openCreate">新建分组</button>
</div> </div>
</div>
<el-row :gutter="12" style="margin-bottom: 12px"> <div class="form-row groups-filter-row">
<el-col :span="8"> <div class="form-group" style="min-width: 200px">
<el-card shadow="never"> <label>分组名称</label>
<div class="stat-block"> <input v-model="filterKeyword" type="text" placeholder="输入分组名称" @keyup.enter="filterKeyword = filterKeyword" />
<span class="stat-label">分组总数</span> </div>
<span class="stat-value">{{ summary.groupCount }}</span> <div class="form-group" style="min-width: 180px">
</div> <label>组长</label>
</el-card> <input v-model="filterLeader" type="text" placeholder="输入组长用户名" />
</el-col> </div>
<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> </div>
<el-table v-loading="loading" :data="pagedRows" stripe>
<el-table-column prop="name" label="分组名称" min-width="180" /> <div class="table-scroll">
<el-table-column prop="leaderUsername" label="组长" min-width="170" /> <table>
<el-table-column prop="memberCount" label="组员数量" min-width="100" /> <thead>
<el-table-column label="组员" min-width="240"> <tr>
<template #default="{ row }"> <th style="width: 58px">序号</th>
<template v-if="row.memberUsernames.length"> <th style="width: 16%">分组名称</th>
<el-tag v-for="name in row.memberUsernames" :key="name" size="small" class="member-tag">{{ name }}</el-tag> <th style="width: 12%">组长</th>
<th style="width: 10%">组员数量</th>
<th>组员</th>
<th style="width: 16%">创建时间</th>
<th style="width: 16%">修改时间</th>
<th style="width: 160px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredRows.length">
<tr v-for="(group, index) in pagedGroups" :key="group.id">
<td>{{ index + 1 }}</td>
<td>{{ group.name }}</td>
<td>{{ group.leaderUsername || '-' }}</td>
<td>{{ group.memberCount ?? (group.memberUsernames?.length ?? 0) }}</td>
<td>
<div class="shop-group-members">
<span v-for="name in group.memberUsernames" :key="name" class="shop-group-member-chip">{{ name }}</span>
<span v-if="!group.memberUsernames.length" class="member-none"></span>
</div>
</td>
<td>{{ formatDateTime(group.createdAt) }}</td>
<td>{{ formatDateTime(group.updatedAt || group.createdAt) }}</td>
<td class="ops-cell">
<template v-if="canEditOf(group)">
<button class="btn btn-sm" type="button" @click="openEdit(group)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeGroup(group)">删除</button>
</template>
<span v-else class="member-none">-</span>
</td>
</tr>
</template> </template>
<span v-else class="member-none"></span> <tr v-else-if="loading">
</template> <td colspan="8" class="empty-tip">加载中...</td>
</el-table-column> </tr>
<el-table-column prop="createdAt" label="创建时间" min-width="170"> <tr v-else>
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> <td colspan="8" class="empty-tip">{{ filterKeyword || filterLeader ? '暂无匹配分组' : '暂无分组' }}</td>
</el-table-column> </tr>
<el-table-column prop="updatedAt" label="修改时间" min-width="170"> </tbody>
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template> </table>
</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> </div>
</el-card> <OldPagination v-if="filteredRows.length > 0" :total="filteredRows.length" :page="page" :page-size="pageSize" @change="changeGroupPage" @size-change="changeGroupSize" />
</section>
<GroupEditorDialog <GroupEditorDialog
v-model="editorVisible" v-model="editorVisible"
:group="editingGroup" :group="editingGroup"
@@ -142,29 +159,203 @@ onMounted(loadGroups)
</template> </template>
<style scoped> <style scoped>
.stat-block { /* 像素复刻旧版 admin.html panel-group-manage(蓝白末层)。 */
.groups-view {
font-family: inherit;
color: #24384d;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 18px;
} }
.stat-label { .form-box,
color: var(--el-text-color-secondary); .panel-box {
font-size: 13px; width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
} }
.stat-value { h3 {
font-size: 24px; margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.groups-filter-row {
margin: 0 0 16px;
}
.groups-filter-row .form-group {
flex: 1 1 200px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600; font-weight: 600;
line-height: 1.2;
} }
.member-tag { .form-group input,
margin: 2px 4px 2px 0; .form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
} }
.member-more { .form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.groups-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.table-scroll td {
white-space: normal;
word-break: break-word;
}
.shop-group-members {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 96px;
overflow-y: auto;
padding-right: 4px;
}
.shop-group-member-chip {
display: inline-flex;
align-items: center;
max-width: 150px;
padding: 3px 9px;
border-radius: 999px;
background: #e7f0f8;
color: #2f5d8b;
font-size: 12px; font-size: 12px;
color: var(--admin-muted); line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.member-none { .member-none,
color: var(--admin-muted); .member-more {
font-size: 13px; color: #8293a5;
font-size: 12.5px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
} }
</style> </style>
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 菜单权限配置页 · 像素复刻旧版 admin.html panel-columns(自绘:面板头+新增菜单、拖拽手柄 ⠿、树形缩进+└、旧式表格、全量无分页同旧版)。
* script 逻辑沿用现有 Vue 实现(拖拽分组/落点/重排保存、弹窗表单)。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import OldPagination from '@/components/OldPagination.vue'
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api' import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
import { import {
MENU_TYPE_OPTIONS, MENU_TYPE_OPTIONS,
@@ -20,22 +23,43 @@ import {
type MenuManageNode, type MenuManageNode,
} from './menu-manage-model' } from './menu-manage-model'
/** 树行视图:展平 + 深度(缩进与 └ 前缀)。 */
interface MenuRow extends MenuManageNode {
depth: number
}
const loading = ref(false) const loading = ref(false)
const rows = ref<MenuManageNode[]>([]) const rows = ref<MenuManageNode[]>([])
// 客户端分页:列表展示统一带分页组件。 /** 筛选:菜单名称/类型(用户要求账户域子菜单补筛选条件)。 */
const filterName = ref('')
const filterType = ref('')
const filteredMenuRows = computed<MenuRow[]>(() => {
const kw = (filterName.value || '').trim().toLowerCase()
const type = filterType.value || ''
return menuRows.value.filter((row) => {
const nameOk = !kw || (row.name || '').toLowerCase().includes(kw)
const typeOk = !type || row.menuType === type
return nameOk && typeOk
})
})
/** 全站分页统一:菜单树展平行客户端分页(10/20/50/100)。 */
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const total = computed(() => rows.value.length) const pagedMenuRows = computed(() => filteredMenuRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value)) function changeMenuPage(p: number) { page.value = p }
function changeMenuSize(size: number) { pageSize.value = size; page.value = 1 }
function changePage(p: number) { /** 展平视图:前序 + depth。 */
page.value = p const menuRows = computed<MenuRow[]>(() => {
} const out: MenuRow[] = []
const walk = (list: MenuManageNode[], depth: number) => {
function changeSize(size: number) { for (const node of list) {
pageSize.value = size out.push({ ...node, depth })
page.value = 1 if (node.children?.length) walk(node.children, depth + 1)
} }
}
walk(rows.value, 0)
return out
})
const createVisible = ref(false) const createVisible = ref(false)
const saving = ref(false) const saving = ref(false)
const editingNode = ref<MenuManageNode | null>(null) const editingNode = ref<MenuManageNode | null>(null)
@@ -169,15 +193,14 @@ async function removeMenu(node: MenuManageNode) {
ElMessage.warning(reason) ElMessage.warning(reason)
return return
} }
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除菜单“${node.name}”吗?`)) return
try { try {
await ElMessageBox.confirm(`确定删除菜单“${node.name}”吗?`, '删除确认', { type: 'warning' })
await deleteMenu(node.id) await deleteMenu(node.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
await loadMenus() await loadMenus()
} catch (error) { } catch (error) {
if (error !== 'cancel' && error !== 'close') { ElMessage.error(error instanceof Error ? error.message : '删除失败')
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
} }
} }
@@ -185,67 +208,96 @@ onMounted(loadMenus)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="menus-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="menus-head">
<h2>菜单管理</h2> <h3>菜单列表</h3>
<p>维护后台菜单树和页面路由权限粒度仅到菜单/页面</p> <button class="btn" type="button" @click="openCreate">新增菜单</button>
</div> </div>
</div> <p class="columns-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序松开后自动保存</p>
<el-card shadow="never">
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序松开后自动保存</p> <div class="form-row menus-filter-row">
<div class="table-toolbar"> <div class="form-group" style="min-width: 200px">
<el-button type="primary" @click="openCreate">新增菜单</el-button> <label>菜单名称</label>
<input v-model="filterName" type="text" placeholder="输入菜单名称" />
</div>
<div class="form-group" style="min-width: 160px">
<label>菜单类型</label>
<select v-model="filterType">
<option value="">全部</option>
<option value="admin">后台</option>
<option value="app">软件</option>
</select>
</div>
</div> </div>
<el-table v-loading="loading" :data="pagedRows" row-key="id" default-expand-all stripe>
<el-table-column label="排序" min-width="64"> <div class="table-scroll columns-table-scroll">
<template #default="{ row }"> <table>
<span <thead>
class="menu-drag-handle" <tr>
draggable="true" <th class="column-drag-cell"><span class="visually-hidden">排序</span></th>
role="button" <th style="width: 60px">ID</th>
:title="`拖动调整“${(row as MenuManageNode).name}”的顺序`" <th style="width: 200px">菜单名称</th>
@dragstart="onDragStart($event, row as MenuManageNode)" <th style="width: 100px">菜单类型</th>
@dragend="onDragEnd" <th style="width: 120px">上级菜单</th>
@dragover="onDragOver($event, row as MenuManageNode)" <th style="width: 170px">创建时间</th>
@drop="onDrop($event, row as MenuManageNode)" <th style="width: 170px">更新时间</th>
></span> <th style="width: 170px">操作</th>
</template> </tr>
</el-table-column> </thead>
<el-table-column prop="name" label="菜单名称" min-width="180" /> <tbody>
<el-table-column label="菜单类型" min-width="110"> <template v-if="filteredMenuRows.length">
<template #default="{ row }"> <tr
<el-tag size="small" :type="(row as MenuManageNode).menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel((row as MenuManageNode).menuType) }}</el-tag> v-for="row in pagedMenuRows"
</template> :key="row.id"
</el-table-column> :class="{ 'is-column-dragging': dragState?.id === row.id }"
<el-table-column label="上级菜单" min-width="120"> @dragover="onDragOver($event, row)"
<template #default="{ row }"> @drop="onDrop($event, row)"
{{ parentNameOf(row as MenuManageNode) }} >
</template> <td class="column-drag-cell">
</el-table-column> <span
<el-table-column prop="createdAt" label="创建时间" min-width="150"> class="column-drag-handle"
<template #default="{ row }">{{ formatDateTime((row as MenuManageNode).createdAt) }}</template> draggable="true"
</el-table-column> role="button"
<el-table-column label="操作" min-width="160" fixed="right"> :title="`拖动调整“${row.name}”的顺序`"
<template #default="{ row }"> @dragstart="onDragStart($event, row)"
<el-button link type="primary" @click="openEdit(row as MenuManageNode)">编辑</el-button> @dragend="onDragEnd"
<el-button link type="danger" :disabled="menuDeleteReason(row as MenuManageNode) !== undefined" @click="removeMenu(row as MenuManageNode)">删除</el-button> ></span>
</template> </td>
</el-table-column> <td>{{ row.id }}</td>
</el-table> <td class="name-cell">
<div class="table-footer"> <span v-if="row.depth > 0" class="column-child-mark" :style="{ marginLeft: `${row.depth * 16}px` }"></span>
<el-pagination <span class="menu-name-text">{{ row.name }}</span>
background </td>
layout="sizes, prev, pager, next, jumper" <td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
:total="total" <td>{{ parentNameOf(row) }}</td>
:page-size="pageSize" <td>{{ formatDateTime(row.createdAt) }}</td>
:page-sizes="[10, 20, 50, 100]" <td>{{ formatDateTime(row.updatedAt) }}</td>
:current-page="page" <td class="ops-cell">
@current-change="changePage" <button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
@size-change="changeSize" <button
/> class="btn btn-sm btn-danger"
type="button"
:disabled="menuDeleteReason(row) !== undefined"
:title="menuDeleteReason(row) || undefined"
@click="removeMenu(row)"
>
删除
</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="8" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
</tr>
</tbody>
</table>
</div> </div>
</el-card> <OldPagination v-if="filteredMenuRows.length > 0" :total="filteredMenuRows.length" :page="page" :page-size="pageSize" @change="changeMenuPage" @size-change="changeMenuSize" />
</section>
<el-dialog v-model="createVisible" :title="dialogTitle" width="520px"> <el-dialog v-model="createVisible" :title="dialogTitle" width="520px">
<el-form label-width="96px"> <el-form label-width="96px">
@@ -253,12 +305,12 @@ onMounted(loadMenus)
<el-input v-model="menuForm.name" placeholder="如:用户管理" /> <el-input v-model="menuForm.name" placeholder="如:用户管理" />
</el-form-item> </el-form-item>
<el-form-item label="菜单类型"> <el-form-item label="菜单类型">
<el-select v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange"> <el-select filterable 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-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="父菜单"> <el-form-item label="父菜单">
<el-select v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%"> <el-select filterable v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
<el-option <el-option
v-for="node in parentOptions" v-for="node in parentOptions"
:key="node.id" :key="node.id"
@@ -280,26 +332,223 @@ onMounted(loadMenus)
</template> </template>
<style scoped> <style scoped>
.menu-drag-tip { /* 像素复刻旧版 admin.html panel-columns(蓝白末层)。 */
margin: 0 0 10px; .menus-view {
font-size: 13px; font-family: inherit;
color: var(--el-text-color-secondary); color: #24384d;
} }
.menu-drag-handle { .panel-box {
display: inline-block; width: 100%;
cursor: grab; min-width: 0;
color: var(--el-text-color-secondary); padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.menus-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.columns-drag-tip {
margin: 10px 0 16px;
font-size: 13px;
color: #5b6f83;
}
.menus-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.columns-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.column-drag-cell {
width: 48px;
}
.column-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: #93a6b8;
font-size: 15px; font-size: 15px;
line-height: 1; line-height: 1;
padding: 4px 6px; cursor: grab;
border-radius: 4px;
user-select: none; user-select: none;
} }
.menu-drag-handle:hover { .column-drag-handle:hover {
background: var(--el-fill-color-light); border-color: #cbd9e6;
color: var(--el-text-color-primary); background: #eef4fa;
color: #2f5d8b;
} }
.menu-drag-handle:active { .column-drag-handle:active {
cursor: grabbing; cursor: grabbing;
} }
tr.is-column-dragging td {
opacity: 0.45;
}
.column-child-mark {
margin-right: 6px;
color: #9fb1c2;
}
.menu-name-text {
font-weight: 500;
}
.menu-type-text {
color: #5b6f83;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
white-space: nowrap;
clip: rect(0 0 0 0);
clip-path: inset(50%);
}
</style> </style>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import type { MenuOptionNode } from './user-menu-auth' import type { MenuOptionNode } from './user-menu-auth'
import { compactDirectGrantIds } from './user-menu-auth'
import { fetchGrantableMenus } from './user-menu-auth-api' import { fetchGrantableMenus } from './user-menu-auth-api'
const props = defineProps<{ checked?: number[] }>() const props = defineProps<{ checked?: number[] }>()
@@ -11,31 +12,42 @@ const emit = defineEmits<{
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */ /** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown }
const tree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null) const adminTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
const appTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
const data = ref<MenuOptionNode[]>([]) const data = ref<MenuOptionNode[]>([])
const loaded = ref(false) const loaded = ref(false)
/** 两种一级分类…(与 API menuType 分区一致):后台(admin) / 桌面端(app)。 */
const adminNodes = computed(() => data.value.filter((node) => node.type === 'admin'))
const appNodes = computed(() => data.value.filter((node) => node.type === 'app'))
function applyChecked(): void { 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) : [] const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
el.setCheckedKeys(ids) adminTree.value?.setCheckedKeys(ids)
appTree.value?.setCheckedKeys(ids)
} }
function onCheck(): void { function onCheck(): void {
const el: TreeRef | null = tree.value const collect = (el: { getCheckedKeys(): unknown } | null): number[] => {
if (!el) return const keys = el ? el.getCheckedKeys() : []
const keys = el.getCheckedKeys() return (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
const ids = (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0) }
emit('update:checked', ids) const ids = [...collect(adminTree.value), ...collect(appTree.value)]
// 父子联动模式(未启用严格勾选)下勾选父菜单会自动全选全部子菜单;
// 提交前压缩为最小直接授权集:父级已勾选时后代不再重复提交(后端按“父级授权
// 展开全部后代”判断权限,与旧 admin.html 勾父清后代语义一致)。
emit('update:checked', compactDirectGrantIds(ids, data.value))
} }
async function load(): Promise<void> { async function load(): Promise<void> {
try { try {
data.value = await fetchGrantableMenus() data.value = await fetchGrantableMenus()
loaded.value = true loaded.value = true
applyChecked()
emit('load', true) emit('load', true)
// el-tree 的 store 通过异步 watch 同步 props.datasetCheckedKeys 必须等树
// 渲染完成后再应用已勾选 id,否则作用在空 store 上导致已授权不回显。
await nextTick()
applyChecked()
} catch { } catch {
loaded.value = false loaded.value = false
emit('load', false) emit('load', false)
@@ -53,15 +65,67 @@ watch(
<template> <template>
<el-scrollbar max-height="300px" class="user-menu-tree-scroll"> <el-scrollbar max-height="300px" class="user-menu-tree-scroll">
<el-tree <template v-if="adminNodes.length || appNodes.length">
ref="tree" <div class="menu-group-columns">
:data="data" <div v-if="adminNodes.length" class="menu-group-block">
node-key="id" <div class="menu-group-title">后台</div>
show-checkbox <!-- 父子联动模式未启用严格勾选勾选父菜单自动全选全部子菜单
check-strictly 勾父即代表全部后代与后端父级授权展开后代的权限语义一致 -->
default-expand-all <el-tree
:props="{ label: 'name', children: 'children' }" ref="adminTree"
@check="onCheck" :data="adminNodes"
/> node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck"
/>
</div>
<div v-if="appNodes.length" class="menu-group-block">
<div class="menu-group-title">桌面端</div>
<el-tree
ref="appTree"
:data="appNodes"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck"
/>
</div>
</div>
</template>
<p v-else class="menu-group-empty">暂无可用菜单</p>
</el-scrollbar> </el-scrollbar>
</template> </template>
<style scoped>
.menu-group-columns {
display: flex;
align-items: flex-start;
gap: 16px;
}
.menu-group-block {
flex: 1 1 0;
min-width: 0;
padding: 0 10px 8px 0;
}
.menu-group-block:last-child {
padding-right: 0;
border-left: 1px solid #e6edf4;
padding-left: 16px;
}
.menu-group-title {
padding: 4px 0 6px;
font-size: 12.5px;
font-weight: 650;
color: #40586e;
border-bottom: 1px solid #e6edf4;
margin-bottom: 4px;
}
.menu-group-empty {
margin: 12px 0;
color: #8293a5;
font-size: 13px;
}
</style>
@@ -0,0 +1,395 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import OldPagination from '@/components/OldPagination.vue'
import {
fetchUserSecretUsage,
type AdminUserSecretUsageRow,
} from '@/api/user-secret-usage'
import type { GroupOption } from '@/api/user-secrets'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<AdminUserSecretUsageRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const keyword = ref('')
const startDate = ref('')
const endDate = ref('')
const groupFilter = ref<number | null>(null)
const groupOptions = ref<GroupOption[]>([])
/** 所选范围内三类总次数(后端按筛选条件统计,不分页)。 */
const summary = ref({ totalCalls: 0, similarAsinCalls: 0, appearancePatentCalls: 0, proxyCalls: 0 })
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
function formatCount(value: number | undefined) {
const num = Number(value || 0)
return num.toLocaleString('zh-CN')
}
async function load() {
loading.value = true
try {
const result = await fetchUserSecretUsage({
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
keyword: keyword.value.trim() || undefined,
groupId: groupFilter.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
groupOptions.value = result?.groupOptions || []
summary.value = {
totalCalls: Number(result?.totalCalls || 0),
similarAsinCalls: Number(result?.similarAsinCalls || 0),
appearancePatentCalls: Number(result?.appearancePatentCalls || 0),
proxyCalls: Number(result?.proxyCalls || 0),
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
keyword.value = ''
startDate.value = ''
endDate.value = ''
groupFilter.value = null
page.value = 1
load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
onMounted(load)
</script>
<template>
<div class="secret-usage-view">
<section class="panel-box">
<div class="usage-head">
<h3>密钥用量统计</h3>
<span class="usage-note">口径真实对外请求次数LLM 每次请求代理每次成功提取</span>
</div>
<div class="summary-row">
<div class="summary-card">
<div class="summary-label">总调用次数</div>
<div class="summary-value">{{ formatCount(summary.totalCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">货源查询密钥</div>
<div class="summary-value">{{ formatCount(summary.similarAsinCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">外观专利密钥</div>
<div class="summary-value">{{ formatCount(summary.appearancePatentCalls) }}</div>
</div>
<div class="summary-card">
<div class="summary-label">代理提取</div>
<div class="summary-value">{{ formatCount(summary.proxyCalls) }}</div>
</div>
</div>
<div class="form-row usage-filter-row">
<div class="form-group" style="min-width: 160px">
<label>开始日期</label>
<input v-model="startDate" type="date" />
</div>
<div class="form-group" style="min-width: 160px">
<label>结束日期</label>
<input v-model="endDate" type="date" />
</div>
<div class="form-group" style="min-width: 200px">
<label>用户名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
</div>
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
<label>分组</label>
<select v-model="groupFilter">
<option :value="null">全部分组</option>
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="usage-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 220px">用户</th>
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
<th style="width: 170px">货源查询密钥</th>
<th style="width: 170px">外观专利密钥</th>
<th style="width: 150px">代理提取</th>
<th style="width: 130px">合计</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.userId">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td>
<span class="user-name">{{ row.username || `UID ${row.userId}` }}</span>
</td>
<td v-if="isSuperAdmin">
<span class="group-name">{{ row.groups?.length ? row.groups.join('、') : '—' }}</span>
</td>
<td><span class="count-value">{{ formatCount(row.similarAsinCount) }}</span></td>
<td><span class="count-value">{{ formatCount(row.appearancePatentCount) }}</span></td>
<td><span class="count-value">{{ formatCount(row.proxyCount) }}</span></td>
<td><span class="count-value count-total">{{ formatCount(row.totalCount) }}</span></td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">
{{ keyword || startDate || endDate || groupFilter ? '暂无匹配记录' : '暂无用量记录' }}
</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
</div>
</template>
<style scoped>
/* 与密钥管理页保持同一视觉语言(像素复刻后台面板风格)。 */
.secret-usage-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.usage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.usage-note {
color: #7d8fa2;
font-size: 12px;
}
.summary-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
flex: 1 1 160px;
min-width: 150px;
padding: 12px 16px;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #f7fafd;
}
.summary-label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.summary-value {
margin-top: 6px;
color: #2f5d8b;
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.usage-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.usage-table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.usage-table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1020px;
}
.usage-table-scroll th,
.usage-table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usage-table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.usage-table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.usage-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.user-name {
color: #24384d;
font-weight: 600;
white-space: normal;
overflow-wrap: anywhere;
}
.group-name {
color: #5b6f83;
font-size: 12.5px;
}
.count-value {
color: #2f5d8b;
font-variant-numeric: tabular-nums;
font-size: 13.5px;
}
.count-total {
font-weight: 700;
}
.empty-tip {
color: #8293a5;
text-align: center;
}
</style>
@@ -0,0 +1,564 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { formatDateTime } from '@/utils/datetime'
import OldPagination from '@/components/OldPagination.vue'
import {
checkUserSecret,
deleteUserSecret,
fetchUserSecretList,
type AdminUserSecretModule,
type AdminUserSecretRow,
type GroupOption,
} from '@/api/user-secrets'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<AdminUserSecretRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const keyword = ref('')
const statusFilter = ref('')
const groupFilter = ref<number | null>(null)
/** 分组下拉(超管=全部;主管=自己带的分组,由列表接口带回)。 */
const groupOptions = ref<GroupOption[]>([])
/** 正在检测的行 userId,用于按钮 loading 态。 */
const checkingId = ref<number | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'passed', label: '检测通过' },
{ value: 'failed', label: '检测失败' },
{ value: 'incomplete', label: '未配齐' },
{ value: 'error', label: '无法判定' },
{ value: 'unknown', label: '未检测' },
]
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
function rowStatusMeta(status: string) {
switch (status) {
case 'passed':
return { label: '检测通过', tone: 'is-allowed' }
case 'failed':
return { label: '检测失败', tone: 'is-blocked' }
case 'incomplete':
return { label: '未配齐', tone: 'is-warn' }
case 'error':
return { label: '无法判定', tone: 'is-warn' }
default:
return { label: '未检测', tone: 'is-unknown' }
}
}
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
if (!module || !module.exists) {
return { label: '未配置', tone: 'is-unknown' }
}
switch (module.checkStatus) {
case 'passed':
return { label: '通过', tone: 'is-allowed' }
case 'failed':
if (module.checkCode === 'insufficient_balance') {
return { label: '欠费', tone: 'is-warn' }
}
return { label: '失败', tone: 'is-blocked' }
case 'error':
return { label: '无法判定', tone: 'is-warn' }
default:
return { label: '未检测', tone: 'is-unknown' }
}
}
/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */
function moduleTooltip(module: AdminUserSecretModule | undefined) {
if (!module || !module.exists) return '未配置'
const parts: string[] = []
if (module.full) parts.push(module.full)
if (module.checkMessage) parts.push(module.checkMessage)
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
return parts.join('') || '暂无检测记录'
}
/** 分组列文案:优先分组名;未建分组的用户回退展示所属主管,便于判断归属。 */
function groupTextOf(row: AdminUserSecretRow) {
if (row.groups?.length) return row.groups.join('、')
if (row.leaderUsername) return `${row.leaderUsername}(未建分组)`
return '—'
}
function rowStatusTooltip(row: AdminUserSecretRow) {
const parts: string[] = []
if (row.statusMessage) parts.push(row.statusMessage)
if (row.updatedAt) parts.push(`最近更新:${formatDateTime(row.updatedAt)}`)
return parts.join('') || '暂无检测记录'
}
async function load() {
loading.value = true
try {
const result = await fetchUserSecretList({
keyword: keyword.value.trim() || undefined,
checkStatus: statusFilter.value || undefined,
groupId: groupFilter.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
groupOptions.value = result?.groupOptions || []
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
keyword.value = ''
statusFilter.value = ''
groupFilter.value = null
page.value = 1
load()
}
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
async function check(row: AdminUserSecretRow) {
checkingId.value = row.userId
try {
const results = await checkUserSecret(row.userId)
if (!results || !results.length) {
ElMessage.warning('该用户尚未配置任何密钥或代理')
} else {
const failed = results.filter((item) => item.checkStatus === 'failed')
const errors = results.filter((item) => item.checkStatus === 'error')
if (failed.length) {
ElMessage.warning(failed[0].checkMessage || '存在检测失败项')
} else if (errors.length) {
ElMessage.warning('部分配置本次无法判定')
} else {
ElMessage.success('检测通过')
}
}
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '检测失败')
} finally {
checkingId.value = null
}
}
async function remove(row: AdminUserSecretRow) {
const who = row.username || `UID ${row.userId}`
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
try {
await deleteUserSecret(row.userId)
ElMessage.success('已清空')
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清空失败')
}
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
onMounted(load)
</script>
<template>
<div class="user-secrets-view">
<section class="panel-box">
<div class="secrets-head">
<h3>用户密钥列表</h3>
</div>
<div class="form-row secrets-filter-row">
<div class="form-group" style="min-width: 220px">
<label>用户名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
</div>
<div class="form-group" style="min-width: 170px">
<label>状态三项都通过才算通过</label>
<select v-model="statusFilter">
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</div>
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
<label>分组</label>
<select v-model="groupFilter">
<option :value="null">全部分组</option>
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="secrets-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 260px">用户</th>
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
<th style="width: 230px">货源查询密钥</th>
<th style="width: 230px">外观专利密钥</th>
<th style="width: 380px">代理设置</th>
<th style="width: 130px">状态</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.userId">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td>
<span class="user-name">{{ row.username || '—' }}</span>
</td>
<td v-if="isSuperAdmin">
<span class="creator-name">{{ groupTextOf(row) }}</span>
</td>
<td>
<div class="module-cell">
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.similarAsin).tone" :title="moduleTooltip(row.similarAsin)">
{{ moduleStatusMeta(row.similarAsin).label }}
</span>
</div>
</td>
<td>
<div class="module-cell">
<span v-if="row.appearancePatent?.exists" class="mono-mask" :title="moduleTooltip(row.appearancePatent)">{{ row.appearancePatent.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.appearancePatent).tone" :title="moduleTooltip(row.appearancePatent)">
{{ moduleStatusMeta(row.appearancePatent).label }}
</span>
</div>
</td>
<td>
<div class="module-cell module-cell--stack">
<span v-if="row.proxy?.exists" class="mono-mask mono-full" :title="moduleTooltip(row.proxy)">{{ row.proxy.full || row.proxy.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
{{ moduleStatusMeta(row.proxy).label }}
</span>
</div>
</td>
<td>
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
{{ rowStatusMeta(row.status).label }}
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button
class="btn btn-sm"
type="button"
:disabled="checkingId === row.userId"
@click="check(row)"
>
{{ checkingId === row.userId ? '检测中…' : '立即检测' }}
</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html 面板风格(与店铺密钥页同一视觉语言)。 */
.user-secrets-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.secrets-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.secrets-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.secrets-table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.secrets-table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1440px;
}
.secrets-table-scroll th,
.secrets-table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secrets-table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.secrets-table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.secrets-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.user-name {
color: #24384d;
font-weight: 600;
/* 用户名(含中文)可完整换行展示,不截断 */
white-space: normal;
overflow-wrap: anywhere;
}
.creator-name {
color: #5b6f83;
font-size: 12.5px;
}
.module-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
.module-cell--stack {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.module-cell .mono-mask {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* 代理列展示完整明文:允许折行显示,不截断、不省略 */
.module-cell .mono-full {
flex: 1 1 auto;
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.45;
cursor: text;
user-select: all;
}
.mono-mask {
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12.5px;
letter-spacing: 0.5px;
}
.empty-value {
color: #a7b4c1;
}
.ops-cell {
display: flex;
gap: 8px;
}
.empty-tip {
color: #8293a5;
text-align: center;
}
.wh-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid;
font-size: 12px;
line-height: 1.7;
cursor: help;
flex-shrink: 0;
}
.wh-pill.is-allowed {
background: #e8f6ee;
border-color: #b9e0c9;
color: #2f7d52;
}
.wh-pill.is-blocked {
background: #fdecef;
border-color: #f2c4cd;
color: #b04a5a;
}
.wh-pill.is-warn {
background: #fdf6e3;
border-color: #f0dfae;
color: #8f6d1e;
}
.wh-pill.is-unknown {
background: #f0f3f6;
border-color: #d6dee6;
color: #6b7d8f;
}
</style>
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 用户管理页 · 像素复刻旧版 admin.html panel-users(自绘:panel-box + 页头按钮 + 筛选 form-row + 旧式表格/分页 + 原生 confirm 删除)。
* script 逻辑沿用现有 Vue 实现;创建/编辑弹窗保留现有组件(含用户菜单权限树)。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import type { AdminUser } from '@/types/admin' import type { AdminUser } from '@/types/admin'
import { roleLabel } from '@/types/admin' import { roleLabel } from '@/types/admin'
import { useAdminSessionStore } from '@/stores/admin-session' import { useAdminSessionStore } from '@/stores/admin-session'
@@ -13,6 +15,7 @@ import { listPhaseOf, userListEmptyHint } from './user-list-state'
import { isEditableUser } from './user-edit-model' import { isEditableUser } from './user-edit-model'
import CreateUserDialog from './CreateUserDialog.vue' import CreateUserDialog from './CreateUserDialog.vue'
import EditUserDialog from './EditUserDialog.vue' import EditUserDialog from './EditUserDialog.vue'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore() const session = useAdminSessionStore()
const loading = ref(false) const loading = ref(false)
@@ -25,6 +28,8 @@ const filters = reactive<UserFilterState>(createUserFilterState())
const isSuperAdmin = computed(() => session.isSuperAdmin) const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null) const currentUserId = computed(() => session.user?.id ?? null)
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value })) const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, totalPageCount(total.value, filters.pageSize)))
const createVisible = ref(false) const createVisible = ref(false)
const editVisible = ref(false) const editVisible = ref(false)
@@ -85,17 +90,14 @@ async function removeUser(row: AdminUser) {
ElMessage.warning(reason) ElMessage.warning(reason)
return return
} }
// 对齐旧版:浏览器原生 confirm 确认后删除。
if (!window.confirm(deleteConfirmMessage(row))) return
try { try {
await ElMessageBox.confirm(deleteConfirmMessage(row), '删除用户', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteUser(row.id) await deleteUser(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
await loadUsers() await loadUsers()
} catch (error) { } catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败') ElMessage.error(error instanceof Error ? error.message : '删除失败')
} }
} }
@@ -110,106 +112,366 @@ function changeSize(size: number) {
void loadUsers() void loadUsers()
} }
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
onMounted(loadUsers) onMounted(loadUsers)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="users-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="users-head">
<h2>用户管理</h2> <h3>用户列表</h3>
<p>当前没有开放注册入口仅管理员可在此创建用户层级关系超级管理员 -&gt; 管理员 -&gt; 普通账号</p> <button class="btn" type="button" @click="openCreate">新建用户</button>
</div> </div>
</div> <p class="users-desc">当前没有开放注册入口仅管理员可在此创建用户层级关系超级管理员 -&gt; 管理员 -&gt; 普通账号</p>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="search"> <div class="form-row users-filter-row">
<el-form-item label="用户名(模糊搜索)"> <div class="form-group" style="min-width: 180px">
<el-input v-model="filters.keyword" clearable placeholder="输入用户名关键字" @keyup.enter="search" /> <label>用户名模糊搜索</label>
</el-form-item> <input v-model="filters.keyword" type="text" placeholder="输入用户名关键字" @keyup.enter="search" />
<el-form-item v-if="isSuperAdmin" label="所属管理员"> </div>
<el-select <div v-if="isSuperAdmin" class="form-group" style="min-width: 160px">
v-model="filters.createdByAdminId" <label>所属管理员</label>
clearable <el-select v-model="filters.createdByAdminId" class="admin-filter" filterable clearable placeholder="全部" @change="search">
filterable <el-option v-for="admin in admins" :key="admin.id" :label="admin.username" :value="admin.id" />
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-select>
</el-form-item> </div>
<el-form-item> <div class="form-group" style="min-width: 140px">
<el-button type="primary" @click="search">查询</el-button> <label>角色</label>
</el-form-item> <el-select v-model="filters.role" class="admin-filter" filterable clearable placeholder="全部角色" @change="search">
</el-form> <el-option label="超级管理员" value="super_admin" />
</el-card> <el-option label="管理员" value="admin" />
<el-card shadow="never"> <el-option label="普通账号" value="normal" />
<el-alert </el-select>
v-if="statePhase === 'error' && loadError" </div>
:title="loadError" <button class="btn" type="button" @click="search">查询</button>
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> </div>
<el-table v-loading="loading" :data="rows" stripe :empty-text="userListEmptyHint()">
<el-table-column prop="username" label="用户名" min-width="180" /> <div v-if="statePhase === 'error' && loadError" class="msg err">
<el-table-column label="角色" min-width="140"> <span>加载失败: {{ loadError }}</span>
<template #default="{ row }"> <button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</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>
<div class="table-footer">
<span> {{ total }} </span>
<el-pagination
background
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> </div>
</el-card>
<div class="table-scroll user-table-scroll">
<table>
<thead>
<tr>
<th style="width: 70px">ID</th>
<th style="width: 180px">用户名</th>
<th style="width: 140px">角色</th>
<th style="width: 160px">所属管理员</th>
<th style="width: 180px">创建时间</th>
<th style="width: 180px">更新时间</th>
<th style="width: 160px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td>{{ row.username }}</td>
<td>{{ roleLabel(row.role) }}</td>
<td>{{ row.creatorUsername || '-' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button
class="btn btn-sm"
type="button"
:disabled="!editableOf(row)"
:title="!editableOf(row) ? '该账号不可编辑' : undefined"
@click="openEdit(row)"
>
编辑
</button>
<button
class="btn btn-sm btn-danger"
type="button"
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
:title="deleteBlockReason(row, currentUserId) || undefined"
@click="removeUser(row)"
>
删除
</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">{{ userListEmptyHint() }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="filters.page" :page-size="filters.pageSize" @change="changePage" @size-change="changeSize" />
</section>
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" /> <CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" />
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" /> <EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.filter-card :deep(.el-form-item) { /* 像素复刻旧版 admin.html panel-users(蓝白末层)。 */
.users-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.users-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.users-desc {
margin: 10px 0 16px;
font-size: 13px;
color: #5b6f83;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0; margin-bottom: 0;
} }
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.msg {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 10px 12px;
border: 1px solid;
border-radius: 9px;
font-size: 13px;
}
.msg.err {
color: #91474f;
background: #f8ebeb;
border-color: #e4c2c5;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.user-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-size-select {
min-height: 30px;
padding: 3px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
</style> </style>
@@ -15,6 +15,7 @@ export interface MenuManageNode {
routePath: string routePath: string
sortOrder: number sortOrder: number
createdAt?: string createdAt?: string
updatedAt?: string
children?: MenuManageNode[] children?: MenuManageNode[]
} }
@@ -90,6 +91,8 @@ export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
if (rootColumnKey) node.rootColumnKey = rootColumnKey if (rootColumnKey) node.rootColumnKey = rootColumnKey
const createdAt = text(record.created_at) const createdAt = text(record.created_at)
if (createdAt) node.createdAt = createdAt if (createdAt) node.createdAt = createdAt
const updatedAt = text(record.updated_at)
if (updatedAt) node.updatedAt = updatedAt
return node return node
} }
@@ -3,8 +3,10 @@ import { http } from '@/api/http'
import { unwrap } from '@/api/envelope' import { unwrap } from '@/api/envelope'
import { import {
ADMIN_MENU_TYPE, ADMIN_MENU_TYPE,
APP_MENU_TYPE,
buildMenuOptionTree, buildMenuOptionTree,
buildUserColumnPermissionPayload, buildUserColumnPermissionPayload,
normalizeMenuIds,
parseMenuOptionList, parseMenuOptionList,
parseUserGrantedColumnIds, parseUserGrantedColumnIds,
type MenuOptionNode, type MenuOptionNode,
@@ -13,18 +15,33 @@ import {
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus' export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users' export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */ /** 拉取可授权菜单树(后台 admin + 前端客户端 app 两类合并,按 sort 排序)。 */
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> { export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
const { data } = await http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }) const [adminRes, appRes] = await Promise.all([
return buildMenuOptionTree(parseMenuOptionList(data)) http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }),
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: APP_MENU_TYPE } }),
])
const nodes = [
...parseMenuOptionList(adminRes.data, ADMIN_MENU_TYPE),
...parseMenuOptionList(appRes.data, APP_MENU_TYPE),
]
return buildMenuOptionTree(nodes)
} }
/** 拉取某用户当前直接授权菜单 id 列表。 */ /** 拉取某用户当前直接授权菜单 id 列表admin + app 合并去重)。 */
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> { export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
const { data } = await http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, { const [adminRes, appRes] = await Promise.all([
params: { menuType: ADMIN_MENU_TYPE }, http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
}) params: { menuType: ADMIN_MENU_TYPE },
return parseUserGrantedColumnIds(data) }),
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: APP_MENU_TYPE },
}),
])
return normalizeMenuIds([
...parseUserGrantedColumnIds(adminRes.data),
...parseUserGrantedColumnIds(appRes.data),
])
} }
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */ /** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
@@ -3,6 +3,8 @@ import { unwrap } from '../../api/envelope.ts'
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */ /** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
export const ADMIN_MENU_TYPE = 'admin' export const ADMIN_MENU_TYPE = 'admin'
/** 前端客户端菜单类型(桌面端功能模块,如视频/前端工具/运营工具/后勤工具)。 */
export const APP_MENU_TYPE = 'app'
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */ /** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
export interface MenuOptionNode { export interface MenuOptionNode {
@@ -12,6 +14,13 @@ export interface MenuOptionNode {
name: string name: string
sort: number sort: number
parentId: number | null parentId: number | null
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
type: string
/**
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
*/
disabled?: boolean
children?: MenuOptionNode[] children?: MenuOptionNode[]
} }
@@ -20,7 +29,7 @@ function numberOrNull(value: unknown): number | null {
} }
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */ /** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null { export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode | null {
if (!raw || typeof raw !== 'object') return null if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown> const record = raw as Record<string, unknown>
const id = numberOrNull(record.id) const id = numberOrNull(record.id)
@@ -34,15 +43,18 @@ export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单', name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
sort: sortRaw === null ? 0 : sortRaw, sort: sortRaw === null ? 0 : sortRaw,
parentId: parentId === null ? null : parentId, parentId: parentId === null ? null : parentId,
type,
// 缺省(菜单管理页等未标记的接口)按可授予处理,保持旧行为
disabled: record.grantable === false,
} }
} }
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */ /** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
export function parseMenuOptionList(payload: unknown): MenuOptionNode[] { export function parseMenuOptionList(payload: unknown, type = ''): MenuOptionNode[] {
const data = unwrap<unknown>(payload) const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return [] if (!Array.isArray(data)) return []
return data return data
.map((raw) => parsePermissionMenuItem(raw)) .map((raw) => parsePermissionMenuItem(raw, type))
.filter((node): node is MenuOptionNode => node !== null) .filter((node): node is MenuOptionNode => node !== null)
} }
@@ -100,3 +112,29 @@ export function normalizeMenuIds(raw: unknown): number[] {
export function buildUserColumnPermissionPayload(raw: unknown): { columnIds: number[] } { export function buildUserColumnPermissionPayload(raw: unknown): { columnIds: number[] } {
return { columnIds: normalizeMenuIds(raw) } return { columnIds: normalizeMenuIds(raw) }
} }
/**
* 把权限树勾选 id 压缩为后端意义上的“直接授权最小集”:父级已勾选时后代无需
* 重复提交(后端读取/校验时父级授权会向全部后代展开,与旧 admin.html“勾父清
* 后代”语义一致,避免 user_column_permission 存储冗余行)。未勾选父级但单独
* 勾选子级的部分授权原样保留。
*/
export function compactDirectGrantIds(checkedIds: unknown, tree: MenuOptionNode[]): number[] {
const ids = normalizeMenuIds(checkedIds)
if (ids.length === 0 || tree.length === 0) return ids
const idSet = new Set(ids)
/** 因“有已勾选祖先”而被父级覆盖、无需直接授权的 id。 */
const coveredByAncestor = new Set<number>()
const walk = (nodes: MenuOptionNode[], ancestorChecked: boolean): void => {
for (const node of nodes) {
if (ancestorChecked) {
coveredByAncestor.add(node.id)
if (node.children?.length) walk(node.children, true)
continue
}
if (node.children?.length) walk(node.children, idSet.has(node.id))
}
}
walk(tree, false)
return ids.filter((id) => !coveredByAncestor.has(id))
}
@@ -7,18 +7,20 @@ export interface UserFilterState {
keyword: string keyword: string
/** 按创建管理员过滤(可选)。 */ /** 按创建管理员过滤(可选)。 */
createdByAdminId: number | null createdByAdminId: number | null
/** 角色过滤(''=全部;super_admin/admin/normal)。 */
role: string
page: number page: number
pageSize: number pageSize: number
} }
export function createUserFilterState(): UserFilterState { export function createUserFilterState(): UserFilterState {
return { keyword: '', createdByAdminId: null, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE } return { keyword: '', createdByAdminId: null, role: '', page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
} }
/** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */ /** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */
export function withFilterReset( export function withFilterReset(
state: UserFilterState, state: UserFilterState,
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId'>>, next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId' | 'role'>>,
): UserFilterState { ): UserFilterState {
return { ...state, ...next, page: USER_PAGE_MIN_PAGE } return { ...state, ...next, page: USER_PAGE_MIN_PAGE }
} }
@@ -36,10 +38,13 @@ export function isFilterActive(state: UserFilterState): boolean {
/** 页面状态 → 查询适配层参数(空关键字不下发)。 */ /** 页面状态 → 查询适配层参数(空关键字不下发)。 */
export function toUserListParams(state: UserFilterState): UserListParams { export function toUserListParams(state: UserFilterState): UserListParams {
const keyword = state.keyword.trim() const keyword = state.keyword.trim()
return { const params: UserListParams = {
page: state.page, page: state.page,
pageSize: state.pageSize, pageSize: state.pageSize,
keyword: keyword || undefined, keyword: keyword || undefined,
createdById: state.createdByAdminId, createdById: state.createdByAdminId,
} }
const role = (state.role || '').trim()
if (role) params.role = role
return params
} }
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 不符合 ASIN(品牌数据库):分页 + 手动新增/编辑/删除。 */ /** 品牌数据库(不符合ASIN)页 · 像素复刻旧版 admin.html panel-invalid-asin-data(自绘:面板头+新增、筛选、旧式表格/分页、原生 confirm 删除)。
import { computed, onMounted, reactive, ref } from 'vue' * script 逻辑沿用现有 Vue 实现(分组锁定/来源 AUTO 编辑隐藏分组);弹窗保留现有组件。 */
import { ElMessage, ElMessageBox } from 'element-plus' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session' import { useAdminSessionStore } from '@/stores/admin-session'
import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts' import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts'
import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts' import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts'
@@ -10,6 +11,7 @@ import { invalidAsinDeleteText, invalidAsinLockedGroupId, recordSourceLabel } fr
import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts' import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts' import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts' import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore() const session = useAdminSessionStore()
const loading = ref(false) const loading = ref(false)
@@ -18,6 +20,8 @@ const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([]) const groups = ref<ShopGroupOption[]>([])
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null }) const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
@@ -75,15 +79,28 @@ function apply() {
load() load()
} }
function reset() { function changePage(next: number) {
filter.dataValue = '' if (next < 1 || next > totalPages.value) return
filter.brand = '' page.value = next
// 非超管重置不解除分组锁定。 load()
filter.groupId = isSuperAdmin.value ? null : lockedGroupId.value }
function changeSize(size: number) {
pageSize.value = size
page.value = 1 page.value = 1
load() load()
} }
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
function openCreate() { function openCreate() {
editingId.value = null editingId.value = null
editingSource.value = '' editingSource.value = ''
@@ -128,11 +145,8 @@ async function submit() {
} }
async function remove(row: InvalidAsinItem) { async function remove(row: InvalidAsinItem) {
try { // 对齐旧版:原生 confirm。
await ElMessageBox.confirm(invalidAsinDeleteText(row), '删除', { type: 'warning' }) if (!window.confirm(invalidAsinDeleteText(row))) return
} catch {
return
}
try { try {
await deleteInvalidAsin(row.id) await deleteInvalidAsin(row.id)
ElMessage.success('已删除') ElMessage.success('已删除')
@@ -142,6 +156,10 @@ async function remove(row: InvalidAsinItem) {
} }
} }
watch(isSuperAdmin, () => {
if (isSuperAdmin.value) filter.groupId = null
})
onMounted(() => { onMounted(() => {
loadGroups() loadGroups()
load() load()
@@ -149,69 +167,76 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="page-stack"> <div class="invalid-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="invalid-head">
<h2>不符合 ASIN 数据</h2> <h3>品牌数据列表</h3>
<p>管理命中"不符合 ASIN"的品牌数据库记录</p> <button class="btn" type="button" @click="openCreate">新增</button>
</div> </div>
</div>
<el-card shadow="never" style="margin-bottom: 14px"> <div class="form-row invalid-filter-row">
<div class="filter-grid"> <div class="form-group" style="min-width: 200px">
<div class="f-item">
<label>ASIN</label> <label>ASIN</label>
<el-input v-model="filter.dataValue" placeholder="精确/模糊搜索ASIN" clearable @keyup.enter="apply" /> <input v-model="filter.dataValue" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
</div> </div>
<div class="f-item"> <div class="form-group" style="min-width: 200px">
<label>品牌</label> <label>品牌</label>
<el-input v-model="filter.brand" placeholder="品牌模糊" clearable @keyup.enter="apply" /> <input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
</div> </div>
<div class="f-item"> <div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
<label>分组</label> <label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable :disabled="!isSuperAdmin"> <el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</div> </div>
<div class="f-item btn-row"> <button class="btn" type="button" @click="apply">查询</button>
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div> </div>
</el-card>
<el-card shadow="never"> <div class="table-scroll invalid-asin-table-scroll">
<div class="table-toolbar"> <table>
<el-button type="primary" @click="openCreate">新增</el-button> <thead>
<tr>
<th style="width: 70px">ID</th>
<th style="width: 170px">ASIN</th>
<th style="width: 150px">品牌</th>
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
<th style="width: 110px">来源</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td class="mono-cell">{{ row.dataValue }}</td>
<td>{{ row.brand || '—' }}</td>
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
<td>
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
{{ recordSourceLabel(row.recordSource) }}
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div> </div>
<el-table v-loading="loading" :data="rows" stripe border> <OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
<el-table-column prop="dataValue" label="ASIN" min-width="160" /> </section>
<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-dialog v-model="dialogVisible" :title="editingId == null ? '新增不符合ASIN' : '编辑不符合ASIN'" width="520px">
<el-form label-width="110px"> <el-form label-width="110px">
@@ -236,12 +261,232 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; } /* 像素复刻旧版 admin.html panel-invalid-asin-data(蓝白末层)。 */
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; } .invalid-view {
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; } font-family: inherit;
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; } color: #24384d;
.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; } .panel-box {
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; } width: 100%;
.table-footer b { color: var(--el-text-color-primary); } min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.invalid-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.invalid-asin-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.mono-cell {
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
font-size: 12.5px;
}
.source-text {
padding: 2px 8px;
border-radius: 6px;
border: 1px solid #cbd9e6;
background: #fff;
color: #5b6f83;
font-size: 12px;
}
.source-text.is-auto {
background: #e7f0f8;
border-color: #c2d5e6;
color: #2f5d8b;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
</style> </style>
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 数据去重总数据(registry):对齐 admin panel-dedupe-total-data —— 顶部数据权限分组汇总卡、扩充筛选(用户名/日期/分组)、 /** 数据去重总数据(registry) · 像素复刻旧版 admin.html panel-dedupe-total-data(自绘:分组摘要 + 双行筛选 + 旧式表格/分页 + 编辑/导入弹窗 + 导出等待遮罩)。
* 行编辑/删除、新增/删除导入(轮询)、导出(等待遮罩+已等待秒数)。 */ * script 逻辑沿用现有 Vue 实现(查询/编辑/删除/导入轮询/导出秒表)。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { import {
fetchDedupeTotalList, fetchDedupeTotalList,
updateDedupeTotal, updateDedupeTotal,
@@ -12,7 +11,7 @@ import {
fetchDedupeTotalGroups, fetchDedupeTotalGroups,
type DedupeGroupOption, type DedupeGroupOption,
} from './dedupe-total-api.ts' } from './dedupe-total-api.ts'
import { dedupeGroupSummaryOf, type DedupeTotalItem } from './dedupe-total-model.ts' import type { DedupeTotalItem } from './dedupe-total-model.ts'
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts' import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts' import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
import { import {
@@ -24,8 +23,13 @@ import {
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts' import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
import type { DedupeImportProgress } from './dedupe-import-model.ts' import type { DedupeImportProgress } from './dedupe-import-model.ts'
import { toExportUrl } from './dedupe-total-export.ts' import { toExportUrl } from './dedupe-total-export.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore() const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false) const loading = ref(false)
const rows = ref<DedupeTotalItem[]>([]) const rows = ref<DedupeTotalItem[]>([])
const total = ref(0) const total = ref(0)
@@ -33,11 +37,18 @@ const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const filter = reactive(createDedupeTotalFilterState()) const filter = reactive(createDedupeTotalFilterState())
const groups = ref<DedupeGroupOption[]>([]) const groups = ref<DedupeGroupOption[]>([])
/** 非超管仅有一个可访问分组时,编辑/导入弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
const jumpPage = ref('')
/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */
const pageCursor = ref<number | null>(null)
/** 本次请求实际使用的游标(load 时决定) */
let pendingCursor: number | null = null
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const groupSummary = computed(() =>
dedupeGroupSummaryOf(groups.value, session.user?.id ?? null, session.user?.role || ''),
)
const editVisible = ref(false) const editVisible = ref(false)
const editSaving = ref(false) const editSaving = ref(false)
@@ -78,10 +89,15 @@ function stopExportWait(): void {
async function load(): Promise<void> { async function load(): Promise<void> {
loading.value = true loading.value = true
try { try {
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize.value)) const result = await fetchDedupeTotalList(
toDedupeListParams(filter, page.value, pageSize.value, pendingCursor),
)
rows.value = result.items rows.value = result.items
total.value = result.total total.value = result.total
if (result.page >= 1) page.value = result.page if (result.page >= 1) page.value = result.page
// 本页响应回传的游标留作"下一页"用;空页则清空(没有更多)
pageCursor.value = result.nextLastId ?? null
pendingCursor = null
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败') ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
} finally { } finally {
@@ -91,19 +107,40 @@ async function load(): Promise<void> {
function apply(): void { function apply(): void {
page.value = 1 page.value = 1
pageCursor.value = null
pendingCursor = null
void load() void load()
} }
function reset(): void { function changePage(next: number): void {
Object.assign(filter, createDedupeTotalFilterState()) if (next < 1 || next > totalPages.value) return
// 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变
pendingCursor = next === page.value + 1 ? pageCursor.value : null
page.value = next
void load()
}
function goJump(): void {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1 page.value = 1
pageCursor.value = null
pendingCursor = null
void load() void load()
} }
function openEdit(row: DedupeTotalItem): void { function openEdit(row: DedupeTotalItem): void {
editTarget.value = row editTarget.value = row
editValue.value = row.dataValue editValue.value = row.dataValue
editGroupId.value = row.groupId editGroupId.value = row.groupId ?? lockedGroupId.value
editVisible.value = true editVisible.value = true
} }
@@ -133,24 +170,19 @@ async function saveEdit(): Promise<void> {
} }
async function removeRow(row: DedupeTotalItem): Promise<void> { async function removeRow(row: DedupeTotalItem): Promise<void> {
// 对齐旧版:浏览器原生 confirm 确认后删除。
if (!window.confirm(`确定删除总数据“${row.dataValue}”吗?`)) return
try { try {
await ElMessageBox.confirm(`确定删除总数据“${row.dataValue}”吗?`, '删除总数据', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
await deleteDedupeTotal(row.id) await deleteDedupeTotal(row.id)
ElMessage.success('删除成功') ElMessage.success('删除成功')
await load() await load()
} catch (error) { } catch (error) {
if (error !== 'cancel' && error !== 'close') { ElMessage.error(error instanceof Error ? error.message : '删除失败')
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
} }
} }
function resetImport(): void { function resetImport(): void {
importGroupId.value = null importGroupId.value = lockedGroupId.value
importFile.value = null importFile.value = null
importProgress.value = '' importProgress.value = ''
importRunning.value = false importRunning.value = false
@@ -208,15 +240,7 @@ async function submitImport(): Promise<void> {
const file = importFile.value as File const file = importFile.value as File
const groupId = importGroupId.value as number const groupId = importGroupId.value as number
if (importMode.value === 'delete') { if (importMode.value === 'delete') {
try { if (!window.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?')) return
await ElMessageBox.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
} }
const mode = importMode.value const mode = importMode.value
importRunning.value = true importRunning.value = true
@@ -288,153 +312,163 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="page-stack"> <div class="dedupe-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="dedupe-panel-head">
<h2>数据去重总数据</h2> <h3>ASIN列表</h3>
<p>去重总数据的 ASIN 值台账可按 ASIN/用户名/分组/国家/日期筛选支持 Excel 导入与导出</p> <div class="dedupe-head-actions">
<button class="btn" type="button" @click="openImportAdd">新增导入</button>
<button class="btn" type="button" @click="openImportDelete">删除导入</button>
</div>
</div>
<div class="dedupe-filter-row">
<div class="form-group">
<label>ASIN</label>
<input v-model="filter.keyword" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
</div>
<div class="form-group">
<label>用户名模糊搜索</label>
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
</div>
<div v-if="isSuperAdmin" class="form-group">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</div>
<div class="form-group">
<label>国家</label>
<select v-model="filter.country">
<option value="">全部国家</option>
<option v-for="code in ASIN_COUNTRY_CODES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="dedupe-filter-actions">
<button class="btn" type="button" @click="apply">查询</button>
</div>
</div>
<div class="dedupe-filter-date-row">
<div class="form-group">
<label>开始日期</label>
<input v-model="filter.startDate" type="date" />
</div>
<div class="form-group">
<label>结束日期</label>
<input v-model="filter.endDate" type="date" />
</div>
<div class="dedupe-filter-actions">
<button class="btn btn-secondary" type="button" :disabled="exporting" @click="doExport">
{{ exporting ? '导出中...' : '导出 XLSX' }}
</button>
</div>
</div>
<div class="table-scroll dedupe-table-scroll">
<table>
<thead>
<tr>
<th>ID</th>
<th>ASIN</th>
<th>国家</th>
<th>用户名</th>
<th v-if="isSuperAdmin">分组</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td class="mono-cell">{{ row.dataValue }}</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<td>{{ row.username || '-' }}</td>
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无总数据</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<div v-if="editVisible" class="modal-mask" @click.self="editVisible = false">
<div class="modal">
<h3>编辑ASIN</h3>
<div class="form-group">
<label>ASIN</label>
<input v-model="editValue" type="text" placeholder="请输入总数据值" />
</div>
<div class="form-group">
<label>分组</label>
<select v-model="editGroupId" :disabled="lockedGroupId != null">
<option disabled :value="null">请选择分组</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="modal-actions">
<button class="btn" type="button" :disabled="editSaving" @click="saveEdit">保存</button>
<button class="btn btn-secondary" type="button" @click="editVisible = false">取消</button>
</div>
</div> </div>
</div> </div>
<el-card shadow="never" class="group-summary-card"> <div v-if="importVisible" class="modal-mask" @click.self="importVisible = false">
<div class="group-summary"> <div class="modal">
<span class="group-summary-title">数据权限分组</span> <h3>{{ importMode === 'add' ? '新增导入' : '删除导入' }}</h3>
<div class="group-summary-body"> <div class="form-group">
<template v-if="groupSummary.kind === 'all'"> <label>{{ importMode === 'add' ? '上传 Excel 文件(读取 ASIN 列,可选 国家 列)' : '上传 Excel 文件(读取 ASIN 列)' }}</label>
<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" /> <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> </div>
</el-form-item> <div class="form-group">
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon /> <label>分组</label>
</el-form> <select v-model="importGroupId" :disabled="importRunning || lockedGroupId != null">
<template #footer> <option disabled :value="null">请选择分组</option>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button> <option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
<el-button type="primary" :loading="importRunning" @click="submitImport"> </select>
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }} </div>
</el-button> <div v-if="importProgress" class="progress-wrap">
</template> <div class="progress-text">{{ importProgress }}</div>
</el-dialog> </div>
<div class="modal-actions">
<button
class="btn"
:class="{ 'btn-danger': importMode === 'delete' }"
type="button"
:disabled="importRunning"
@click="submitImport"
>
{{ importMode === 'add' ? '上传并导入' : '上传并删除' }}
</button>
<button class="btn btn-secondary" type="button" :disabled="importRunning" @click="importVisible = false">取消</button>
</div>
</div>
</div>
<div v-if="exportWaitVisible" class="export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title"> <div v-if="exportWaitVisible" class="dedupe-export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
<div class="export-wait"> <div class="dedupe-export-wait">
<span class="wait-spinner" aria-hidden="true"></span> <span class="request-spinner" aria-hidden="true"></span>
<div> <div>
<div class="export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div> <div class="dedupe-export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
<div class="export-wait-detail">数据量较大请耐心等待并保持页面打开已等待 {{ exportWaitSeconds }} </div> <div class="dedupe-export-wait-detail">
数据量较大请耐心等待并保持页面打开已等待 <span>{{ exportWaitSeconds }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -442,60 +476,352 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; } /* 像素复刻旧版 admin.html panel-dedupe-total-data(蓝白末层样式)。 */
.heading-actions { display: flex; gap: 8px; flex: none; } .dedupe-view {
.group-summary-card { margin-bottom: 12px; } font-family: inherit;
.group-summary { display: flex; align-items: flex-start; gap: 14px; } color: var(--c-text, #24384d);
.group-summary-title { flex: none; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 24px; } display: flex;
.group-summary-body { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } flex-direction: column;
.summary-chip { gap: 18px;
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; } .form-box,
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; } .panel-box {
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; } width: 100%;
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; } min-width: 0;
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; } padding: 20px 22px 24px;
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; } border: 1px solid #d8e3ee;
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; } border-radius: 14px;
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; } background: linear-gradient(145deg, #ffffff, #f9fbfd);
.export-wait-mask { box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.dedupe-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dedupe-panel-head h3 {
margin: 0;
}
.dedupe-head-actions {
display: flex;
gap: 10px;
}
.dedupe-filter-row {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
gap: 12px;
align-items: end;
margin-bottom: 16px;
}
.dedupe-filter-row .form-group {
min-width: 0;
}
.dedupe-filter-actions {
display: flex;
gap: 10px;
align-items: center;
white-space: nowrap;
}
.dedupe-filter-date-row {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 16px;
}
.dedupe-filter-date-row .form-group {
flex: 0 0 200px;
min-width: 0;
}
.dedupe-filter-date-row .dedupe-filter-actions {
margin-left: auto;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.form-group input[type="file"] {
background: #ffffff;
padding: 6px 10px;
}
.form-group input[type="date"] {
color-scheme: light;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.dedupe-table-scroll > table {
min-width: 900px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.mono-cell {
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
font-size: 12.5px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
text-align: right;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.modal-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 3000; z-index: 3000;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(24, 29, 43, 0.45); padding: 40px 20px;
backdrop-filter: blur(3px); background: rgba(45, 66, 86, 0.36);
backdrop-filter: blur(6px);
} }
.export-wait { .modal {
display: flex;
flex-direction: column;
width: min(560px, 100%);
max-height: 84vh;
padding: 22px 24px;
border: 1px solid #c7d7e5;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
overflow-y: auto;
}
.modal .form-group {
margin-bottom: 14px;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.progress-wrap {
margin-top: 12px;
}
.progress-text {
font-size: 12.5px;
color: #5b6f83;
line-height: 1.6;
}
.dedupe-export-wait-mask {
position: fixed;
inset: 0;
z-index: 3100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(45, 66, 86, 0.36);
backdrop-filter: blur(6px);
}
.dedupe-export-wait {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
background: var(--el-bg-color);
border-radius: 14px;
padding: 22px 28px; padding: 22px 28px;
box-shadow: 0 12px 40px -12px rgba(39, 67, 94, 0.35); border: 1px solid #c7d7e5;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
} }
.wait-spinner { .request-spinner {
width: 26px; width: 26px;
height: 26px; height: 26px;
flex: none; flex: none;
border: 3px solid var(--el-color-primary-light-7); border: 3px solid #d5e3ef;
border-top-color: var(--el-color-primary); border-top-color: #5f85ad;
border-radius: 50%; border-radius: 50%;
animation: dedupe-spin 0.9s linear infinite; animation: dedupe-spin 0.9s linear infinite;
} }
@keyframes dedupe-spin { @keyframes dedupe-spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
.export-wait-title { font-size: 15px; font-weight: 600; } .dedupe-export-wait-title {
.export-wait-detail { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12.5px; } font-size: 15px;
font-weight: 600;
color: #24384d;
}
.dedupe-export-wait-detail {
margin-top: 4px;
font-size: 12.5px;
color: #5b6f83;
}
</style> </style>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** 商品类目:对齐 admin panel-product-categories —— 树节点带 来源/排序/备注 + 行内编辑删除(有子禁用)、 /** 商品类目 · 像素复刻旧版 admin.html panel-product-categories(自绘:面板头搜索框组+新增类目 同行、原生树表格[+/−]折叠+懒加载「加载更多 已加载 x/y」、搜索态旧式分页)。
* 按需懒加载子级(「加载更多 已加载 x/y」)、搜索态独立分页/加载更多、导出、新增/编辑弹窗语义。 */ * script 逻辑沿用现有 Vue 实现(懒加载树/编辑删除/导出/弹窗)。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts' import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
@@ -15,6 +15,8 @@ import {
} from './product-category-model.ts' } from './product-category-model.ts'
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts' import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from './product-category-export.ts' import { buildProductCategoryExportUrl } from './product-category-export.ts'
import { formatDateTime } from '@/utils/datetime'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false) const loading = ref(false)
const tree = ref<ProductCategoryNode[]>([]) const tree = ref<ProductCategoryNode[]>([])
@@ -23,6 +25,7 @@ const selected = ref<ProductCategoryNode | null>(null)
const searchLoading = ref(false) const searchLoading = ref(false)
const searchRows = ref<ProductCategoryNode[]>([]) const searchRows = ref<ProductCategoryNode[]>([])
const searchPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const searchTotal = ref(0) const searchTotal = ref(0)
const searchPage = ref(1) const searchPage = ref(1)
const searchHasMore = ref(false) const searchHasMore = ref(false)
@@ -54,17 +57,23 @@ function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
const allNodes = computed(() => flatten(tree.value)) const allNodes = computed(() => flatten(tree.value))
const searching = computed(() => (keyword.value || '').trim().length > 0) const searching = computed(() => (keyword.value || '').trim().length > 0)
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目')) const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
const searchTotalPages = computed(() => Math.max(1, Math.ceil(searchTotal.value / searchPageSize.value)))
/** 拉取根级第一页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */ /** 顶层翻页(服务端分页,10/20/50/100 可选;子级保留懒加载+加载更多对齐旧版)。 */
const rootPage = ref(1)
const rootPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const rootTotal = ref(0)
/** 拉取根级当前页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
async function loadTree(): Promise<void> { async function loadTree(): Promise<void> {
loading.value = true loading.value = true
try { try {
const result = await fetchProductCategoryChildren(null, 1, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE) const result = await fetchProductCategoryChildren(null, rootPage.value, rootPageSize.value)
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false } const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
childrenState.set(ROOT_KEY, state) childrenState.set(ROOT_KEY, state)
tree.value = result.hasMore // 顶层当前页直接作为树根(不含合成「加载更多」节点,翻页由 OldPagination 承担)。
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)] tree.value = result.items
: result.items rootTotal.value = result.total
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目加载失败') ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
} finally { } finally {
@@ -72,6 +81,17 @@ async function loadTree(): Promise<void> {
} }
} }
function changeRootPage(p: number) {
rootPage.value = p
void loadTree()
}
function changeRootSize(size: number) {
rootPageSize.value = size
rootPage.value = 1
void loadTree()
}
/** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */ /** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */
interface TreeRow { interface TreeRow {
node: ProductCategoryNode node: ProductCategoryNode
@@ -167,11 +187,15 @@ function moreLoading(more: ProductCategoryNode): boolean {
return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false) return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false)
} }
function changeSearchSize(size: number) {
searchPageSize.value = size
void runSearch(1)
}
async function runSearch(page: number): Promise<void> { async function runSearch(page: number): Promise<void> {
searchLoading.value = true searchLoading.value = true
try { try {
const result = await searchProductCategories(keyword.value, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE) const result = await searchProductCategories(keyword.value, page, searchPageSize.value)
// 分页换页模式:每页独立展示当前页结果(对齐列表统一分页交互)。
searchRows.value = result.items searchRows.value = result.items
searchTotal.value = result.total searchTotal.value = result.total
searchPage.value = result.page searchPage.value = result.page
@@ -197,10 +221,6 @@ function reset(): void {
void loadTree() void loadTree()
} }
function searchChangePage(p: number): void {
void runSearch(p)
}
function openCreate(parent: ProductCategoryNode | null) { function openCreate(parent: ProductCategoryNode | null) {
editingId.value = null editingId.value = null
Object.assign(draft, createProductCategoryDraft()) Object.assign(draft, createProductCategoryDraft())
@@ -236,21 +256,16 @@ async function removeCategory(node: ProductCategoryNode): Promise<void> {
ElMessage.warning('存在子类目,不能删除') ElMessage.warning('存在子类目,不能删除')
return return
} }
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除“${node.name}”?`)) return
try { try {
await ElMessageBox.confirm(`确定删除“${node.name}”?`, '删除类目', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
await deleteProductCategory(node.id) await deleteProductCategory(node.id)
ElMessage.success('已删除') ElMessage.success('已删除')
if (selected.value?.id === node.id) selected.value = null if (selected.value?.id === node.id) selected.value = null
if (searching.value) void runSearch(1) if (searching.value) void runSearch(1)
else void loadTree() else void loadTree()
} catch (error) { } catch (error) {
if (error !== 'cancel' && error !== 'close') { ElMessage.error(error instanceof Error ? error.message : '删除失败')
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
} }
} }
@@ -293,179 +308,336 @@ onMounted(loadTree)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="category-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="category-head">
<h2>商品类目</h2> <h3>商品类目</h3>
<p>维护商品类目树与类目名称/说明有子类目节点不可删除</p> <div class="category-head-tools">
<input v-model="keyword" type="text" placeholder="搜索类目名称、编码或备注" class="category-keyword" @keyup.enter="apply" />
<button class="btn btn-sm" type="button" @click="apply">搜索</button>
<button class="btn btn-sm btn-secondary" type="button" @click="reset">清空</button>
<button class="btn btn-sm btn-secondary" type="button" @click="exportCategories">导出</button>
<button v-if="selected && !searching" class="btn btn-sm" type="button" @click="openCreate(selected)">新增子类目</button>
<button class="btn" type="button" @click="openCreate(null)">新增类目</button>
</div>
</div> </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="table-scroll category-table-scroll">
<div class="filter-row"> <table>
<el-input v-model="keyword" placeholder="搜索类目名称、编码或备注" clearable style="width: 280px" @keyup.enter="apply" /> <thead>
<el-button type="primary" @click="apply">搜索</el-button> <tr>
<el-button @click="reset">清空</el-button> <th style="width: 34%">类目</th>
</div> <th>层级路径</th>
</el-card> <th style="width: 90px">排序</th>
<th style="width: 120px">来源</th>
<el-card v-if="!searching" shadow="never"> <th>备注</th>
<el-table v-loading="loading" :data="treeRows" row-key="node.id" :class="{ 'tree-table': true }"> <th style="width: 170px">创建时间</th>
<el-table-column label="类目名称" min-width="320"> <th style="width: 170px">更新时间</th>
<template #default="{ row }"> <th style="width: 150px">操作</th>
<template v-if="isCategoryLoadMoreNode(row.node)"> </tr>
<div class="tree-name-cell"> </thead>
<span class="tree-indent" :style="{ '--indent': `${(row.depth + 1) * 24}px` }"></span> <tbody v-if="!searching">
<el-button link size="small" type="primary" :loading="moreLoading(row.node)" @click="loadMoreChildren(row.node)">加载更多</el-button> <template v-if="treeRows.length">
<span class="dim">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span> <template v-for="row in treeRows" :key="String(row.node.id)">
</div> <tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
<td colspan="8">
<div class="load-more-cell">
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
<button class="btn btn-sm btn-secondary" type="button" :disabled="moreLoading(row.node)" @click="loadMoreChildren(row.node)">
{{ moreLoading(row.node) ? '加载中...' : '加载更多' }}
</button>
<span class="loaded-count">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
</div>
</td>
</tr>
<tr v-else>
<td>
<div class="tree-name-cell" @click="onNodeClick(row.node)">
<span class="tree-indent" :style="{ width: `${row.depth * 24}px` }"></span>
<button
type="button"
class="tree-node-mark"
:class="{ 'is-leaf': row.node.childCount <= 0 }"
:disabled="row.node.childCount <= 0"
:title="expandedIds.has(row.node.id) ? '折叠' : '展开'"
@click.stop="toggleExpand(row.node)"
>
{{ row.node.childCount > 0 ? (expandedIds.has(row.node.id) ? '' : '+') : '' }}
</button>
<strong class="node-name">{{ row.node.name }}</strong>
<span v-if="selected?.id === row.node.id" class="selected-flag">已选</span>
</div>
</td>
<td>
<span class="category-path-value" :title="row.node.path || row.node.name">{{ row.node.path || row.node.name }}</span>
</td>
<td>{{ row.node.sortOrder ?? 0 }}</td>
<td>
<span class="category-tag" :class="{ 'is-custom': !row.node.isBuiltin }">{{ productCategorySourceLabel(row.node) }}</span>
</td>
<td>
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.node.createdAt) }}</td>
<td>{{ formatDateTime(row.node.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
<button
class="btn btn-sm btn-danger"
type="button"
:disabled="!canDelete(row.node)"
:title="!canDelete(row.node) ? '存在子类目,不能删除' : undefined"
@click="removeCategory(row.node)"
>
删除
</button>
</td>
</tr>
</template>
</template> </template>
<div v-else class="tree-name-cell" @click="onNodeClick(row.node as ProductCategoryNode)"> <tr v-else-if="loading">
<span class="tree-indent" :style="{ '--indent': `${row.depth * 24}px` }"></span> <td colspan="8" class="empty-tip">加载中...</td>
<button </tr>
type="button" <tr v-else>
class="tree-node-mark" <td colspan="8" class="empty-tip">暂无商品类目</td>
:class="{ 'is-leaf': (row.node as ProductCategoryNode).childCount <= 0 }" </tr>
:disabled="(row.node as ProductCategoryNode).childCount <= 0" </tbody>
:title="expandedIds.has((row.node as ProductCategoryNode).id) ? '折叠' : '展开'" <tbody v-else>
@click.stop="toggleExpand(row.node as ProductCategoryNode)" <template v-if="searchRows.length">
> <tr v-for="row in searchRows" :key="row.id">
{{ (row.node as ProductCategoryNode).childCount > 0 ? (expandedIds.has((row.node as ProductCategoryNode).id) ? '' : '+') : '' }} <td><strong class="node-name">{{ row.name }}</strong></td>
</button> <td><span class="category-path-value" :title="row.path || row.name">{{ row.path || row.name }}</span></td>
<strong class="node-name">{{ (row.node as ProductCategoryNode).name }}</strong> <td>{{ row.sortOrder ?? '—' }}</td>
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'"> <td>
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }} <span class="category-tag" :class="{ 'is-custom': !row.isBuiltin }">{{ productCategorySourceLabel(row) }}</span>
</el-tag> </td>
</div> <td>
</template> <span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
</el-table-column> <span v-else class="dim"></span>
<el-table-column label="层级路径" min-width="220"> </td>
<template #default="{ row }"> <td>{{ formatDateTime(row.createdAt) }}</td>
<span class="dim" :title="(row.node as ProductCategoryNode).path">{{ (row.node as ProductCategoryNode).path || (row.node as ProductCategoryNode).name }}</span> <td>{{ formatDateTime(row.updatedAt) }}</td>
</template> <td class="ops-cell">
</el-table-column> <button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<el-table-column label="排序" min-width="80"> <button
<template #default="{ row }">{{ (row.node as ProductCategoryNode).sortOrder ?? 0 }}</template> class="btn btn-sm btn-danger"
</el-table-column> type="button"
<el-table-column label="来源" min-width="90"> :disabled="!canDelete(row)"
<template #default="{ row }"> :title="!canDelete(row) ? '存在子类目,不能删除' : undefined"
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'"> @click="removeCategory(row)"
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }} >
</el-tag> 删除
</template> </button>
</el-table-column> </td>
<el-table-column label="说明" min-width="180"> </tr>
<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>
</template> <tr v-else-if="searchLoading">
</el-table-column> <td colspan="8" class="empty-tip">搜索中...</td>
</el-table> </tr>
<div class="tree-actions"> <tr v-else>
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span> <td colspan="8" class="empty-tip">暂无搜索结果</td>
<span class="btn-group"> </tr>
<el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button> </tbody>
</span> </table>
</div> </div>
</el-card>
<el-card v-else shadow="never"> <OldPagination v-if="searching" :total="searchTotal" :page="searchPage" :page-size="searchPageSize" @change="runSearch" @size-change="changeSearchSize" />
<div class="search-head"> <OldPagination v-else-if="rootTotal > 0" :total="rootTotal" :page="rootPage" :page-size="rootPageSize" @change="changeRootPage" @size-change="changeRootSize" />
<span>搜索结果 {{ searchTotal.toLocaleString() }} </span> </section>
<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-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form label-width="110px"> <el-form label-width="110px">
<el-form-item label="父级类目"> <el-form-item label="父级类目">
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable> <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-option
v-for="node in allNodes"
:key="node.id"
:label="node.path || node.name"
:value="node.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="类目名称" required> <el-form-item label="类目名称" required>
<el-input v-model="draft.name" placeholder="类目名称" /> <el-input v-model="draft.name" placeholder="如:洁面" />
</el-form-item> </el-form-item>
<el-form-item label="排序"> <el-form-item label="排序">
<el-input v-model="draft.sortOrder" placeholder="非负整数,可选" /> <el-input v-model="draft.sortOrder" placeholder="如 10" />
</el-form-item> </el-form-item>
<el-form-item label="说明"> <el-form-item label="备注">
<el-input v-model="draft.description" type="textarea" :rows="2" placeholder="可选" /> <el-input v-model="draft.description" type="textarea" :rows="3" placeholder="可选" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button> <el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存类目</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ editingId == null ? '保存' : '保存类目' }}</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; } /* 像素复刻旧版 admin.html panel-product-categories(蓝白末层)。 */
.actions { display: flex; align-items: center; gap: 10px; } .category-view {
.filter-row { display: flex; gap: 10px; align-items: center; } font-family: inherit;
.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; } color: #24384d;
.dim { color: var(--el-text-color-secondary); font-size: 12px; } }
.btn-group { display: inline-flex; gap: 6px; } .panel-box {
.search-head { display: flex; justify-content: space-between; margin-bottom: 12px; } width: 100%;
.load-more-row { text-align: center; margin-top: 12px; } min-width: 0;
/* 树形表格(对齐旧版 panel-product-categories):缩进 + [+/] 折叠标记 + 粗体名称。 */ padding: 20px 22px 24px;
.tree-name-cell { display: flex; align-items: center; gap: 8px; min-height: 24px; } border: 1px solid #d8e3ee;
.tree-indent { flex: 0 0 auto; width: var(--indent, 0px); } border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.category-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.category-head-tools {
display: flex;
align-items: center;
gap: 8px;
}
.category-keyword {
width: 240px;
min-height: 36px;
padding: 6px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.category-keyword:hover {
border-color: #9fb7cd;
}
.category-keyword:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.category-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.tree-name-cell {
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
cursor: pointer;
}
.tree-indent {
flex: 0 0 auto;
display: inline-block;
}
.tree-node-mark { .tree-node-mark {
width: 20px; width: 20px;
height: 20px; height: 20px;
border: 0; border: 0;
border-radius: 5px; border-radius: 5px;
background: #eef0fe; background: var(--admin-primary-soft, #e7f0f8);
color: #6366f1; color: var(--admin-primary-strong, #2f5d8b);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -477,9 +649,115 @@ onMounted(loadTree)
flex: none; flex: none;
transition: background 0.12s ease, color 0.12s ease; transition: background 0.12s ease, color 0.12s ease;
} }
.tree-node-mark:not(.is-leaf):hover { background: #6366f1; color: #fff; } .tree-node-mark:not(.is-leaf):hover {
.tree-node-mark.is-leaf { background: transparent; cursor: default; } background: var(--admin-primary-strong, #2f5d8b);
.node-name { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; } color: #ffffff;
.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; } .tree-node-mark.is-leaf {
background: transparent;
cursor: default;
}
.node-name {
font-weight: 600;
color: #24384d;
white-space: nowrap;
}
.selected-flag {
padding: 1px 8px;
border-radius: 999px;
background: #e7f0f8;
color: #2f5d8b;
font-size: 11px;
}
.category-tag {
padding: 2px 8px;
border-radius: 6px;
border: 1px solid #c2d5e6;
background: #e7f0f8;
color: #2f5d8b;
font-size: 12px;
}
.category-tag.is-custom {
border-color: #bcd9c6;
background: #eef7f0;
color: #3d7158;
}
.category-path-value {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-desc {
color: #5b6f83;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
display: inline-block;
vertical-align: middle;
}
.dim {
color: #8293a5;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.load-more-row td {
background: #fbfdfe;
}
.load-more-cell {
display: flex;
align-items: center;
gap: 8px;
}
.loaded-count {
color: #8293a5;
font-size: 12.5px;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
</style> </style>
@@ -1,19 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
/** 查询 ASIN:对齐 admin panel-query-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国 ASIN 行可点击复制) + /** 查询 ASIN · 像素复刻旧版 admin.html panel-query-asin(自绘:面板头操作组、筛选+导出 XLSX、原生 rowspan 表格+可复制 ASIN、旧式分页)。
* 新增 ASIN 弹窗(分组→店铺联动→国家→ASIN) + 行内「配置抽屉 + 导入添加/删除导入/导出。 */ * script 逻辑沿用现有 Vue 实现(新增级联/配置抽屉/导入轮询/导出。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue' import CopyText from '@/components/CopyText.vue'
import { formatDateTime } from '@/utils/datetime'
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts' import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts' import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
import { asinCountryLabel } from './asin-country.ts' import { asinCountryLabel } from './asin-country.ts'
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts' import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
import { toQueryAsinExportUrl } from './query-asin-export.ts'
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts' import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts' import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts' import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
import { isAllowedExcelImportFile } from './import-progress-model.ts' import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts' import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts' import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false) const loading = ref(false)
const rows = ref<QueryAsinItem[]>([]) const rows = ref<QueryAsinItem[]>([])
@@ -21,19 +29,18 @@ const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([]) const groups = ref<ShopGroupOption[]>([])
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState()) const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */ /** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1)) 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 { 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 } const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
return map[country] || '' return map[country] || ''
@@ -57,6 +64,21 @@ function normalizeAsinInput(country: string): void {
draftAsin[country] = draftAsin[country].trim().toUpperCase() draftAsin[country] = draftAsin[country].trim().toUpperCase()
} }
async function removeShopAsin(item: QueryAsinItem) {
// 删除该店铺全部站点的 ASIN 配置(逐国调用现有端点)。
if (!window.confirm(`确定删除店铺“${item.shopName}”的全部站点 ASIN 配置吗?`)) return
try {
for (const country of QUERY_ASIN_COUNTRIES) {
if (asinOf(item, country)) await deleteQueryAsinCountryAsin(item.id, country)
}
ElMessage.success('已删除')
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function saveConfig(): Promise<void> { async function saveConfig(): Promise<void> {
const item = drawerItem.value const item = drawerItem.value
if (!item) return if (!item) return
@@ -104,7 +126,7 @@ const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null) const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void { function openCreate(): void {
createGroupId.value = null createGroupId.value = lockedGroupId.value
createShopName.value = '' createShopName.value = ''
createCountry.value = '' createCountry.value = ''
createAsin.value = '' createAsin.value = ''
@@ -112,6 +134,8 @@ function openCreate(): void {
createMsgOk.value = false createMsgOk.value = false
shopNames.value = [] shopNames.value = []
createVisible.value = true createVisible.value = true
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
if (createGroupId.value != null) void onCreateGroupChange()
} }
async function onCreateGroupChange(): Promise<void> { async function onCreateGroupChange(): Promise<void> {
@@ -178,7 +202,7 @@ const importRunning = ref(false)
const importProgress = ref('') const importProgress = ref('')
function resetImport(): void { function resetImport(): void {
importGroupId.value = null importGroupId.value = lockedGroupId.value
importFile.value = null importFile.value = null
importProgress.value = '' importProgress.value = ''
importRunning.value = false importRunning.value = false
@@ -216,15 +240,7 @@ async function submitImport(): Promise<void> {
return return
} }
if (importMode.value === 'delete') { if (importMode.value === 'delete') {
try { if (!window.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) return
await ElMessageBox.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
} }
const mode = importMode.value const mode = importMode.value
const groupId = importGroupId.value ?? undefined const groupId = importGroupId.value ?? undefined
@@ -261,8 +277,9 @@ async function submitImport(): Promise<void> {
// ---- 导出 ---- // ---- 导出 ----
function doExport(): void { function doExport(): void {
// 带当前筛选(分组/店铺名/ASIN/国家)导出,范围与列表筛选一致(此前不带参数导致导出恒为全量)。
const anchor = document.createElement('a') const anchor = document.createElement('a')
anchor.href = '/api/admin/query-asins/export' anchor.href = toQueryAsinExportUrl(filter)
document.body.appendChild(anchor) document.body.appendChild(anchor)
anchor.click() anchor.click()
anchor.remove() anchor.remove()
@@ -303,12 +320,28 @@ function apply() {
void load() void load()
} }
function reset() { function changePage(next: number) {
Object.assign(filter, createQueryAsinFilterState()) if (next < 1 || next > totalPages.value) return
page.value = 1 page.value = next
void load() void load()
} }
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
onMounted(() => { onMounted(() => {
void loadGroups() void loadGroups()
void load() void load()
@@ -316,94 +349,98 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="page-stack"> <div class="query-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="query-head">
<h2>查询 ASIN</h2> <h3>店铺列表</h3>
<p>每店铺在 5 个站点查询到的 ASIN 清单行内可逐站配置 ASIN</p> <div class="query-head-actions">
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入添加</button>
<button class="btn" type="button" @click="openImportDelete">导入删除</button>
</div>
</div> </div>
</div>
<el-card shadow="never"> <div class="form-row query-filter-row">
<div class="filter-grid"> <div v-if="isSuperAdmin" class="form-group">
<div class="f-item">
<label>分组</label> <label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable> <el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</div> </div>
<div class="f-item"> <div class="form-group">
<label>店铺名</label> <label>店铺名</label>
<el-input v-model="filter.shopName" placeholder="请输入店铺名" clearable @keyup.enter="apply" /> <input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div> </div>
<div class="f-item"> <div class="form-group">
<label>ASIN</label>
<el-input v-model="filter.asin" placeholder="请输入 ASIN" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>国家</label> <label>国家</label>
<el-select v-model="filter.country" placeholder="全部国家" clearable> <select v-model="filter.country">
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" /> <option value="">全部国家</option>
</el-select> <option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div> </div>
<div class="f-item btn-row"> <div class="form-group">
<el-button type="primary" @click="apply">查询</el-button> <label>ASIN</label>
<el-button @click="reset">重置</el-button> <input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
</div> </div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
</div> </div>
</el-card>
<el-card shadow="never"> <div class="table-scroll query-asin-table-scroll">
<div class="table-toolbar"> <table>
<el-button type="primary" @click="openCreate">新增 ASIN</el-button> <thead>
<el-button @click="openImportAdd">导入添加</el-button> <tr>
<el-button @click="openImportDelete">导入删除</el-button> <th style="width: 6%">序号</th>
<el-button @click="doExport">导出</el-button> <th v-if="isSuperAdmin" style="width: 13%">分组</th>
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
<tbody>
<template v-if="displayRows.length">
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '' }}</td>
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
</template>
<td>
<template v-if="row.asin">
<CopyText :text="row.asin" class="asin-cell" />
</template>
<span v-else class="asin-empty">-</span>
</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
</td>
</template>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div> </div>
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border> <OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
<el-table-column label="分组" min-width="130"> </section>
<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-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent> <el-form label-position="top" @submit.prevent>
<el-form-item label="分组"> <el-form-item label="分组">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange"> <el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -420,7 +457,7 @@ onMounted(() => {
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="国家"> <el-form-item label="国家">
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%"> <el-select filterable 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-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -455,7 +492,7 @@ onMounted(() => {
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px"> <el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="分组(Excel 未提供时的兜底,可选)"> <el-form-item label="分组(Excel 未提供时的兜底,可选)">
<el-select v-model="importGroupId" placeholder="可不选" clearable style="width: 100%"> <el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%" :disabled="lockedGroupId != null">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -476,16 +513,274 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; } /* 像素复刻旧版 admin.html panel-query-asin(蓝白末层)。 */
.heading-actions { display: flex; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; } .query-view {
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; } font-family: inherit;
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; } color: #24384d;
.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; } .panel-box {
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; } width: 100%;
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; } min-width: 0;
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; } padding: 20px 22px 24px;
.dim { color: var(--el-text-color-placeholder); } border: 1px solid #d8e3ee;
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; } border-radius: 14px;
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; } background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.query-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.query-head-actions {
display: flex;
gap: 10px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 12px;
margin-bottom: 16px;
}
.query-filter-row > .form-group {
flex: 1 1 140px;
min-width: 0;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.query-asin-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.table-scroll td {
vertical-align: top;
}
.asin-cell {
display: block;
max-width: 100%;
margin: -3px 0 -3px -7px;
padding: 3px 7px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: inherit;
font-size: 13.5px;
font-variant-numeric: tabular-nums;
line-height: 1.5;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.asin-cell:hover {
border-color: #c7d7e5;
background: #eef4fa;
color: #2f5d8b;
}
.asin-empty {
color: #8b9aaa;
}
.asin-col-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
white-space: nowrap;
background: #ffffff;
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.drawer-tip {
color: #5b6f83;
font-size: 12px;
margin: 0 0 12px;
}
.import-tip {
margin: 6px 0 0;
color: #5b6f83;
font-size: 12px;
}
</style> </style>
@@ -1,20 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国 /** 最低价 ASIN(跳过跟价)· 像素复刻旧版 admin.html panel-skip-price-asin(自绘:面板头操作组、筛选(分组/店铺/国家/ASIN/最低价≥≤)+导出 XLSX、原生 rowspan 表格、旧式分页)。
* ASIN 可点击复制 + 最低价两位小数)、新增 ASIN 弹窗(分组→店铺联动→国家→ASIN+最低价)、价格范围筛选、 * script 逻辑沿用现有 Vue 实现(新增级联+最低价、配置抽屉双列、导入轮询、导出)。 */
* 行内「配置」抽屉(逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue' import CopyText from '@/components/CopyText.vue'
import { formatDateTime } from '@/utils/datetime'
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts' import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts' import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
import { asinCountryLabel } from './asin-country.ts' import { asinCountryLabel } from './asin-country.ts'
import { createSkipPriceFilterState, toSkipPriceParams, type SkipPriceFilterState } from './skip-price-filter.ts' import { createSkipPriceFilterState, toSkipPriceParams, type SkipPriceFilterState } from './skip-price-filter.ts'
import { deleteSkipPriceCountry, updateSkipPriceCountry } from './skip-price-detail-api.ts' import { deleteSkipPriceAsin, deleteSkipPriceCountry, updateSkipPriceCountry } from './skip-price-detail-api.ts'
import { fetchSkipPriceDeleteImportProgress, fetchSkipPriceImportProgress, startSkipPriceDeleteImport, startSkipPriceImport } from './skip-price-import-api.ts' import { fetchSkipPriceDeleteImportProgress, fetchSkipPriceImportProgress, startSkipPriceDeleteImport, startSkipPriceImport } from './skip-price-import-api.ts'
import { isAllowedExcelImportFile } from './import-progress-model.ts' import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopNamesByGroup } from './query-asin-api.ts' import { fetchShopNamesByGroup } from './query-asin-api.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts' import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts' import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false) const loading = ref(false)
const rows = ref<SkipPriceItem[]>([]) const rows = ref<SkipPriceItem[]>([])
@@ -22,7 +28,14 @@ const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([]) const groups = ref<ShopGroupOption[]>([])
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState()) const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const 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 ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
@@ -40,14 +53,6 @@ function priceOf(row: SkipPriceItem, code: string): number | null {
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */ /** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1)) 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 ---- // ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin ----
const createVisible = ref(false) const createVisible = ref(false)
const createGroupId = ref<number | null>(null) const createGroupId = ref<number | null>(null)
@@ -63,7 +68,7 @@ const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null) const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void { function openCreate(): void {
createGroupId.value = null createGroupId.value = lockedGroupId.value
createShopName.value = '' createShopName.value = ''
createCountry.value = '' createCountry.value = ''
createAsin.value = '' createAsin.value = ''
@@ -72,6 +77,8 @@ function openCreate(): void {
createMsgOk.value = false createMsgOk.value = false
shopNames.value = [] shopNames.value = []
createVisible.value = true createVisible.value = true
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
if (createGroupId.value != null) void onCreateGroupChange()
} }
async function onCreateGroupChange(): Promise<void> { async function onCreateGroupChange(): Promise<void> {
@@ -210,6 +217,30 @@ async function saveConfig(): Promise<void> {
} }
} }
// ---- 整条删除 ----
async function removeRow(item: SkipPriceItem): Promise<void> {
try {
await ElMessageBox.confirm(
`确定删除「${item.shopName}${item.groupName ? `${item.groupName}` : ''}全部站点的最低价 ASIN 配置吗?`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await deleteSkipPriceAsin(item.id)
ElMessage.success('删除成功')
// 删除后当前页若已空且非首页,回退一页避免展示空页。
if (rows.value.length === 1 && page.value > 1) {
page.value -= 1
}
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
// ---- 导入添加 / 删除导入(group_id 必填) ---- // ---- 导入添加 / 删除导入(group_id 必填) ----
type ImportMode = 'add' | 'delete' type ImportMode = 'add' | 'delete'
const importVisible = ref(false) const importVisible = ref(false)
@@ -220,7 +251,7 @@ const importRunning = ref(false)
const importProgress = ref('') const importProgress = ref('')
function resetImport(): void { function resetImport(): void {
importGroupId.value = null importGroupId.value = lockedGroupId.value
importFile.value = null importFile.value = null
importProgress.value = '' importProgress.value = ''
importRunning.value = false importRunning.value = false
@@ -262,15 +293,7 @@ async function submitImport(): Promise<void> {
return return
} }
if (importMode.value === 'delete') { if (importMode.value === 'delete') {
try { if (!window.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) return
await ElMessageBox.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
} }
const mode = importMode.value const mode = importMode.value
const groupId = importGroupId.value as number const groupId = importGroupId.value as number
@@ -341,12 +364,28 @@ function apply() {
void load() void load()
} }
function reset() { function changePage(next: number) {
Object.assign(filter, createSkipPriceFilterState()) if (next < 1 || next > totalPages.value) return
page.value = 1 page.value = next
void load() void load()
} }
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
onMounted(() => { onMounted(() => {
void loadGroups() void loadGroups()
void load() void load()
@@ -354,108 +393,108 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="page-stack"> <div class="skip-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="skip-head">
<h2>最低价 ASIN / 跳过跟价</h2> <h3>店铺列表</h3>
<p>每店铺在 5 个站点设定的最低价 ASIN 清单行内可逐站配置 ASIN 与最低价</p> <div class="skip-head-actions">
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入文件新增</button>
<button class="btn" type="button" @click="openImportDelete">导入文件删除</button>
</div>
</div> </div>
</div>
<el-card shadow="never"> <div class="form-row skip-filter-row">
<div class="filter-grid"> <div v-if="isSuperAdmin" class="form-group">
<div class="f-item">
<label>分组</label> <label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable> <el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</div> </div>
<div class="f-item"> <div class="form-group">
<label>店铺</label> <label>店铺</label>
<el-input v-model="filter.shopName" placeholder="店铺名模糊" clearable @keyup.enter="apply" /> <input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div> </div>
<div class="f-item"> <div class="form-group">
<label>ASIN</label>
<el-input v-model="filter.asin" placeholder="ASIN 搜索" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>国家</label> <label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable> <select v-model="filter.country">
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" /> <option value="">全部国家</option>
</el-select> <option v-for="code in COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div> </div>
<div class="f-item price-range"> <div class="form-group">
<label>最低价</label> <label>ASIN</label>
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
</div>
<div class="form-group price-range">
<label>最低价范围</label>
<div class="price-range-row"> <div class="price-range-row">
<el-input v-model="filter.minimumPriceFrom" placeholder="≥ 最低值" clearable @keyup.enter="apply" /> <input v-model="filter.minimumPriceFrom" type="number" min="0" step="0.01" placeholder="最低价 ≥" @keyup.enter="apply" />
<span class="range-sep"></span> <span class="range-sep">-</span>
<el-input v-model="filter.minimumPriceTo" placeholder="≤ 最高值" clearable @keyup.enter="apply" /> <input v-model="filter.minimumPriceTo" type="number" min="0" step="0.01" placeholder="最高价 ≤" @keyup.enter="apply" />
</div> </div>
</div> </div>
<div class="f-item btn-row"> <button class="btn" type="button" @click="apply">查询</button>
<el-button type="primary" @click="apply">查询</el-button> <button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
<el-button @click="reset">重置</el-button>
</div>
</div> </div>
</el-card>
<el-card shadow="never"> <div class="table-scroll skip-price-asin-table-scroll">
<div class="table-toolbar"> <table>
<el-button type="primary" @click="openCreate">新增 ASIN</el-button> <thead>
<el-button @click="openImportAdd">导入添加</el-button> <tr>
<el-button @click="openImportDelete">导入删除</el-button> <th style="width: 6%">序号</th>
<el-button @click="doExport">导出</el-button> <th v-if="isSuperAdmin" style="width: 13%">分组</th>
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 10%">最低价</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
<tbody>
<template v-if="displayRows.length">
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '' }}</td>
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
</template>
<td>
<template v-if="row.asin">
<CopyText :text="row.asin" class="asin-cell" />
</template>
<span v-else class="asin-empty">-</span>
</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
</td>
</template>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div> </div>
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border> <OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
<el-table-column label="分组" min-width="130"> </section>
<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-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent> <el-form label-position="top" @submit.prevent>
<el-form-item label="分组"> <el-form-item label="分组">
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange"> <el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -472,7 +511,7 @@ onMounted(() => {
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="国家"> <el-form-item label="国家">
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%"> <el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" /> <el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -513,7 +552,7 @@ onMounted(() => {
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px"> <el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="分组(必选)"> <el-form-item label="分组(必选)">
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%"> <el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%" :disabled="lockedGroupId != null">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -534,22 +573,301 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; } /* 像素复刻旧版 admin.html panel-skip-price-asin(蓝白末层)。 */
.heading-actions { display: flex; gap: 8px; flex: none; } .skip-view {
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; } font-family: inherit;
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; } color: #24384d;
.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; } .panel-box {
/* 最低价范围筛选:≥ 与 ≤ 并排同一行。 */ width: 100%;
.f-item.price-range { width: 300px; } min-width: 0;
.price-range-row { display: flex; align-items: center; gap: 8px; width: 100%; } padding: 20px 22px 24px;
.price-range-row .el-input { flex: 1; } border: 1px solid #d8e3ee;
.range-sep { color: var(--el-text-color-secondary); font-size: 12px; flex: none; } border-radius: 14px;
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; } background: linear-gradient(145deg, #ffffff, #f9fbfd);
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; } box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; } }
.dim { color: var(--el-text-color-placeholder); } h3 {
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; } margin: 0;
.country-cell { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; } font-size: 15px;
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; } font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.skip-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.skip-head-actions {
display: flex;
gap: 10px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 12px;
margin-bottom: 16px;
}
.skip-filter-row > .form-group {
flex: 1 1 130px;
min-width: 0;
}
.skip-filter-row .form-group.price-range {
flex: 1 1 220px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.price-range-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.price-range-row input {
flex: 1;
min-width: 0;
}
.price-range-row .range-sep {
color: #8293a5;
flex: none;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.skip-price-asin-table-scroll > table {
min-width: 900px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.table-scroll td {
vertical-align: top;
}
.asin-cell {
display: block;
max-width: 100%;
margin: -3px 0 -3px -7px;
padding: 3px 7px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: inherit;
font-size: 13.5px;
font-variant-numeric: tabular-nums;
line-height: 1.5;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.asin-cell:hover {
border-color: #c7d7e5;
background: #eef4fa;
color: #2f5d8b;
}
.asin-empty {
color: #8b9aaa;
}
.price-cell {
text-align: right;
font-variant-numeric: tabular-nums;
}
.asin-col-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
white-space: nowrap;
background: #ffffff;
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.drawer-tip {
color: #5b6f83;
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: #5b6f83;
font-size: 12px;
}
</style> </style>
@@ -1,6 +1,6 @@
/** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */ /** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */
export const ASIN_PAGE_DEFAULT_SIZE = 15 export const ASIN_PAGE_DEFAULT_SIZE = 10
export const ASIN_PAGE_MIN_PAGE = 1 export const ASIN_PAGE_MIN_PAGE = 1
export const ASIN_PAGE_MAX_SIZE = 200 export const ASIN_PAGE_MAX_SIZE = 200
@@ -24,6 +24,8 @@ export interface AsinListParams {
groupId?: number | null groupId?: number | null
/** 国家代码(如 DE、UK)。 */ /** 国家代码(如 DE、UK)。 */
country?: string country?: string
/** 顺序翻页游标(上一页返回的 nextLastId):传了就忽略 page 偏移,走 keyset。 */
lastId?: number
} }
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */ /** 序列化到 Java 分页接口的查询参数(snake_case)。 */
@@ -36,6 +38,8 @@ export interface AsinPageQuery {
end_date?: string end_date?: string
group_id?: number group_id?: number
country?: string country?: string
/** 顺序翻页游标(keyset):传了就忽略 page 偏移。 */
last_id?: number
} }
function finiteInt(value: unknown): number | null { function finiteInt(value: unknown): number | null {
@@ -77,5 +81,6 @@ export function toAsinPageQuery(params: AsinListParams): AsinPageQuery {
if (params.endDate) query.end_date = params.endDate if (params.endDate) query.end_date = params.endDate
if (params.groupId != null) query.group_id = params.groupId if (params.groupId != null) query.group_id = params.groupId
if (params.country) query.country = params.country if (params.country) query.country = params.country
if (typeof params.lastId === 'number' && params.lastId > 0) query.last_id = params.lastId
return query return query
} }
@@ -30,8 +30,10 @@ export function toDedupeListParams(
state: DedupeTotalFilterState, state: DedupeTotalFilterState,
page: number, page: number,
pageSize: number, pageSize: number,
lastId?: number | null,
): AsinListParams { ): AsinListParams {
const params: AsinListParams = { page, pageSize } const params: AsinListParams = { page, pageSize }
if (typeof lastId === 'number' && lastId > 0) params.lastId = lastId
const keyword = (state.keyword || '').trim() const keyword = (state.keyword || '').trim()
const username = (state.username || '').trim() const username = (state.username || '').trim()
const country = (state.country || '').trim() const country = (state.country || '').trim()
@@ -11,6 +11,7 @@ export interface DedupeTotalItem {
uploaderUserId: number | null uploaderUserId: number | null
username: string username: string
createdAt?: string createdAt?: string
updatedAt?: string
} }
export interface DedupeTotalPageResult { export interface DedupeTotalPageResult {
@@ -18,6 +19,8 @@ export interface DedupeTotalPageResult {
total: number total: number
page: number page: number
pageSize: number pageSize: number
/** 顺序翻页游标:本页最后一行 id;下一页回传它即可走 keyset。 */
nextLastId?: number
} }
export function emptyDedupeTotalPage(): DedupeTotalPageResult { export function emptyDedupeTotalPage(): DedupeTotalPageResult {
@@ -49,6 +52,8 @@ export function toDedupeTotalItem(raw: unknown): DedupeTotalItem | null {
} }
const createdAt = text(record.createdAt ?? record.created_at) const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item return item
} }
@@ -65,6 +70,8 @@ export function parseDedupeTotalPage(payload: unknown): DedupeTotalPageResult {
} }
if (typeof record.total === 'number') out.total = Math.floor(record.total) 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) if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
const rawNextLastId = record.nextLastId ?? record.next_last_id
if (typeof rawNextLastId === 'number' && rawNextLastId > 0) out.nextLastId = Math.floor(rawNextLastId)
const rawSize = record.pageSize ?? record.page_size const rawSize = record.pageSize ?? record.page_size
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize) if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
return out return out
@@ -97,7 +104,7 @@ export function parseDedupeGroupOptionList(payload: unknown): DedupeGroupOption[
const record = raw as Record<string, unknown> const record = raw as Record<string, unknown>
const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id) const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id)
if (!Number.isFinite(id) || id <= 0) continue if (!Number.isFinite(id) || id <= 0) continue
const nameRaw = record.group_name ?? record.name const nameRaw = record.groupName ?? record.group_name ?? record.name
const name = typeof nameRaw === 'string' ? nameRaw.trim() : '' const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''
const option: DedupeGroupOption = { id, name: name || `分组${id}` } const option: DedupeGroupOption = { id, name: name || `分组${id}` }
const leaderRaw = record.leader_user_id ?? record.leaderUserId const leaderRaw = record.leader_user_id ?? record.leaderUserId
@@ -25,7 +25,7 @@ export async function searchProductCategories(
page: number, page: number,
pageSize: number, pageSize: number,
): Promise<ProductCategorySearchPage> { ): Promise<ProductCategorySearchPage> {
const params: Record<string, string | number> = { page, pageSize } const params: Record<string, string | number> = { page, page_size: pageSize }
const trimmed = keyword.trim() const trimmed = keyword.trim()
if (trimmed) params.keyword = trimmed if (trimmed) params.keyword = trimmed
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params }) const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
@@ -1,7 +1,7 @@
/** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */ /** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */
import { unwrap } from '../../api/envelope.ts' import { unwrap } from '../../api/envelope.ts'
export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 20 export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 10
export interface ProductCategoryNode { export interface ProductCategoryNode {
id: number id: number
@@ -14,6 +14,8 @@ export interface ProductCategoryNode {
childCount: number childCount: number
level: number | null level: number | null
path: string path: string
createdAt?: string
updatedAt?: string
children: ProductCategoryNode[] children: ProductCategoryNode[]
} }
@@ -72,6 +74,10 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null
path: text(record.path), path: text(record.path),
children: [], children: [],
} }
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) node.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) node.updatedAt = updatedAt
if (Array.isArray(record.children)) { if (Array.isArray(record.children)) {
node.children = record.children node.children = record.children
.map((child) => toProductCategoryNode(child)) .map((child) => toProductCategoryNode(child))
@@ -8,7 +8,9 @@ import {
export const QUERY_ASIN_DELETE_IMPORT_ENDPOINT = '/api/admin/query-asins/delete-import' export const QUERY_ASIN_DELETE_IMPORT_ENDPOINT = '/api/admin/query-asins/delete-import'
/** 启动导入删除;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */ /** 启动导入删除;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。
* 注意:后端 deleteImportExcel 仍是隐式 camel 参数名(groupId),此处必须发 groupId
* 与 import 的 group_id 不同源,后端统一为 snake 时需两端同步改。 */
export async function startQueryAsinDeleteImport(file: File, groupId?: number): Promise<string> { export async function startQueryAsinDeleteImport(file: File, groupId?: number): Promise<string> {
const form = new FormData() const form = new FormData()
form.append('file', file, file.name) form.append('file', file, file.name)
@@ -0,0 +1,24 @@
/** 查询 ASIN 导出 URL 构建:按当前筛选下发 group_id/shop_name/asin/country(对齐 Java export @RequestParam snake_case)。 */
import { normalizeQueryAsinCountry, type QueryAsinFilterState } from './query-asin-filter.ts'
export const QUERY_ASIN_EXPORT_ENDPOINT = '/api/admin/query-asins/export'
/** 按导出可用筛选(分组/店铺名/ASIN/国家)构建查询串;空白/非法条件不下发,避免导出范围被误解。 */
export function buildQueryAsinExportQuery(state: QueryAsinFilterState): string {
const params = new URLSearchParams()
const groupId = state.groupId
if (typeof groupId === 'number' && groupId >= 1) params.set('group_id', String(Math.floor(groupId)))
const shopName = (state.shopName || '').trim()
if (shopName) params.set('shop_name', shopName)
const asin = (state.asin || '').trim()
if (asin) params.set('asin', asin)
const country = normalizeQueryAsinCountry(state.country)
if (country) params.set('country', country)
return params.toString()
}
/** 导出下载地址:浏览器直接访问该 URL 触发流式下载;无筛选时为全量导出。 */
export function toQueryAsinExportUrl(state: QueryAsinFilterState): string {
const query = buildQueryAsinExportQuery(state)
return query ? `${QUERY_ASIN_EXPORT_ENDPOINT}?${query}` : QUERY_ASIN_EXPORT_ENDPOINT
}
@@ -1,4 +1,5 @@
/** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;Java 参数名 camelCase。 */ /** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;
* Java 参数名 snake_case(与 asin-filter 共享契约一致):page_size/group_id/shop_name,裸 camel 后端收不到会导致筛选失效。 */
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts' import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts' import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts'
@@ -24,9 +25,9 @@ export interface QueryAsinListParams {
export interface QueryAsinQuery { export interface QueryAsinQuery {
page: number page: number
pageSize: number page_size: number
groupId?: number group_id?: number
shopName?: string shop_name?: string
asin?: string asin?: string
country?: string country?: string
} }
@@ -92,11 +93,11 @@ export function toQueryAsinParams(
}) })
} }
/** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(camel) 对齐,仅下发非空字段。 */ /** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(snake_case: page_size/group_id/shop_name/asin/country) 对齐,仅下发非空字段。 */
export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery { export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery {
const query: QueryAsinQuery = { page: params.page, pageSize: params.pageSize } const query: QueryAsinQuery = { page: params.page, page_size: params.pageSize }
if (typeof params.groupId === 'number' && params.groupId >= 1) query.groupId = params.groupId if (typeof params.groupId === 'number' && params.groupId >= 1) query.group_id = params.groupId
if (params.shopName) query.shopName = params.shopName if (params.shopName) query.shop_name = params.shopName
if (params.asin) query.asin = params.asin if (params.asin) query.asin = params.asin
if (params.country) query.country = params.country if (params.country) query.country = params.country
return query return query
@@ -8,12 +8,13 @@ import {
export const QUERY_ASIN_IMPORT_ENDPOINT = '/api/admin/query-asins/import' export const QUERY_ASIN_IMPORT_ENDPOINT = '/api/admin/query-asins/import'
/** 启动导入添加;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */ /** 启动导入添加;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。
* 参数名必须用 group_idJava importExcel @RequestParam(name="group_id"),发 groupId 会被丢弃致兜底分组失效。 */
export async function startQueryAsinImport(file: File, groupId?: number): Promise<string> { export async function startQueryAsinImport(file: File, groupId?: number): Promise<string> {
const form = new FormData() const form = new FormData()
form.append('file', file, file.name) form.append('file', file, file.name)
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) { if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
form.append('groupId', String(Math.floor(groupId))) form.append('group_id', String(Math.floor(groupId)))
} }
const { data } = await http.post<unknown>(QUERY_ASIN_IMPORT_ENDPOINT, form) const { data } = await http.post<unknown>(QUERY_ASIN_IMPORT_ENDPOINT, form)
return parseQueryAsinImportStart(data) return parseQueryAsinImportStart(data)
@@ -21,3 +21,8 @@ export async function updateSkipPriceCountry(
export async function deleteSkipPriceCountry(id: number, country: string): Promise<void> { export async function deleteSkipPriceCountry(id: number, country: string): Promise<void> {
await http.delete<unknown>(countryUrl(id, country)) await http.delete<unknown>(countryUrl(id, country))
} }
/** 删除整条最低价 ASIN 记录(该店铺全部站点):DELETE /api/admin/skip-price-asins/{id}。 */
export async function deleteSkipPriceAsin(id: number): Promise<void> {
await http.delete<unknown>(`${SKIP_PRICE_DETAIL_ENDPOINT}/${id}`)
}
@@ -11,9 +11,9 @@ export interface SkipPriceFilterState {
asin: string asin: string
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */ /** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
country: string country: string
/** 最低价区间输入原文;空串不限。 */ /** 最低价区间输入原文;空串不限(原生 number input v-model 会转 number,需兼容)。 */
minimumPriceFrom: string minimumPriceFrom: string | number
minimumPriceTo: string minimumPriceTo: string | number
} }
export interface SkipPriceListParams { export interface SkipPriceListParams {
@@ -42,9 +42,9 @@ export function createSkipPriceFilterState(): SkipPriceFilterState {
return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' } return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' }
} }
/** 非负可解析价格 → number;空/非法/负数返回 null。 */ /** 非负可解析价格 → number;空/非法/负数返回 null。入参兼容 number(原生 number input 的 v-model 会转 number)。 */
export function normalizePriceInput(value: string): number | null { export function normalizePriceInput(value: string | number): number | null {
const trimmed = (value || '').trim() const trimmed = String(value ?? '').trim()
if (!trimmed) return null if (!trimmed) return null
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
const num = Number(trimmed) const num = Number(trimmed)
@@ -12,6 +12,7 @@ export interface InvalidAsinItem {
groupName: string groupName: string
recordSource: string recordSource: string
createdAt?: string createdAt?: string
updatedAt?: string
} }
export interface InvalidAsinPageResult { export interface InvalidAsinPageResult {
@@ -74,6 +75,8 @@ export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | null {
} }
const createdAt = text(record.createdAt ?? record.created_at) const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item return item
} }
@@ -18,6 +18,8 @@ const errorMessage = ref('')
const logoUrl = joinAdminPath('assets', 'logo.jpg') const logoUrl = joinAdminPath('assets', 'logo.jpg')
const inputType = computed(() => (showPassword.value ? 'text' : 'password')) const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
/** 单设备登录:被新设备顶下线后跳回登录页(整页跳转携带 ?kicked=1) */
const kickedNotice = computed(() => route.query.kicked === '1')
function deviceId(): string { function deviceId(): string {
try { try {
@@ -120,6 +122,7 @@ onMounted(() => {
<p class="login-subtitle">使用管理员账号进入数富AI运营后台</p> <p class="login-subtitle">使用管理员账号进入数富AI运营后台</p>
</header> </header>
<form novalidate @submit.prevent="submit"> <form novalidate @submit.prevent="submit">
<p v-if="kickedNotice" class="kicked-msg" role="alert">该账号已在其他设备登录本设备已下线如非本人操作请及时修改密码</p>
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p> <p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
<div class="form-group"> <div class="form-group">
<label for="loginUsername">用户名</label> <label for="loginUsername">用户名</label>
@@ -154,7 +157,7 @@ onMounted(() => {
</div> </div>
</template> </template>
<style> <style scoped>
.auth-page { .auth-page {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
@@ -296,6 +299,20 @@ button, input { font: inherit; }
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800; border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
} }
/* 单设备登录:被新设备顶下线的提示 */
.kicked-msg {
display: flex; align-items: flex-start; gap: 9px;
margin: -4px 0 18px; padding: 11px 12px;
border: 1px solid #e0cf9f; border-radius: 11px; background: #fbf4e2; color: #8a6a1f;
font-size: 13px; line-height: 1.55;
}
.kicked-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 { .btn-login {
display: inline-flex; align-items: center; justify-content: center; gap: 9px; display: inline-flex; align-items: center; justify-content: center; gap: 9px;
width: 100%; min-height: 48px; padding: 12px 18px; width: 100%; min-height: 48px; padding: 12px 18px;
@@ -0,0 +1,719 @@
<script setup lang="ts">
/** 日志管理页:桌面客户端 / 麦象采集机上报的日志浏览(仅超管)。
* 支持来源/日期/关键字筛选,尾部内容查看(自动滚到底、可向前加载更早内容)、
* 完整下载、删除;「采集配置」可调全量/精选模式(全局默认 + 终端覆盖)。 */
import { computed, nextTick, onMounted, ref } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
import { formatDateTime } from '@/utils/datetime'
import {
deleteDeviceLogFile,
deleteDeviceLogOverride,
deviceLogDownloadUrl,
fetchDeviceLogConfig,
fetchDeviceLogContent,
fetchDeviceLogDevices,
fetchDeviceLogFiles,
updateDeviceLogGlobalMode,
updateDeviceLogOverride,
type DeviceLogContent,
type DeviceLogDevice,
type DeviceLogFileRow,
type DeviceLogOverride,
} from '@/api/device-logs'
const SOURCE_OPTIONS = [
{ value: '', label: '全部来源' },
{ value: 'client', label: '桌面客户端' },
{ value: 'maixiang', label: '麦象采集机' },
]
function sourceLabel(source: string): string {
if (source === 'client') return '桌面客户端'
if (source === 'maixiang') return '麦象采集机'
return source || '—'
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let value = bytes / 1024
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
}
const loading = ref(false)
const rows = ref<DeviceLogFileRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const sourceFilter = ref('')
const keyword = ref('')
const startDate = ref('')
const endDate = ref('')
const retentionDays = ref(7)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
async function load() {
loading.value = true
try {
const result = await fetchDeviceLogFiles({
source: sourceFilter.value || undefined,
keyword: keyword.value.trim() || undefined,
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
if (result?.retentionDays) retentionDays.value = result.retentionDays
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '日志列表加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
sourceFilter.value = ''
keyword.value = ''
startDate.value = ''
endDate.value = ''
page.value = 1
load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
// ---------------------------------------------------------------- 内容查看
const TAIL_STEP = 256 * 1024
const TAIL_MAX = 8 * 1024 * 1024
const viewerVisible = ref(false)
const viewerLoading = ref(false)
const viewerFile = ref<DeviceLogFileRow | null>(null)
const viewerContent = ref<DeviceLogContent | null>(null)
const viewerMaxBytes = ref(TAIL_STEP)
const viewerPre = ref<HTMLElement | null>(null)
async function openViewer(row: DeviceLogFileRow) {
viewerFile.value = row
viewerMaxBytes.value = TAIL_STEP
viewerContent.value = null
viewerVisible.value = true
await loadContent(true)
}
async function loadContent(scrollToBottom: boolean) {
if (!viewerFile.value) return
viewerLoading.value = true
try {
viewerContent.value = await fetchDeviceLogContent(viewerFile.value.id, viewerMaxBytes.value)
if (scrollToBottom) {
await nextTick()
if (viewerPre.value) viewerPre.value.scrollTop = viewerPre.value.scrollHeight
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '日志内容加载失败')
} finally {
viewerLoading.value = false
}
}
function loadMore() {
viewerMaxBytes.value = Math.min(viewerMaxBytes.value * 2, TAIL_MAX)
loadContent(false)
}
async function remove(row: DeviceLogFileRow) {
if (!window.confirm(`确认删除「${row.fileName}」(${row.deviceName || row.deviceId})的云端日志?删除后不可恢复。`)) return
try {
await deleteDeviceLogFile(row.id)
ElMessage.success('已删除')
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
// ---------------------------------------------------------------- 采集配置
const configVisible = ref(false)
const configLoading = ref(false)
const globalMode = ref('full')
const overrides = ref<DeviceLogOverride[]>([])
const devices = ref<DeviceLogDevice[]>([])
const newOverrideKey = ref('')
const newOverrideMode = ref('selected')
const MODE_OPTIONS = [
{ value: 'full', label: '全量采集' },
{ value: 'selected', label: '精选采集' },
]
function modeLabel(mode: string): string {
return mode === 'selected' ? '精选' : '全量'
}
async function openConfig() {
configVisible.value = true
await loadConfig()
}
async function loadConfig() {
configLoading.value = true
try {
const data = await fetchDeviceLogConfig()
globalMode.value = data.globalMode || 'full'
overrides.value = data.overrides || []
devices.value = (await fetchDeviceLogDevices()) || []
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '采集配置加载失败')
} finally {
configLoading.value = false
}
}
async function saveGlobalMode(mode: string) {
if (mode === globalMode.value) return
try {
await updateDeviceLogGlobalMode(mode)
globalMode.value = mode
ElMessage.success(`全局采集模式已切换为「${modeLabel(mode)}`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
}
}
async function addOverride() {
if (!newOverrideKey.value) {
ElMessage.warning('请先选择终端')
return
}
const [source, deviceId] = newOverrideKey.value.split('|')
const device = devices.value.find((item) => item.source === source && item.deviceId === deviceId)
try {
await updateDeviceLogOverride(source, deviceId, device?.deviceName || null, newOverrideMode.value)
ElMessage.success('终端覆盖已保存')
newOverrideKey.value = ''
await loadConfig()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
}
}
async function removeOverride(row: DeviceLogOverride) {
const who = row.deviceName || row.deviceId
if (!window.confirm(`确认删除终端「${who}」的采集覆盖(回落到全局默认)?`)) return
try {
await deleteDeviceLogOverride(row.id)
ElMessage.success('已删除')
await loadConfig()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
onMounted(load)
</script>
<template>
<div class="device-logs-view">
<section class="panel-box">
<div class="logs-head">
<h3>日志文件列表</h3>
<div class="logs-head-tools">
<span class="retention-tip">云端仅保留 {{ retentionDays }} </span>
<button class="btn btn-ghost" type="button" @click="openConfig">采集配置</button>
<button class="btn" type="button" @click="load">刷新</button>
</div>
</div>
<div class="form-row logs-filter-row">
<div class="form-group" style="min-width: 150px">
<label>来源</label>
<select v-model="sourceFilter">
<option v-for="option in SOURCE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</div>
<div class="form-group" style="min-width: 220px">
<label>设备 / 文件名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索设备名、设备ID、文件名" @keyup.enter="search" />
</div>
<div class="form-group" style="min-width: 150px">
<label>日志日期</label>
<input v-model="startDate" type="date" />
</div>
<div class="form-group" style="min-width: 150px">
<label>日志日期</label>
<input v-model="endDate" type="date" />
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="logs-table-scroll">
<table>
<thead>
<tr>
<th style="width: 110px">来源</th>
<th style="width: 230px">设备</th>
<th style="width: 130px">用户</th>
<th style="width: 230px">文件名</th>
<th style="width: 110px">日志日期</th>
<th style="width: 100px">已收大小</th>
<th style="width: 80px">片段数</th>
<th style="width: 170px">最后更新</th>
<th style="width: 190px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="row in rows" :key="row.id">
<td>
<span class="source-pill" :class="row.source === 'maixiang' ? 'is-maixiang' : 'is-client'">
{{ sourceLabel(row.source) }}
</span>
</td>
<td>
<span class="device-name" :title="row.deviceId">{{ row.deviceName || '—' }}</span>
<span class="device-id" :title="row.deviceId">{{ row.deviceId }}</span>
</td>
<td>
<span v-if="row.username" class="user-name">{{ row.username }}</span>
<span v-else-if="row.uid" class="user-name">UID {{ row.uid }}</span>
<span v-else class="dim"></span>
</td>
<td>
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
</td>
<td>{{ row.logDate }}</td>
<td>{{ formatBytes(row.uploadedBytes) }}</td>
<td>{{ row.partCount ?? 0 }}</td>
<td>{{ row.lastUploadAt ? formatDateTime(row.lastUploadAt) : '—' }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openViewer(row)">查看</button>
<a class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(row.id)" download>下载</a>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="9" class="empty-tip">
{{ keyword || sourceFilter || startDate || endDate ? '暂无匹配日志' : '暂无日志上报(客户端/采集机上报后自动出现在这里)' }}
</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="viewerVisible" :title="viewerFile ? `${viewerFile.fileName}${viewerFile.deviceName || viewerFile.deviceId}` : '日志内容'" width="900px" top="5vh">
<div class="viewer-toolbar">
<span class="viewer-meta">
云端已收 {{ formatBytes(viewerContent?.totalBytes ?? viewerFile?.uploadedBytes) }}
<template v-if="viewerContent"> · 当前展示 {{ formatBytes(viewerContent.shownBytes) }}</template>
<template v-if="viewerContent?.truncated"> · 更早内容未加载</template>
</span>
<span class="viewer-actions">
<button class="btn btn-sm btn-ghost" type="button" :disabled="viewerLoading" @click="loadContent(true)">刷新</button>
<button
class="btn btn-sm btn-ghost"
type="button"
:disabled="viewerLoading || !viewerContent?.truncated || viewerMaxBytes >= TAIL_MAX"
@click="loadMore"
>加载更早</button>
<a v-if="viewerFile" class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(viewerFile.id)" download>下载完整日志</a>
</span>
</div>
<pre ref="viewerPre" class="log-pre">{{ viewerLoading && !viewerContent ? '加载中...' : (viewerContent?.content || '(暂无内容)') }}</pre>
</el-dialog>
<el-dialog v-model="configVisible" title="采集配置" width="720px">
<div class="config-block">
<h4>全局默认模式</h4>
<p class="config-desc">对未单独配置的终端生效全量=上传日志目录内全部文件精选=排除低价值大日志客户端排除 pywebview麦象排除 kk-browser / 控制台 / 测试日志终端在下一次上报周期 1 分钟内跟随新配置</p>
<div class="mode-switch">
<button
v-for="option in MODE_OPTIONS"
:key="option.value"
class="mode-btn"
:class="{ 'is-active': globalMode === option.value }"
type="button"
@click="saveGlobalMode(option.value)"
>{{ option.label }}</button>
</div>
</div>
<div class="config-block">
<h4>终端覆盖</h4>
<div class="override-add">
<select v-model="newOverrideKey" class="override-select">
<option value="">选择终端最近上报的设备</option>
<option v-for="device in devices" :key="`${device.source}|${device.deviceId}`" :value="`${device.source}|${device.deviceId}`">
{{ sourceLabel(device.source) }} · {{ device.deviceName || device.deviceId }}{{ device.deviceId }}
</option>
</select>
<select v-model="newOverrideMode">
<option v-for="option in MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<button class="btn btn-sm" type="button" @click="addOverride">添加/更新覆盖</button>
</div>
<table class="override-table">
<thead>
<tr>
<th style="width: 100px">来源</th>
<th>设备</th>
<th style="width: 80px">模式</th>
<th style="width: 160px">更新时间</th>
<th style="width: 90px">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in overrides" :key="row.id">
<td>{{ sourceLabel(row.source) }}</td>
<td>
<span class="device-name">{{ row.deviceName || row.deviceId }}</span>
<span class="device-id">{{ row.deviceId }}</span>
</td>
<td>{{ modeLabel(row.mode) }}</td>
<td>{{ row.updatedAt ? formatDateTime(row.updatedAt) : '—' }}</td>
<td class="ops-cell">
<button class="btn btn-sm btn-danger" type="button" @click="removeOverride(row)">删除</button>
</td>
</tr>
<tr v-if="!overrides.length">
<td colspan="5" class="empty-tip">{{ configLoading ? '加载中...' : '暂无终端覆盖(全部跟随全局默认)' }}</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<el-button @click="configVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 沿用「记录与版本」系列的旧后台面板视觉语言。 */
.device-logs-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.logs-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.logs-head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.retention-tip {
color: #8598ab;
font-size: 12.5px;
}
.logs-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
text-decoration: none;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 32px;
padding: 4px 12px;
font-size: 12.5px;
border-radius: 7px;
}
.logs-table-scroll {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
padding: 10px 12px;
text-align: left;
color: #5b6f83;
font-weight: 650;
font-size: 12.5px;
border-bottom: 1px solid #dbe6f0;
white-space: nowrap;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #eaf1f7;
vertical-align: top;
}
.source-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.source-pill.is-client {
background: #e8f1fb;
color: #37618f;
}
.source-pill.is-maixiang {
background: #eef7ec;
color: #3f7a42;
}
.device-name {
display: block;
font-weight: 600;
word-break: break-all;
}
.device-id {
display: block;
color: #8598ab;
font-size: 11.5px;
word-break: break-all;
}
.user-name {
font-weight: 600;
}
.file-name {
display: block;
word-break: break-all;
}
.dim {
color: #9db0c2;
}
.ops-cell {
display: flex;
gap: 8px;
flex-wrap: wrap;
border-bottom: none;
}
.empty-tip {
padding: 26px 0;
text-align: center;
color: #8598ab;
}
.dl-link {
text-decoration: none;
}
.viewer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.viewer-meta {
color: #5b6f83;
font-size: 12.5px;
}
.viewer-actions {
display: flex;
gap: 8px;
}
.log-pre {
max-height: 62vh;
margin: 0;
padding: 12px 14px;
overflow: auto;
border: 1px solid #d8e3ee;
border-radius: 10px;
background: #0f1c29;
color: #d7e4f1;
font-family: Consolas, 'Courier New', monospace;
font-size: 12.5px;
line-height: 1.55;
white-space: pre;
}
.config-block {
margin-bottom: 18px;
}
.config-block h4 {
margin: 0 0 6px;
font-size: 13.5px;
color: #24384d;
}
.config-desc {
margin: 0 0 10px;
color: #66798d;
font-size: 12.5px;
line-height: 1.6;
}
.mode-switch {
display: flex;
gap: 10px;
}
.mode-btn {
min-height: 38px;
padding: 8px 20px;
border: 1px solid #c7d7e5;
border-radius: 9px;
background: #ffffff;
color: #4f78a5;
font-size: 13.5px;
font-family: inherit;
cursor: pointer;
}
.mode-btn.is-active {
border-color: #4f78a5;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-weight: 600;
}
.override-add {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.override-add select {
min-height: 38px;
padding: 6px 10px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
color-scheme: light;
}
.override-select {
flex: 1;
min-width: 0;
}
.override-table td {
vertical-align: middle;
}
</style>
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
/** 数字人版本管理页module 13 task 261 对齐 admin panel-digital-human-version;本轮补齐分页/三态/上传进度/下载语义)。 */ /** 数字人版本管理页 · 像素复刻旧版 admin.html panel-digital-human-version(自绘:面板头+上传、三态状态色、最新★、操作矩阵、数字页码分页)。
* script 逻辑沿用现有 Vue 实现(确认文案/上传进度/下载取链接);确认走原生 confirm 对齐旧版。 */
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
deleteDigitalHumanVersion, deleteDigitalHumanVersion,
fetchDigitalHumanDownloadUrl, fetchDigitalHumanDownloadUrl,
@@ -20,18 +21,28 @@ import {
import { validateDigitalHumanUpload } from './digitalhuman-upload-model.ts' import { validateDigitalHumanUpload } from './digitalhuman-upload-model.ts'
import { canReleaseDigitalHumanVersion, releaseDigitalHumanConfirmText } from './digitalhuman-release.ts' import { canReleaseDigitalHumanVersion, releaseDigitalHumanConfirmText } from './digitalhuman-release.ts'
import { canSetLatestDigitalHumanVersion, setLatestDigitalHumanConfirmText } from './digitalhuman-latest.ts' import { canSetLatestDigitalHumanVersion, setLatestDigitalHumanConfirmText } from './digitalhuman-latest.ts'
import OldPagination from '@/components/OldPagination.vue'
import { digitalHumanStatusDisplay } from './digitalhuman-status-display.ts' import { digitalHumanStatusDisplay } from './digitalhuman-status-display.ts'
const loading = ref(false) const loading = ref(false)
const items = ref<DigitalHumanVersion[]>([]) const items = ref<DigitalHumanVersion[]>([])
/** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */ /** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */
const pageSize = 20 const pageSize = ref(10)
const page = ref(1) const page = ref(1)
const pagedItems = computed(() => { /** 版本号搜索(客户端过滤)。 */
const start = (page.value - 1) * pageSize const versionKeyword = ref('')
return items.value.slice(start, start + pageSize) const filteredItems = computed(() => {
const kw = (versionKeyword.value || '').trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((item) => `${item.version}`.toLowerCase().includes(kw))
}) })
const pagedItems = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredItems.value.slice(start, start + pageSize.value)
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
const jumpPage = ref('')
const uploadVisible = ref(false) const uploadVisible = ref(false)
const uploading = ref(false) const uploading = ref(false)
@@ -45,9 +56,7 @@ async function load() {
loading.value = true loading.value = true
try { try {
items.value = (await fetchDigitalHumanVersions()).items items.value = (await fetchDigitalHumanVersions()).items
// 数据收缩后页码越界回钳。 // 页码越界回钳见下方 watch(items)
const last = Math.max(Math.ceil(items.value.length / pageSize), 1)
if (page.value > last) page.value = last
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败') ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
} finally { } finally {
@@ -55,6 +64,31 @@ async function load() {
} }
} }
function changePage(next: number): void {
if (next < 1 || next > totalPages.value) return
page.value = next
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
}
// 版本号搜索导致数据收缩时回钳页码,避免停在空页。
watch(items, () => {
const last = Math.max(Math.ceil(filteredItems.value.length / pageSize.value), 1)
if (page.value > last) page.value = last
})
function goJump(): void {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
function onFileChange(file: File) { function onFileChange(file: File) {
form.value.file = file form.value.file = file
} }
@@ -91,14 +125,14 @@ async function submitUpload() {
async function release(row: DigitalHumanVersion) { async function release(row: DigitalHumanVersion) {
const text = releaseDigitalHumanConfirmText(row.version) const text = releaseDigitalHumanConfirmText(row.version)
if (!text) return if (!text) return
// 对齐旧版:原生 confirm。
if (!window.confirm(text)) return
confirming.value = row.version confirming.value = row.version
try { try {
await ElMessageBox.confirm(text, '发布版本', { type: 'warning', confirmButtonText: '发布', cancelButtonText: '取消' })
await releaseDigitalHumanVersion(row.version) await releaseDigitalHumanVersion(row.version)
ElMessage.success('已发布') ElMessage.success('已发布')
load() load()
} catch (error) { } catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '发布失败') ElMessage.error(error instanceof Error ? error.message : '发布失败')
} finally { } finally {
confirming.value = null confirming.value = null
@@ -108,14 +142,13 @@ async function release(row: DigitalHumanVersion) {
async function setLatest(row: DigitalHumanVersion) { async function setLatest(row: DigitalHumanVersion) {
const text = setLatestDigitalHumanConfirmText(row.version) const text = setLatestDigitalHumanConfirmText(row.version)
if (!text) return if (!text) return
if (!window.confirm(text)) return
confirming.value = row.version confirming.value = row.version
try { try {
await ElMessageBox.confirm(text, '设为最新', { type: 'warning', confirmButtonText: '设为最新', cancelButtonText: '取消' })
await setLatestDigitalHumanVersion(row.version) await setLatestDigitalHumanVersion(row.version)
ElMessage.success('已设为最新') ElMessage.success('已设为最新')
load() load()
} catch (error) { } catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '操作失败') ElMessage.error(error instanceof Error ? error.message : '操作失败')
} finally { } finally {
confirming.value = null confirming.value = null
@@ -146,18 +179,13 @@ async function download(row: DigitalHumanVersion) {
async function remove(row: DigitalHumanVersion) { async function remove(row: DigitalHumanVersion) {
const text = digitalHumanDeleteConfirmText(row.version) const text = digitalHumanDeleteConfirmText(row.version)
if (!text) return if (!text) return
if (!window.confirm(text)) return
confirming.value = row.version confirming.value = row.version
try { try {
await ElMessageBox.confirm(text, '删除版本', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await deleteDigitalHumanVersion(row.version) await deleteDigitalHumanVersion(row.version)
ElMessage.success('删除成功') ElMessage.success('删除成功')
load() load()
} catch (error) { } catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '删除失败') ElMessage.error(error instanceof Error ? error.message : '删除失败')
} finally { } finally {
confirming.value = null confirming.value = null
@@ -176,70 +204,74 @@ onMounted(load)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="dh-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="dh-head">
<h2>数字人版本管理</h2> <h3>版本列表</h3>
<p>管理数字人客户端的草稿/已发布/已废弃版本</p> <div class="dh-head-tools">
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="dh-keyword" />
<button class="btn" type="button" @click="uploadVisible = true">上传新版本</button>
</div>
</div> </div>
<div class="actions">
<el-button type="primary" @click="uploadVisible = true">上传草稿状态</el-button>
</div>
</div>
<el-card shadow="never"> <div class="table-scroll digital-human-version-table-scroll">
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无数字人版本"> <table>
<el-table-column prop="version" label="版本号" min-width="150" /> <thead>
<el-table-column label="状态" min-width="100"> <tr>
<template #default="{ row }"> <th style="width: 80px">版本号</th>
<el-tag :type="statusOf(row as DigitalHumanVersion).tag" size="small"> <th style="width: 60px">状态</th>
{{ statusOf(row as DigitalHumanVersion).text }} <th style="width: 60px">最新</th>
</el-tag> <th style="width: 80px">文件大小</th>
</template> <th style="width: 120px">MD5</th>
</el-table-column> <th>更新日志</th>
<el-table-column label="最新" min-width="60" align="center"> <th style="width: 140px">发布时间</th>
<template #default="{ row }"> <th style="width: 240px">操作</th>
<span v-if="(row as DigitalHumanVersion).isLatest" class="star" title="最新版本"></span> </tr>
<span v-else></span> </thead>
</template> <tbody>
</el-table-column> <template v-if="pagedItems.length">
<el-table-column label="文件大小" min-width="100"> <tr v-for="row in pagedItems" :key="row.version + row.id">
<template #default="{ row }">{{ digitalHumanVersionFileSizeText((row as DigitalHumanVersion).fileSize) }}</template> <td><span class="mono-cell">{{ row.version }}</span></td>
</el-table-column> <td>
<el-table-column label="MD5" min-width="130"> <span class="st-pill" :class="`is-${statusOf(row).tag}`">{{ statusOf(row).text }}</span>
<template #default="{ row }"> </td>
<span :title="(row as DigitalHumanVersion).md5 || ''">{{ (row as DigitalHumanVersion).md5 ? `${((row as DigitalHumanVersion).md5 || '').substring(0, 12)}...` : '—' }}</span> <td><span v-if="row.isLatest" class="star" title="最新版本">★</span><span v-else class="dim"></span></td>
</template> <td>{{ digitalHumanVersionFileSizeText(row.fileSize) }}</td>
</el-table-column> <td><span :title="row.md5 || ''" class="mono-cell">{{ row.md5 ? `${row.md5.substring(0, 12)}...` : '—' }}</span></td>
<el-table-column label="更新说明" min-width="180"> <td>
<template #default="{ row }"> <span :title="row.changelog || ''" class="changelog-cell">{{ digitalHumanChangelogShort(row.changelog) }}</span>
<span :title="(row as DigitalHumanVersion).changelog || ''">{{ digitalHumanChangelogShort((row as DigitalHumanVersion).changelog) }}</span> </td>
</template> <td>{{ formatDateTime(row.releasedAt) }}</td>
</el-table-column> <td class="ops-cell">
<el-table-column label="发布时间" min-width="170"> <button v-if="canReleaseDigitalHumanVersion(row.status)" class="btn btn-sm" type="button" :disabled="confirming === row.version" @click="release(row)">发布</button>
<template #default="{ row }">{{ formatDateTime((row as DigitalHumanVersion).releasedAt) }}</template> <button
</el-table-column> v-if="!canReleaseDigitalHumanVersion(row.status) && canSetLatestDigitalHumanVersion(row.status) && !row.isLatest"
<el-table-column label="操作" min-width="240" fixed="right"> class="btn btn-sm"
<template #default="{ row }"> type="button"
<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> :disabled="confirming === row.version"
<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> @click="setLatest(row)"
<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> </button>
</el-table-column> <button v-if="canSetLatestDigitalHumanVersion(row.status)" class="btn btn-sm btn-secondary" type="button" :disabled="downloadingVersion === row.version" @click="download(row)">下载</button>
</el-table> <button v-if="canDelete(row)" class="btn btn-sm btn-danger" type="button" :disabled="confirming === row.version" @click="remove(row)">删除</button>
<div class="table-footer"> </td>
<span> {{ items.length.toLocaleString() }} </span> </tr>
<el-pagination </template>
background <tr v-else-if="loading">
layout="prev, pager, next, jumper" <td colspan="8" class="empty-tip">加载中...</td>
:total="items.length" </tr>
:page-size="pageSize" <tr v-else>
:current-page="page" <td colspan="8" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
@current-change="(p: number) => { page = p }" </tr>
/> </tbody>
</table>
</div> </div>
</el-card>
<div v-if="filteredItems.length > pageSize" class="pagination">
<OldPagination :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</div>
</section>
<el-dialog v-model="uploadVisible" title="上传数字人版本" width="560px"> <el-dialog v-model="uploadVisible" title="上传数字人版本" width="560px">
<p class="upload-desc">上传数字人程序 ZIP 系统会自动计算 MD5 并存储到 OSS上传后状态为草稿需要手动发布</p> <p class="upload-desc">上传数字人程序 ZIP 系统会自动计算 MD5 并存储到 OSS上传后状态为草稿需要手动发布</p>
@@ -270,11 +302,267 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; } /* 像素复刻旧版 admin.html panel-digital-human-version(蓝白末层)。 */
.actions { display: flex; align-items: center; gap: 10px; } .dh-view {
.dim { color: var(--el-text-color-secondary); font-size: 12px; } font-family: inherit;
.star { color: #e6a23c; font-weight: 700; } color: #24384d;
.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; } .panel-box {
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; } width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.dh-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dh-head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.dh-keyword {
width: 200px;
min-height: 42px;
padding: 6px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.dh-keyword:hover {
border-color: #9fb7cd;
}
.dh-keyword:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.digital-human-version-table-scroll > table {
min-width: 1120px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.mono-cell {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 12.5px;
}
.st-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
line-height: 1.7;
border: 1px solid transparent;
}
.st-pill.is-info {
color: #6b7280;
background: #f1f3f5;
border-color: #dfe3e8;
}
.st-pill.is-success {
color: #398a3f;
background: #eaf7ec;
border-color: #bfe3c5;
}
.st-pill.is-danger {
color: #b8454d;
background: #fbedee;
border-color: #ecc9cc;
}
.star {
color: #d64545;
font-weight: 700;
}
.changelog-cell {
display: block;
color: #5b6f83;
overflow: hidden;
text-overflow: ellipsis;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-nums {
display: inline-flex;
gap: 6px;
}
.num-page {
min-width: 30px;
padding: 4px 8px;
}
.num-page.is-active {
background: #2f5d8b;
border-color: #2f5d8b;
color: #ffffff;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.dim {
color: #8293a5;
font-size: 12px;
}
.upload-desc {
margin: 0 0 12px;
color: #5b6f83;
font-size: 12.5px;
line-height: 1.6;
}
</style> </style>
@@ -1,29 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
/** 历史生成记录页module 13 task 259 对齐 admin panel-history):指定用户下拉 + 起止时间筛选、类型中文、≤3 缩略图、大图预览、空态。 */ /** 生成记录页 · 像素复刻旧版 admin.html panel-history(自绘:form-box 筛选 + panel-box 表格 + 旧式分页 + thumb 缩略图)。
* script 逻辑沿用现有 Vue 实现(查询/翻页/跳页/点击缩略图放大预览)。 */
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { fetchHistoryList, fetchHistoryUserOptions, clearHistory } from './history-api.ts' import { fetchHistoryList, fetchHistoryUserOptions } from './history-api.ts'
import type { HistoryUserOption } from './history-user-option.ts' import type { HistoryUserOption } from './history-user-option.ts'
import type { HistoryRecordItem } from './history-dto.ts' import type { HistoryRecordItem } from './history-dto.ts'
import { historyEmptyText, historyErrorText } from './history-feedback.ts' import { historyEmptyText, historyErrorText } from './history-feedback.ts'
import { historyPanelTypeLabel } from './history-type.ts' import { historyPanelTypeLabel } from './history-type.ts'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false) const loading = ref(false)
const userLoading = ref(false) const userLoading = ref(false)
const rows = ref<HistoryRecordItem[]>([]) const rows = ref<HistoryRecordItem[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = 15 const pageSize = ref(10)
const userOptions = ref<HistoryUserOption[]>([]) const userOptions = ref<HistoryUserOption[]>([])
const errorText = ref('') const errorText = ref('')
const filter = reactive({ userId: '', timeStart: '', timeEnd: '' }) const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
const previewVisible = ref(false) const previewVisible = ref(false)
const previewItem = ref<HistoryRecordItem | null>(null) const previewItem = ref<HistoryRecordItem | null>(null)
const jumpPage = ref('')
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd)) const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value })) const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value }))
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
/** datetime-local 原生值(YYYY-MM-DDTHH:mm) 转后端兼容格式。 */
function toApiTime(value: string): string | undefined {
const t = (value || '').trim()
if (!t) return undefined
return `${t.replace('T', ' ')}:00`
}
/** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */ /** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */
function previewUrls(item: HistoryRecordItem): string[] { function previewUrls(item: HistoryRecordItem): string[] {
@@ -46,10 +57,10 @@ async function load() {
try { try {
const result = await fetchHistoryList({ const result = await fetchHistoryList({
page: page.value, page: page.value,
pageSize, pageSize: pageSize.value,
userId: filter.userId ? Number(filter.userId) : undefined, userId: filter.userId ? Number(filter.userId) : undefined,
timeStart: filter.timeStart || undefined, timeStart: toApiTime(filter.timeStart),
timeEnd: filter.timeEnd || undefined, timeEnd: toApiTime(filter.timeEnd),
}) })
rows.value = result.items rows.value = result.items
total.value = result.total total.value = result.total
@@ -77,54 +88,37 @@ function apply() {
load() load()
} }
function reset() {
filter.userId = ''
filter.timeStart = ''
filter.timeEnd = ''
page.value = 1
load()
}
function retry() { function retry() {
load() load()
} }
function changePage(next: number) { function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next page.value = next
load() load()
} }
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(n)
}
function openPreview(item: HistoryRecordItem) { function openPreview(item: HistoryRecordItem) {
previewItem.value = item previewItem.value = item
previewVisible.value = true 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(() => { onMounted(() => {
load() load()
loadUserOptions() loadUserOptions()
@@ -132,143 +126,443 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="page-stack"> <div class="history-view">
<div class="page-heading"> <section class="form-box">
<div> <h3>筛选条件</h3>
<h2>生成记录</h2> <div class="form-row">
<p>查看各用户在后台生成的历史记录与结果图</p> <div class="form-group" style="min-width: 150px">
<label>指定用户</label>
<select v-model="filter.userId" :disabled="userLoading">
<option value="">全部用户</option>
<option v-for="u in userOptions" :key="u.id" :value="String(u.id)">{{ u.username }}</option>
</select>
</div>
<div class="form-group" style="min-width: 176px">
<label>开始时间</label>
<input v-model="filter.timeStart" type="datetime-local" />
</div>
<div class="form-group" style="min-width: 176px">
<label>结束时间</label>
<input v-model="filter.timeEnd" type="datetime-local" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
</div> </div>
<div class="heading-actions"> </section>
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
<section class="panel-box">
<h3>生成记录</h3>
<div v-if="errorText" class="msg err">
<span>加载失败: {{ errorText }}</span>
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
</div>
<div class="table-scroll history-table-scroll">
<table>
<thead>
<tr>
<th style="width: 70px">ID</th>
<th style="width: 130px">用户</th>
<th style="width: 160px">类型</th>
<th style="width: 170px">创建时间</th>
<th>结果预览</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="h in rows" :key="h.id">
<td>{{ h.id }}</td>
<td>{{ h.username || '-' }}</td>
<td>{{ historyPanelTypeLabel(h.panelType) }}</td>
<td>{{ formatDateTime(h.createdAt) }}</td>
<td>
<div class="thumb-wrap">
<img
v-for="url in previewUrls(h).slice(0, 3)"
:key="url"
class="thumb"
:src="url"
alt=""
title="点击放大预览"
@click="openPreview(h)"
/>
<span v-if="!previewUrls(h).length" class="empty-mini">暂无图片</span>
</div>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="5" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="5" class="empty-tip">{{ emptyText }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<div v-if="previewVisible" class="modal-mask" @click.self="previewVisible = false">
<div class="modal">
<div class="modal-head">
<h3 v-if="previewItem">记录 #{{ previewItem.id }} 结果图</h3>
<button class="modal-close" type="button" aria-label="关闭" @click="previewVisible = false">×</button>
</div>
<div v-if="previewItem" class="preview-meta">
<span>{{ previewItem.username || '-' }}</span>
<span>·</span>
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
<span>·</span>
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
</div>
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
<a
v-for="url in previewUrls(previewItem)"
:key="url"
:href="url"
target="_blank"
rel="noopener"
title="新标签打开原图"
>
<img :src="url" alt="" />
</a>
</div>
<div v-else class="modal-empty">无结果图片</div>
<div class="modal-foot">
<button class="btn btn-secondary" type="button" @click="previewVisible = false">关闭</button>
</div>
</div> </div>
</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> </div>
</template> </template>
<style scoped> <style scoped>
.filter-card { margin-bottom: 14px; } /* 像素复刻旧版 admin.html 蓝白末层样式:form-box/panel-box 双卡 + table-scroll 表格 + 旧式分页 + thumb 缩略图。 */
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; } .history-view {
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; } font-family: inherit;
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; } color: var(--c-text, #24384d);
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; } display: flex;
.thumbs { display: flex; align-items: center; gap: 6px; } flex-direction: column;
.thumb { width: 56px; height: 56px; border-radius: 4px; border: 1px solid var(--el-border-color-lighter); flex: none; } gap: 18px;
.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; } .form-box,
.table-footer b { color: var(--el-text-color-primary); } .panel-box {
.dim { color: var(--el-text-color-secondary); font-size: 12px; } width: 100%;
.preview-meta { display: flex; align-items: center; gap: 12px; color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 12px; } min-width: 0;
.preview-grid { display: flex; flex-direction: column; gap: 12px; } padding: 20px 22px 24px;
.preview-img { width: 100%; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; } border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.msg {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 10px 12px;
border: 1px solid;
border-radius: 9px;
font-size: 13px;
}
.msg.err {
color: #91474f;
background: #f8ebeb;
border-color: #e4c2c5;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.history-table-scroll > table {
min-width: 900px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
white-space: nowrap;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.thumb-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.thumb {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #d8e3ee;
cursor: zoom-in;
}
.thumb:hover {
border-color: #5f85ad;
}
.empty-mini {
color: #8293a5;
font-size: 12.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.modal-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: rgba(45, 66, 86, 0.36);
backdrop-filter: blur(6px);
}
.modal {
display: flex;
flex-direction: column;
width: min(880px, 100%);
max-height: 84vh;
padding: 20px 22px;
border: 1px solid #c7d7e5;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-head h3 {
margin: 0;
}
.modal-close {
width: 30px;
height: 30px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: #5b6f83;
font-size: 20px;
line-height: 1;
cursor: pointer;
}
.modal-close:hover {
border-color: #cbd9e6;
background: #eef4fa;
color: #24384d;
}
.preview-meta {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 10px;
color: #5b6f83;
font-size: 12.5px;
}
.preview-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
border: 1px solid #dbe5ee;
border-radius: 10px;
padding: 12px;
background: #f8fbfd;
}
.preview-grid a {
display: block;
}
.preview-grid img {
display: block;
width: 100%;
border-radius: 6px;
border: 1px solid #d8e3ee;
background: #fff;
}
.modal-empty {
padding: 48px 20px;
text-align: center;
color: #8293a5;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 14px;
}
</style> </style>
@@ -1,26 +1,81 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 软件版本管理页:客户端版本列表 + 上传新版本(成功留驻弹窗含链接,对齐 admin panel-version)。 */ /** 软件版本管理页 · 像素复刻旧版 admin.html panel-version(自绘:面板头+上传新版本、版本列表、全量无分页同旧版)。
import { computed, onMounted, ref } from 'vue' * script 逻辑沿用现有 Vue 实现(上传弹窗成功留驻含链接)。 */
import { computed, onMounted, ref, watch } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts' import { fetchSoftwareVersions, uploadSoftwareVersion, deleteSoftwareVersions } from './version-api.ts'
import type { SoftwareVersionItem } from './version-dto.ts' import type { SoftwareVersionItem } from './version-dto.ts'
import { isNonEmptyVersion } from './version-dto.ts' import { isNonEmptyVersion } from './version-dto.ts'
const loading = ref(false) const loading = ref(false)
const items = ref<SoftwareVersionItem[]>([]) const items = ref<SoftwareVersionItem[]>([])
// 客户端分页:全量拉取后按页切片展示。 /** 版本号搜索(客户端过滤)。 */
const versionKeyword = ref('')
const filteredItems = computed(() => {
const kw = (versionKeyword.value || '').trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((item) => `${item.version}`.toLowerCase().includes(kw))
})
/** 全站分页统一:版本列表客户端分页(10/20/50/100)。 */
const page = ref(1) const page = ref(1)
const pageSize = 10 const pageSize = ref(10)
const total = computed(() => items.value.length) const pagedVersions = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const pagedItems = computed(() => items.value.slice((page.value - 1) * pageSize, page.value * pageSize)) function changeVersionPage(p: number) { page.value = p }
function changeVersionSize(size: number) { pageSize.value = size; page.value = 1 }
// 版本号搜索导致数据收缩时回钳页码,避免停在空页。
watch(filteredItems, () => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
})
function changePage(p: number) { // 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
page.value = p const selectedIds = ref<Set<number>>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
function toggleSelect(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function toggleSelectAllPage() {
const pageRows = pagedVersions.value
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
const next = new Set(selectedIds.value)
if (allOn) pageRows.forEach((row) => next.delete(row.id))
else pageRows.forEach((row) => next.add(row.id))
selectedIds.value = next
}
async function removeSelected() {
if (!selectedCount.value) return
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
const sample = rows.slice(0, 3).map((row) => row.version).join('、')
const summary = rows.length > 3 ? `${sample}${rows.length}` : sample
if (!window.confirm(`确认删除选中的 ${rows.length} 个版本(${summary})?此操作不可恢复。`)) return
try {
await deleteSoftwareVersions(Array.from(selectedIds.value))
ElMessage.success(`已删除 ${rows.length} 个版本`)
selectedIds.value = new Set()
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function removeOne(row: SoftwareVersionItem) {
if (!window.confirm(`确认删除版本 ${row.version}?此操作不可恢复。`)) return
try {
await deleteSoftwareVersions([row.id])
ElMessage.success('删除成功')
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
} }
const uploadVisible = ref(false) const uploadVisible = ref(false)
const uploading = ref(false) const uploading = ref(false)
const uploadPercent = ref(0)
const newVersion = ref('') const newVersion = ref('')
const pickedFile = ref<File | null>(null) const pickedFile = ref<File | null>(null)
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */ /** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
@@ -53,6 +108,7 @@ function openUpload() {
async function submitUpload() { async function submitUpload() {
uploadMsg.value = '' uploadMsg.value = ''
uploadMsgOk.value = false uploadMsgOk.value = false
uploadPercent.value = 0
if (!isNonEmptyVersion(newVersion.value)) { if (!isNonEmptyVersion(newVersion.value)) {
uploadMsg.value = '请填写版本号' uploadMsg.value = '请填写版本号'
return return
@@ -65,16 +121,23 @@ async function submitUpload() {
uploadMsg.value = '仅支持 .zip 格式' uploadMsg.value = '仅支持 .zip 格式'
return return
} }
if (pickedFile.value.size > 512 * 1024 * 1024) {
uploadMsg.value = '文件超过允许的大小限制'
return
}
uploading.value = true uploading.value = true
try { try {
const created = await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name) // 浏览器直传 MinIOpresign → PUT(进度条)→ confirm 落库。
const version = created?.version || newVersion.value.trim() await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name, (p) => {
const link = created?.fileUrl uploadPercent.value = p
// 对齐 admin.js:6484-6488:弹窗保持打开,成功文案(含链接)留驻可复制,仅清空输入并刷新列表。 uploadMsg.value = p >= 100 ? '上传完成,正在登记版本...' : `正在上传:${p}%`
uploadMsg.value = link ? `发布成功。版本:${version},链接:${link}` : `发布成功。版本:${version}` })
// 成功仅提示“发布成功”,弹窗留驻,不再展示版本号与链接。
uploadMsg.value = '发布成功'
uploadMsgOk.value = true uploadMsgOk.value = true
newVersion.value = '' newVersion.value = ''
pickedFile.value = null pickedFile.value = null
uploadPercent.value = 0
load() load()
} catch (error) { } catch (error) {
uploadMsg.value = error instanceof Error ? error.message : '上传失败' uploadMsg.value = error instanceof Error ? error.message : '上传失败'
@@ -87,40 +150,60 @@ onMounted(load)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="version-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="version-head">
<h2>软件版本管理</h2> <h3>版本列表</h3>
<p>管理客户端更新包版本用于 /api/version/latest 下发</p> <div class="version-head-tools">
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
<button class="btn" type="button" @click="openUpload">上传新版本</button>
</div>
</div> </div>
<div class="actions">
<el-button type="primary" @click="openUpload">上传新版本</el-button>
</div>
</div>
<el-card shadow="never"> <div class="table-scroll version-table-scroll">
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无版本记录"> <table>
<el-table-column prop="version" label="版本号" min-width="180" /> <thead>
<el-table-column label="下载链接" min-width="240"> <tr>
<template #default="{ row }"> <th style="width: 40px" class="th-select">
<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> <input type="checkbox" :checked="pagedVersions.length > 0 && pagedVersions.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedVersions.length" />
<span v-else class="dim"></span> </th>
</template> <th style="width: 110px">版本号</th>
</el-table-column> <th>下载链接</th>
<el-table-column prop="createdAt" label="创建时间" min-width="190"> <th style="width: 150px">创建时间</th>
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> <th style="width: 150px">操作</th>
</el-table-column> </tr>
<el-table-column label="操作" min-width="90" fixed="right"> </thead>
<template #default="{ row }"> <tbody>
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" download>下载</a> <template v-if="filteredItems.length">
<span v-else class="dim"></span> <tr v-for="row in pagedVersions" :key="row.version + row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
</template> <td class="td-select">
</el-table-column> <input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
</el-table> </td>
<div class="table-footer"> <td>{{ row.version }}</td>
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" /> <td>
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td class="ops-cell">
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
<span v-else class="dim"></span>
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="5" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="5" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
</tr>
</tbody>
</table>
</div> </div>
</el-card> <OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changeVersionPage" @size-change="changeVersionSize" />
</section>
<el-dialog v-model="uploadVisible" title="上传新版本" width="520px"> <el-dialog v-model="uploadVisible" title="上传新版本" width="520px">
<p class="upload-desc">上传软件 ZIP 系统会自动计算 MD5 并存储到 OSS</p> <p class="upload-desc">上传软件 ZIP 系统会自动计算 MD5 并存储到 OSS</p>
@@ -135,6 +218,7 @@ onMounted(load)
<p class="zip-hint">仅支持 .zip 格式</p> <p class="zip-hint">仅支持 .zip 格式</p>
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div> <div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
</el-form-item> </el-form-item>
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" /> <el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
</el-form> </el-form>
<template #footer> <template #footer>
@@ -146,11 +230,210 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; } /* 像素复刻旧版 admin.html panel-version(蓝白末层)。 */
.actions { display: flex; align-items: center; gap: 10px; } .version-view {
.dim { color: var(--el-text-color-secondary); font-size: 12px; } font-family: inherit;
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; } color: #24384d;
.zip-hint { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 12px; } }
.upload-msg { margin-bottom: 8px; } .panel-box {
.upload-msg :deep(.el-alert__title) { word-break: break-all; } width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.version-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.version-head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.version-keyword {
width: 200px;
min-height: 42px;
padding: 6px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.version-keyword:hover {
border-color: #9fb7cd;
}
.version-keyword:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.version-table-scroll > table {
min-width: 840px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.link-cell {
display: block;
color: #2f5d8b;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.link-cell:hover {
text-decoration: underline;
}
.dl-btn {
text-decoration: none;
}
.dim {
color: #8293a5;
font-size: 12px;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.upload-desc {
margin: 0 0 12px;
color: #5b6f83;
font-size: 12.5px;
line-height: 1.6;
}
.zip-hint {
margin: 4px 0 0;
color: #5b6f83;
font-size: 12px;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.row-selected td {
background: #fdf0ee;
}
.th-select,
.td-select {
text-align: center;
padding-left: 4px !important;
padding-right: 4px !important;
}
.th-select input,
.td-select input {
cursor: pointer;
accent-color: #b33a2e;
}
.upload-progress {
margin: 2px 0 10px;
}
.upload-msg {
margin-bottom: 8px;
}
.upload-msg :deep(.el-alert__title) {
word-break: break-all;
}
</style> </style>
@@ -0,0 +1,554 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 教程管理页:工具台首页「立即下载教程」的包体管理(上传新包 / 列表 / 下载 / 删除)。
* 上传走浏览器直传 MinIOpresign → PUT 带进度 → confirm 落库),与软件版本管理页同一套链路;
* 上传时可填版本号(仅展示与排序用);工具台始终下载"最新上传"的包(不随列表排序变化)。
* 列表默认按上传时间降序,「版本号」「上传时间」表头可点击切换升降序。 */
import { computed, onMounted, ref, watch } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
import { fetchTutorialPackages, uploadTutorialPackage, deleteTutorialPackages } from './tutorial-api.ts'
import { formatFileSize } from './tutorial-model.ts'
import type { TutorialPackageItem } from './tutorial-dto.ts'
const loading = ref(false)
const items = ref<TutorialPackageItem[]>([])
/** 文件名搜索(客户端过滤)。 */
const keyword = ref('')
const filteredItems = computed(() => {
const kw = (keyword.value || '').trim().toLowerCase()
if (!kw) return items.value
return items.value.filter((item) => item.fileName.toLowerCase().includes(kw))
})
/** 全站分页统一:客户端分页(10/20/50/100)。 */
const page = ref(1)
const pageSize = ref(10)
/** 排序:默认按上传时间降序(最新上传在最前,与后端返回顺序一致);点表头切换升降序。 */
type SortKey = 'version' | 'createdAt'
const sortKey = ref<SortKey>('createdAt')
const sortAsc = ref(false)
function toggleSort(key: SortKey) {
if (sortKey.value === key) {
sortAsc.value = !sortAsc.value
} else {
sortKey.value = key
sortAsc.value = false
}
page.value = 1
}
/** 排序标记:未激活 ▲▼,激活时只留方向箭头(比 ⇅/↕ 字形支持好,避免 Windows 缺字形显示方框)。 */
function sortMark(key: SortKey): string {
if (sortKey.value !== key) return '▲▼'
return sortAsc.value ? '▲' : '▼'
}
function compareText(a: string, b: string): number {
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' })
}
const sortedItems = computed(() => {
const rows = [...filteredItems.value]
rows.sort((a, b) => {
// 版本号为空的历史行始终排在末尾,避免切换排序时"无版本"占满首页。
if (sortKey.value === 'version') {
const av = a.version
const bv = b.version
if (!av || !bv) {
if (!av && !bv) return 0
return av ? -1 : 1
}
const diff = compareText(av, bv)
return sortAsc.value ? diff : -diff
}
const at = a.createdAt
const bt = b.createdAt
if (!at || !bt) {
if (!at && !bt) return 0
return at ? -1 : 1
}
const diff = compareText(at, bt)
return sortAsc.value ? diff : -diff
})
return rows
})
const pagedItems = computed(() => sortedItems.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 }
// 搜索/排序导致数据收缩时回钳页码,避免停在空页。
watch(sortedItems, () => {
page.value = Math.min(page.value, Math.max(1, Math.ceil(sortedItems.value.length / pageSize.value)))
})
/** 当前生效包 = 列表第一条(工具台下发的就是它)。 */
const activeId = computed(() => items.value[0]?.id ?? null)
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
const selectedIds = ref<Set<number>>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
function toggleSelect(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function toggleSelectAllPage() {
const pageRows = pagedItems.value
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
const next = new Set(selectedIds.value)
if (allOn) pageRows.forEach((row) => next.delete(row.id))
else pageRows.forEach((row) => next.add(row.id))
selectedIds.value = next
}
async function removeSelected() {
if (!selectedCount.value) return
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
const sample = rows.slice(0, 3).map((row) => row.fileName).join('、')
const summary = rows.length > 3 ? `${sample}${rows.length}` : sample
if (!window.confirm(`确认删除选中的 ${rows.length} 个教程包(${summary})?此操作不可恢复。`)) return
try {
await deleteTutorialPackages(Array.from(selectedIds.value))
ElMessage.success(`已删除 ${rows.length} 个教程包`)
selectedIds.value = new Set()
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function removeOne(row: TutorialPackageItem) {
if (!window.confirm(`确认删除教程包 ${row.fileName}?此操作不可恢复。`)) return
try {
await deleteTutorialPackages([row.id])
ElMessage.success('删除成功')
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
const uploadVisible = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
const newVersion = ref('')
const pickedFile = ref<File | null>(null)
/** 弹窗内成功/失败文案留驻(对齐软件版本页交互)。 */
const uploadMsg = ref('')
const uploadMsgOk = ref(false)
async function load() {
loading.value = true
try {
items.value = (await fetchTutorialPackages()).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
uploadPercent.value = 0
if (!pickedFile.value) {
uploadMsg.value = '请选择 zip 压缩包'
return
}
if (!/\.zip$/i.test(pickedFile.value.name)) {
uploadMsg.value = '仅支持 .zip 格式'
return
}
if (pickedFile.value.size > 512 * 1024 * 1024) {
uploadMsg.value = '文件超过允许的大小限制'
return
}
uploading.value = true
try {
// 浏览器直传 MinIOpresign → PUT(进度条)→ confirm 落库。
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, newVersion.value.trim(), (p) => {
uploadPercent.value = p
uploadMsg.value = p >= 100 ? '上传完成,正在登记教程包...' : `正在上传:${p}%`
})
uploadMsg.value = '上传成功,工具台「立即下载教程」已切换为该包'
uploadMsgOk.value = true
newVersion.value = ''
pickedFile.value = null
uploadPercent.value = 0
load()
} catch (error) {
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
} finally {
uploading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="tutorial-view">
<section class="panel-box">
<div class="tutorial-head">
<h3>教程包列表</h3>
<div class="tutorial-head-tools">
<input v-model="keyword" type="text" placeholder="搜索文件名" class="tutorial-keyword" />
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
<button class="btn" type="button" @click="openUpload">上传教程包</button>
</div>
</div>
<div class="table-scroll tutorial-table-scroll">
<table>
<thead>
<tr>
<th style="width: 40px" class="th-select">
<input type="checkbox" :checked="pagedItems.length > 0 && pagedItems.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedItems.length" />
</th>
<th style="width: 260px">文件名</th>
<th style="width: 110px">
<button class="sort-th sort-version" type="button" @click="toggleSort('version')">版本号<span class="sort-mark">{{ sortMark('version') }}</span></button>
</th>
<th style="width: 110px">大小</th>
<th style="width: 150px">
<button class="sort-th sort-time" type="button" @click="toggleSort('createdAt')">上传时间<span class="sort-mark">{{ sortMark('createdAt') }}</span></button>
</th>
<th>下载链接</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredItems.length">
<tr v-for="row in pagedItems" :key="row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
<td class="td-select">
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
</td>
<td>
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
<span v-if="row.id === activeId" class="tag-active">当前生效</span>
</td>
<td>
<span v-if="row.version" class="version-cell" :title="row.version">{{ row.version }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatFileSize(row.fileSize) }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
<span v-else class="dim"></span>
</td>
<td class="ops-cell">
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
<span v-else class="dim"></span>
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="uploadVisible" title="上传教程包" width="520px">
<p class="upload-desc">上传教程 ZIP 包后工具台首页立即下载教程将以下载该包为准以最新上传的为主</p>
<el-form label-width="110px">
<el-form-item label="版本号">
<el-input v-model="newVersion" placeholder="例如:v2026.09 或留空" maxlength="64" />
<p class="zip-hint">仅作展示与排序用可留空不影响工具台按最新上传下载</p>
</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 格式,最大 512MB</p>
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}{{ formatFileSize(pickedFile.size) }}</div>
</el-form-item>
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
<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>
/* 沿用软件版本管理页(旧版 admin.html panel-version)的样式语言。 */
.tutorial-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.tutorial-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.tutorial-head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.tutorial-keyword {
width: 200px;
min-height: 42px;
padding: 6px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.tutorial-keyword:hover {
border-color: #9fb7cd;
}
.tutorial-keyword:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.tutorial-table-scroll > table {
min-width: 940px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.file-name {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
/* 表头排序:按钮铺满单元格,仅 hover 时加深字色,保持旧版表头观感。 */
.sort-th {
display: inline-flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 0;
border: 0;
background: none;
color: inherit;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
letter-spacing: inherit;
text-align: left;
cursor: pointer;
}
.sort-th:hover {
color: #2f5d8b;
}
.sort-mark {
color: #8293a5;
font-size: 12px;
}
.sort-th:hover .sort-mark {
color: #5f85ad;
}
.sort-version {
width: 110px;
}
.sort-time {
width: 150px;
}
.version-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.tag-active {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border: 1px solid #4f9a72;
border-radius: 999px;
background: #eaf6ef;
color: #2f7a55;
font-size: 11.5px;
vertical-align: middle;
}
.link-cell {
display: block;
color: #2f5d8b;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.link-cell:hover {
text-decoration: underline;
}
.dl-btn {
text-decoration: none;
}
.dim {
color: #8293a5;
font-size: 12px;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.upload-desc {
margin: 0 0 12px;
color: #5b6f83;
font-size: 12.5px;
line-height: 1.6;
}
.zip-hint {
margin: 4px 0 0;
color: #5b6f83;
font-size: 12px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.row-selected td {
background: #fdf0ee;
}
.th-select,
.td-select {
text-align: center;
padding-left: 4px !important;
padding-right: 4px !important;
}
.th-select input,
.td-select input {
cursor: pointer;
accent-color: #b33a2e;
}
.upload-progress {
margin: 2px 0 10px;
}
.upload-msg {
margin-bottom: 8px;
}
.upload-msg :deep(.el-alert__title) {
word-break: break-all;
}
</style>
@@ -17,11 +17,3 @@ export async function fetchHistoryUserOptions(): Promise<HistoryUserOption[]> {
const { data } = await http.get<unknown>('/api/admin/users', { params: { page: 1, page_size: 999 } }) const { data } = await http.get<unknown>('/api/admin/users', { params: { page: 1, page_size: 999 } })
return parseHistoryUserOptions(data) return parseHistoryUserOptions(data)
} }
/** 清空全部生成记录(验收反馈:生成记录数据要可清理)。返回清理条数。 */
export async function clearHistory(): Promise<number> {
const { data } = await http.delete<unknown>(HISTORY_ENDPOINT)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
const removed = record?.data
return typeof removed === 'number' ? removed : 0
}
@@ -1,7 +1,7 @@
/** 历史生成记录 DTO(任务 121):列表筛选/分页归一与类型边界;与 Java ImageHistoryController /** 历史生成记录 DTO(任务 121):列表筛选/分页归一与类型边界;与 Java ImageHistoryController
* snake 契约对齐;历史页与后台版本/公开版本接口分离。纯逻辑。 */ * snake 契约对齐;历史页与后台版本/公开版本接口分离。纯逻辑。 */
export const HISTORY_DEFAULT_PAGE_SIZE = 15 export const HISTORY_DEFAULT_PAGE_SIZE = 10
export const HISTORY_MIN_PAGE = 1 export const HISTORY_MIN_PAGE = 1
export const HISTORY_MAX_PAGE_SIZE = 200 export const HISTORY_MAX_PAGE_SIZE = 200
@@ -0,0 +1,81 @@
/** 教程包接口适配(教程管理页):列表 / 直传预签名 / 确认 / 删除。 */
import axios from 'axios'
import { http, unwrap } from '@/api/http'
import { parseTutorialPackageList, parseTutorialPackageUpload } from './tutorial-model.ts'
import type { TutorialPackageItem, TutorialPackageList } from './tutorial-dto.ts'
export const TUTORIALS_ENDPOINT = '/api/admin/tutorials'
export const TUTORIAL_UPLOAD_ENDPOINT = '/api/admin/tutorial'
export const TUTORIAL_PRESIGN_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/presign`
export const TUTORIAL_CONFIRM_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/confirm`
export const TUTORIAL_DELETE_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/delete`
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
export async function fetchTutorialPackages(): Promise<TutorialPackageList> {
const { data } = await http.get<unknown>(TUTORIALS_ENDPOINT)
return parseTutorialPackageList(data)
}
export interface TutorialUploadTarget {
objectKey: string
uploadUrl: string
fileUrl: string
version: string
}
/** 第一步:向后端申请直传 PUT 预签名地址(登录会话签发,3 分钟有效)。 */
export async function requestTutorialPresign(fileName: string, version = ''): Promise<TutorialUploadTarget> {
const { data } = await http.post<unknown>(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName, version } })
const core = (unwrap(data) ?? {}) as Record<string, unknown>
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
const objectKey = typeof core.object_key === 'string' ? core.object_key : ''
if (!uploadUrl || !objectKey) {
throw new Error('后端未返回直传地址,请重试')
}
return {
objectKey,
uploadUrl,
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
version: typeof core.version === 'string' ? core.version : version.trim(),
}
}
/** 第三步:直传完成后通知后端校验对象并写入记录(data.item 为新记录行)。 */
export async function confirmTutorialPackage(objectKey: string, fileName: string, version = ''): Promise<TutorialPackageItem | null> {
const { data } = await http.post<unknown>(TUTORIAL_CONFIRM_ENDPOINT, null, {
params: { object_key: objectKey, file_name: fileName, version },
})
return parseTutorialPackageUpload(data)
}
/** 按 id 批量删除教程包(后端会顺带清理无引用对象的 MinIO 包体);返回实际删除行数。 */
export async function deleteTutorialPackages(ids: number[]): Promise<number> {
if (!ids.length) return 0
const { data } = await http.post<unknown>(TUTORIAL_DELETE_ENDPOINT, ids)
const core = (unwrap(data) ?? {}) as Record<string, unknown>
return typeof core.deleted === 'number' ? core.deleted : ids.length
}
/**
* 上传教程包(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
*/
export async function uploadTutorialPackage(
file: Blob,
fileName: string,
version = '',
onProgress?: (percent: number) => void,
): Promise<TutorialPackageItem | null> {
const target = await requestTutorialPresign(fileName, version)
await directPut.put(target.uploadUrl, file, {
headers: { 'Content-Type': 'application/octet-stream' },
onUploadProgress: (event) => {
if (!onProgress) return
const total = event.total || 0
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
},
})
return confirmTutorialPackage(target.objectKey, fileName, target.version)
}
@@ -0,0 +1,21 @@
/** 教程包 DTO(教程管理页):工具台下载入口对应的教程压缩包行类型;纯逻辑。 */
/** 教程包行(GET /api/admin/tutorials data.itemsJava TutorialPackageService snake)。 */
export interface TutorialPackageItem {
id: number
fileName: string
/** 版本号(上传时填写,V126 之前的历史行为空串) */
version: string
objectKey: string
fileSize: number
fileUrl: string
createdAt: string
}
export interface TutorialPackageList {
items: TutorialPackageItem[]
}
export function emptyTutorialPackageList(): TutorialPackageList {
return { items: [] }
}
@@ -0,0 +1,60 @@
/** 教程包列表加载模型:解析 /api/admin/tutorials data.items(snake);纯逻辑。 */
import { unwrap } from '../../api/envelope.ts'
import { type TutorialPackageItem, type TutorialPackageList } from './tutorial-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 toTutorialPackageItem(raw: unknown): TutorialPackageItem | 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
return {
id,
fileName: text(r.file_name ?? r.fileName),
version: text(r.version),
objectKey: text(r.object_key ?? r.objectKey),
fileSize: numberOrNull(r.file_size ?? r.fileSize) ?? 0,
fileUrl: text(r.file_url ?? r.fileUrl),
createdAt: text(r.created_at ?? r.createdAt),
}
}
/** 归一化教程包列表负载为前端结果;缺省回空列表。 */
export function parseTutorialPackageList(payload: unknown): TutorialPackageList {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : {}
const items = Array.isArray(record.items)
? record.items.map((raw) => toTutorialPackageItem(raw)).filter((item): item is TutorialPackageItem => item !== null)
: []
return { items }
}
/** 上传成功后返回的新记录行(data.item);无 item 回 null。 */
export function parseTutorialPackageUpload(payload: unknown): TutorialPackageItem | null {
const data = unwrap<unknown>(payload)
if (!data || typeof data !== 'object') return null
const item = (data as Record<string, unknown>).item
return item ? toTutorialPackageItem(item) : null
}
/** 字节数展示:0=未知展示为 -,其余按 KB/MB/GB 人性化。 */
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB']
let value = bytes / 1024
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
}
@@ -1,21 +1,83 @@
/** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */ /** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */
import { http } from '@/api/http' import axios from 'axios'
import { http, unwrap } from '@/api/http'
import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts' import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts'
import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts' import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions' export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version' export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version'
export const SOFTWARE_VERSION_PRESIGN_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/presign`
export const SOFTWARE_VERSION_CONFIRM_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/confirm`
export const SOFTWARE_VERSION_DELETE_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/delete`
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> { export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> {
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT) const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
return parseSoftwareVersionList(data) return parseSoftwareVersionList(data)
} }
/** 上传客户端软件版本(multipart version + file);返回新版本行(含下载链接)。 */ export interface SoftwareVersionUploadTarget {
export async function uploadSoftwareVersion(version: string, file: Blob, filename = ''): Promise<SoftwareVersionItem | null> { uploadUrl: string
const form = new FormData() fileUrl: string
form.append('version', version) version: string
form.append('file', file, filename || (file instanceof File ? file.name : 'version.zip')) }
const { data } = await http.post<unknown>(SOFTWARE_VERSION_UPLOAD_ENDPOINT, form)
export interface SoftwareVersionUploadResult {
version: string
fileUrl: string
item: SoftwareVersionItem | null
}
/** 第一步:向后端申请直传 PUT 预签名地址与公开下载 URL(登录会话签发,3 分钟有效)。 */
export async function requestSoftwareVersionPresign(version: string): Promise<SoftwareVersionUploadTarget> {
const { data } = await http.post<unknown>(SOFTWARE_VERSION_PRESIGN_ENDPOINT, null, { params: { version } })
const core = (unwrap(data) ?? {}) as Record<string, unknown>
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
if (!uploadUrl) {
throw new Error('后端未返回直传地址,请重试')
}
return {
uploadUrl,
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
version: typeof core.version === 'string' ? core.version : version.trim(),
}
}
/** 第三步:直传完成后通知后端校验对象并写入版本记录(data.item 为新版本行)。 */
export async function confirmSoftwareVersion(version: string): Promise<SoftwareVersionItem | null> {
const { data } = await http.post<unknown>(SOFTWARE_VERSION_CONFIRM_ENDPOINT, null, { params: { version } })
return parseSoftwareVersionUpload(data) return parseSoftwareVersionUpload(data)
} }
/** 按 id 批量删除版本(后端会顺带清理无引用对象的 MinIO 安装包);返回实际删除行数。 */
export async function deleteSoftwareVersions(ids: number[]): Promise<number> {
if (!ids.length) return 0
const { data } = await http.post<unknown>(SOFTWARE_VERSION_DELETE_ENDPOINT, ids)
const core = (unwrap(data) ?? {}) as Record<string, unknown>
return typeof core.deleted === 'number' ? core.deleted : ids.length
}
/**
* 上传客户端软件版本(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
*/
export async function uploadSoftwareVersion(
version: string,
file: Blob,
_filename = '',
onProgress?: (percent: number) => void,
): Promise<SoftwareVersionUploadResult> {
const target = await requestSoftwareVersionPresign(version)
await directPut.put(target.uploadUrl, file, {
headers: { 'Content-Type': 'application/octet-stream' },
onUploadProgress: (event) => {
if (!onProgress) return
const total = event.total || 0
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
},
})
const item = await confirmSoftwareVersion(target.version)
return { version: target.version, fileUrl: item?.fileUrl || target.fileUrl, item }
}
@@ -1,26 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 店铺密钥管理页:分页 + 新增/编辑/删除;紫鸟令牌掩码展示。 */ /** 店铺密钥管理页 · 像素复刻旧版 admin.html panel-shop-keys(自绘:面板头+新增店铺密钥、序号列、令牌掩码、白名单状态药丸+悬停详情、旧式分页)。
import { onMounted, reactive, ref } from 'vue' * script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
import { ElMessage, ElMessageBox } from 'element-plus' import { computed, onMounted, reactive, ref } from 'vue'
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } from './shop-key-api.ts' import { ElMessage } from 'element-plus'
import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts' import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey, checkShopKeyWhitelist } from './shop-key-api.ts'
import { emptyShopKeyForm, normalizeZiniaoToken, validateProxyUrl } from './shop-key-form-model.ts'
import type { ShopKeyItem } from './shop-dto.ts' import type { ShopKeyItem } from './shop-dto.ts'
import { whitelistStatusMeta } from './shop-key-model.ts' import { whitelistStatusMeta } from './shop-key-model.ts'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false) const loading = ref(false)
const rows = ref<ShopKeyItem[]>([]) const rows = ref<ShopKeyItem[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
/** 筛选:备注名/紫鸟账号(用户要求店铺密钥列表补筛选)。 */
const filterKeyword = ref('')
const filteredRows = computed(() => {
const kw = (filterKeyword.value || '').trim().toLowerCase()
if (!kw) return rows.value
return rows.value.filter((r) =>
`${r.remarkName || ''} ${r.ziniaoAccountName || ''}`.toLowerCase().includes(kw),
)
})
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
const saving = ref(false) const saving = ref(false)
/** 正在检测白名单的行 id,用于按钮 loading 态。 */
const checkingId = ref<number | null>(null)
const form = reactive(emptyShopKeyForm()) const form = reactive(emptyShopKeyForm())
function maskToken(token: string): string { function maskToken(token: string): string {
const value = (token || '') const value = token || ''
if (value.length <= 8) return '••••••••' if (value.length <= 8) return '••••••••'
return `${value.slice(0, 4)}••••••••${value.slice(-4)}` return `${value.slice(0, 4)}••••••••${value.slice(-4)}`
} }
@@ -33,6 +48,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
const parts: string[] = [] const parts: string[] = []
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`) if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage) if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage)
if (row.ipWhitelistFailCount > 0) parts.push(`连续失败 ${row.ipWhitelistFailCount}`)
if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`) if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`)
return parts.join('\n') return parts.join('\n')
} }
@@ -63,9 +79,30 @@ function openEdit(row: ShopKeyItem) {
Object.assign(form, emptyShopKeyForm()) Object.assign(form, emptyShopKeyForm())
form.remarkName = row.remarkName form.remarkName = row.remarkName
form.ziniaoAccountName = row.ziniaoAccountName form.ziniaoAccountName = row.ziniaoAccountName
form.proxyUrl = row.proxyUrl || ''
dialogVisible.value = true dialogVisible.value = true
} }
/** 手动检测白名单:绕过缓存真实请求一次紫鸟,并重置自动重试次数。 */
async function checkWhitelist(row: ShopKeyItem) {
checkingId.value = row.id
try {
const updated = await checkShopKeyWhitelist(row.id)
if (updated.ipWhitelistStatus === 'ALLOWED') {
ElMessage.success('白名单检测通过')
} else if (updated.ipWhitelistStatus === 'BLOCKED') {
ElMessage.error(`白名单未放行:${updated.ipWhitelistMessage || '请求被拒绝'}`)
} else {
ElMessage.warning(updated.ipWhitelistMessage || '检测未通过')
}
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '检测失败')
} finally {
checkingId.value = null
}
}
async function submit() { async function submit() {
const token = normalizeZiniaoToken(form.ziniaoToken) const token = normalizeZiniaoToken(form.ziniaoToken)
const creating = editingId.value == null const creating = editingId.value == null
@@ -77,6 +114,11 @@ async function submit() {
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌') ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
return return
} }
const proxyError = validateProxyUrl(form.proxyUrl)
if (proxyError) {
ElMessage.warning(proxyError)
return
}
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。 // 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
form.ziniaoToken = creating ? token : token || originalToken.value form.ziniaoToken = creating ? token : token || originalToken.value
saving.value = true saving.value = true
@@ -97,11 +139,8 @@ async function submit() {
} }
async function remove(row: ShopKeyItem) { async function remove(row: ShopKeyItem) {
try { // 对齐旧版:原生 confirm。
await ElMessageBox.confirm(`确定删除店铺密钥“${row.ziniaoAccountName}”吗?`, '删除确认', { type: 'warning' }) if (!window.confirm(`确定删除店铺密钥“${row.ziniaoAccountName}”吗?`)) return
} catch {
return
}
try { try {
await deleteShopKey(row.id) await deleteShopKey(row.id)
ElMessage.success('已删除') ElMessage.success('已删除')
@@ -111,61 +150,113 @@ async function remove(row: ShopKeyItem) {
} }
} }
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="keys-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="keys-head">
<h2>店铺密钥管理</h2> <h3>店铺密钥列表</h3>
<p>管理各店铺的紫鸟浏览器账号与令牌</p> <button class="btn" type="button" @click="openCreate">新增店铺密钥</button>
</div> </div>
</div>
<el-card shadow="never"> <div class="form-row keys-filter-row">
<div class="table-toolbar"> <div class="form-group" style="min-width: 220px">
<el-button type="primary" @click="openCreate">新增密钥</el-button> <label>备注名 / 紫鸟账号</label>
<input v-model="filterKeyword" type="text" placeholder="模糊搜索备注名或紫鸟账号" />
</div>
</div> </div>
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="remarkName" label="备注名" min-width="140"> <div class="shop-key-table-scroll">
<template #default="{ row }">{{ (row as ShopKeyItem).remarkName || '—' }}</template> <table>
</el-table-column> <thead>
<el-table-column prop="ziniaoAccountName" label="紫鸟账号名称" min-width="160" /> <tr>
<el-table-column label="紫鸟令牌" min-width="200"> <th style="width: 58px">序号</th>
<template #default="{ row }"> <th style="width: 150px">备注名</th>
<el-tooltip :content="(row as ShopKeyItem).ziniaoToken" placement="top"> <th style="width: 160px">紫鸟账号名称</th>
<code class="mono-mask">{{ maskToken((row as ShopKeyItem).ziniaoToken) }}</code> <th style="width: 220px">紫鸟令牌</th>
</el-tooltip> <th style="width: 190px">代理</th>
</template> <th style="width: 150px">白名单状态 <span class="table-help" aria-hidden="true">?</span></th>
</el-table-column> <th style="width: 170px">创建时间</th>
<el-table-column label="白名单状态" min-width="150" align="center"> <th style="width: 170px">修改时间</th>
<template #default="{ row }"> <th style="width: 160px">操作</th>
<el-tooltip :content="whitelistTooltip(row as ShopKeyItem)" placement="top"> </tr>
<el-tag :type="whitelistStatusMeta((row as ShopKeyItem).ipWhitelistStatus).tag" size="small"> </thead>
{{ whitelistStatusMeta((row as ShopKeyItem).ipWhitelistStatus).label }} <tbody>
</el-tag> <template v-if="filteredRows.length">
</el-tooltip> <tr v-for="(row, index) in filteredRows" :key="row.id">
</template> <td>{{ (page - 1) * pageSize + index + 1 }}</td>
</el-table-column> <td>{{ row.remarkName || '—' }}</td>
<el-table-column prop="createdAt" label="创建时间" min-width="170"> <td>{{ row.ziniaoAccountName }}</td>
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template> <td>
</el-table-column> <span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
<el-table-column prop="updatedAt" label="修改时间" min-width="170"> </td>
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template> <td>
</el-table-column> <span class="mono-mask" :title="row.proxyUrl || '直连'">{{ row.proxyUrl || '直连' }}</span>
<el-table-column label="操作" min-width="160" fixed="right"> </td>
<template #default="{ row }"> <td>
<el-button text type="primary" size="small" @click="openEdit(row as ShopKeyItem)">编辑</el-button> <span
<el-button text type="danger" size="small" @click="remove(row as ShopKeyItem)">删除</el-button> class="wh-pill"
</template> :class="{
</el-table-column> 'is-allowed': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'success',
</el-table> 'is-blocked': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'danger',
<div class="table-footer"> 'is-unknown': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'info',
<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() }" /> :title="whitelistTooltip(row)"
>
{{ whitelistStatusMeta(row.ipWhitelistStatus).label }}
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
class="btn btn-sm btn-ghost"
type="button"
:disabled="checkingId === row.id"
@click="checkWhitelist(row)"
>
{{ checkingId === row.id ? '检测中…' : '检测白名单' }}
</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="9" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
</tr>
</tbody>
</table>
</div> </div>
</el-card> <OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增密钥' : '编辑密钥'" width="560px"> <el-dialog v-model="dialogVisible" :title="editingId == null ? '新增密钥' : '编辑密钥'" width="560px">
<el-form label-width="110px"> <el-form label-width="110px">
@@ -183,6 +274,15 @@ onMounted(load)
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'" :placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
/> />
</el-form-item> </el-form-item>
<el-form-item label="代理地址">
<el-input
v-model="form.proxyUrl"
placeholder="留空直连;形如 http://host:port 或 http://user:pass@host:port"
/>
<div class="form-hint">
仅当该账号的服务器出口 IP 不在紫鸟白名单时填写代理 IP 需已由紫鸟公司加白留空则直连
</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
@@ -193,9 +293,276 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; } /* 像素复刻旧版 admin.html panel-shop-keys(蓝白末层)。 */
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; } .keys-view {
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; } font-family: inherit;
.table-footer b { color: var(--el-text-color-primary); } color: #24384d;
.mono-mask { font-family: Consolas, monospace; font-size: 12px; background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 4px; } }
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.keys-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.keys-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.form-hint {
margin-top: 4px;
color: #8293a5;
font-size: 12px;
line-height: 1.5;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.shop-key-table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.shop-key-table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1200px;
}
.shop-key-table-scroll th,
.shop-key-table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shop-key-table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.shop-key-table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.shop-key-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.table-help {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
margin-left: 3px;
border-radius: 50%;
background: #c7d7e5;
color: #fff;
font-size: 10px;
line-height: 1;
}
.mono-mask {
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12.5px;
letter-spacing: 0.5px;
}
.wh-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid;
font-size: 12px;
line-height: 1.7;
}
.wh-pill.is-allowed {
color: #3d7158;
background: #e8f2eb;
border-color: #bdd7c5;
}
.wh-pill.is-blocked {
color: #91474f;
background: #f8ebeb;
border-color: #e4c2c5;
}
.wh-pill.is-unknown,
.wh-pill.is-pending,
.wh-pill.is-notchecked {
color: #5b6f83;
background: #f1f6fa;
border-color: #d6e2ec;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
</style> </style>
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 店铺管理页:分组/关键字筛选 + 分页 + 新增/编辑/凭证明文/删除。 */ /** 店铺管理页 · 像素复刻旧版 admin.html panel-shop-manage(自绘:面板头+新增店铺、筛选、密码列 ******+眼睛实时读明文、旧式表格/分页、原生 confirm 删除)。
* script 逻辑沿用现有 Vue 实现(凭证明文 dialog/编辑弹窗/分组筛选)。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session' import { useAdminSessionStore } from '@/stores/admin-session'
import { import {
deleteShopManage, deleteShopManage,
@@ -14,13 +15,18 @@ import {
} from './shop-manage-api.ts' } from './shop-manage-api.ts'
import { emptyShopManageForm } from './shop-manage-form-model.ts' import { emptyShopManageForm } from './shop-manage-form-model.ts'
import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts' import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore() const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false) const loading = ref(false)
const rows = ref<ShopSummary[]>([]) const rows = ref<ShopSummary[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const groups = ref<ShopGroupOption[]>([]) const groups = ref<ShopGroupOption[]>([])
const filter = reactive({ shopName: '', groupId: null as number | null }) const filter = reactive({ shopName: '', groupId: null as number | null })
@@ -38,6 +44,11 @@ const revealedPasswordCache = ref<Record<number, string>>({})
const revealedRowId = ref<number | null>(null) const revealedRowId = ref<number | null>(null)
const groupOptions = computed(() => groups.value) const groupOptions = computed(() => groups.value)
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
function shownPassword(row: ShopSummary): string { function shownPassword(row: ShopSummary): string {
if (revealedRowId.value !== row.id) return row.passwordMasked || '******' if (revealedRowId.value !== row.id) return row.passwordMasked || '******'
@@ -94,29 +105,39 @@ function apply() {
load() load()
} }
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
void load()
}
function changeSize(size: number) { function changeSize(size: number) {
pageSize.value = size pageSize.value = size
page.value = 1 page.value = 1
load() load()
} }
function reset() { function goJump() {
filter.shopName = '' const n = Number.parseInt(jumpPage.value, 10)
filter.groupId = null if (Number.isNaN(n)) {
page.value = 1 ElMessage.warning('请输入页码')
load() return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
} }
function openCreate() { function openCreate() {
editingId.value = null editingId.value = null
Object.assign(form, emptyShopManageForm()) Object.assign(form, emptyShopManageForm())
if (lockedGroupId.value != null) form.groupId = lockedGroupId.value
dialogVisible.value = true dialogVisible.value = true
} }
function openEdit(row: ShopSummary) { function openEdit(row: ShopSummary) {
editingId.value = row.id editingId.value = row.id
Object.assign(form, { Object.assign(form, {
groupId: row.groupId, groupId: row.groupId ?? lockedGroupId.value,
shopName: row.shopName, shopName: row.shopName,
mallName: row.mallName, mallName: row.mallName,
znUsername: row.znUsername, znUsername: row.znUsername,
@@ -166,11 +187,8 @@ async function showCredential(row: ShopSummary) {
} }
async function remove(row: ShopSummary) { async function remove(row: ShopSummary) {
try { // 对齐旧版:原生 confirm。
await ElMessageBox.confirm(`确定删除店铺“${row.shopName}”吗?`, '删除确认', { type: 'warning' }) if (!window.confirm(`确定删除店铺“${row.shopName}”吗?`)) return
} catch {
return
}
try { try {
await deleteShopManage(row.id) await deleteShopManage(row.id)
ElMessage.success('已删除') ElMessage.success('已删除')
@@ -196,83 +214,88 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="page-stack"> <div class="shop-view">
<div class="page-heading"> <section class="panel-box">
<div> <div class="shop-head">
<h2>店铺管理</h2> <h3>店铺列表</h3>
<p>管理店铺账号所属分组与自动化账号</p> <button class="btn" type="button" @click="openCreate">新增店铺</button>
</div> </div>
</div>
<el-card shadow="never" style="margin-bottom: 14px"> <div class="form-row shop-filter-row">
<div class="filter-grid"> <div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
<div class="f-item">
<label>分组</label> <label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable> <el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</div> </div>
<div class="f-item"> <div class="form-group" style="min-width: 220px">
<label>店铺名</label> <label>店铺名</label>
<el-input v-model="filter.shopName" placeholder="模糊搜索店铺名" clearable @keyup.enter="apply" /> <input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div> </div>
<button class="btn" type="button" @click="apply">查询</button>
</div> </div>
</el-card>
<el-card shadow="never"> <div class="table-scroll shop-manage-table-scroll">
<div class="table-toolbar"> <table>
<el-button type="primary" @click="openCreate">新增店铺</el-button> <thead>
<tr>
<th style="width: 58px">序号</th>
<th v-if="isSuperAdmin" style="width: 120px">分组</th>
<th style="width: 150px">店铺名</th>
<th style="width: 130px">店铺商城名</th>
<th style="width: 130px">自动化账号</th>
<th style="width: 130px">账号</th>
<th style="width: 170px">密码</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">修改时间</th>
<th style="width: 220px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.id">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td v-if="isSuperAdmin" data-text :title="row.groupName || '—'">{{ row.groupName || '' }}</td>
<td :title="row.shopName">{{ row.shopName }}</td>
<td :title="row.mallName || '—'">{{ row.mallName || '—' }}</td>
<td :title="row.znUsername || '—'">{{ row.znUsername || '—' }}</td>
<td :title="row.account">{{ row.account }}</td>
<td>
<span class="pwd-cell">{{ shownPassword(row) }}</span>
<button
class="pwd-eye btn btn-sm btn-secondary"
type="button"
:aria-label="revealedRowId === row.id ? '隐藏密码' : '显示密码'"
@click="togglePassword(row)"
>
{{ revealedRowId === row.id ? '隐藏' : '显示' }}
</button>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-secondary" type="button" @click="showCredential(row)">凭证</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">暂无店铺</td>
</tr>
</tbody>
</table>
</div> </div>
<el-table v-loading="loading" :data="rows" stripe border> <OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
<el-table-column prop="groupName" label="分组" min-width="120" /> </section>
<el-table-column prop="shopName" label="店铺名" min-width="160" />
<el-table-column prop="mallName" label="店铺商城名" min-width="120" />
<el-table-column prop="znUsername" label="自动化账号" min-width="140">
<template #default="{ row }">{{ (row as ShopSummary).znUsername || '—' }}</template>
</el-table-column>
<el-table-column prop="account" label="账号" min-width="140" />
<el-table-column label="密码" min-width="170">
<template #default="{ row }">
<span class="pwd-cell">{{ shownPassword(row as ShopSummary) }}</span>
<el-button
link
type="primary"
size="small"
:aria-label="revealedRowId === (row as ShopSummary).id ? '隐藏密码' : '显示密码'"
@click="togglePassword(row as ShopSummary)"
>
{{ revealedRowId === (row as ShopSummary).id ? '隐藏' : '显示' }}
</el-button>
</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) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="220" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openEdit(row as ShopSummary)">编辑</el-button>
<el-button text type="warning" size="small" @click="showCredential(row as ShopSummary)">凭证</el-button>
<el-button text type="danger" size="small" @click="remove(row as ShopSummary)">删除</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="changeSize" />
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px"> <el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
<el-form label-width="120px"> <el-form label-width="120px">
<el-form-item label="所属分组" required> <el-form-item label="所属分组" required>
<el-select v-model="form.groupId" placeholder="选择分组"> <el-select filterable v-model="form.groupId" placeholder="选择分组" :disabled="lockedGroupId != null">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" /> <el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -301,7 +324,7 @@ onMounted(() => {
<el-dialog v-model="credentialVisible" title="店铺明文凭据" width="560px"> <el-dialog v-model="credentialVisible" title="店铺明文凭据" width="560px">
<el-descriptions v-if="credential" :column="1" border> <el-descriptions v-if="credential" :column="1" border>
<el-descriptions-item label="店铺名">{{ credential.shopName }}</el-descriptions-item> <el-descriptions-item label="店铺名">{{ credential.shopName }}</el-descriptions-item>
<el-descriptions-item label="分组">{{ credential.groupName || '—' }}</el-descriptions-item> <el-descriptions-item v-if="isSuperAdmin" label="分组">{{ credential.groupName || '' }}</el-descriptions-item>
<el-descriptions-item label="店铺商城名">{{ credential.mallName || '—' }}</el-descriptions-item> <el-descriptions-item label="店铺商城名">{{ credential.mallName || '—' }}</el-descriptions-item>
<el-descriptions-item label="账号">{{ credential.account }}</el-descriptions-item> <el-descriptions-item label="账号">{{ credential.account }}</el-descriptions-item>
<el-descriptions-item label="密码"> <el-descriptions-item label="密码">
@@ -316,14 +339,239 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; } /* 像素复刻旧版 admin.html panel-shop-manage(蓝白末层)。 */
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; } .shop-view {
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; } font-family: inherit;
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; } color: #24384d;
.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; } .panel-box {
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; } width: 100%;
.table-footer b { color: var(--el-text-color-primary); } min-width: 0;
.dim { color: var(--el-text-color-secondary); font-size: 12px; } padding: 20px 22px 24px;
.pwd-cell { font-family: Consolas, monospace; font-size: 12px; margin-right: 6px; } border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.shop-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.shop-manage-table-scroll > table {
min-width: 1200px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.pwd-cell {
font-family: Consolas, monospace;
font-size: 12.5px;
margin-right: 6px;
}
.pwd-eye {
min-height: 28px;
padding: 3px 10px;
font-size: 12px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.dim {
color: #8293a5;
font-size: 12px;
}
</style> </style>
@@ -22,9 +22,13 @@ export interface ShopKeyItem {
ziniaoAccountName: string ziniaoAccountName: string
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */ /** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
ziniaoToken: SensitiveString ziniaoToken: SensitiveString
/** 静态代理地址;为空表示直连。用于出口 IP 不在紫鸟白名单的 key。 */
proxyUrl: string
ipWhitelistStatus: IpWhitelistStatus ipWhitelistStatus: IpWhitelistStatus
ipWhitelistCheckedAt?: string ipWhitelistCheckedAt?: string
ipWhitelistMessage?: string ipWhitelistMessage?: string
/** 连续白名单失败次数;达上限后不再自动重试,需人工检测。 */
ipWhitelistFailCount: number
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
} }
@@ -55,3 +55,13 @@ export async function updateShopKey(id: number, form: ShopKeyFormValues): Promis
export async function deleteShopKey(id: number): Promise<void> { export async function deleteShopKey(id: number): Promise<void> {
await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`) await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`)
} }
/** 手动检测 IP 白名单:POST /api/admin/shop-keys/{id}/check-whitelist;返回更新后的记录。 */
export async function checkShopKeyWhitelist(id: number): Promise<ShopKeyItem> {
const { data } = await http.post<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}/check-whitelist`)
const item = toShopKeyItem(unwrap<unknown>(data))
if (!item) {
throw new Error('白名单检测响应异常:未返回有效记录')
}
return item
}
@@ -16,5 +16,6 @@ export function buildShopKeyEditForm(item: ShopKeyItem | null | undefined): Shop
remarkName: typeof item.remarkName === 'string' ? item.remarkName : '', remarkName: typeof item.remarkName === 'string' ? item.remarkName : '',
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '', ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '', ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '',
proxyUrl: typeof item.proxyUrl === 'string' ? item.proxyUrl : '',
} }
} }
@@ -4,18 +4,22 @@
export const SHOP_KEY_REMARK_MAX_LEN = 128 export const SHOP_KEY_REMARK_MAX_LEN = 128
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128 export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
export const SHOP_KEY_TOKEN_MAX_LEN = 512 export const SHOP_KEY_TOKEN_MAX_LEN = 512
export const SHOP_KEY_PROXY_MAX_LEN = 255
export interface ShopKeyFormValues { export interface ShopKeyFormValues {
remarkName: string remarkName: string
ziniaoAccountName: string ziniaoAccountName: string
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */ /** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
ziniaoToken: string ziniaoToken: string
/** 静态代理地址(http://host:port 或 http://user:pass@host:port);留空直连。 */
proxyUrl: string
} }
export interface ShopKeyFormErrors { export interface ShopKeyFormErrors {
remarkName?: string remarkName?: string
ziniaoAccountName?: string ziniaoAccountName?: string
ziniaoToken?: string ziniaoToken?: string
proxyUrl?: string
} }
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */ /** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
@@ -23,10 +27,33 @@ export interface ShopKeyPayload {
remarkName?: string remarkName?: string
ziniaoAccountName: string ziniaoAccountName: string
ziniaoToken: string ziniaoToken: string
proxyUrl?: string
} }
export function emptyShopKeyForm(): ShopKeyFormValues { export function emptyShopKeyForm(): ShopKeyFormValues {
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '' } return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '', proxyUrl: '' }
}
/** 校验代理地址(镜像后端 normalizeProxyUrl):留空合法,非空须为 http(s)://host:port。 */
export function validateProxyUrl(value: string): string | null {
const proxy = (value || '').trim()
if (!proxy) return null
if (proxy.length > SHOP_KEY_PROXY_MAX_LEN) {
return `代理地址长度不能超过${SHOP_KEY_PROXY_MAX_LEN}个字符`
}
let parsed: URL
try {
parsed = new URL(proxy)
} catch {
return '代理地址格式不合法,应形如 http://host:port'
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return '代理地址仅支持 http/https 协议'
}
if (!parsed.hostname || !parsed.port) {
return '代理地址缺少主机或端口,应形如 http://host:port'
}
return null
} }
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */ /** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
@@ -61,6 +88,10 @@ export function validateShopKeyForm(form: ShopKeyFormValues): { valid: boolean;
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) { } else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符` errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符`
} }
const proxyError = validateProxyUrl(form.proxyUrl)
if (proxyError) {
errors.proxyUrl = proxyError
}
return { valid: Object.keys(errors).length === 0, errors } return { valid: Object.keys(errors).length === 0, errors }
} }
@@ -72,5 +103,7 @@ export function toShopKeyCreateRequest(form: ShopKeyFormValues): ShopKeyPayload
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken), ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
} }
if (remark) payload.remarkName = remark if (remark) payload.remarkName = remark
const proxy = (form.proxyUrl || '').trim()
if (proxy) payload.proxyUrl = proxy
return payload return payload
} }
@@ -55,8 +55,10 @@ export function toShopKeyItem(raw: unknown): ShopKeyItem | null {
remarkName: text(record.remarkName ?? record.remark_name), remarkName: text(record.remarkName ?? record.remark_name),
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name), ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)), ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
proxyUrl: text(record.proxyUrl ?? record.proxy_url),
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status), ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message), ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message),
ipWhitelistFailCount: numberOrNull(record.ipWhitelistFailCount ?? record.ip_whitelist_fail_count) ?? 0,
} }
const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at) const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at)
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt if (checkedAt) item.ipWhitelistCheckedAt = checkedAt
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 视频任务记录页:任务卡片网格(播放/下载/复制)+ 筛选 + 批量打包下载 + 明细 + 数据范围授权。 */ /** 视频任务记录页 · 像素复刻旧版 admin.html panel-image-video-tasks(自绘:form-box 筛选[用户名/提交起止+查询/重置] + panel-box toolbar[全选/批量下载/权限配置/汇总] + 任务卡片网格 + 旧式分页)。
* script 逻辑沿用现有 Vue 实现(批量打包/明细抽屉/权限授权)。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { requestVideoZipDownload, fetchImageVideoTasks, fetchImageVideoTaskDetail } from './image-video-api.ts' import { requestVideoZipDownload, fetchImageVideoTasks, fetchImageVideoTaskDetail } from './image-video-api.ts'
@@ -16,12 +17,17 @@ import {
} from './image-video-selection.ts' } from './image-video-selection.ts'
import { grantedUserIds, type TaskPermissionItem } from './task-permission.ts' import { grantedUserIds, type TaskPermissionItem } from './task-permission.ts'
import { imageVideoDownloadFilename, videoKeysOf } from './image-video-view.ts' import { imageVideoDownloadFilename, videoKeysOf } from './image-video-view.ts'
import { useAdminSessionStore } from '@/stores/admin-session'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false) const loading = ref(false)
const tasks = ref<ImageVideoRow[]>([]) const tasks = ref<ImageVideoRow[]>([])
const totalTasks = ref(0) const totalTasks = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = 20 const pageSize = ref(10)
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize.value)))
const filter = reactive<ImageVideoFilter & { dateRange: string[] }>({ ...createImageVideoFilter(), dateRange: [] }) const filter = reactive<ImageVideoFilter & { dateRange: string[] }>({ ...createImageVideoFilter(), dateRange: [] })
const selection = ref<Set<string>>(new Set()) const selection = ref<Set<string>>(new Set())
@@ -34,21 +40,47 @@ const permissionVisible = ref(false)
const permissionItems = ref<TaskPermissionItem[]>([]) const permissionItems = ref<TaskPermissionItem[]>([])
const permissionInitial = ref<number[]>([]) const permissionInitial = ref<number[]>([])
const permissionSaving = ref(false) const permissionSaving = ref(false)
/** 权限弹窗用户表客户端分页(全量用户在表格内翻页,10/20/50/100 可选)。 */
const permPage = ref(1)
const permPageSize = ref(10)
const pagedPermissionItems = computed(() =>
permissionItems.value.slice((permPage.value - 1) * permPageSize.value, permPage.value * permPageSize.value),
)
const downloading = ref(false) const downloading = ref(false)
const downloadingKey = ref('') const downloadingKey = ref('')
/** 旧版状态 label 映射(admin.js 前缀兜底:RUN/WAIT/PROCESS/POLL → 处理中)。 */
function statusLabel(status: string): string { function statusLabel(status: string): string {
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' } const map: Record<string, string> = {
return map[status] || status PENDING: '待执行',
WAITING: '排队中',
RUNNING: '执行中',
POLLING: '处理中',
SUCCESS: '已完成',
COMPLETED: '已完成',
DONE: '已完成',
FAILED: '失败',
ERROR: '失败',
CANCELLED: '已取消',
CANCELED: '已取消',
DELETED: '已删除',
STOPPED: '已停止',
UNKNOWN: '未知',
}
const key = (status || '').toUpperCase()
if (map[key]) return map[key]
if (/^(RUN|WAIT|PROCESS|POLL)/.test(key)) return '处理中'
return key || '未知'
} }
function statusType(status: string): 'info' | 'warning' | 'success' | 'danger' | 'primary' { function statusType(status: string): 'pending' | 'running' | 'success' | 'failed' | 'cancelled' {
if (status === 'SUCCESS') return 'success' const key = (status || '').toUpperCase()
if (status === 'RUNNING') return 'warning' if (key === 'SUCCESS' || key === 'COMPLETED' || key === 'DONE') return 'success'
if (status === 'FAILED') return 'danger' if (key === 'FAILED' || key === 'ERROR') return 'failed'
if (status === 'CANCELLED') return 'info' if (key === 'CANCELLED' || key === 'CANCELED') return 'cancelled'
return 'info' if (key === 'PENDING' || key === 'WAITING') return 'pending'
return 'running'
} }
const currentVideoKeys = computed(() => { const currentVideoKeys = computed(() => {
@@ -78,7 +110,7 @@ function toFilter(): ImageVideoFilter {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize) const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize.value)
tasks.value = result.items tasks.value = result.items
totalTasks.value = result.total totalTasks.value = result.total
page.value = result.page page.value = result.page
@@ -101,6 +133,29 @@ function reset() {
void load() void load()
} }
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
selection.value = new Set()
void load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
function toggleSelectAll() { function toggleSelectAll() {
selection.value = allSelected.value ? clearVideoSelection() : new Set(currentVideoKeys.value) selection.value = allSelected.value ? clearVideoSelection() : new Set(currentVideoKeys.value)
} }
@@ -195,6 +250,7 @@ async function openPermission() {
try { try {
permissionItems.value = await fetchImageVideoPermissionUsers() permissionItems.value = await fetchImageVideoPermissionUsers()
permissionInitial.value = grantedUserIds(permissionItems.value) permissionInitial.value = grantedUserIds(permissionItems.value)
permPage.value = 1
permissionVisible.value = true permissionVisible.value = true
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败') ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
@@ -221,7 +277,8 @@ async function savePermission() {
} }
function videoErrorText(task: ImageVideoRow): string { function videoErrorText(task: ImageVideoRow): string {
if (task.status === 'FAILED' || task.status === 'CANCELLED') return '任务失败,未生成视频' const key = (task.status || '').toUpperCase()
if (key === 'FAILED' || key === 'ERROR' || key === 'CANCELLED') return '任务失败,未生成视频'
return '视频生成中或暂无结果' return '视频生成中或暂无结果'
} }
@@ -233,97 +290,89 @@ onMounted(load)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="iv-view">
<div class="page-heading"> <section class="form-box">
<div> <h3>视频任务记录筛选</h3>
<h2>视频任务记录</h2> <div class="form-row">
<p>查看图生视频任务卡片在线预览与下载视频配置数据范围授权</p> <div class="form-group" style="min-width: 160px">
<label>用户名</label>
<input v-model="filter.username" type="text" placeholder="模糊搜索" @keyup.enter="apply" />
</div>
<div class="form-group" style="min-width: 176px">
<label>提交开始</label>
<input v-model="filter.dateRange[0]" type="datetime-local" />
</div>
<div class="form-group" style="min-width: 176px">
<label>提交结束</label>
<input v-model="filter.dateRange[1]" type="datetime-local" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="reset">重置</button>
</div> </div>
<div class="actions"> </section>
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选当前页</el-checkbox>
<el-button :disabled="selectionCount === 0" :loading="downloading" @click="downloadBatch"> <section class="panel-box iv-panel">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }} <div class="iv-toolbar">
</el-button> <div class="iv-toolbar-main">
<el-button @click="openPermission">权限配置</el-button> <button v-if="session.isSuperAdmin" class="btn btn-secondary" type="button" @click="openPermission">权限配置</button>
<button class="btn iv-batch-btn" type="button" :disabled="selectionCount === 0 || downloading" @click="downloadBatch">
批量下载{{ selectionCount ? ` (${selectionCount})` : '' }}
</button>
<label class="iv-select-all">
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" />
全选当前页
</label>
</div>
<span class="iv-summary"> {{ totalTasks }} 个任务 · 本页 {{ currentVideoCount }} 个视频</span>
</div> </div>
</div>
<el-card shadow="never" style="margin-bottom: 14px"> <div v-loading="loading" class="task-card-grid">
<div class="filter-grid"> <template v-if="tasks.length">
<div class="f-item"> <section v-for="task in tasks" :key="String(task.taskId)" class="iv-card">
<label>用户名模糊搜索</label> <header class="iv-card-head">
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" /> <input type="checkbox" :checked="cardSelected(task)" :disabled="!hasVideos(task)" @change="toggleCard(task)" />
</div> <span class="iv-card-title">任务 {{ task.taskId }} · 视频 {{ task.videos.length }}</span>
<div class="f-item wide"> <span class="st-pill" :class="`is-${statusType(task.status)}`">{{ statusLabel(task.status) }}</span>
<label>提交时间</label> </header>
<el-date-picker v-model="filter.dateRange" type="datetimerange" range-separator="至" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DDTHH:mm" unlink-panels />
</div> <div class="iv-card-info">
<div class="f-item btn-row"> <div class="iv-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
<el-button type="primary" @click="apply">查询</el-button> <div v-if="session.isSuperAdmin" class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
<el-button @click="reset">重置</el-button> <div class="iv-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
</div> <div class="iv-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
<div class="iv-info-row">
<label>调试链接</label>
<button v-if="task.debugUrl" class="btn btn-sm" type="button" @click="copyLink(task.debugUrl)">复制链接</button>
<span v-else>-</span>
</div>
</div>
<div v-if="hasVideos(task)" class="iv-video-list">
<div v-for="(video, index) in task.videos" :key="`${task.taskId}-${index}`" class="iv-video-row">
<input type="checkbox" :checked="selection.has(`${task.taskId}:${index}`)" @change="toggleVideoKey(`${task.taskId}:${index}`)" />
<video v-if="video.displayUrl" class="iv-player" :src="video.displayUrl" controls preload="metadata"></video>
<span v-else class="iv-no-preview">视频加载失败可尝试下载</span>
<div class="iv-video-ops">
<button
class="btn btn-sm btn-secondary"
type="button"
:disabled="!video.displayUrl"
:aria-busy="downloadingKey === `${task.taskId}:${index}`"
@click="downloadVideo(task, index, video)"
>
下载
</button>
<button v-if="video.displayUrl" class="btn btn-sm" type="button" @click="copyLink(video.displayUrl)">复制链接</button>
</div>
</div>
</div>
<div v-else class="iv-empty-note">{{ videoErrorText(task) }}</div>
</section>
</template>
<div v-else-if="!loading" class="empty-tip">暂无符合条件的视频任务</div>
</div> </div>
</el-card> <OldPagination :total="totalTasks" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<div v-loading="loading" class="video-summary-row">
<span> {{ totalTasks }} 个任务 · 本页 {{ currentVideoCount }} 个视频</span>
<el-pagination
background
layout="prev, pager, next, jumper"
:total="totalTasks"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p; selection = new Set(); load() }"
/>
</div>
<div v-if="tasks.length" class="task-card-grid">
<section v-for="task in tasks" :key="String(task.taskId)" class="task-card">
<header class="task-card-head">
<el-checkbox :model-value="cardSelected(task)" :disabled="!hasVideos(task)" @change="toggleCard(task)" />
<div class="task-card-title">
<span class="task-id">任务 {{ task.taskId }}</span>
</div>
<el-tag :type="statusType(task.status)" size="small">{{ statusLabel(task.status) }}</el-tag>
<el-button text type="primary" size="small" @click="openDetail(task)">明细</el-button>
</header>
<div class="task-card-info">
<div class="task-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
<div class="task-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
<div class="task-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
<div class="task-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
<div class="task-info-row">
<label>调试链接</label>
<el-button v-if="task.debugUrl" text type="primary" size="small" @click="copyLink(task.debugUrl)">复制链接</el-button>
<span v-else>-</span>
</div>
</div>
<div v-if="hasVideos(task)" class="video-list">
<div v-for="(video, index) in task.videos" :key="`${task.taskId}-${index}`" class="video-row">
<el-checkbox :model-value="selection.has(`${task.taskId}:${index}`)" @change="toggleVideoKey(`${task.taskId}:${index}`)" />
<video v-if="video.displayUrl" class="video-player" :src="video.displayUrl" controls preload="metadata" />
<span v-else class="video-no-preview">视频加载失败,可尝试下载</span>
<span class="video-index">第 {{ index + 1 }} 个</span>
<el-button
text
type="success"
size="small"
:disabled="!video.displayUrl"
:loading="downloadingKey === `${task.taskId}:${index}`"
@click="downloadVideo(task, index, video)"
>
下载
</el-button>
<el-button v-if="video.displayUrl" text type="primary" size="small" @click="copyLink(video.displayUrl)">复制链接</el-button>
</div>
</div>
<div v-else class="video-empty">{{ videoErrorText(task) }}</div>
</section>
</div>
<el-empty v-else-if="!loading" description="暂无视频任务记录" />
<el-drawer v-model="detailVisible" title="任务明细" size="720px"> <el-drawer v-model="detailVisible" title="任务明细" size="720px">
<div v-loading="detailLoading"> <div v-loading="detailLoading">
@@ -335,26 +384,16 @@ onMounted(load)
<el-descriptions-item label="任务模式">{{ detail.mode || '—' }}</el-descriptions-item> <el-descriptions-item label="任务模式">{{ detail.mode || '—' }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.submittedAt) }}</el-descriptions-item> <el-descriptions-item label="提交时间">{{ formatDateTime(detail.submittedAt) }}</el-descriptions-item>
<el-descriptions-item label="生成时间">{{ formatDateTime(imageVideoGeneratedAt(detail)) }}</el-descriptions-item> <el-descriptions-item label="生成时间">{{ formatDateTime(imageVideoGeneratedAt(detail)) }}</el-descriptions-item>
<el-descriptions-item label="调试链接">
<el-button v-if="detail.debugUrl" text type="primary" size="small" @click="copyLink(detail.debugUrl)">复制链接</el-button>
<span v-else>—</span>
</el-descriptions-item>
<el-descriptions-item label="执行ID">{{ detail.cozeExecuteId || '—' }}</el-descriptions-item> <el-descriptions-item label="执行ID">{{ detail.cozeExecuteId || '—' }}</el-descriptions-item>
<el-descriptions-item label="错误">{{ detail.errorMessage || '—' }}</el-descriptions-item> <el-descriptions-item label="错误">{{ detail.errorMessage || '—' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<h4>请求</h4>
<pre class="json-block">{{ JSON.stringify(detail.request, null, 2) }}</pre>
<h4>提交响应</h4>
<pre class="json-block">{{ JSON.stringify(detail.submitResponse, null, 2) }}</pre>
<h4>结果</h4>
<pre class="json-block">{{ JSON.stringify(detail.result, null, 2) }}</pre>
</template> </template>
</div> </div>
</el-drawer> </el-drawer>
<el-dialog v-model="permissionVisible" title="视频任务数据范围授权" width="620px"> <el-dialog v-model="permissionVisible" title="视频任务记录权限配置" width="620px">
<p class="dim">勾选允许查看/操作视频任务的用户(仅数据范围)。当前已授权 {{ permissionGrantedCount }} 人。</p> <p class="dim">勾选允许查看视频任务记录的用户仅数据范围当前已授权 {{ permissionGrantedCount }} </p>
<el-table :data="permissionItems" border size="small" max-height="420"> <el-table :data="pagedPermissionItems" border size="small" max-height="420">
<el-table-column prop="id" label="ID" min-width="80" /> <el-table-column prop="id" label="ID" min-width="80" />
<el-table-column prop="username" label="用户名" min-width="180" /> <el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column label="授权" min-width="90" align="center"> <el-table-column label="授权" min-width="90" align="center">
@@ -363,6 +402,7 @@ onMounted(load)
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<OldPagination v-if="permissionItems.length > permPageSize" :total="permissionItems.length" :page="permPage" :page-size="permPageSize" @change="permPage = $event" @size-change="(s: number) => { permPageSize = s; permPage = 1 }" />
<template #footer> <template #footer>
<el-button @click="permissionVisible = false">取消</el-button> <el-button @click="permissionVisible = false">取消</el-button>
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button> <el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
@@ -372,30 +412,315 @@ onMounted(load)
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; } /* 像素复刻旧版 admin.html panel-image-video-tasks(蓝白末层)。 */
.actions { display: flex; align-items: center; gap: 12px; } .iv-view {
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; } font-family: inherit;
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; } color: #24384d;
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; } display: flex;
.f-item.wide { width: 340px; } flex-direction: column;
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; margin-left: auto; flex: none; } gap: 18px;
.video-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; } }
.task-card-grid { display: flex; flex-direction: column; gap: 14px; } .form-box,
.task-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; } .panel-box {
.task-card-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); } width: 100%;
.task-card-title { display: flex; align-items: center; gap: 10px; min-width: 0; } min-width: 0;
.task-id { font-weight: 600; color: var(--el-text-color-primary); } padding: 20px 22px 24px;
.task-card-info { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 6px 14px; padding: 12px 16px; border-bottom: 1px dashed var(--el-border-color-lighter); } border: 1px solid #d8e3ee;
.task-info-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; min-width: 0; } border-radius: 14px;
.task-info-row label { flex: none; color: var(--el-text-color-secondary); min-width: 60px; } background: linear-gradient(145deg, #ffffff, #f9fbfd);
.task-info-row span { color: var(--el-text-color-regular); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
.video-list { padding: 6px 16px; } }
.video-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px dashed var(--el-border-color-lighter); } h3 {
.video-row:last-child { border-bottom: 0; } margin: 0 0 16px;
.video-player { width: 300px; max-height: 180px; border-radius: 8px; background: #0d1117; } font-size: 15px;
.video-no-preview { color: var(--el-text-color-secondary); font-size: 13px; } font-weight: 650;
.video-index { color: var(--el-text-color-secondary); font-size: 12.5px; } color: #24384d;
.video-empty { padding: 18px 16px; color: var(--el-text-color-secondary); font-size: 13px; } letter-spacing: 0.2px;
.dim { color: var(--el-text-color-secondary); font-size: 12px; } }
.json-block { background: var(--el-fill-color-lighter); border-radius: 6px; padding: 10px; max-height: 320px; overflow: auto; font-size: 12px; } .form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.iv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.iv-toolbar-main {
display: inline-flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.iv-select-all {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: #5b6f83;
cursor: pointer;
}
.iv-summary {
color: #5b6f83;
font-size: 13px;
white-space: nowrap;
}
.task-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.iv-card {
border: 1px solid #d8e3ee;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
overflow: hidden;
}
.iv-card-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #e6edf4;
}
.iv-card-title {
font-weight: 600;
color: #24384d;
font-size: 13px;
}
.iv-card-info {
padding: 8px 16px 0;
}
.iv-info-row {
display: flex;
gap: 10px;
padding: 4px 0;
font-size: 13px;
}
.iv-info-row label {
flex: none;
width: 64px;
color: #8293a5;
}
.iv-info-row span {
color: #40586e;
min-width: 0;
}
.iv-video-list {
padding: 8px 16px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.iv-video-row {
display: flex;
align-items: center;
gap: 10px;
}
.iv-player {
flex: 1;
min-width: 0;
max-height: 240px;
border-radius: 8px;
background: #0f1722;
}
.iv-no-preview {
flex: 1;
padding: 18px 12px;
text-align: center;
color: #8293a5;
font-size: 12.5px;
border: 1px dashed #d8e3ee;
border-radius: 8px;
}
.iv-video-ops {
display: flex;
flex-direction: column;
gap: 6px;
}
.iv-empty-note {
padding: 16px;
color: #8293a5;
font-size: 12.5px;
}
.st-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
line-height: 1.7;
border: 1px solid transparent;
white-space: nowrap;
}
.st-pill.is-success {
color: #3d7158;
background: #e8f2eb;
border-color: #bdd7c5;
}
.st-pill.is-failed {
color: #91474f;
background: #f8ebeb;
border-color: #e4c2c5;
}
.st-pill.is-cancelled {
color: #5b6f83;
background: #f1f6fa;
border-color: #d6e2ec;
}
.st-pill.is-pending {
color: #8a6d3b;
background: #fbf4ea;
border-color: #e7d3ae;
}
.st-pill.is-running {
color: #8a6d3b;
background: #fbf4ea;
border-color: #e7d3ae;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.dim {
color: #8293a5;
font-size: 12px;
}
@media (max-width: 1100px) {
.task-card-grid {
grid-template-columns: 1fr;
}
}
</style> </style>
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatDateTime } from '@/utils/datetime' import { formatDateTime } from '@/utils/datetime'
/** 店铺数据任务/记录页:每条任务一张卡片(店铺名/任务号/状态/分组/最新/国家/文件 + 下载文件/删除) /** 店铺数据记录页 · 像素复刻旧版 admin.html panel-shop-data-crawl-tasks(自绘:form-box 筛选[h4 筛选条件] + panel-box toolbar[全选/批量下载/权限配置/汇总] + 每店卡片网格 + 旧式分页(店铺级+卡片级 9/页))。
* + 筛选 + 批量下载 + 数据范围授权(对齐 admin.js renderShopDataRecordTable)。 */ * script 逻辑沿用现有 Vue 实现(批量打包/删除结果文件[保留]/权限授权)。 */
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { asinCountryLabel } from '../asin/asin-country.ts' import { asinCountryLabel } from '../asin/asin-country.ts'
import { createShopDataFilter, type ShopDataFilter } from './shop-data-filter.ts' import { createShopDataFilter, type ShopDataFilter } from './shop-data-filter.ts'
import type { ShopDataResultRow, ShopDataTaskGroup } from './shop-data-model.ts' import type { ShopDataResultRow, ShopDataTaskGroup } from './shop-data-model.ts'
@@ -24,15 +24,21 @@ import {
createShopDataSelection, createShopDataSelection,
toggleShopDataSelection, toggleShopDataSelection,
} from './shop-data-selection.ts' } from './shop-data-selection.ts'
import { useAdminSessionStore } from '@/stores/admin-session'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false) const loading = ref(false)
const groups = ref<ShopDataTaskGroup[]>([]) const groups = ref<ShopDataTaskGroup[]>([])
const totalShops = ref(0) const totalShops = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = 20 // 店铺级一次全载(>200 家店铺时后端按页截断,可上调或恢复底部店铺级分页)。
// 卡片宫格分页:一行 3 个、一页 9 张卡片(在店铺分页的结果文件内翻页)。 const pageSize = ref(200)
// 卡片宫格分页:一行 4 个、一页 10 张卡片(在店铺分页的结果文件内翻页)。
const cardPage = ref(1) const cardPage = ref(1)
const cardPageSize = 9 const cardPageSize = ref(10)
const cardJump = ref('')
const cardTotalPages = computed(() => Math.max(1, Math.ceil(allResultRows.value.length / cardPageSize.value)))
const filter = reactive<ShopDataFilter & { dateRange: string[] }>({ ...createShopDataFilter(), dateRange: [] }) const filter = reactive<ShopDataFilter & { dateRange: string[] }>({ ...createShopDataFilter(), dateRange: [] })
const selection = ref<Set<string>>(new Set()) const selection = ref<Set<string>>(new Set())
@@ -40,20 +46,61 @@ const selection = ref<Set<string>>(new Set())
const permissionVisible = ref(false) const permissionVisible = ref(false)
const permissionItems = ref<TaskPermissionItem[]>([]) const permissionItems = ref<TaskPermissionItem[]>([])
const permissionSaving = ref(false) const permissionSaving = ref(false)
/** 权限弹窗用户表客户端分页(全量用户在表格内翻页,10/20/50/100 可选)。 */
const permPage = ref(1)
const permPageSize = ref(10)
const pagedPermissionItems = computed(() =>
permissionItems.value.slice((permPage.value - 1) * permPageSize.value, permPage.value * permPageSize.value),
)
const busyKey = ref('') const busyKey = ref('')
const deletingKey = ref('') const deletingKey = ref('')
function statusLabel(status: string): string { /** 下载进度弹窗:percent=0~100indeterminate=true 表示后端未回 Content-Length,只显示流动条。 */
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' } const downloadProgress = reactive({ visible: false, percent: 0, indeterminate: false, label: '' })
return map[status] || status
function startDownloadProgress(label: string) {
downloadProgress.label = label
downloadProgress.percent = 0
downloadProgress.indeterminate = false
downloadProgress.visible = true
} }
function statusType(status: string): 'info' | 'warning' | 'success' | 'danger' { function updateDownloadProgress(percent: number) {
if (status === 'SUCCESS') return 'success' if (percent < 0) {
if (status === 'RUNNING') return 'warning' downloadProgress.indeterminate = true
if (status === 'FAILED') return 'danger' return
return 'info' }
downloadProgress.indeterminate = false
downloadProgress.percent = percent
}
/** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */
function statusLabel(status: string): string {
const map: Record<string, string> = {
PENDING: '待执行',
WAITING: '排队中',
RUNNING: '执行中',
PROCESSING: '处理中',
SUCCESS: '已完成',
COMPLETED: '已完成',
FAILED: '失败',
ERROR: '失败',
CANCELLED: '已取消',
UNKNOWN: '未知',
}
const key = (status || '').toUpperCase()
if (map[key]) return map[key]
return key || '未知'
}
function statusType(status: string): 'pending' | 'running' | 'success' | 'failed' | 'cancelled' {
const key = (status || '').toUpperCase()
if (key === 'SUCCESS' || key === 'COMPLETED') return 'success'
if (key === 'FAILED' || key === 'ERROR') return 'failed'
if (key === 'CANCELLED') return 'cancelled'
if (key === 'PENDING' || key === 'WAITING') return 'pending'
return 'running'
} }
function toFilter(): ShopDataFilter { function toFilter(): ShopDataFilter {
@@ -69,12 +116,12 @@ function toFilter(): ShopDataFilter {
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize) const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize.value)
groups.value = result.items groups.value = result.items
totalShops.value = result.total totalShops.value = result.total
page.value = result.page page.value = result.page
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '店铺数据任务加载失败') ElMessage.error(error instanceof Error ? error.message : '店铺数据记录加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -94,7 +141,7 @@ function reset() {
const allResultRows = computed(() => groups.value.flatMap((group) => group.results)) const allResultRows = computed(() => groups.value.flatMap((group) => group.results))
const pagedResultRows = computed(() => const pagedResultRows = computed(() =>
allResultRows.value.slice((cardPage.value - 1) * cardPageSize, cardPage.value * cardPageSize), allResultRows.value.slice((cardPage.value - 1) * cardPageSize.value, cardPage.value * cardPageSize.value),
) )
const selectionCount = computed(() => countShopDataSelection(selection.value)) const selectionCount = computed(() => countShopDataSelection(selection.value))
@@ -102,6 +149,20 @@ function changeCardPage(p: number) {
cardPage.value = p cardPage.value = p
} }
function changeCardSize(size: number) {
cardPageSize.value = size
cardPage.value = 1
}
function goCardJump() {
const n = Number.parseInt(cardJump.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changeCardPage(Math.min(Math.max(n, 1), cardTotalPages.value))
}
function resultFileCount(): number { function resultFileCount(): number {
return groups.value.reduce((sum, group) => sum + group.results.length, 0) return groups.value.reduce((sum, group) => sum + group.results.length, 0)
} }
@@ -127,15 +188,27 @@ function rowSelected(row: ShopDataResultRow): boolean {
return selection.value.has(row.resultId) return selection.value.has(row.resultId)
} }
function selectAllChecked(): boolean {
const rows = selectableRows()
return rows.length > 0 && rows.every((row) => selection.value.has(row.resultId))
}
function selectAllIndeterminate(): boolean {
const rows = selectableRows()
return selectionCount.value > 0 && selectionCount.value < rows.length
}
async function downloadRow(row: ShopDataResultRow) { async function downloadRow(row: ShopDataResultRow) {
if (!row.fileReady) { if (!row.fileReady) {
ElMessage.warning('文件未就绪,无法下载') ElMessage.warning('文件未就绪,无法下载')
return return
} }
busyKey.value = row.resultId busyKey.value = row.resultId
startDownloadProgress(`正在下载「${row.shopName || '-'}」的数据文件`)
try { try {
const { blob, filename } = await fetchShopDataResultDownload(row.resultId) const { blob, filename } = await fetchShopDataResultDownload(row.resultId, updateDownloadProgress)
saveBlob(blob, filename) saveBlob(blob, filename)
downloadProgress.visible = false
ElMessage.success('下载已开始') ElMessage.success('下载已开始')
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败') ElMessage.error(error instanceof Error ? error.message : '下载失败')
@@ -151,9 +224,11 @@ async function downloadBatch() {
return return
} }
busyKey.value = 'batch' busyKey.value = 'batch'
startDownloadProgress(`正在打包下载 ${resultIds.length} 个结果文件`)
try { try {
const zip = await requestShopDataZipDownload(resultIds) const zip = await requestShopDataZipDownload(resultIds, updateDownloadProgress)
saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`) saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`)
downloadProgress.visible = false
const note = zip.errorCount ? `${zip.errorCount} 个失败(详见包内错误清单)` : '' const note = zip.errorCount ? `${zip.errorCount} 个失败(详见包内错误清单)` : ''
ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`) ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`)
} catch (error) { } catch (error) {
@@ -164,22 +239,15 @@ async function downloadBatch() {
} }
async function removeRowByRow(row: ShopDataResultRow) { async function removeRowByRow(row: ShopDataResultRow) {
try { // 管理端真删:删除该店这条店铺数据记录(累计档 + 文件 + 明细),前台任务记录仍保留但文件不可再下载
await ElMessageBox.confirm(`确认删除店铺“${row.shopName}的任务 ${row.taskNo || row.resultId} 及结果文件?`, '删除前确认', { if (!window.confirm(`确认删除店铺“${row.shopName}这条店铺数据记录及其数据文件?此操作不可恢复。`)) return
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
deletingKey.value = row.resultId deletingKey.value = row.resultId
try { try {
await deleteShopDataResultHistory(row.resultId) await deleteShopDataResultHistory(row.resultId)
ElMessage.success('删除成功') ElMessage.success('删除成功')
await load() await load()
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : `正在删除任务 ${row.taskNo || row.resultId}...`) ElMessage.error(error instanceof Error ? error.message : `删除店铺“${row.shopName}”的记录失败`)
} finally { } finally {
deletingKey.value = '' deletingKey.value = ''
} }
@@ -197,6 +265,7 @@ function saveBlob(blob: Blob, filename: string) {
async function openPermission() { async function openPermission() {
try { try {
permissionItems.value = await fetchShopDataTaskPermissionUsers() permissionItems.value = await fetchShopDataTaskPermissionUsers()
permPage.value = 1
permissionVisible.value = true permissionVisible.value = true
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败') ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
@@ -227,139 +296,120 @@ onMounted(load)
</script> </script>
<template> <template>
<div class="page-stack"> <div class="sd-view">
<div class="page-heading"> <section class="form-box">
<div> <h3>店铺数据记录</h3>
<h2>店铺数据任务/记录</h2> <h4 class="sd-filter-title">筛选条件</h4>
<p>按店铺查看数据抓取结果文件支持筛选下载删除与数据范围授权</p> <div class="form-row">
</div> <div class="form-group" style="min-width: 160px">
<div class="actions"> <label>店铺</label>
<el-checkbox <input v-model="filter.shopName" type="text" placeholder="模糊搜索店铺名" @keyup.enter="apply" />
:model-value="selectableRows().length > 0 && selectableRows().every((row) => rowSelected(row))"
:indeterminate="selectionCount > 0 && selectionCount < selectableRows().length"
@change="toggleSelectAllReady"
>
全选可下载
</el-checkbox>
<el-button :disabled="selectionCount === 0" :loading="busyKey === 'batch'" @click="downloadBatch">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
</el-button>
<el-button @click="openPermission">权限配置</el-button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-grid">
<div class="f-item">
<label>店铺(模糊搜索)</label>
<el-input v-model="filter.shopName" placeholder="输入店铺关键字" clearable @keyup.enter="apply" />
</div> </div>
<div class="f-item"> <div v-if="session.isSuperAdmin" class="form-group" style="min-width: 150px">
<label>分组(模糊搜索)</label> <label>分组</label>
<el-input v-model="filter.groupName" placeholder="输入分组关键字" clearable @keyup.enter="apply" /> <input v-model="filter.groupName" type="text" placeholder="模糊搜索分组名" @keyup.enter="apply" />
</div> </div>
<div class="f-item"> <div class="form-group" style="min-width: 130px">
<label>国家</label> <label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable> <select v-model="filter.country">
<el-option v-for="code in ['UK', 'DE', 'FR', 'ES', 'IT']" :key="code" :label="asinCountryLabel(code)" :value="code" /> <option value="">全部</option>
</el-select> <option v-for="code in ['UK', 'DE', 'FR', 'ES', 'IT']" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div> </div>
<div class="f-item wide"> <div class="form-group" style="min-width: 176px">
<label>创建时间</label> <label>创建开始</label>
<el-date-picker v-model="filter.dateRange" type="datetimerange" range-separator="" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DDTHH:mm" unlink-panels /> <input v-model="filter.dateRange[0]" type="datetime-local" />
</div> </div>
<div class="f-item btn-row"> <div class="form-group" style="min-width: 176px">
<el-button type="primary" @click="apply">查询</el-button> <label>创建结束</label>
<el-button @click="reset">重置</el-button> <input v-model="filter.dateRange[1]" type="datetime-local" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="reset">重置</button>
</div>
</section>
<section class="panel-box">
<div class="sd-toolbar">
<div class="sd-toolbar-main">
<label class="sd-select-all">
<input type="checkbox" :checked="selectAllChecked()" :indeterminate="selectAllIndeterminate()" @change="toggleSelectAllReady" />
全选可下载
</label>
<button class="btn" type="button" :disabled="selectionCount === 0 || busyKey === 'batch'" @click="downloadBatch">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
</button>
<button v-if="session.isSuperAdmin" class="btn btn-secondary" type="button" @click="openPermission">权限配置</button>
</div>
<div class="sd-toolbar-side">
<span class="sd-summary"> {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
</div> </div>
</div> </div>
</el-card>
<div v-loading="loading" class="shop-summary-row"> <div v-loading="loading" class="shop-card-grid">
<span>共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span> <template v-if="allResultRows.length">
<el-pagination <article
background v-for="row in pagedResultRows"
layout="prev, pager, next, jumper" :key="row.resultId || `${row.taskNo}-${row.filename}`"
:total="totalShops" class="sd-card"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p; cardPage = 1; selection = new Set(); load() }"
/>
</div>
<div v-if="allResultRows.length" class="shop-card-grid">
<article v-for="row in pagedResultRows" :key="row.resultId || `${row.taskNo}-${row.filename}`" class="shop-card">
<header class="shop-card-head">
<span class="shop-name" :title="row.shopName || '-'">{{ row.shopName || '-' }}</span>
</header>
<div class="shop-card-select-row">
<el-checkbox
:model-value="rowSelected(row)"
:disabled="!isUsableShopDataResult(row)"
@change="toggleRow(row)"
/>
<span class="shop-card-task-no">任务 {{ row.taskNo || row.resultId }}</span>
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</div>
<div class="shop-card-row">
<span class="shop-card-label">分组</span>
<span class="shop-card-value">{{ row.groupName || '-' }}</span>
</div>
<div class="shop-card-row">
<span class="shop-card-label">最新</span>
<span class="shop-card-value">{{ formatDateTime(row.updatedAt || row.createdAt) || '-' }}</span>
</div>
<div class="shop-card-row">
<span class="shop-card-label">国家</span>
<span class="shop-card-value">
<span v-for="code in row.countryCodes" :key="code" class="country-chip">{{ asinCountryLabel(code) }}</span>
<span v-if="!row.countryCodes.length" class="dim">-</span>
</span>
</div>
<div class="shop-card-row">
<span class="shop-card-label">文件</span>
<span class="shop-card-value shop-card-file" :title="row.filename">{{ row.filename || '-' }}</span>
</div>
<div class="shop-card-actions">
<el-button
text
type="success"
size="small"
:loading="busyKey === row.resultId"
:disabled="!isUsableShopDataResult(row)"
@click="downloadRow(row)"
> >
下载文件 <header class="sd-card-head">
</el-button> <span class="sd-shop-name" :title="row.shopName || '-'">{{ row.shopName || '-' }}</span>
<el-button </header>
v-if="isTerminalStatus(row.status) && !!row.resultId" <div class="sd-card-select-row">
text <input type="checkbox" :checked="rowSelected(row)" :disabled="!isUsableShopDataResult(row)" @change="toggleRow(row)" />
type="danger" <span class="shop-card-task-no" :title="`任务 ${row.taskId || row.taskNo || row.resultId}`">任务 {{ row.taskId || row.taskNo || row.resultId }}</span>
size="small" <span class="st-pill" :class="`is-${statusType(row.status)}`">{{ statusLabel(row.status) }}</span>
:loading="deletingKey === row.resultId" </div>
@click="removeRowByRow(row)" <div v-if="session.isSuperAdmin && row.groupName" class="sd-card-row">
> <span class="sd-card-label">分组</span>
删除 <span class="sd-card-value">{{ row.groupName }}</span>
</el-button> </div>
</div> <div class="sd-card-row">
</article> <span class="sd-card-label">最新</span>
</div> <span class="sd-card-value">{{ formatDateTime(row.updatedAt || row.createdAt) || '-' }}</span>
<div v-if="allResultRows.length > cardPageSize" class="table-footer"> </div>
<el-pagination <div class="sd-card-row">
background <span class="sd-card-label">国家</span>
layout="prev, pager, next, jumper" <span class="sd-card-value">
:total="allResultRows.length" <span v-for="code in row.countryCodes" :key="code" class="country-chip">{{ asinCountryLabel(code) }}</span>
:page-size="cardPageSize" <span v-if="!row.countryCodes.length" class="dim">-</span>
:current-page="cardPage" </span>
@current-change="changeCardPage" </div>
/> <div class="sd-card-row">
</div> <span class="sd-card-label">文件</span>
<span class="sd-card-value sd-card-file" :title="row.filename">{{ row.filename || '-' }}</span>
</div>
<div class="sd-card-actions">
<button
class="btn btn-sm"
type="button"
:disabled="!isUsableShopDataResult(row) || busyKey === row.resultId"
@click="downloadRow(row)"
>
{{ busyKey === row.resultId ? '下载中...' : '下载文件' }}
</button>
<button
v-if="isTerminalStatus(row.status) && !!row.resultId"
class="btn btn-sm btn-danger"
type="button"
:disabled="deletingKey === row.resultId"
@click="removeRowByRow(row)"
>
{{ deletingKey === row.resultId ? '删除中...' : '删除' }}
</button>
</div>
</article>
</template>
<div v-else-if="!loading" class="empty-tip">暂无符合条件的店铺数据记录</div>
</div>
<el-empty v-else-if="!loading" description="暂无符合条件的店铺数据任务" /> <OldPagination v-if="allResultRows.length > 0" :total="allResultRows.length" :page="cardPage" :page-size="cardPageSize" @change="changeCardPage" @size-change="changeCardSize" />
</section>
<el-dialog v-model="permissionVisible" title="店铺数据任务数据范围授权" width="620px"> <el-dialog v-model="permissionVisible" title="店铺数据记录数据范围授权" width="620px">
<p class="dim">勾选允许查看/操作店铺数据任务的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p> <p class="dim">勾选允许查看/操作店铺数据记录的用户仅数据范围当前已授权 {{ grantedCount }} </p>
<el-table :data="permissionItems" border size="small" max-height="420"> <el-table :data="pagedPermissionItems" border size="small" max-height="420">
<el-table-column prop="id" label="ID" min-width="80" /> <el-table-column prop="id" label="ID" min-width="80" />
<el-table-column prop="username" label="用户名" min-width="180" /> <el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column label="授权" min-width="90" align="center"> <el-table-column label="授权" min-width="90" align="center">
@@ -368,34 +418,376 @@ onMounted(load)
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<OldPagination v-if="permissionItems.length > permPageSize" :total="permissionItems.length" :page="permPage" :page-size="permPageSize" @change="permPage = $event" @size-change="(s: number) => { permPageSize = s; permPage = 1 }" />
<template #footer> <template #footer>
<el-button @click="permissionVisible = false">取消</el-button> <el-button @click="permissionVisible = false">取消</el-button>
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button> <el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="downloadProgress.visible" :title="downloadProgress.label" width="440px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<el-progress
:percentage="downloadProgress.indeterminate ? 50 : downloadProgress.percent"
:indeterminate="downloadProgress.indeterminate"
:duration="downloadProgress.indeterminate ? 3 : undefined"
:stroke-width="14"
/>
<p class="sd-progress-hint">{{ downloadProgress.indeterminate ? '正在接收数据,大小未知,请稍候…' : `已完成 ${downloadProgress.percent}%` }}</p>
</el-dialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; } /* 像素复刻旧版 admin.html panel-shop-data-crawl-tasks(蓝白末层)。 */
.actions { display: flex; align-items: center; gap: 12px; } .sd-view {
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; } font-family: inherit;
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; } color: #24384d;
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; } display: flex;
.f-item.wide { width: 340px; } flex-direction: column;
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; } gap: 18px;
.shop-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; } }
.shop-card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } .form-box,
.shop-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; } .panel-box {
.shop-card-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); } width: 100%;
.shop-name { font-weight: 600; color: var(--el-text-color-primary); } min-width: 0;
.shop-card-select-row { display: flex; align-items: center; gap: 10px; padding: 10px 16px 0; } padding: 20px 22px 24px;
.shop-card-task-no { font-weight: 600; font-size: 13px; color: var(--el-text-color-primary); } border: 1px solid #d8e3ee;
.shop-card-row { display: flex; align-items: flex-start; gap: 10px; padding: 6px 16px; font-size: 13px; } border-radius: 14px;
.shop-card-label { flex: none; width: 52px; color: var(--el-text-color-secondary); } background: linear-gradient(145deg, #ffffff, #f9fbfd);
.shop-card-value { color: var(--el-text-color-regular); min-width: 0; } box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
.shop-card-file { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } }
.shop-card-actions { display: flex; gap: 6px; padding: 4px 16px 12px; } h3 {
.country-chip { padding: 1px 8px; margin-right: 4px; border-radius: 999px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 12px; } margin: 0 0 14px;
.dim { color: var(--el-text-color-secondary); font-size: 12px; } font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.sd-filter-title {
margin: 0 0 14px;
font-size: 13.5px;
font-weight: 600;
color: #40586e;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.sd-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.sd-toolbar-main {
display: inline-flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.sd-toolbar-side {
display: inline-flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.sd-select-all {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: #5b6f83;
cursor: pointer;
}
.sd-summary {
color: #5b6f83;
font-size: 13px;
white-space: nowrap;
}
.sd-shop-pager {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #5b6f83;
}
.sd-shop-pager button,
.pagination button,
.jump-btn {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.sd-shop-pager button:hover:not(:disabled),
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.sd-shop-pager button:disabled,
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.sd-shop-pager input,
.page-jump input {
width: 52px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.shop-card-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.sd-card {
border: 1px solid #d8e3ee;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
overflow: hidden;
}
.sd-card-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #e6edf4;
}
.sd-shop-name {
font-weight: 600;
color: #24384d;
}
.sd-card-select-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px 0;
}
.shop-card-task-no {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
font-size: 12px;
color: #24384d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sd-card-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 6px 16px;
font-size: 13px;
}
.sd-card-label {
flex: none;
width: 52px;
color: #8293a5;
}
.sd-card-value {
color: #40586e;
min-width: 0;
}
.sd-card-file {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.sd-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 6px 16px 14px;
}
.st-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
line-height: 1.7;
border: 1px solid transparent;
white-space: nowrap;
flex: none;
}
.st-pill.is-success {
color: #3d7158;
background: #e8f2eb;
border-color: #bdd7c5;
}
.st-pill.is-failed {
color: #91474f;
background: #f8ebeb;
border-color: #e4c2c5;
}
.st-pill.is-cancelled {
color: #5b6f83;
background: #f1f6fa;
border-color: #d6e2ec;
}
.st-pill.is-pending,
.st-pill.is-running {
color: #8a6d3b;
background: #fbf4ea;
border-color: #e7d3ae;
}
.country-chip {
padding: 1px 8px;
margin-right: 4px;
border-radius: 999px;
background: #eef4fa;
color: #40586e;
font-size: 12px;
}
.dim {
color: #8293a5;
font-size: 12px;
}
.sd-progress-hint {
margin: 10px 0 0;
text-align: center;
color: #5b6f83;
font-size: 13px;
}
.empty-tip {
grid-column: 1 / -1;
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
@media (max-width: 1400px) {
.shop-card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1100px) {
.shop-card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 760px) {
.shop-card-grid {
grid-template-columns: 1fr;
}
}
</style> </style>
@@ -55,8 +55,8 @@ export async function fetchDuplicateLedger(
return parseLedgerPage(data) return parseLedgerPage(data)
} }
/** 导出 CSV(撞款 view=monitor / 台账 view=all)GET /duplicate-check-export → Blob。 */ /** 导出 Excel(撞款 view=monitor / 台账 view=all)GET /duplicate-check-export → xlsx Blob。 */
export async function requestDuplicateCsvBlob(filter: DuplicateFilter, view: string): Promise<Blob> { export async function requestDuplicateExportBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, { const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100), params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
responseType: 'blob', responseType: 'blob',
@@ -1,6 +1,6 @@
/** 重复检查明细筛选与分页(任务 117):与 /duplicate-check-items 的 snake 参数契约对齐;纯逻辑。 */ /** 重复检查明细筛选与分页(任务 117):与 /duplicate-check-items 的 snake 参数契约对齐;纯逻辑。 */
export const DUPLICATE_DEFAULT_PAGE_SIZE = 20 export const DUPLICATE_DEFAULT_PAGE_SIZE = 10
export const DUPLICATE_MIN_PAGE_SIZE = 10 export const DUPLICATE_MIN_PAGE_SIZE = 10
export const DUPLICATE_MAX_PAGE_SIZE = 100 export const DUPLICATE_MAX_PAGE_SIZE = 100
export const DUPLICATE_PAGE_MIN = 1 export const DUPLICATE_PAGE_MIN = 1
@@ -1,7 +1,7 @@
/** 视频任务筛选与分页(任务 102):图生视频任务列表筛选/分页归一,与 Java AdminImageVideoTaskController /** 视频任务筛选与分页(任务 102):图生视频任务列表筛选/分页归一,与 Java AdminImageVideoTaskController
* snake_case 查询契约对齐;纯逻辑。 */ * snake_case 查询契约对齐;纯逻辑。 */
export const IMAGE_VIDEO_DEFAULT_PAGE_SIZE = 20 export const IMAGE_VIDEO_DEFAULT_PAGE_SIZE = 10
export const IMAGE_VIDEO_MIN_PAGE_SIZE = 10 export const IMAGE_VIDEO_MIN_PAGE_SIZE = 10
export const IMAGE_VIDEO_MAX_PAGE_SIZE = 100 export const IMAGE_VIDEO_MAX_PAGE_SIZE = 100
export const IMAGE_VIDEO_PAGE_MIN = 1 export const IMAGE_VIDEO_PAGE_MIN = 1
@@ -27,10 +27,15 @@ export interface ShopDataZipDownloadResult {
errorCount?: number errorCount?: number
} }
/** 批量下载选中结果文件为 zipPOST /download-zip(blob);从响应头读成功/失败计数。 */ /** 批量下载选中结果文件为 zipPOST /download-zip(blob);从响应头读成功/失败计数。
export async function requestShopDataZipDownload(resultIds: readonly (number | string)[]): Promise<ShopDataZipDownloadResult> { * onProgress: 可选下载进度回调(0-100,后端未回 Content-Length 时退化为不定进度)。 */
export async function requestShopDataZipDownload(
resultIds: readonly (number | string)[],
onProgress?: (percent: number) => void,
): Promise<ShopDataZipDownloadResult> {
const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), { const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), {
responseType: 'blob', responseType: 'blob',
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
}) })
const counts = downloadZipHeaderCounts(headers as Record<string, string>) const counts = downloadZipHeaderCounts(headers as Record<string, string>)
return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount } return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount }
@@ -41,18 +46,34 @@ export interface ShopDataResultDownload {
filename: string filename: string
} }
/** 下载单个结果文件(服务端原文件名):GET /api/admin/shop-data-crawl/results/{resultId}/download。 */ /** 下载单店最新采集文件(按每日累计档 id):GET /api/admin/shop-data-crawl-tasks/daily-files/{id}/download。
export async function fetchShopDataResultDownload(resultId: number | string): Promise<ShopDataResultDownload> { * onProgress: 可选下载进度回调(0-100)。 */
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_SINGLE_ENDPOINT}/results/${resultId}/download`, { export async function fetchShopDataResultDownload(
resultId: number | string,
onProgress?: (percent: number) => void,
): Promise<ShopDataResultDownload> {
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}/download`, {
responseType: 'blob', responseType: 'blob',
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
}) })
const disposition = (headers as Record<string, string> | undefined)?.['content-disposition'] const disposition = (headers as Record<string, string> | undefined)?.['content-disposition']
return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) } return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) }
} }
/** 删除单条结果历史(仅终态):DELETE /api/admin/shop-data-crawl/history/{resultId}。 */ /** 将 axios 进度事件转为 0-100 百分比;响应未回 Content-Length 时报 -1,前端显示不定进度。 */
function toAxiosProgress(onProgress: (percent: number) => void) {
return (event: { loaded: number; total?: number }) => {
if (event.total) {
onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
} else {
onProgress(-1)
}
}
}
/** 删除该店这条店铺数据记录(按每日累计档 id,管理端显式操作):DELETE .../daily-files/{id}。 */
export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> { export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> {
await http.delete<unknown>(`${SHOP_DATA_SINGLE_ENDPOINT}/history/${resultId}`) await http.delete<unknown>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}`)
} }
/** 加载店铺数据任务数据范围授权用户:GET /api/admin/shop-data-crawl-task-permissions。 */ /** 加载店铺数据任务数据范围授权用户:GET /api/admin/shop-data-crawl-task-permissions。 */
@@ -1,6 +1,6 @@
/** 店铺数据任务筛选与分页(任务 109):与 Java AdminShopDataCrawlTasksController snake 契约对齐;纯逻辑。 */ /** 店铺数据任务筛选与分页(任务 109):与 Java AdminShopDataCrawlTasksController snake 契约对齐;纯逻辑。 */
export const SHOP_DATA_DEFAULT_PAGE_SIZE = 20 export const SHOP_DATA_DEFAULT_PAGE_SIZE = 10
export const SHOP_DATA_MIN_PAGE_SIZE = 10 export const SHOP_DATA_MIN_PAGE_SIZE = 10
export const SHOP_DATA_MAX_PAGE_SIZE = 100 export const SHOP_DATA_MAX_PAGE_SIZE = 100
export const SHOP_DATA_PAGE_MIN = 1 export const SHOP_DATA_PAGE_MIN = 1
+5 -1
View File
@@ -17,14 +17,18 @@ export const adminPages: AdminPageDef[] = [
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') }, { path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') }, { path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') }, { path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
{ path: 'account/user-secrets', menuKey: 'admin_user_secrets', title: '密钥管理', load: () => import('@/pages/account/UserSecretsPage.vue') },
{ path: 'account/user-secret-usage', menuKey: 'admin_user_secret_usage', title: '密钥用量统计', load: () => import('@/pages/account/UserSecretUsagePage.vue') },
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') }, { path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') }, { path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') }, { path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') }, { path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') }, { path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
{ path: 'records/tutorial', menuKey: 'admin_tutorial', title: '教程管理', load: () => import('@/pages/records/RecordsTutorialPage.vue') },
{ path: 'records/device-logs', menuKey: 'admin_device_logs', title: '日志管理', load: () => import('@/pages/records/DeviceLogsPage.vue') },
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') }, { path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') }, { path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据任务/记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') }, { path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
{ path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') }, { path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') },
{ path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') }, { path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') },
{ path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') }, { path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') },
+24
View File
@@ -183,6 +183,30 @@ button, input, textarea, select { font: inherit; }
.el-tag { border-radius: 6px; } .el-tag { border-radius: 6px; }
.el-button { border-radius: 9px; } .el-button { border-radius: 9px; }
.el-message-box { border-radius: 14px; } .el-message-box { border-radius: 14px; }
/* 消息提示层级:Element 弹层 z-index 自 2000 递增,低于页面自定义 modal-mask(3000/3100)
遮罩弹窗内触发的 ElMessage 警告(如去重台账导入/编辑未选分组)会被浮层挡住看不见,
统一把消息提示抬到 4000(高于全部自定义浮层),!important 覆盖实例内联层级。
消息盒确认弹窗同类隐患一并抬到 4100z-index 落在 .el-overlay.is-message-box 内联样式)。 */
.el-message { z-index: 4000 !important; }
.el-overlay.is-message-box { z-index: 4100 !important; }
/* 分组筛选下拉(可模糊搜索):对齐旧版控件观感(42px/9px/浅底/focus ring)。 */
.el-select.admin-filter {
width: 100%;
}
.el-select.admin-filter .el-select__wrapper {
min-height: 42px;
border-radius: 9px;
background: #f8fbfd;
box-shadow: 0 0 0 1px #cbd9e6 inset;
}
.el-select.admin-filter .el-select__wrapper:hover {
box-shadow: 0 0 0 1px #9fb7cd inset;
}
.el-select.admin-filter.is-focused .el-select__wrapper {
background: #ffffff;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
@media (max-width: 820px) { @media (max-width: 820px) {
.admin-sidebar { flex-basis: 64px; } .admin-sidebar { flex-basis: 64px; }
+1
View File
@@ -12,6 +12,7 @@ export interface AdminUser {
createdById?: number | null createdById?: number | null
creatorUsername?: string creatorUsername?: string
createdAt?: string createdAt?: string
updatedAt?: string
pinyinAbbr?: string pinyinAbbr?: string
} }
@@ -0,0 +1,34 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
/** 对齐旧版 .btn-smaccount 域行内操作统一实心小按钮(不再 link/text)。 */
function assertRowButtonSolid(source: string, action: string) {
assert.match(source, new RegExp(`<el-button[^>]*size="small"[^>]*@click="${action}`), `${action} 为 size=small 实心按钮`)
assert.doesNotMatch(source, new RegExp(`<el-button[^>]*link[^>]*@click="${action}`), `${action} 不再 link`)
assert.doesNotMatch(source, new RegExp(`<el-button[^>]*text[^>]*@click="${action}`), `${action} 不再 text`)
}
test('align_users_row_buttons_old_btn', () => {
const s = readSource('src/pages/account/UsersPage.vue')
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="removeUser\(row\)"/, '删除用旧版 btn-sm btn-danger')
assert.doesNotMatch(s, /el-button/, '用户页已自绘,无 EP 按钮')
assert.match(s, /window\.confirm\(deleteConfirmMessage\(row\)\)/, '删除确认走原生 confirm 对齐旧版')
})
test('align_groups_row_buttons_old_btn', () => {
const s = readSource('src/pages/account/GroupsPage.vue')
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(group\)"/, '编辑用旧版 btn-sm 实心小按钮')
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="removeGroup\(group\)"/, '删除用旧版 btn-sm btn-danger')
assert.doesNotMatch(s, /el-button[^>]*@click="openEdit/, '行内不再用 EP 按钮')
})
test('align_menus_row_buttons_old_btn', () => {
const s = readSource('src/pages/account/MenusPage.vue')
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="removeMenu\(row\)"/, '删除用旧版 btn-sm btn-danger')
assert.doesNotMatch(s, /el-button[^>]*@click="openEdit/, '行内不再用 EP 按钮')
assert.match(s, /window\.confirm\(`确定删除菜单/, '删除确认走原生 confirm 对齐旧版')
})
@@ -0,0 +1,69 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
/** 对齐旧版 .btn-sm:asin 域 5 页已像素复刻,行内操作(编辑/删除/配置)用原生 btn btn-sm 实心小按钮(非 EP 按钮)。 */
// 断言单个 el-button 标签同时含 btn-sm 与目标 @click,且不含 link/text 修饰(action 含正则元字符需转义)。
const escapeRe = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const OLD_BTN = (action: string) => new RegExp(`class="btn btn-sm[^"]*"[^>]*@click="${escapeRe(action)}`)
const NO_LINK = (action: string) => new RegExp(`<el-button[^>]*link[^>]*@click="${escapeRe(action)}`)
const NO_TEXT = (action: string) => new RegExp(`<el-button[^>]*text[^>]*@click="${escapeRe(action)}`)
const NO_EL = (action: string) => new RegExp(`<el-button[^>]*@click="${escapeRe(action)}`)
function assertOldRowButton(source: string, action: string, page: string) {
assert.match(source, OLD_BTN(action), `${page} ${action} 为 btn-sm 实心按钮`)
assert.doesNotMatch(source, NO_LINK(action), `${page} ${action} 不再 link`)
assert.doesNotMatch(source, NO_TEXT(action), `${page} ${action} 不再 text`)
assert.doesNotMatch(source, NO_EL(action), `${page} ${action} 不再用 EP 按钮`)
}
test('align_invalid_asin_row_buttons_old_btn', () => {
const s = readSource('src/pages/asin/AsinInvalidPage.vue')
assertOldRowButton(s, 'openEdit(row)', '不符合ASIN')
assertOldRowButton(s, 'remove(row)', '不符合ASIN')
assert.match(s, /window\.confirm\(invalidAsinDeleteText\(row\)\)/, '删除确认原生 confirm 对齐旧版')
})
test('align_query_asin_config_old_btn', () => {
assertOldRowButton(readSource('src/pages/asin/QueryAsinPage.vue'), 'openConfig(row.item as QueryAsinItem)', '查询ASIN')
})
test('align_skip_price_config_old_btn', () => {
assertOldRowButton(readSource('src/pages/asin/SkipPricePage.vue'), 'openConfig(row.item as SkipPriceItem)', '最低价ASIN')
})
test('align_product_category_row_buttons_old_btn', () => {
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
assertOldRowButton(s, 'openEdit(row.node)', '商品类目树视图')
assertOldRowButton(s, 'openEdit(row)', '商品类目搜索视图')
assert.match(s, /btn-sm btn-danger"[^>]*@click="removeCategory/, '删除为 btn-sm btn-danger')
})
test('align_product_category_no_indigo_left', () => {
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.doesNotMatch(s, /#6366f1|#eef0fe|6366f1|eef0fe/, '靛蓝色点已清')
assert.match(s, /--admin-primary-soft/, '折叠钮改用蓝白主色 soft token')
})
test('align_product_category_load_more_old_class', () => {
// 类目「加载更多」行按钮沿用 btn-sm btn-secondary(旧版小组按钮样式)。
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(s, /class="btn btn-sm btn-secondary"[^>]*loadMoreChildren/, '加载更多为旧版 btn-sm btn-secondary')
})
test('align_dedupe_registry_row_buttons_old_btn', () => {
const s = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="removeRow\(row\)"/, '删除用旧版 btn-sm btn-danger')
assert.doesNotMatch(s, /el-button/, '去重汇总已自绘,无 EP 按钮')
assert.match(s, /window\.confirm\(`确定删除总数据/, '删除确认文案对齐旧版(原生 confirm)')
})
test('align_asin_actions_cell_spacing', () => {
// 反馈:查询ASIN操作按钮要有间距——asin-col-actions 统一 flex + gap 8px(对齐全站 ops-cell)。
for (const page of ['src/pages/asin/QueryAsinPage.vue', 'src/pages/asin/SkipPricePage.vue']) {
const s = readSource(page)
assert.match(s, /\.asin-col-actions\s*\{[^}]*gap:\s*8px/, `${page} 操作列按钮间距 8px`)
}
})
@@ -16,9 +16,9 @@ test('align_category_tree_table_normal_primary_path', () => {
test('align_category_tree_table_normal_variant_input', () => { test('align_category_tree_table_normal_variant_input', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue') const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /expandedIds/, '客户端展开状态集合') assert.match(page, /expandedIds/, '客户端展开状态集合')
assert.match(page, /flatRows|treeRows/, '按展开状态扁平化行集') assert.match(page, /treeRows/, '按展开状态扁平化行集')
for (const col of ['层级路径', '排序', '来源', '说明']) { for (const col of ['层级路径', '排序', '来源', '备注']) {
assert.match(page, new RegExp(col), `树形表格含「${col}」列`) assert.match(page, new RegExp(col), `树形表格含「${col}」列(旧版列名为备注)`)
} }
}) })
@@ -46,8 +46,8 @@ test('align_category_tree_table_repeated_operation_is_idempotent', () => {
}) })
test('align_category_tree_table_dependency_failure_returns_actionable_message', () => { test('align_category_tree_table_dependency_failure_returns_actionable_message', () => {
// 视觉复刻旧版:20px 圆角方块标记、缩进宽度变量、名称加粗。 // 视觉复刻旧版:圆角方块标记、层级缩进(24px/级)、名称加粗。
const page = readSource('src/pages/asin/ProductCategoryPage.vue') const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /tree-node-mark/, '展开标记样式存在') assert.match(page, /tree-node-mark/, '展开标记样式存在')
assert.match(page, /var\(--indent|--indent/, '缩进变量') assert.match(page, /row\.depth \* 24/, '缩进 24px/级')
}) })
@@ -7,19 +7,6 @@ import { readSource } from './helpers.ts'
// 例外:DuplicateCheckPage 不动;dialog/表单的 width 不属于表格列。 // 例外:DuplicateCheckPage 不动;dialog/表单的 width 不属于表格列。
const PAGES = [ const PAGES = [
'src/pages/account/GroupsPage.vue',
'src/pages/account/MenusPage.vue',
'src/pages/account/UsersPage.vue',
'src/pages/asin/AsinInvalidPage.vue',
'src/pages/asin/DedupeRegistryPage.vue',
'src/pages/asin/ProductCategoryPage.vue',
'src/pages/asin/QueryAsinPage.vue',
'src/pages/asin/SkipPricePage.vue',
'src/pages/records/RecordsDigitalHumanVersionPage.vue',
'src/pages/records/RecordsHistoryPage.vue',
'src/pages/records/RecordsSoftwareVersionPage.vue',
'src/pages/shop/ShopKeysPage.vue',
'src/pages/shop/ShopManagePage.vue',
'src/pages/tasks/ImageVideoTasksPage.vue', 'src/pages/tasks/ImageVideoTasksPage.vue',
'src/pages/tasks/ShopDataTasksPage.vue', 'src/pages/tasks/ShopDataTasksPage.vue',
] ]
@@ -47,9 +34,40 @@ test('align_column_width_normal_variant_input', () => {
}) })
test('align_column_width_boundary_empty_input', () => { test('align_column_width_boundary_empty_input', () => {
// 例外页 DuplicateCheckPage 保持自身实现不动 // 例外页 DuplicateCheckPage 已整页复刻 reference 撞款控制台(原生 ledger 表格),不参与 el-table 列宽约定
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue') const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(dup, /el-table-column/, 'DuplicateCheckPage 表格仍在且未被修改') assert.equal(/el-table-column/.test(dup), false, '撞款台账已用 reference 原生表格,不再有 el-table-column')
assert.match(dup, /class="ledger"/, '撞款台账仍为表格实现')
const hist = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.equal(/el-table-column/.test(hist), false, '生成记录已像素复刻 admin panel-history 原生表格,不再有 el-table-column')
assert.match(hist, /class="table-scroll history-table-scroll"/, '生成记录用原生 .table-scroll 表格')
assert.match(hist, /<th[^>]*>结果预览<\/th>/, '原生表含结果预览列')
const dedupe = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.equal(/el-table-column/.test(dedupe), false, '去重汇总已像素复刻 admin panel-dedupe-total-data 原生表格,不再有 el-table-column')
assert.match(dedupe, /class="table-scroll dedupe-table-scroll"/, '去重汇总用原生 .table-scroll 表格')
const users = readSource('src/pages/account/UsersPage.vue')
assert.equal(/el-table-column/.test(users), false, '用户管理已像素复刻 admin panel-users 原生表格,不再有 el-table-column')
assert.match(users, /class="table-scroll user-table-scroll"/, '用户管理用原生 .table-scroll 表格')
const menus = readSource('src/pages/account/MenusPage.vue')
assert.equal(/el-table-column/.test(menus), false, '菜单配置已像素复刻 admin panel-columns 原生表格,不再有 el-table-column')
assert.match(menus, /class="table-scroll columns-table-scroll"/, '菜单配置用原生 .table-scroll 表格')
const groups = readSource('src/pages/account/GroupsPage.vue')
assert.equal(/el-table-column/.test(groups), false, '分组管理已像素复刻 admin panel-group-manage 原生表格,不再有 el-table-column')
assert.match(groups, /class="table-scroll"/, '分组管理用原生 .table-scroll 表格')
for (const [page, cls] of [
['src/pages/asin/AsinInvalidPage.vue', 'invalid-asin-table-scroll'],
['src/pages/asin/QueryAsinPage.vue', 'query-asin-table-scroll'],
['src/pages/asin/SkipPricePage.vue', 'skip-price-asin-table-scroll'],
['src/pages/asin/ProductCategoryPage.vue', 'category-table-scroll'],
['src/pages/shop/ShopKeysPage.vue', 'shop-key-table-scroll'],
['src/pages/shop/ShopManagePage.vue', 'shop-manage-table-scroll'],
['src/pages/records/RecordsSoftwareVersionPage.vue', 'version-table-scroll'],
['src/pages/records/RecordsDigitalHumanVersionPage.vue', 'digital-human-version-table-scroll'],
]) {
const src = readSource(page)
assert.equal(/el-table-column/.test(src), false, `${page} 已像素复刻原生表格,不再有 el-table-column`)
assert.match(src, new RegExp(cls), `${page} 用原生 .table-scroll 表格`)
}
}) })
test('align_column_width_dependency_failure_returns_actionable_message', () => { test('align_column_width_dependency_failure_returns_actionable_message', () => {
@@ -53,11 +53,20 @@ test('align_dedupe_group_option_parses_leader', () => {
assert.deepEqual(options, [{ id: 3, name: 'C组', leaderUserId: 12 }]) assert.deepEqual(options, [{ id: 3, name: 'C组', leaderUserId: 12 }])
}) })
test('align_dedupe_page_summary_and_export_wait_wiring', () => { test('align_dedupe_group_option_parses_java_groupName_field', () => {
// Java /api/admin/shop-manages/groups 返回 groupName(驼峰);解析器需同时认驼峰与 snake。
const options = parseDedupeGroupOptionList({
success: true,
data: { items: [{ id: 2, groupName: '周丽娥', leaderUserId: 23 }] },
})
assert.deepEqual(options, [{ id: 2, name: '周丽娥', leaderUserId: 23 }])
})
test('align_dedupe_page_no_summary_block', () => {
// 用户反馈(样办2):数据权限分组摘要块多余,从页面移除;模型纯函数保留。
const page = readSource('src/pages/asin/DedupeRegistryPage.vue') const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
assert.match(page, /数据权限分组/, '页顶数据权限分组汇总卡') assert.doesNotMatch(page, /dedupe-group-access|dedupeGroupSummaryOf|groupSummary/, '页面不再有分组摘要块与接线')
assert.match(page, /dedupeGroupSummaryOf/, '汇总走纯模型')
assert.match(page, /正在生成导出文件/, '导出等待遮罩文案对齐') assert.match(page, /正在生成导出文件/, '导出等待遮罩文案对齐')
assert.match(page, /已等待/, '导出等待含等待秒数') assert.match(page, /已等待/, '导出等待含等待秒数')
assert.match(page, /未分组/, '分组空值显示未分组') assert.match(page, /未分组/, '分组空值行内显示未分组')
}) })
@@ -58,14 +58,16 @@ test('align_digital_human_api_download_url', () => {
test('align_digital_human_page_wiring', () => { test('align_digital_human_page_wiring', () => {
const page = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue') const page = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
assert.match(page, /pageSize\s*=\s*20|:page-size="20"/, '分页大小 20') assert.match(page, /pageSize\s*=\s*ref\(10\)/, '分页大小默认 10(全站统一)')
assert.match(page, /el-pagination/, '分页组件') assert.match(page, /page-nums/, '数字页码分页(像素复刻旧版)')
assert.match(page, /设为最新/, '按钮名对齐“设为最新”') assert.match(page, /设为最新/, '按钮名对齐“设为最新”')
assert.match(page, /上传(草稿状态)/, '上传按钮名对齐') assert.match(page, /上传新版本/, '页头上传按钮名对齐')
assert.match(page, /上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布/, '上传弹窗描述对齐') assert.match(page, /上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布/, '上传弹窗描述对齐')
assert.match(page, /正在上传/, '上传进度文案') assert.match(page, /正在上传/, '上传进度文案')
assert.match(page, /digitalHumanDeleteConfirmText/, '删除确认文案走对齐模型') assert.match(page, /digitalHumanDeleteConfirmText/, '删除确认文案走对齐模型')
assert.match(page, /downloadUrl|fetchDigitalHumanDownloadUrl/, 'RELEASED 下载按钮点击取链接') assert.match(page, /fetchDigitalHumanDownloadUrl/, 'RELEASED 下载按钮点击取链接')
assert.match(page, /已废弃/, '状态三态接入') assert.match(page, /digitalHumanStatusDisplay/, '状态走展示层模型')
const display = readSource('src/pages/records/digitalhuman-status-display.ts')
assert.match(display, /已废弃/, '状态三态接入(草稿/已发布/已废弃)')
assert.match(page, /digitalHumanChangelogShort/, '更新说明 50 字符截断') assert.match(page, /digitalHumanChangelogShort/, '更新说明 50 字符截断')
}) })
@@ -42,7 +42,23 @@ test('align_dup_console_drawer_template', () => {
assert.match(page, /drawerAggRows/, '抽屉渲染聚合行') assert.match(page, /drawerAggRows/, '抽屉渲染聚合行')
assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章') assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章')
assert.match(page, /次上架/, '汇总含 N 次上架') assert.match(page, /次上架/, '汇总含 N 次上架')
assert.match(page, /label="次数"/, '聚合表含次数列') assert.match(page, /<th[^>]*>次数<\/th>/, '聚合表含次数列')
assert.match(page, /v-for="row in drawerRows"/, '抽屉聚合表绑定分页切片')
assert.match(page, /:total="drawerAggRows\.length"/, '抽屉聚合表有分页器')
})
test('align_dup_console_pagination_all_lists', () => {
// 全站统一收官:撞款页四处列表(分布/矩阵/撞款详情卡片/抽屉明细)均挂共享 OldPagination
// 且分布/矩阵/详情常显(不足一页也显示分页器,反馈:数据少时看不到分页)。
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(page, /:total="distCount"/, '店铺上架分布有分页器(常显)')
assert.match(page, /:total="dupRows\.length"/, '撞款覆盖矩阵有分页器(常显)')
assert.match(page, /v-for="item in dupCardRows"/, '撞款详情卡片绑定分页切片')
assert.match(page, /:total="dupRows\.length"[\s\S]*dupCardPageSize/, '撞款详情卡片有分页器(常显)')
assert.match(page, /<OldPagination[^>]*:total="distCount"/, '分布分页器无 v-if 条件')
assert.match(page, /<OldPagination[^>]*:total="dupRows\.length"/, '矩阵/详情分页器无 v-if 条件')
assert.match(page, /v-for="group in pagedGroupStats"/, '分组说明抽屉表绑定分页切片')
assert.match(page, /:total="groupStats\.length"/, '分组说明抽屉表有分页器')
}) })
test('align_dup_console_ledger_total_preload', () => { test('align_dup_console_ledger_total_preload', () => {
@@ -56,3 +72,30 @@ test('align_dup_console_matrix_sticky', () => {
assert.match(page, /table\.matrix th\s*\{[^}]*position:\s*sticky/, '矩阵表头吸顶') assert.match(page, /table\.matrix th\s*\{[^}]*position:\s*sticky/, '矩阵表头吸顶')
assert.match(page, /\.matrix-wrap\s*\{[^}]*max-height/, '矩阵容器限高滚动') assert.match(page, /\.matrix-wrap\s*\{[^}]*max-height/, '矩阵容器限高滚动')
}) })
test('align_dup_console_role_scope_group_filter_only_super', () => {
// 角色范围:分组筛选下拉仅超管需要;管理员数据由后端裁到本组。
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(page, /useAdminSessionStore/, '页面读取登录会话取角色')
assert.match(page, /const isSuper = computed\(\(\) => session\.isSuperAdmin\)/, 'isSuper 由会话角色推导')
assert.match(page, /<div v-if="isSuper" class="f-group">\s*<label><\/label>/s, '')
assert.match(page, /group: filter\.group\.trim\(\)/, '分组筛选仍参与查询参数')
})
test('align_dup_console_role_scope_group_controls_only_super', () => {
// 分组撞款/分组说明为超管专属交互;后端超管全量、普通管理员所管分组店铺。
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(page, /<div v-if="isSuper" class="scope-bar">/, '分组撞款分段仅超管可见')
assert.match(page, /v-if="isSuper" class="btn btn-ghost btn-sm" type="button" @click="groupInfoVisible = true"/, '分组说明按钮仅超管可见')
const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java')
assert.match(service, /if \(superAdmin\) \{\s*return null;/, '超管可见范围=全量(null)')
assert.match(service, /selectManagedShopNames\(operatorId\)/, '普通管理员可见范围=所管分组店铺')
})
test('align_dup_console_country_labels_chinese', () => {
// 反馈:国家显示一定要用中文——站点/国家一律经 asinCountryLabel 映射,禁止裸国家码直出。
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(page, /asinCountryLabel\(/, '站点显示走中文映射函数')
assert.doesNotMatch(page, /\{\{\s*(row|item)\.country\s*\}\}/, '表格/抽屉无裸国家码直出')
assert.match(page, /asinCountryLabel\(code\)/, '筛选下拉站点显示中文')
})
@@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// 回归(生产:用户菜单权限“自己没掉”):编辑用户弹窗打开时先把 columnIds 清空,再异步回填;
// 若授权尚未加载完成(或加载失败)就点保存,会把空数组提交成“清空授权”,整树权限被抹掉。
// 修复:授权未就绪时禁用保存并明确提示。
test('edit_user_dialog_blocks_save_until_auth_loaded', () => {
const dialog = readSource('src/pages/account/EditUserDialog.vue')
assert.match(dialog, /authReady/, '存在授权就绪标记')
assert.match(dialog, /authReady\.value = false/, '打开/失败时标记为未就绪')
// 加载失败分支必须保持未就绪:不能回退成“可以保存空授权”
const refreshBlock = dialog.slice(
dialog.indexOf('async function refreshAuth'),
dialog.indexOf('function close'),
)
assert.match(
refreshBlock,
/catch \(error\) \{[\s\S]*?authReady\.value = false/,
'授权加载失败保持未就绪,不用空数组覆盖服务端授权',
)
assert.match(refreshBlock, /authReady\.value = true/, '仅在加载成功后置为就绪')
// save() 自身兜底拦截,不依赖按钮禁用
const saveBlock = dialog.slice(dialog.indexOf('async function save'), dialog.indexOf('async function save') + 400)
assert.match(saveBlock, /if \(!authReady\.value\)/, '未就绪时直接拦截保存')
// 保存按钮在未就绪时禁用
assert.match(dialog, /:disabled="!authReady"/, '未就绪时保存按钮禁用')
assert.match(dialog, /加载中/, '加载中有提示')
assert.match(dialog, /保存已禁用/, '加载失败有明确提示')
})
@@ -0,0 +1,92 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
/**
* 分组维度仅超管可见:非超管(管理员/普通用户)数据已由后端裁剪到本人分组,
* 后台各页的分组筛选与分组列一律不展示,只有超管需要按分组筛选/区分。
* 参考实现见撞款检测页(DuplicateCheckPage.vue 分组筛选用 isSuper 门控)。
*/
/** 页面 → 分组筛选门控片段(正则不做宽松匹配,确保门控真实存在)。 */
const FILTER_GATES: Array<[string, RegExp]> = [
['src/pages/asin/AsinInvalidPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/asin/QueryAsinPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/asin/SkipPricePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/asin/DedupeRegistryPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/shop/ShopManagePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
['src/pages/tasks/ShopDataTasksPage.vue', /v-if="session\.isSuperAdmin"[\s\S]{0,160}模糊搜索分组名/],
['src/pages/account/UserSecretsPage.vue', /v-if="isSuperAdmin[\s\S]{0,160}<label>分组<\/label>/],
]
/** 页面 → 分组列表头门控片段。 */
const COLUMN_GATES: Array<[string, RegExp]> = [
['src/pages/asin/AsinInvalidPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/asin/QueryAsinPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/asin/SkipPricePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/asin/DedupeRegistryPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/shop/ShopManagePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
['src/pages/tasks/DuplicateCheckPage.vue', /<th v-if="isSuper"[^>]*>分组<\/th>/],
['src/pages/account/UserSecretsPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
]
test('align_group_visibility_filter_gated_by_super_admin', () => {
for (const [file, pattern] of FILTER_GATES) {
assert.match(readSource(file), pattern, `${file} 分组筛选未按超管门控`)
}
})
test('align_group_visibility_column_gated_by_super_admin', () => {
for (const [file, pattern] of COLUMN_GATES) {
assert.match(readSource(file), pattern, `${file} 分组列未按超管门控`)
}
})
test('align_group_visibility_no_ungated_group_column_header', () => {
// 不变量:以上页面的“分组”数据列表头必须带 v-if 门控,防止后续改动误放开。
// DuplicateCheckPage 的分组说明抽屉内另有一张分组统计表,该抽屉由已门控的按钮打开,
// 非超管无法触达,故不纳入本不变量(单独在 detail_cards 用例中校验按钮门控)。
const ungated = /<th(?![^>]*v-if)[^>]*>分组<\/th>/
for (const [file] of COLUMN_GATES) {
if (file.endsWith('DuplicateCheckPage.vue')) continue
assert.doesNotMatch(readSource(file), ungated, `${file} 存在未门控的分组列表头`)
}
})
test('align_group_visibility_detail_cards_gated', () => {
// 明细卡片/信息行里的分组展示同样只给超管。
assert.match(
readSource('src/pages/tasks/ImageVideoTasksPage.vue'),
/v-if="session\.isSuperAdmin"[\s\S]{0,120}所属分组/,
'图片视频任务页「所属分组」未门控',
)
assert.match(
readSource('src/pages/tasks/ShopDataTasksPage.vue'),
/v-if="session\.isSuperAdmin && row\.groupName"/,
'店铺数据记录页卡片分组行未门控',
)
assert.match(
readSource('src/pages/tasks/DuplicateCheckPage.vue'),
/v-if="isSuper && block\.groupName"/,
'撞款检测页卡片分组标签未门控',
)
// 分组说明抽屉整体为分组维度内容:入口按钮与分组范围切换条均已按超管门控。
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
assert.match(dup, /<button v-if="isSuper"[^>]*@click="groupInfoVisible = true"/, '分组说明入口未门控')
assert.match(dup, /<div v-if="isSuper" class="scope-bar"/, '分组撞款范围条未门控')
})
test('align_group_visibility_dialogs_keep_locked_selector', () => {
// 弹窗分组选择保留展示但非超管锁定(单分组时),不隐藏。
const lockedPages = [
'src/pages/asin/QueryAsinPage.vue',
'src/pages/asin/SkipPricePage.vue',
'src/pages/asin/DedupeRegistryPage.vue',
'src/pages/shop/ShopManagePage.vue',
]
for (const file of lockedPages) {
const src = readSource(file)
assert.match(src, /lockedGroupId/, `${file} 缺少弹窗分组锁定计算`)
assert.match(src, /:disabled="lockedGroupId != null"/, `${file} 弹窗分组未锁定`)
}
})
@@ -2,29 +2,23 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readSource } from './helpers.ts' import { readSource } from './helpers.ts'
// 验收反馈:生成记录的数据要可清理 // 用户反馈(round2 像素复刻样办):生成记录页还原旧版交互——「清理数据」为旧版没有的新增入口,从页面移除
// 契约:页面有「清理数据」入口(二次确认)、前端 DELETE /api/admin/history、后端清空端点与日志 // 契约:页面无清理入口、无二次确认文案;前端适配层不再暴露 clearHistory;后端 DELETE /history 端点保留(非破坏性,供后续需要时再接线)
test('align_history_clear_normal_primary_path', () => { test('align_history_clear_removed_from_page', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue') const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /清理数据/, '页头有清理数据按钮') assert.doesNotMatch(page, /清理数据/, '页面不再有「清理数据按钮')
assert.match(page, /确定清空全部生成记录吗/, '清理前需二次确认') assert.doesNotMatch(page, /clearAll|clearHistory/, '页面不再调用清理逻辑')
assert.match(page, /clearHistory\(\)/, '页面调用清理 API') assert.doesNotMatch(page, /确定清空全部生成记录吗/, '无清理二次确认文案')
}) })
test('align_history_clear_normal_variant_input', () => { test('align_history_clear_api_no_longer_exposed', () => {
const api = readSource('src/pages/records/history-api.ts') const api = readSource('src/pages/records/history-api.ts')
assert.match(api, /http\.delete<unknown>\(HISTORY_ENDPOINT\)/, '清理走 DELETE /api/admin/history') assert.doesNotMatch(api, /clearHistory/, '前端适配层不再暴露 clearHistory')
assert.match(api, /clearHistory/, '暴露 clearHistory 适配函数')
}) })
test('align_history_clear_boundary_empty_input', () => { test('align_history_clear_backend_endpoint_kept', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue') // 后端契约保留:DELETE /history 端点 + service 清空 + 中文排查日志(从前端摘除入口,不动后端)。
assert.match(page, /已清空 \$\{removed\} 条生成记录/, '成功提示含清理条数')
})
test('align_history_clear_dependency_failure_returns_actionable_message', () => {
// 后端契约:DELETE /history 端点 + service 清空 + 中文排查日志。
const ctrl = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java') const ctrl = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java')
assert.match(ctrl, /@DeleteMapping\("\/history"\)/, '后端有 DELETE /api/admin/history 端点') assert.match(ctrl, /@DeleteMapping\("\/history"\)/, '后端有 DELETE /api/admin/history 端点')
assert.match(ctrl, /admin_history/, '清理端点沿用生成记录模块权限') assert.match(ctrl, /admin_history/, '清理端点沿用生成记录模块权限')
@@ -7,8 +7,8 @@ import { historyEmptyText } from '../src/pages/records/history-feedback.ts'
test('align_history_column_labels', () => { test('align_history_column_labels', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue') const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /label="类型"/, '列名对齐参考「类型」') assert.match(page, /<th[^>]*>类型<\/th>/, '列名对齐参考「类型」(自绘 th)')
assert.match(page, /label="创建时间"/, '列名对齐参考「创建时间」') assert.match(page, /<th[^>]*>创建时间<\/th>/, '列名对齐参考「创建时间」(自绘 th)')
assert.doesNotMatch(page, /记录类型/, '去掉“记录类型”列名') assert.doesNotMatch(page, /记录类型/, '去掉“记录类型”列名')
assert.doesNotMatch(page, /label="生成时间"/, '去掉“生成时间”列名') assert.doesNotMatch(page, /label="生成时间"/, '去掉“生成时间”列名')
}) })
@@ -18,3 +18,11 @@ test('align_history_empty_text', () => {
assert.equal(historyEmptyText({ hasFilter: false, total: 0 }), '暂无记录') assert.equal(historyEmptyText({ hasFilter: false, total: 0 }), '暂无记录')
assert.equal(historyEmptyText({ hasFilter: false, total: 2 }), '') assert.equal(historyEmptyText({ hasFilter: false, total: 2 }), '')
}) })
test('align_history_form_group_no_global_margin_leak', () => {
// 修复按钮与筛选输入框不对齐:全局 .form-group{margin-bottom:20px} 由登录页非 scoped 样式泄漏;登录页样式收窄为 scoped,页面内显式重置。
const login = readSource('src/pages/login/LoginPage.vue')
assert.match(login, /<style scoped>/, '登录页样式 scoped,不再全局泄漏 .form-group')
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /\.form-group \{[^}]*margin-bottom: 0/, '页面内 form-group 显式重置 margin')
})

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