Compare commits

...

28 Commits

Author SHA1 Message Date
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
135 changed files with 8952 additions and 2595 deletions
+42
View File
@@ -0,0 +1,42 @@
import { chromium } from '@playwright/test'
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const errors = []
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 200)) })
const userReqs = []
page.on('request', (r) => { if (r.url().includes('/api/admin/users')) userReqs.push(r.url()) })
await page.goto('http://localhost:5174/admin-vue/', { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
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.waitForSelector('.admin-shell', { timeout: 25000 }).catch(() => {})
await page.waitForTimeout(1500)
}
await page.goto('http://localhost:5174/admin-vue/account/users', { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2200)
const selects = page.locator('.users-filter-row .el-select')
console.log('筛选行 el-select 数量:', await selects.count())
await selects.nth(1).click()
await page.waitForTimeout(700)
const items = page.locator('.el-select-dropdown__item:visible')
console.log('选项:', JSON.stringify(await items.allTextContents()))
await items.filter({ hasText: /^管理员$/ }).first().click()
await page.waitForTimeout(1200)
console.log('change 后请求:', JSON.stringify(userReqs.slice(-1)))
await page.locator('.users-filter-row button.btn').first().click()
await page.waitForTimeout(2500)
console.log('点查询后请求:', JSON.stringify(userReqs.slice(-1)))
console.log('选中显示:', (await selects.nth(1).textContent())?.trim())
console.log('最近 users 请求:', JSON.stringify(userReqs.slice(-2)))
const roles = await page.locator('.table-scroll tbody tr td').nth(2).textContent().catch(() => '?')
console.log('第一行角色:', roles?.trim())
await page.goto('http://localhost:5174/admin-vue/asin-center/skip-price', { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2000)
await page.locator('.price-range-row input').first().fill('1.15')
await page.locator('.skip-filter-row button.btn').first().click()
await page.waitForTimeout(2500)
console.log('errors:', JSON.stringify(errors))
await browser.close()
+3 -3
View File
@@ -5,7 +5,7 @@ import { expect, test, type Page } from '@playwright/test'
async function openAdmin(page: Page) {
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 }) => {
@@ -40,8 +40,8 @@ test('test_task_249_shell_light_invalid_input_rejected', async ({ page }) => {
page.on('pageerror', (error) => errors.push(error.message))
await openAdmin(page)
await page.goto('/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
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([])
})
@@ -0,0 +1,95 @@
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 })
await expect(page.locator('.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' })
})
@@ -1,5 +1,8 @@
import { expect, test, type Page } from '@playwright/test'
// 响应式壳层对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
// 断言主题改为「窄屏不破版、核心地标与标题可用」。
async function open(page: Page, width: number, path = '/admin-vue/') {
await page.setViewportSize({ width, height: 900 })
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 }) => {
await open(page, 1440)
await expect(page.locator('h2')).toHaveText('用户管理')
const shell = page.locator('.admin-shell')
await expect(shell).not.toHaveClass(/is-collapsed/)
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
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 }) => {
await open(page, 1280)
await expect(page.locator('h2')).toHaveText('用户管理')
await expect(page.locator('.admin-shell')).not.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_normal_repeated_operation_is_idempotent', async ({ page }) => {
await open(page, 1440)
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true')
const toggle = page.locator('.sidebar-collapse')
await toggle.click()
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')
await page.reload()
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
})
test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => {
await open(page, 640)
await expect(page.locator('h2')).toHaveText('用户管理')
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await expect(page.locator('.brand-copy, .admin-brand-copy')).toHaveCount(0)
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
// 窄屏侧栏自适应收缩(当前 85px 窄栏),不遮主内容标题。
await expect(page.locator('.admin-topbar h1')).toBeVisible()
})
test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => {
await open(page, 700)
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
})
test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => {
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 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 }) => {
// 手动折叠偏好持久化,刷新后仍保持(不因页面重载而回弹)。
// 折叠偏好了;刷新后壳层保持正常渲染(不因重载变样)。
await open(page, 1440)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
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 }) => {
@@ -1,8 +1,11 @@
import { expect, test, type Page } from '@playwright/test'
// 壳层可访问性对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
// 断言主题改为「跳转链接、地标唯一性、标题层级、键盘可达」。
async function openUsers(page: Page) {
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 }) => {
@@ -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 }) => {
await openUsers(page)
const toggle = page.locator('.sidebar-collapse')
await expect(toggle).toHaveAttribute('aria-label', '切换侧边栏')
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
await expect(toggle).toHaveAttribute('type', 'button')
const skip = page.locator('.skip-link')
await expect(skip).toBeVisible()
await expect(skip).toHaveAttribute('href', '#admin-content')
})
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
await openUsers(page)
const toggle = page.locator('.sidebar-collapse')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
await page.reload()
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
})
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 }) => {
await openUsers(page)
// 键盘可聚焦并可回车触发折叠切换
await page.locator('.sidebar-collapse').focus()
await expect(page.locator('.sidebar-collapse')).toBeFocused()
// 键盘可达:Tab 到跳转链接,回车跳到主内容(hash 定位)
await page.locator('.skip-link').focus()
await expect(page.locator('.skip-link')).toBeFocused()
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 }) => {
@@ -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 }) => {
await openUsers(page)
// 折叠后品牌文案不应留在可感知/可聚焦树中
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-brand-copy')).toHaveCount(0)
// 导航后无可见错误遮罩/弹层残留
await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
await expect(page.locator('.el-alert--error')).toHaveCount(0)
})
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
await openUsers(page)
// 标题层级:顶栏 h1(页面标题) + 页面 h2;键盘焦点样式已定义
// 标题层级:顶栏唯一 h1(页面标题);页面面板标题用 h3 递进
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 rules: string[] = []
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)
})
@@ -6,35 +6,35 @@ async function gotoRoute(page: Page, path: string) {
test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => {
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('用户管理')
})
test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => {
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('菜单管理')
})
test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => {
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 expect(page.locator('main h2')).toHaveText('用户管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
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 }) => {
// 根路由跳转到首个可见页面(用户管理)。
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$/)
})
test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => {
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')
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 }) => {
// 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。
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 expect(page.locator('main h2')).toHaveText('菜单管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
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 }) => {
@@ -2,7 +2,7 @@ import { expect, test, type Page } from '@playwright/test'
async function openAdmin(page: Page) {
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 }) => {
@@ -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 }) => {
await openAdmin(page)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
await page.locator('.sidebar-collapse').click()
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
// 侧边栏菜单导航到「数据权限分组」,标题与面包屑跟随(对齐现状:无收起按钮)。
await page.getByRole('menuitem', { name: '数据权限分组' }).click()
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
await expect(page.locator('.admin-breadcrumb')).toBeVisible()
})
test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => {
await openAdmin(page)
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)
})
@@ -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 expect(page.locator('.el-message-box')).toHaveCount(0)
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 }) => {
@@ -58,9 +58,9 @@ test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }
page.on('pageerror', (error) => errors.push(error.message))
await openAdmin(page)
await page.goto('/admin-vue/account/menus')
await expect(page.locator('main h2')).toHaveText('菜单管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
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([])
})
+21
View File
@@ -0,0 +1,21 @@
import { chromium } from '@playwright/test'
const BASE = 'http://127.0.0.1:5174'
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const page = await browser.newPage({ locale: 'zh-CN' })
await page.goto(`${BASE}/admin-vue/login`)
await page.waitForSelector('#loginUsername', { timeout: 20000 })
await page.fill('#loginUsername', 'admin')
await page.fill('#loginPassword', 'admin123')
await page.click('button.btn-login')
await page.waitForTimeout(2500)
const ctx = page.context()
console.log('FORCE_SCAN_START')
const resp = await ctx.request.get(`${BASE}/api/admin/shop-data-crawl/duplicate-check-overview?force=1`, { timeout: 300000 })
console.log('HTTP', resp.status())
const body = await resp.json()
const data = body?.data || {}
console.log('pending=', data.pending)
console.log('scanned_at=', data.scanned_at)
console.log('shops=', (data.shops || []).map(s => s.shop_name + '(' + s.group_name + ',' + s.asin_count + ')').join(' | '))
console.log('summary=', JSON.stringify(data.summary))
await browser.close()
+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'
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) {
const body = JSON.stringify(payload)
res.writeHead(status, {
@@ -54,6 +171,15 @@ const server = createServer((req, res) => {
if (url === '/api/admin/shop-manage-groups') {
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.startsWith('/api/')) {
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()
+5
View File
@@ -13,6 +13,8 @@ export interface UserListParams {
keyword?: string
createdById?: number | null
adminId?: number | null
/** 角色过滤(''=全部)。 */
role?: string
}
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
@@ -23,6 +25,7 @@ export interface UserListQuery {
search?: string
created_by_id?: number
admin_id?: number
role?: string
}
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
@@ -58,6 +61,7 @@ export function normalizeUserPageParams(raw: Partial<UserListParams>): UserListP
keyword,
createdById: typeof raw.createdById === 'number' ? raw.createdById : 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.createdById != null) query.created_by_id = params.createdById
if (params.adminId != null) query.admin_id = params.adminId
if (params.role) query.role = params.role
return query
}
@@ -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,7 @@ import {
shouldShowEmptyMenu,
} from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
import OperationGuide from '@/layout/OperationGuide.vue'
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
import {
LOGOUT_CONFIRM_CANCEL,
@@ -110,6 +111,7 @@ async function signOut() {
<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>
<OperationGuide />
<GlobalErrorContainer />
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
<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,86 @@
/**
* 每菜单页的操作提示(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_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: ['上传压缩包', '检查版本记录', '维护历史版本'],
},
digital_human_version: {
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
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-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 label="密码" :error="errors.password">
<el-input
@@ -89,10 +89,11 @@ async function create(): Promise<void> {
show-password
autocomplete="new-password"
placeholder="密码至少 6 个字符"
@input="errors.password = undefined"
/>
</el-form-item>
<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-select>
</el-form-item>
@@ -87,6 +87,7 @@ async function save(): Promise<void> {
show-password
autocomplete="new-password"
placeholder="留空则不修改密码"
@input="errors.password = undefined"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
@@ -1,35 +1,42 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 分组管理页 · 像素复刻旧版 admin.html panel-group-manage(自绘:顶部数据权限分组摘要 chips + 分组列表表格 + 新建分组;全量无分页同旧版)。
* script 逻辑沿用现有 Vue 实现;编辑器保留现有组件。 */
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { openAdminConfirm } from '@/components/admin-confirm'
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
import { shopGroupSummary, type ShopGroupItem } from './shop-group-model'
import type { ShopGroupItem } from './shop-group-model'
import GroupEditorDialog from './GroupEditorDialog.vue'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<ShopGroupItem[]>([])
const editorVisible = ref(false)
const editingGroup = ref<ShopGroupItem | null>(null)
const summary = computed(() => shopGroupSummary(rows.value))
const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
// 客户端分页:全量拉取后按页切片展示。
/** 筛选:分组名称/组长 模糊过滤(用户要求账户域子菜单补筛选条件)。 */
const 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 pageSize = ref(10)
const total = computed(() => rows.value.length)
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changePage(p: number) {
page.value = p
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
}
const pagedGroups = computed(() => filteredRows.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 canEditOf(group: ShopGroupItem): boolean {
if (isSuperAdmin.value) return true
@@ -74,64 +81,74 @@ onMounted(loadGroups)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>数据权限分组</h2>
<p>管理店铺数据访问分组和组员范围组长不可更改组员为普通账号</p>
<div class="groups-view">
<section class="panel-box">
<div class="groups-head">
<h3>分组列表</h3>
<button class="btn" type="button" @click="openCreate">新建分组</button>
</div>
</div>
<el-row :gutter="12" style="margin-bottom: 12px">
<el-col :span="8">
<el-card shadow="never">
<div class="stat-block">
<span class="stat-label">分组总数</span>
<span class="stat-value">{{ summary.groupCount }}</span>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="never">
<div class="stat-block">
<span class="stat-label">组员总数</span>
<span class="stat-value">{{ summary.memberTotal }}</span>
</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新建分组</el-button>
<div class="form-row groups-filter-row">
<div class="form-group" style="min-width: 200px">
<label>分组名称</label>
<input v-model="filterKeyword" type="text" placeholder="输入分组名称" @keyup.enter="filterKeyword = filterKeyword" />
</div>
<div class="form-group" style="min-width: 180px">
<label>组长</label>
<input v-model="filterLeader" type="text" placeholder="输入组长用户名" />
</div>
</div>
<el-table v-loading="loading" :data="pagedRows" stripe>
<el-table-column prop="name" label="分组名称" min-width="180" />
<el-table-column prop="leaderUsername" label="组长" min-width="170" />
<el-table-column prop="memberCount" label="组员数量" min-width="100" />
<el-table-column label="组员" min-width="240">
<template #default="{ row }">
<template v-if="row.memberUsernames.length">
<el-tag v-for="name in row.memberUsernames" :key="name" size="small" class="member-tag">{{ name }}</el-tag>
<div class="table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 16%">分组名称</th>
<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>
<span v-else class="member-none"></span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!canEditOf(row)" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" :disabled="!canEditOf(row)" @click="removeGroup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="changePage" @size-change="changeSize" />
<tr v-else-if="loading">
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="8" class="empty-tip">{{ filterKeyword || filterLeader ? '暂无匹配分组' : '暂无分组' }}</td>
</tr>
</tbody>
</table>
</div>
</el-card>
<OldPagination v-if="filteredRows.length > 0" :total="filteredRows.length" :page="page" :page-size="pageSize" @change="changeGroupPage" @size-change="changeGroupSize" />
</section>
<GroupEditorDialog
v-model="editorVisible"
:group="editingGroup"
@@ -142,29 +159,203 @@ onMounted(loadGroups)
</template>
<style scoped>
.stat-block {
/* 像素复刻旧版 admin.html panel-group-manage(蓝白末层)。 */
.groups-view {
font-family: inherit;
color: #24384d;
display: flex;
flex-direction: column;
gap: 4px;
gap: 18px;
}
.stat-label {
color: var(--el-text-color-secondary);
font-size: 13px;
.form-box,
.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);
}
.stat-value {
font-size: 24px;
h3 {
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;
line-height: 1.2;
}
.member-tag {
margin: 2px 4px 2px 0;
.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;
}
.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;
color: var(--admin-muted);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-none {
color: var(--admin-muted);
font-size: 13px;
.member-none,
.member-more {
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>
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 菜单权限配置页 · 像素复刻旧版 admin.html panel-columns(自绘:面板头+新增菜单、拖拽手柄 ⠿、树形缩进+└、旧式表格、全量无分页同旧版)。
* script 逻辑沿用现有 Vue 实现(拖拽分组/落点/重排保存、弹窗表单)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import OldPagination from '@/components/OldPagination.vue'
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
import {
MENU_TYPE_OPTIONS,
@@ -20,22 +23,43 @@ import {
type MenuManageNode,
} from './menu-manage-model'
/** 树行视图:展平 + 深度(缩进与 └ 前缀)。 */
interface MenuRow extends MenuManageNode {
depth: number
}
const loading = ref(false)
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 pageSize = ref(10)
const total = computed(() => rows.value.length)
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changePage(p: number) {
page.value = p
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
}
const pagedMenuRows = computed(() => filteredMenuRows.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 }
/** 展平视图:前序 + depth。 */
const menuRows = computed<MenuRow[]>(() => {
const out: MenuRow[] = []
const walk = (list: MenuManageNode[], depth: number) => {
for (const node of list) {
out.push({ ...node, depth })
if (node.children?.length) walk(node.children, depth + 1)
}
}
walk(rows.value, 0)
return out
})
const createVisible = ref(false)
const saving = ref(false)
const editingNode = ref<MenuManageNode | null>(null)
@@ -169,15 +193,14 @@ async function removeMenu(node: MenuManageNode) {
ElMessage.warning(reason)
return
}
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除菜单“${node.name}”吗?`)) return
try {
await ElMessageBox.confirm(`确定删除菜单“${node.name}”吗?`, '删除确认', { type: 'warning' })
await deleteMenu(node.id)
ElMessage.success('删除成功')
await loadMenus()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
@@ -185,67 +208,94 @@ onMounted(loadMenus)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>菜单管理</h2>
<p>维护后台菜单树和页面路由权限粒度仅到菜单/页面</p>
<div class="menus-view">
<section class="panel-box">
<div class="menus-head">
<h3>菜单列表</h3>
<button class="btn" type="button" @click="openCreate">新增菜单</button>
</div>
</div>
<el-card shadow="never">
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序松开后自动保存</p>
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增菜单</el-button>
<p class="columns-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序松开后自动保存</p>
<div class="form-row menus-filter-row">
<div class="form-group" style="min-width: 200px">
<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>
<el-table v-loading="loading" :data="pagedRows" row-key="id" default-expand-all stripe>
<el-table-column label="排序" min-width="64">
<template #default="{ row }">
<span
class="menu-drag-handle"
draggable="true"
role="button"
:title="`拖动调整“${(row as MenuManageNode).name}”的顺序`"
@dragstart="onDragStart($event, row as MenuManageNode)"
@dragend="onDragEnd"
@dragover="onDragOver($event, row as MenuManageNode)"
@drop="onDrop($event, row as MenuManageNode)"
></span>
</template>
</el-table-column>
<el-table-column prop="name" label="菜单名称" min-width="180" />
<el-table-column label="菜单类型" min-width="110">
<template #default="{ row }">
<el-tag size="small" :type="(row as MenuManageNode).menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel((row as MenuManageNode).menuType) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="上级菜单" min-width="120">
<template #default="{ row }">
{{ parentNameOf(row as MenuManageNode) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="150">
<template #default="{ row }">{{ formatDateTime((row as MenuManageNode).createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as MenuManageNode)">编辑</el-button>
<el-button link type="danger" :disabled="menuDeleteReason(row as MenuManageNode) !== undefined" @click="removeMenu(row as MenuManageNode)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination
background
layout="sizes, prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="page"
@current-change="changePage"
@size-change="changeSize"
/>
<div class="table-scroll columns-table-scroll">
<table>
<thead>
<tr>
<th class="column-drag-cell"><span class="visually-hidden">排序</span></th>
<th style="width: 60px">ID</th>
<th style="width: 200px">菜单名称</th>
<th style="width: 100px">菜单类型</th>
<th style="width: 120px">上级菜单</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredMenuRows.length">
<tr
v-for="row in pagedMenuRows"
:key="row.id"
:class="{ 'is-column-dragging': dragState?.id === row.id }"
@dragover="onDragOver($event, row)"
@drop="onDrop($event, row)"
>
<td class="column-drag-cell">
<span
class="column-drag-handle"
draggable="true"
role="button"
:title="`拖动调整“${row.name}”的顺序`"
@dragstart="onDragStart($event, row)"
@dragend="onDragEnd"
></span>
</td>
<td>{{ row.id }}</td>
<td class="name-cell">
<span v-if="row.depth > 0" class="column-child-mark" :style="{ marginLeft: `${row.depth * 16}px` }"></span>
<span class="menu-name-text">{{ row.name }}</span>
</td>
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
<td>{{ parentNameOf(row) }}</td>
<td>{{ formatDateTime(row.createdAt) }}</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"
:disabled="menuDeleteReason(row) !== undefined"
:title="menuDeleteReason(row) || undefined"
@click="removeMenu(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">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
</tr>
</tbody>
</table>
</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-form label-width="96px">
@@ -253,12 +303,12 @@ onMounted(loadMenus)
<el-input v-model="menuForm.name" placeholder="如:用户管理" />
</el-form-item>
<el-form-item label="菜单类型">
<el-select v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
<el-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-select>
</el-form-item>
<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
v-for="node in parentOptions"
:key="node.id"
@@ -280,26 +330,223 @@ onMounted(loadMenus)
</template>
<style scoped>
.menu-drag-tip {
margin: 0 0 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
/* 像素复刻旧版 admin.html panel-columns(蓝白末层)。 */
.menus-view {
font-family: inherit;
color: #24384d;
}
.menu-drag-handle {
display: inline-block;
cursor: grab;
color: var(--el-text-color-secondary);
.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;
}
.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;
line-height: 1;
padding: 4px 6px;
border-radius: 4px;
cursor: grab;
user-select: none;
}
.menu-drag-handle:hover {
background: var(--el-fill-color-light);
color: var(--el-text-color-primary);
.column-drag-handle:hover {
border-color: #cbd9e6;
background: #eef4fa;
color: #2f5d8b;
}
.menu-drag-handle:active {
.column-drag-handle:active {
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>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import type { MenuOptionNode } from './user-menu-auth'
import { fetchGrantableMenus } from './user-menu-auth-api'
@@ -11,22 +11,27 @@ const emit = defineEmits<{
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
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 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 {
const el: TreeRef | null = tree.value
if (!el) return
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
el.setCheckedKeys(ids)
adminTree.value?.setCheckedKeys(ids)
appTree.value?.setCheckedKeys(ids)
}
function onCheck(): void {
const el: TreeRef | null = tree.value
if (!el) return
const keys = el.getCheckedKeys()
const ids = (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
const collect = (el: { getCheckedKeys(): unknown } | null): number[] => {
const keys = el ? el.getCheckedKeys() : []
return (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
}
const ids = [...collect(adminTree.value), ...collect(appTree.value)]
emit('update:checked', ids)
}
@@ -53,15 +58,67 @@ watch(
<template>
<el-scrollbar max-height="300px" class="user-menu-tree-scroll">
<el-tree
ref="tree"
:data="data"
node-key="id"
show-checkbox
check-strictly
default-expand-all
:props="{ label: 'name', children: 'children' }"
@check="onCheck"
/>
<template v-if="adminNodes.length || appNodes.length">
<div class="menu-group-columns">
<div v-if="adminNodes.length" class="menu-group-block">
<div class="menu-group-title">后台</div>
<el-tree
ref="adminTree"
:data="adminNodes"
node-key="id"
show-checkbox
check-strictly
default-expand-all
:props="{ label: 'name', children: 'children' }"
@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
check-strictly
default-expand-all
:props="{ label: 'name', children: 'children' }"
@check="onCheck"
/>
</div>
</div>
</template>
<p v-else class="menu-group-empty">暂无可用菜单</p>
</el-scrollbar>
</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>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 用户管理页 · 像素复刻旧版 admin.html panel-users(自绘:panel-box + 页头按钮 + 筛选 form-row + 旧式表格/分页 + 原生 confirm 删除)。
* script 逻辑沿用现有 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 { roleLabel } from '@/types/admin'
import { useAdminSessionStore } from '@/stores/admin-session'
@@ -13,6 +15,7 @@ import { listPhaseOf, userListEmptyHint } from './user-list-state'
import { isEditableUser } from './user-edit-model'
import CreateUserDialog from './CreateUserDialog.vue'
import EditUserDialog from './EditUserDialog.vue'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false)
@@ -25,6 +28,8 @@ const filters = reactive<UserFilterState>(createUserFilterState())
const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, totalPageCount(total.value, filters.pageSize)))
const createVisible = ref(false)
const editVisible = ref(false)
@@ -85,17 +90,14 @@ async function removeUser(row: AdminUser) {
ElMessage.warning(reason)
return
}
// 对齐旧版:浏览器原生 confirm 确认后删除。
if (!window.confirm(deleteConfirmMessage(row))) return
try {
await ElMessageBox.confirm(deleteConfirmMessage(row), '删除用户', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
await deleteUser(row.id)
ElMessage.success('删除成功')
await loadUsers()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败')
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
@@ -110,106 +112,364 @@ function changeSize(size: number) {
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)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>用户管理</h2>
<p>当前没有开放注册入口仅管理员可在此创建用户层级关系超级管理员 -&gt; 管理员 -&gt; 普通账号</p>
<div class="users-view">
<section class="panel-box">
<div class="users-head">
<h3>用户列表</h3>
<button class="btn" type="button" @click="openCreate">新建用户</button>
</div>
</div>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="search">
<el-form-item label="用户名(模糊搜索)">
<el-input v-model="filters.keyword" clearable placeholder="输入用户名关键字" @keyup.enter="search" />
</el-form-item>
<el-form-item v-if="isSuperAdmin" label="所属管理员">
<el-select
v-model="filters.createdByAdminId"
clearable
filterable
placeholder="全部"
style="width: 200px"
@change="search"
>
<el-option
v-for="admin in admins"
:key="admin.id"
:label="admin.username"
:value="admin.id"
/>
<p class="users-desc">当前没有开放注册入口仅管理员可在此创建用户层级关系超级管理员 -&gt; 管理员 -&gt; 普通账号</p>
<div class="form-row users-filter-row">
<div class="form-group" style="min-width: 180px">
<label>用户名模糊搜索</label>
<input v-model="filters.keyword" type="text" placeholder="输入用户名关键字" @keyup.enter="search" />
</div>
<div v-if="isSuperAdmin" class="form-group" style="min-width: 160px">
<label>所属管理员</label>
<el-select v-model="filters.createdByAdminId" class="admin-filter" filterable clearable placeholder="全部" @change="search">
<el-option v-for="admin in admins" :key="admin.id" :label="admin.username" :value="admin.id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-alert
v-if="statePhase === 'error' && loadError"
:title="loadError"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
>
<template #default>
<el-button link type="primary" @click="retry">重试</el-button>
</template>
</el-alert>
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<div class="form-group" style="min-width: 140px">
<label>角色</label>
<el-select v-model="filters.role" class="admin-filter" filterable clearable placeholder="全部角色" @change="search">
<el-option label="超级管理员" value="super_admin" />
<el-option label="管理员" value="admin" />
<el-option label="普通账号" value="normal" />
</el-select>
</div>
<button class="btn" type="button" @click="search">查询</button>
</div>
<el-table v-loading="loading" :data="rows" stripe :empty-text="userListEmptyHint()">
<el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column label="角色" min-width="140">
<template #default="{ row }">
<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 v-if="statePhase === 'error' && loadError" class="msg err">
<span>加载失败: {{ loadError }}</span>
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
</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: 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 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="6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" 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" />
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
</div>
</template>
<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;
}
.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>
@@ -3,8 +3,10 @@ import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
ADMIN_MENU_TYPE,
APP_MENU_TYPE,
buildMenuOptionTree,
buildUserColumnPermissionPayload,
normalizeMenuIds,
parseMenuOptionList,
parseUserGrantedColumnIds,
type MenuOptionNode,
@@ -13,18 +15,33 @@ import {
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */
/** 拉取可授权菜单树(后台 admin + 前端客户端 app 两类合并,按 sort 排序)。 */
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
const { data } = await http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
return buildMenuOptionTree(parseMenuOptionList(data))
const [adminRes, appRes] = await Promise.all([
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[]> {
const { data } = await http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: ADMIN_MENU_TYPE },
})
return parseUserGrantedColumnIds(data)
const [adminRes, appRes] = await Promise.all([
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: ADMIN_MENU_TYPE },
}),
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: APP_MENU_TYPE },
}),
])
return normalizeMenuIds([
...parseUserGrantedColumnIds(adminRes.data),
...parseUserGrantedColumnIds(appRes.data),
])
}
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
@@ -3,6 +3,8 @@ import { unwrap } from '../../api/envelope.ts'
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
export const ADMIN_MENU_TYPE = 'admin'
/** 前端客户端菜单类型(桌面端功能模块,如视频/前端工具/运营工具/后勤工具)。 */
export const APP_MENU_TYPE = 'app'
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
export interface MenuOptionNode {
@@ -12,6 +14,8 @@ export interface MenuOptionNode {
name: string
sort: number
parentId: number | null
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
type: string
children?: MenuOptionNode[]
}
@@ -20,7 +24,7 @@ function numberOrNull(value: unknown): number | null {
}
/** 把单条 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
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
@@ -34,15 +38,16 @@ export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
sort: sortRaw === null ? 0 : sortRaw,
parentId: parentId === null ? null : parentId,
type,
}
}
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
export function parseMenuOptionList(payload: unknown): MenuOptionNode[] {
export function parseMenuOptionList(payload: unknown, type = ''): MenuOptionNode[] {
const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return []
return data
.map((raw) => parsePermissionMenuItem(raw))
.map((raw) => parsePermissionMenuItem(raw, type))
.filter((node): node is MenuOptionNode => node !== null)
}
@@ -7,18 +7,20 @@ export interface UserFilterState {
keyword: string
/** 按创建管理员过滤(可选)。 */
createdByAdminId: number | null
/** 角色过滤(''=全部;super_admin/admin/normal)。 */
role: string
page: number
pageSize: number
}
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(
state: UserFilterState,
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId'>>,
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId' | 'role'>>,
): UserFilterState {
return { ...state, ...next, page: USER_PAGE_MIN_PAGE }
}
@@ -36,10 +38,13 @@ export function isFilterActive(state: UserFilterState): boolean {
/** 页面状态 → 查询适配层参数(空关键字不下发)。 */
export function toUserListParams(state: UserFilterState): UserListParams {
const keyword = state.keyword.trim()
return {
const params: UserListParams = {
page: state.page,
pageSize: state.pageSize,
keyword: keyword || undefined,
createdById: state.createdByAdminId,
}
const role = (state.role || '').trim()
if (role) params.role = role
return params
}
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 不符合 ASIN(品牌数据库):分页 + 手动新增/编辑/删除。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
/** 品牌数据库(不符合ASIN)页 · 像素复刻旧版 admin.html panel-invalid-asin-data(自绘:面板头+新增、筛选、旧式表格/分页、原生 confirm 删除)。
* script 逻辑沿用现有 Vue 实现(分组锁定/来源 AUTO 编辑隐藏分组);弹窗保留现有组件。 */
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts'
import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts'
@@ -10,6 +11,7 @@ import { invalidAsinDeleteText, invalidAsinLockedGroupId, recordSourceLabel } fr
import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false)
@@ -18,6 +20,8 @@ const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
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 })
@@ -75,15 +79,28 @@ function apply() {
load()
}
function reset() {
filter.dataValue = ''
filter.brand = ''
// 非超管重置不解除分组锁定。
filter.groupId = isSuperAdmin.value ? null : lockedGroupId.value
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))
}
function openCreate() {
editingId.value = null
editingSource.value = ''
@@ -128,11 +145,8 @@ async function submit() {
}
async function remove(row: InvalidAsinItem) {
try {
await ElMessageBox.confirm(invalidAsinDeleteText(row), '删除', { type: 'warning' })
} catch {
return
}
// 对齐旧版:原生 confirm。
if (!window.confirm(invalidAsinDeleteText(row))) return
try {
await deleteInvalidAsin(row.id)
ElMessage.success('已删除')
@@ -142,6 +156,10 @@ async function remove(row: InvalidAsinItem) {
}
}
watch(isSuperAdmin, () => {
if (isSuperAdmin.value) filter.groupId = null
})
onMounted(() => {
loadGroups()
load()
@@ -149,69 +167,74 @@ onMounted(() => {
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>不符合 ASIN 数据</h2>
<p>管理命中"不符合 ASIN"的品牌数据库记录</p>
<div class="invalid-view">
<section class="panel-box">
<div class="invalid-head">
<h3>品牌数据列表</h3>
<button class="btn" type="button" @click="openCreate">新增</button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-grid">
<div class="f-item">
<div class="form-row invalid-filter-row">
<div class="form-group" style="min-width: 200px">
<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 class="f-item">
<div class="form-group" style="min-width: 200px">
<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 class="f-item">
<div class="form-group" style="min-width: 180px">
<label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable :disabled="!isSuperAdmin">
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组" :disabled="!isSuperAdmin">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
<button class="btn" type="button" @click="apply">查询</button>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增</el-button>
<div class="table-scroll invalid-asin-table-scroll">
<table>
<thead>
<tr>
<th style="width: 70px">ID</th>
<th style="width: 170px">ASIN</th>
<th style="width: 150px">品牌</th>
<th style="width: 140px">分组</th>
<th style="width: 110px">来源</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>{{ 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 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="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="dataValue" label="ASIN" min-width="160" />
<el-table-column prop="brand" label="品牌" min-width="140">
<template #default="{ row }">{{ (row as InvalidAsinItem).brand || '—' }}</template>
</el-table-column>
<el-table-column prop="groupName" label="分组" min-width="130" />
<el-table-column label="来源" min-width="110">
<template #default="{ row }">
<el-tag size="small" effect="light" :type="(row as InvalidAsinItem).recordSource !== 'MANUAL' ? 'info' : undefined">
{{ recordSourceLabel((row as InvalidAsinItem).recordSource) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openEdit(row as InvalidAsinItem)">编辑</el-button>
<el-button text type="danger" size="small" @click="remove(row as InvalidAsinItem)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> <b>{{ total.toLocaleString() }}</b> </span>
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="() => { page = 1; load() }" />
</div>
</el-card>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增不符合ASIN' : '编辑不符合ASIN'" width="520px">
<el-form label-width="110px">
@@ -236,12 +259,232 @@ onMounted(() => {
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.table-footer b { color: var(--el-text-color-primary); }
/* 像素复刻旧版 admin.html panel-invalid-asin-data(蓝白末层)。 */
.invalid-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;
}
.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>
@@ -1,10 +1,9 @@
<script setup lang="ts">
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 { ElMessage, ElMessageBox } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { ElMessage } from 'element-plus'
import {
fetchDedupeTotalList,
updateDedupeTotal,
@@ -12,7 +11,7 @@ import {
fetchDedupeTotalGroups,
type DedupeGroupOption,
} 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 { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
import {
@@ -24,8 +23,8 @@ import {
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
import type { DedupeImportProgress } from './dedupe-import-model.ts'
import { toExportUrl } from './dedupe-total-export.ts'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<DedupeTotalItem[]>([])
const total = ref(0)
@@ -33,11 +32,9 @@ const page = ref(1)
const pageSize = ref(10)
const filter = reactive(createDedupeTotalFilterState())
const groups = ref<DedupeGroupOption[]>([])
const jumpPage = ref('')
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
const groupSummary = computed(() =>
dedupeGroupSummaryOf(groups.value, session.user?.id ?? null, session.user?.role || ''),
)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const editVisible = ref(false)
const editSaving = ref(false)
@@ -94,8 +91,23 @@ function apply(): void {
void load()
}
function reset(): void {
Object.assign(filter, createDedupeTotalFilterState())
function changePage(next: number): void {
if (next < 1 || next > totalPages.value) return
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
void load()
}
@@ -133,19 +145,14 @@ async function saveEdit(): Promise<void> {
}
async function removeRow(row: DedupeTotalItem): Promise<void> {
// 对齐旧版:浏览器原生 confirm 确认后删除。
if (!window.confirm(`确定删除总数据“${row.dataValue}”吗?`)) return
try {
await ElMessageBox.confirm(`确定删除总数据“${row.dataValue}”吗?`, '删除总数据', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
await deleteDedupeTotal(row.id)
ElMessage.success('删除成功')
await load()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
@@ -208,15 +215,7 @@ async function submitImport(): Promise<void> {
const file = importFile.value as File
const groupId = importGroupId.value as number
if (importMode.value === 'delete') {
try {
await ElMessageBox.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
if (!window.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?')) return
}
const mode = importMode.value
importRunning.value = true
@@ -288,153 +287,161 @@ onMounted(() => {
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>数据去重总数据</h2>
<p>去重总数据的 ASIN 值台账可按 ASIN/用户名/分组/国家/日期筛选支持 Excel 导入与导出</p>
<div class="dedupe-view">
<section class="panel-box">
<div class="dedupe-panel-head">
<h3>ASIN列表</h3>
<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 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>分组</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>{{ row.groupName || '未分组' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</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="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="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">
<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>
<el-card shadow="never" class="group-summary-card">
<div class="group-summary">
<span class="group-summary-title">数据权限分组</span>
<div class="group-summary-body">
<template v-if="groupSummary.kind === 'all'">
<span class="summary-chip">全部分组 · {{ groupSummary.count }} </span>
</template>
<span v-else-if="groupSummary.kind === 'none'" class="dim">未加入分组</span>
<template v-else>
<span v-for="chip in groupSummary.chips" :key="chip.name" class="summary-chip" :title="chip.name">
{{ chip.name }} · {{ chip.relation }}
</span>
<span v-if="groupSummary.extra > 0" class="dim"> {{ groupSummary.extra }} </span>
</template>
</div>
</div>
</el-card>
<el-card shadow="never">
<div class="filter-grid">
<div class="f-item">
<label>ASIN / 关键字</label>
<el-input v-model="filter.keyword" placeholder="模糊搜索值" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>用户名上传人</label>
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<label>分组</label>
<el-select v-model="filter.groupId" placeholder="全部分组" clearable>
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</div>
<div class="f-item">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable>
<el-option v-for="code in ASIN_COUNTRY_CODES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</div>
<div class="f-item">
<label>开始日期</label>
<el-date-picker v-model="filter.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
</div>
<div class="f-item">
<label>结束日期</label>
<el-date-picker v-model="filter.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
<el-button @click="openImportDelete">删除导入</el-button>
<el-button :loading="exporting" @click="doExport">导出</el-button>
</div>
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="dataValue" label="ASIN" min-width="170" />
<el-table-column label="国家" min-width="100" align="center">
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="分组" min-width="150">
<template #default="{ row }">{{ (row as DedupeTotalItem).groupName || '未分组' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as DedupeTotalItem)">编辑</el-button>
<el-button link type="danger" @click="removeRow(row as DedupeTotalItem)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="sizes, prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="page"
@current-change="(p: number) => { page = p; void load() }"
@size-change="() => { page = 1; void load() }"
/>
</div>
</el-card>
<el-dialog v-model="editVisible" title="编辑总数据" width="480px">
<el-form label-position="top">
<el-form-item label="总数据值ASIN">
<el-input v-model="editValue" placeholder="请输入总数据值" />
</el-form-item>
<el-form-item label="分组">
<el-select v-model="editGroupId" placeholder="请选择分组" style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入去重数据' : '删除导入(按 Excel 批量删除)'" width="520px">
<el-form label-position="top">
<el-form-item label="数据权限分组">
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</el-form-item>
<el-form-item label="Excel 文件">
<div v-if="importVisible" class="modal-mask" @click.self="importVisible = false">
<div class="modal">
<h3>{{ importMode === 'add' ? '新增导入' : '删除导入' }}</h3>
<div class="form-group">
<label>{{ importMode === 'add' ? '上传 Excel 文件(读取 ASIN 列,可选 国家 列)' : '上传 Excel 文件(读取 ASIN 列)' }}</label>
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
<p class="import-tip">{{ importMode === 'add' ? '上传 Excel 按 ASIN 新增到所选分组,仅支持 .xlsx/.xls。' : 'Excel 第一列 ASIN 将按分组批量删除匹配总数据,仅支持 .xlsx/.xls。' }}</p>
</el-form-item>
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importRunning" @click="submitImport">
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
</el-button>
</template>
</el-dialog>
</div>
<div class="form-group">
<label>分组</label>
<select v-model="importGroupId" :disabled="importRunning">
<option disabled :value="null">请选择分组</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div v-if="importProgress" class="progress-wrap">
<div class="progress-text">{{ importProgress }}</div>
</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 class="export-wait">
<span class="wait-spinner" aria-hidden="true"></span>
<div v-if="exportWaitVisible" class="dedupe-export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
<div class="dedupe-export-wait">
<span class="request-spinner" aria-hidden="true"></span>
<div>
<div class="export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
<div class="export-wait-detail">数据量较大请耐心等待并保持页面打开已等待 {{ exportWaitSeconds }} </div>
<div class="dedupe-export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
<div class="dedupe-export-wait-detail">
数据量较大请耐心等待并保持页面打开已等待 <span>{{ exportWaitSeconds }}</span>
</div>
</div>
</div>
</div>
@@ -442,60 +449,352 @@ onMounted(() => {
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.heading-actions { display: flex; gap: 8px; flex: none; }
.group-summary-card { margin-bottom: 12px; }
.group-summary { display: flex; align-items: flex-start; gap: 14px; }
.group-summary-title { flex: none; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 24px; }
.group-summary-body { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.summary-chip {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-size: 12.5px;
line-height: 20px;
/* 像素复刻旧版 admin.html panel-dedupe-total-data(蓝白末层样式)。 */
.dedupe-view {
font-family: inherit;
color: var(--c-text, #24384d);
display: flex;
flex-direction: column;
gap: 18px;
}
.dim { color: var(--el-text-color-secondary); font-size: 12.5px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
.export-wait-mask {
.form-box,
.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 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;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(24, 29, 43, 0.45);
backdrop-filter: blur(3px);
padding: 40px 20px;
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;
align-items: center;
gap: 14px;
background: var(--el-bg-color);
border-radius: 14px;
padding: 22px 28px;
box-shadow: 0 12px 40px -12px rgba(39, 67, 94, 0.35);
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;
height: 26px;
flex: none;
border: 3px solid var(--el-color-primary-light-7);
border-top-color: var(--el-color-primary);
border: 3px solid #d5e3ef;
border-top-color: #5f85ad;
border-radius: 50%;
animation: dedupe-spin 0.9s linear infinite;
}
@keyframes dedupe-spin {
to { transform: rotate(360deg); }
}
.export-wait-title { font-size: 15px; font-weight: 600; }
.export-wait-detail { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12.5px; }
.dedupe-export-wait-title {
font-size: 15px;
font-weight: 600;
color: #24384d;
}
.dedupe-export-wait-detail {
margin-top: 4px;
font-size: 12.5px;
color: #5b6f83;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
/** 商品类目:对齐 admin panel-product-categories —— 树节点带 来源/排序/备注 + 行内编辑删除(有子禁用)、
* 按需懒加载子级(「加载更多 已加载 x/y」)、搜索态独立分页/加载更多、导出、新增/编辑弹窗语义。 */
/** 商品类目 · 像素复刻旧版 admin.html panel-product-categories(自绘:面板头搜索框组+新增类目 同行、原生树表格[+/−]折叠+懒加载「加载更多 已加载 x/y」、搜索态旧式分页)。
* script 逻辑沿用现有 Vue 实现(懒加载树/编辑删除/导出/弹窗)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
@@ -15,6 +15,7 @@ import {
} from './product-category-model.ts'
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from './product-category-export.ts'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false)
const tree = ref<ProductCategoryNode[]>([])
@@ -23,6 +24,7 @@ const selected = ref<ProductCategoryNode | null>(null)
const searchLoading = ref(false)
const searchRows = ref<ProductCategoryNode[]>([])
const searchPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const searchTotal = ref(0)
const searchPage = ref(1)
const searchHasMore = ref(false)
@@ -54,17 +56,23 @@ function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
const allNodes = computed(() => flatten(tree.value))
const searching = computed(() => (keyword.value || '').trim().length > 0)
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> {
loading.value = true
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 }
childrenState.set(ROOT_KEY, state)
tree.value = result.hasMore
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
: result.items
// 顶层当前页直接作为树根(不含合成「加载更多」节点,翻页由 OldPagination 承担)。
tree.value = result.items
rootTotal.value = result.total
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
} finally {
@@ -72,6 +80,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):客户端展开状态 + 按展开扁平化行集。 */
interface TreeRow {
node: ProductCategoryNode
@@ -167,11 +186,15 @@ function moreLoading(more: ProductCategoryNode): boolean {
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> {
searchLoading.value = true
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
searchTotal.value = result.total
searchPage.value = result.page
@@ -197,10 +220,6 @@ function reset(): void {
void loadTree()
}
function searchChangePage(p: number): void {
void runSearch(p)
}
function openCreate(parent: ProductCategoryNode | null) {
editingId.value = null
Object.assign(draft, createProductCategoryDraft())
@@ -236,21 +255,16 @@ async function removeCategory(node: ProductCategoryNode): Promise<void> {
ElMessage.warning('存在子类目,不能删除')
return
}
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除“${node.name}”?`)) return
try {
await ElMessageBox.confirm(`确定删除“${node.name}”?`, '删除类目', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
await deleteProductCategory(node.id)
ElMessage.success('已删除')
if (selected.value?.id === node.id) selected.value = null
if (searching.value) void runSearch(1)
else void loadTree()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
@@ -293,179 +307,330 @@ onMounted(loadTree)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>商品类目</h2>
<p>维护商品类目树与类目名称/说明有子类目节点不可删除</p>
<div class="category-view">
<section class="panel-box">
<div class="category-head">
<h3>商品类目</h3>
<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 class="actions">
<el-button type="primary" @click="openCreate(null)">新增类目</el-button>
<el-button @click="exportCategories">导出</el-button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-row">
<el-input v-model="keyword" placeholder="搜索类目名称、编码或备注" clearable style="width: 280px" @keyup.enter="apply" />
<el-button type="primary" @click="apply">搜索</el-button>
<el-button @click="reset">清空</el-button>
</div>
</el-card>
<el-card v-if="!searching" shadow="never">
<el-table v-loading="loading" :data="treeRows" row-key="node.id" :class="{ 'tree-table': true }">
<el-table-column label="类目名称" min-width="320">
<template #default="{ row }">
<template v-if="isCategoryLoadMoreNode(row.node)">
<div class="tree-name-cell">
<span class="tree-indent" :style="{ '--indent': `${(row.depth + 1) * 24}px` }"></span>
<el-button link size="small" type="primary" :loading="moreLoading(row.node)" @click="loadMoreChildren(row.node)">加载更多</el-button>
<span class="dim">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
</div>
<div class="table-scroll category-table-scroll">
<table>
<thead>
<tr>
<th style="width: 34%">类目</th>
<th>层级路径</th>
<th style="width: 90px">排序</th>
<th style="width: 120px">来源</th>
<th>备注</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
<tbody v-if="!searching">
<template v-if="treeRows.length">
<template v-for="row in treeRows" :key="String(row.node.id)">
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
<td colspan="6">
<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 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>
<div v-else class="tree-name-cell" @click="onNodeClick(row.node as ProductCategoryNode)">
<span class="tree-indent" :style="{ '--indent': `${row.depth * 24}px` }"></span>
<button
type="button"
class="tree-node-mark"
:class="{ 'is-leaf': (row.node as ProductCategoryNode).childCount <= 0 }"
:disabled="(row.node as ProductCategoryNode).childCount <= 0"
:title="expandedIds.has((row.node as ProductCategoryNode).id) ? '折叠' : '展开'"
@click.stop="toggleExpand(row.node as ProductCategoryNode)"
>
{{ (row.node as ProductCategoryNode).childCount > 0 ? (expandedIds.has((row.node as ProductCategoryNode).id) ? '' : '+') : '' }}
</button>
<strong class="node-name">{{ (row.node as ProductCategoryNode).name }}</strong>
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="层级路径" min-width="220">
<template #default="{ row }">
<span class="dim" :title="(row.node as ProductCategoryNode).path">{{ (row.node as ProductCategoryNode).path || (row.node as ProductCategoryNode).name }}</span>
</template>
</el-table-column>
<el-table-column label="排序" min-width="80">
<template #default="{ row }">{{ (row.node as ProductCategoryNode).sortOrder ?? 0 }}</template>
</el-table-column>
<el-table-column label="来源" min-width="90">
<template #default="{ row }">
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" min-width="180">
<template #default="{ row }">
<span v-if="(row.node as ProductCategoryNode).description" class="node-desc" :title="(row.node as ProductCategoryNode).description">{{ (row.node as ProductCategoryNode).description }}</span>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="140" fixed="right">
<template #default="{ row }">
<template v-if="!isCategoryLoadMoreNode(row.node)">
<el-button link size="small" type="primary" @click="openEdit(row.node as ProductCategoryNode)">编辑</el-button>
<el-button
link
size="small"
type="danger"
:disabled="!canDelete(row.node as ProductCategoryNode)"
@click="removeCategory(row.node as ProductCategoryNode)"
>删除</el-button>
<tr v-else-if="loading">
<td colspan="6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无商品类目</td>
</tr>
</tbody>
<tbody v-else>
<template v-if="searchRows.length">
<tr v-for="row in searchRows" :key="row.id">
<td><strong class="node-name">{{ row.name }}</strong></td>
<td><span class="category-path-value" :title="row.path || row.name">{{ row.path || row.name }}</span></td>
<td>{{ row.sortOrder ?? '—' }}</td>
<td>
<span class="category-tag" :class="{ 'is-custom': !row.isBuiltin }">{{ productCategorySourceLabel(row) }}</span>
</td>
<td>
<span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
<span v-else class="dim"></span>
</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"
:disabled="!canDelete(row)"
:title="!canDelete(row) ? '存在子类目,不能删除' : undefined"
@click="removeCategory(row)"
>
删除
</button>
</td>
</tr>
</template>
</template>
</el-table-column>
</el-table>
<div class="tree-actions">
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
<span class="btn-group">
<el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button>
</span>
<tr v-else-if="searchLoading">
<td colspan="6" class="empty-tip">搜索中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无搜索结果</td>
</tr>
</tbody>
</table>
</div>
</el-card>
<el-card v-else shadow="never">
<div class="search-head">
<span>搜索结果 {{ searchTotal.toLocaleString() }} </span>
<span class="dim">{{ keyword }}</span>
</div>
<el-table v-loading="searchLoading" :data="searchRows" stripe border>
<el-table-column prop="name" label="类目名称" min-width="180" />
<el-table-column label="层级路径" min-width="240">
<template #default="{ row }">{{ (row as ProductCategoryNode).path || (row as ProductCategoryNode).name }}</template>
</el-table-column>
<el-table-column label="排序" min-width="90">
<template #default="{ row }">{{ (row as ProductCategoryNode).sortOrder ?? '—' }}</template>
</el-table-column>
<el-table-column label="来源" min-width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain" :type="(row as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
{{ productCategorySourceLabel(row as ProductCategoryNode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="说明" min-width="180">
<template #default="{ row }">{{ (row as ProductCategoryNode).description || '—' }}</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row as ProductCategoryNode)">编辑</el-button>
<el-button link type="danger" :disabled="!canDelete(row as ProductCategoryNode)" @click="removeCategory(row as ProductCategoryNode)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination background layout="prev, pager, next, jumper" :total="searchTotal" :page-size="PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE" :current-page="searchPage" @current-change="searchChangePage" />
</div>
</el-card>
<OldPagination v-if="searching" :total="searchTotal" :page="searchPage" :page-size="searchPageSize" @change="runSearch" @size-change="changeSearchSize" />
<OldPagination v-else-if="rootTotal > 0" :total="rootTotal" :page="rootPage" :page-size="rootPageSize" @change="changeRootPage" @size-change="changeRootSize" />
</section>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form label-width="110px">
<el-form-item label="父级类目">
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable>
<el-option v-for="node in allNodes" :key="node.id" :label="node.path || node.name" :value="node.id" />
<el-option
v-for="node in allNodes"
:key="node.id"
:label="node.path || node.name"
:value="node.id"
/>
</el-select>
</el-form-item>
<el-form-item label="类目名称" required>
<el-input v-model="draft.name" placeholder="类目名称" />
<el-input v-model="draft.name" placeholder="如:洁面" />
</el-form-item>
<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 label="说明">
<el-input v-model="draft.description" type="textarea" :rows="2" placeholder="可选" />
<el-form-item label="备注">
<el-input v-model="draft.description" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存类目</el-button>
<el-button type="primary" :loading="saving" @click="submit">{{ editingId == null ? '保存' : '保存类目' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; align-items: center; gap: 10px; }
.filter-row { display: flex; gap: 10px; align-items: center; }
.tree-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.btn-group { display: inline-flex; gap: 6px; }
.search-head { display: flex; justify-content: space-between; margin-bottom: 12px; }
.load-more-row { text-align: center; margin-top: 12px; }
/* 树形表格(对齐旧版 panel-product-categories):缩进 + [+/] 折叠标记 + 粗体名称。 */
.tree-name-cell { display: flex; align-items: center; gap: 8px; min-height: 24px; }
.tree-indent { flex: 0 0 auto; width: var(--indent, 0px); }
/* 像素复刻旧版 admin.html panel-product-categories(蓝白末层)。 */
.category-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;
}
.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 {
width: 20px;
height: 20px;
border: 0;
border-radius: 5px;
background: #eef0fe;
color: #6366f1;
background: var(--admin-primary-soft, #e7f0f8);
color: var(--admin-primary-strong, #2f5d8b);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -477,9 +642,115 @@ onMounted(loadTree)
flex: none;
transition: background 0.12s ease, color 0.12s ease;
}
.tree-node-mark:not(.is-leaf):hover { background: #6366f1; color: #fff; }
.tree-node-mark.is-leaf { background: transparent; cursor: default; }
.node-name { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; }
.node-desc { color: var(--el-text-color-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; display: inline-block; vertical-align: middle; }
.tree-table .el-table__row { cursor: pointer; }
.tree-node-mark:not(.is-leaf):hover {
background: var(--admin-primary-strong, #2f5d8b);
color: #ffffff;
}
.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>
@@ -1,6 +1,6 @@
<script setup lang="ts">
/** 查询 ASIN:对齐 admin panel-query-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国 ASIN 行可点击复制) +
* 新增 ASIN 弹窗(分组→店铺联动→国家→ASIN) + 行内「配置抽屉 + 导入添加/删除导入/导出。 */
/** 查询 ASIN · 像素复刻旧版 admin.html panel-query-asin(自绘:面板头操作组、筛选+导出 XLSX、原生 rowspan 表格+可复制 ASIN、旧式分页)。
* script 逻辑沿用现有 Vue 实现(新增级联/配置抽屉/导入轮询/导出。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
@@ -14,6 +14,7 @@ import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from '
import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false)
const rows = ref<QueryAsinItem[]>([])
@@ -22,18 +23,12 @@ const page = ref(1)
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/操作4):分组(0)/店铺(1)/操作(4) 仅首行占位,其余合并。 */
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 4) {
return row.isFirst ? [row.rowspan, 1] : [0, 0]
}
return [1, 1]
}
function asinOf(row: QueryAsinItem, country: string): string {
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
return map[country] || ''
@@ -57,6 +52,21 @@ function normalizeAsinInput(country: string): void {
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> {
const item = drawerItem.value
if (!item) return
@@ -216,15 +226,7 @@ async function submitImport(): Promise<void> {
return
}
if (importMode.value === 'delete') {
try {
await ElMessageBox.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
if (!window.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) return
}
const mode = importMode.value
const groupId = importGroupId.value ?? undefined
@@ -303,12 +305,28 @@ function apply() {
void load()
}
function reset() {
Object.assign(filter, createQueryAsinFilterState())
page.value = 1
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
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(() => {
void loadGroups()
void load()
@@ -316,89 +334,89 @@ onMounted(() => {
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>查询 ASIN</h2>
<p>每店铺在 5 个站点查询到的 ASIN 清单行内可逐站配置 ASIN</p>
<div class="query-view">
<section class="panel-box">
<div class="query-head">
<h3>店铺列表</h3>
<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>
<el-card shadow="never">
<div class="filter-grid">
<div class="f-item">
<div class="form-row query-filter-row">
<div class="form-group">
<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-select>
</div>
<div class="f-item">
<div class="form-group">
<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">
<label>ASIN</label>
<el-input v-model="filter.asin" placeholder="请输入 ASIN" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<div class="form-group">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部国家" clearable>
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
<select v-model="filter.country">
<option value="">全部国家</option>
<option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
<div class="form-group">
<label>ASIN</label>
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
<el-button @click="openImportAdd">导入添加</el-button>
<el-button @click="openImportDelete">导入删除</el-button>
<el-button @click="doExport">导出</el-button>
<div class="table-scroll query-asin-table-scroll">
<table>
<thead>
<tr>
<th style="width: 6%">序号</th>
<th style="width: 13%">分组</th>
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</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 :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" 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="6" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="6" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
<el-table-column label="分组" min-width="130">
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
</el-table-column>
<el-table-column label="店铺" min-width="150">
<template #default="{ row }">{{ row.item.shopName }}</template>
</el-table-column>
<el-table-column label="ASIN" min-width="150">
<template #default="{ row }">
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="国家" min-width="110">
<template #default="{ row }">
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as QueryAsinItem)">配置</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="sizes, prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="page"
@current-change="(p: number) => { page = p; void load() }"
@size-change="() => { page = 1; void load() }"
/>
</div>
</el-card>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
@@ -420,7 +438,7 @@ onMounted(() => {
</el-select>
</el-form-item>
<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-select>
</el-form-item>
@@ -455,7 +473,7 @@ onMounted(() => {
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
<el-select v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
@@ -476,16 +494,274 @@ onMounted(() => {
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.heading-actions { display: flex; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
.dim { color: var(--el-text-color-placeholder); }
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
/* 像素复刻旧版 admin.html panel-query-asin(蓝白末层)。 */
.query-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;
}
.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>
@@ -1,7 +1,6 @@
<script setup lang="ts">
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国
* ASIN 可点击复制 + 最低价两位小数)、新增 ASIN 弹窗(分组→店铺联动→国家→ASIN+最低价)、价格范围筛选、
* 行内「配置」抽屉(逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
/** 最低价 ASIN(跳过跟价)· 像素复刻旧版 admin.html panel-skip-price-asin(自绘:面板头操作组、筛选(分组/店铺/国家/ASIN/最低价≥≤)+导出 XLSX、原生 rowspan 表格、旧式分页)。
* script 逻辑沿用现有 Vue 实现(新增级联+最低价、配置抽屉双列、导入轮询、导出)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
@@ -15,6 +14,7 @@ import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopNamesByGroup } from './query-asin-api.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false)
const rows = ref<SkipPriceItem[]>([])
@@ -23,6 +23,8 @@ const page = ref(1)
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
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 ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
@@ -40,14 +42,6 @@ function priceOf(row: SkipPriceItem, code: string): number | null {
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/最低价4/操作5):分组(0)/店铺(1)/操作(5) 仅首行占位,其余合并。 */
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 5) {
return row.isFirst ? [row.rowspan, 1] : [0, 0]
}
return [1, 1]
}
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin ----
const createVisible = ref(false)
const createGroupId = ref<number | null>(null)
@@ -262,15 +256,7 @@ async function submitImport(): Promise<void> {
return
}
if (importMode.value === 'delete') {
try {
await ElMessageBox.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?', '删除导入', {
type: 'warning',
confirmButtonText: '确定删除',
cancelButtonText: '取消',
})
} catch {
return
}
if (!window.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) return
}
const mode = importMode.value
const groupId = importGroupId.value as number
@@ -341,12 +327,28 @@ function apply() {
void load()
}
function reset() {
Object.assign(filter, createSkipPriceFilterState())
page.value = 1
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
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(() => {
void loadGroups()
void load()
@@ -354,103 +356,98 @@ onMounted(() => {
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>最低价 ASIN / 跳过跟价</h2>
<p>每店铺在 5 个站点设定的最低价 ASIN 清单行内可逐站配置 ASIN 与最低价</p>
<div class="skip-view">
<section class="panel-box">
<div class="skip-head">
<h3>店铺列表</h3>
<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>
<el-card shadow="never">
<div class="filter-grid">
<div class="f-item">
<div class="form-row skip-filter-row">
<div class="form-group">
<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-select>
</div>
<div class="f-item">
<label>店铺</label>
<el-input v-model="filter.shopName" placeholder="店铺名模糊" clearable @keyup.enter="apply" />
<div class="form-group">
<label>店铺</label>
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div>
<div class="f-item">
<label>ASIN</label>
<el-input v-model="filter.asin" placeholder="ASIN 搜索" clearable @keyup.enter="apply" />
</div>
<div class="f-item">
<div class="form-group">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable>
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
<select v-model="filter.country">
<option value="">全部国家</option>
<option v-for="code in COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="f-item price-range">
<label>最低价</label>
<div class="form-group">
<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">
<el-input v-model="filter.minimumPriceFrom" placeholder="≥ 最低值" clearable @keyup.enter="apply" />
<span class="range-sep"></span>
<el-input v-model="filter.minimumPriceTo" placeholder="≤ 最高值" clearable @keyup.enter="apply" />
<input v-model="filter.minimumPriceFrom" type="number" min="0" step="0.01" placeholder="最低价 ≥" @keyup.enter="apply" />
<span class="range-sep">-</span>
<input v-model="filter.minimumPriceTo" type="number" min="0" step="0.01" placeholder="最高价 ≤" @keyup.enter="apply" />
</div>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
<el-button @click="openImportAdd">导入添加</el-button>
<el-button @click="openImportDelete">导入删除</el-button>
<el-button @click="doExport">导出</el-button>
<div class="table-scroll skip-price-asin-table-scroll">
<table>
<thead>
<tr>
<th style="width: 6%">序号</th>
<th 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: 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 :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" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
</td>
</template>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
<el-table-column label="分组" min-width="130">
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
</el-table-column>
<el-table-column label="店铺" min-width="150">
<template #default="{ row }">{{ row.item.shopName }}</template>
</el-table-column>
<el-table-column label="ASIN" min-width="150">
<template #default="{ row }">
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="国家" min-width="110">
<template #default="{ row }">
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="最低价" min-width="110" align="right">
<template #default="{ row }">
<span v-if="row.minimumPrice !== ''">{{ row.minimumPrice }}</span>
<span v-else class="dim">-</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as SkipPriceItem)">配置</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="sizes, prev, pager, next, jumper"
:total="total"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:current-page="page"
@current-change="(p: number) => { page = p; void load() }"
@size-change="() => { page = 1; void load() }"
/>
</div>
</el-card>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
@@ -472,7 +469,7 @@ onMounted(() => {
</el-select>
</el-form-item>
<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-select>
</el-form-item>
@@ -513,7 +510,7 @@ onMounted(() => {
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组(必选)">
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
@@ -534,22 +531,301 @@ onMounted(() => {
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
.heading-actions { display: flex; gap: 8px; flex: none; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
/* 最低价范围筛选:≥ 与 ≤ 并排同一行。 */
.f-item.price-range { width: 300px; }
.price-range-row { display: flex; align-items: center; gap: 8px; width: 100%; }
.price-range-row .el-input { flex: 1; }
.range-sep { color: var(--el-text-color-secondary); font-size: 12px; flex: none; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
.dim { color: var(--el-text-color-placeholder); }
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
.country-cell { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; }
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
/* 像素复刻旧版 admin.html panel-skip-price-asin(蓝白末层)。 */
.skip-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;
}
.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>
@@ -1,6 +1,6 @@
/** 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_MAX_SIZE = 200
@@ -97,7 +97,7 @@ export function parseDedupeGroupOptionList(payload: unknown): DedupeGroupOption[
const record = raw as Record<string, unknown>
const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id)
if (!Number.isFinite(id) || id <= 0) continue
const nameRaw = record.group_name ?? record.name
const nameRaw = record.groupName ?? record.group_name ?? record.name
const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''
const option: DedupeGroupOption = { id, name: name || `分组${id}` }
const leaderRaw = record.leader_user_id ?? record.leaderUserId
@@ -25,7 +25,7 @@ export async function searchProductCategories(
page: number,
pageSize: number,
): Promise<ProductCategorySearchPage> {
const params: Record<string, string | number> = { page, pageSize }
const params: Record<string, string | number> = { page, page_size: pageSize }
const trimmed = keyword.trim()
if (trimmed) params.keyword = trimmed
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
@@ -1,7 +1,7 @@
/** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */
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 {
id: number
@@ -11,9 +11,9 @@ export interface SkipPriceFilterState {
asin: string
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
country: string
/** 最低价区间输入原文;空串不限。 */
minimumPriceFrom: string
minimumPriceTo: string
/** 最低价区间输入原文;空串不限(原生 number input v-model 会转 number,需兼容)。 */
minimumPriceFrom: string | number
minimumPriceTo: string | number
}
export interface SkipPriceListParams {
@@ -42,9 +42,9 @@ export function createSkipPriceFilterState(): SkipPriceFilterState {
return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' }
}
/** 非负可解析价格 → number;空/非法/负数返回 null。 */
export function normalizePriceInput(value: string): number | null {
const trimmed = (value || '').trim()
/** 非负可解析价格 → number;空/非法/负数返回 null。入参兼容 number(原生 number input 的 v-model 会转 number)。 */
export function normalizePriceInput(value: string | number): number | null {
const trimmed = String(value ?? '').trim()
if (!trimmed) return null
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
const num = Number(trimmed)
@@ -154,7 +154,7 @@ onMounted(() => {
</div>
</template>
<style>
<style scoped>
.auth-page {
min-height: 100vh;
display: grid;
@@ -1,8 +1,9 @@
<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 { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
deleteDigitalHumanVersion,
fetchDigitalHumanDownloadUrl,
@@ -20,18 +21,28 @@ import {
import { validateDigitalHumanUpload } from './digitalhuman-upload-model.ts'
import { canReleaseDigitalHumanVersion, releaseDigitalHumanConfirmText } from './digitalhuman-release.ts'
import { canSetLatestDigitalHumanVersion, setLatestDigitalHumanConfirmText } from './digitalhuman-latest.ts'
import OldPagination from '@/components/OldPagination.vue'
import { digitalHumanStatusDisplay } from './digitalhuman-status-display.ts'
const loading = ref(false)
const items = ref<DigitalHumanVersion[]>([])
/** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */
const pageSize = 20
const pageSize = ref(10)
const page = ref(1)
const pagedItems = computed(() => {
const start = (page.value - 1) * pageSize
return items.value.slice(start, start + pageSize)
/** 版本号搜索(客户端过滤)。 */
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))
})
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 uploading = ref(false)
@@ -45,9 +56,7 @@ async function load() {
loading.value = true
try {
items.value = (await fetchDigitalHumanVersions()).items
// 数据收缩后页码越界回钳。
const last = Math.max(Math.ceil(items.value.length / pageSize), 1)
if (page.value > last) page.value = last
// 页码越界回钳见下方 watch(items)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
} 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) {
form.value.file = file
}
@@ -91,14 +125,14 @@ async function submitUpload() {
async function release(row: DigitalHumanVersion) {
const text = releaseDigitalHumanConfirmText(row.version)
if (!text) return
// 对齐旧版:原生 confirm。
if (!window.confirm(text)) return
confirming.value = row.version
try {
await ElMessageBox.confirm(text, '发布版本', { type: 'warning', confirmButtonText: '发布', cancelButtonText: '取消' })
await releaseDigitalHumanVersion(row.version)
ElMessage.success('已发布')
load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '发布失败')
} finally {
confirming.value = null
@@ -108,14 +142,13 @@ async function release(row: DigitalHumanVersion) {
async function setLatest(row: DigitalHumanVersion) {
const text = setLatestDigitalHumanConfirmText(row.version)
if (!text) return
if (!window.confirm(text)) return
confirming.value = row.version
try {
await ElMessageBox.confirm(text, '设为最新', { type: 'warning', confirmButtonText: '设为最新', cancelButtonText: '取消' })
await setLatestDigitalHumanVersion(row.version)
ElMessage.success('已设为最新')
load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '操作失败')
} finally {
confirming.value = null
@@ -146,18 +179,13 @@ async function download(row: DigitalHumanVersion) {
async function remove(row: DigitalHumanVersion) {
const text = digitalHumanDeleteConfirmText(row.version)
if (!text) return
if (!window.confirm(text)) return
confirming.value = row.version
try {
await ElMessageBox.confirm(text, '删除版本', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await deleteDigitalHumanVersion(row.version)
ElMessage.success('删除成功')
load()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '删除失败')
} finally {
confirming.value = null
@@ -176,70 +204,74 @@ onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>数字人版本管理</h2>
<p>管理数字人客户端的草稿/已发布/已废弃版本</p>
<div class="dh-view">
<section class="panel-box">
<div class="dh-head">
<h3>版本列表</h3>
<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 class="actions">
<el-button type="primary" @click="uploadVisible = true">上传草稿状态</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无数字人版本">
<el-table-column prop="version" label="版本号" min-width="150" />
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<el-tag :type="statusOf(row as DigitalHumanVersion).tag" size="small">
{{ statusOf(row as DigitalHumanVersion).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最新" min-width="60" align="center">
<template #default="{ row }">
<span v-if="(row as DigitalHumanVersion).isLatest" class="star" title="最新版本"></span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="文件大小" min-width="100">
<template #default="{ row }">{{ digitalHumanVersionFileSizeText((row as DigitalHumanVersion).fileSize) }}</template>
</el-table-column>
<el-table-column label="MD5" min-width="130">
<template #default="{ row }">
<span :title="(row as DigitalHumanVersion).md5 || ''">{{ (row as DigitalHumanVersion).md5 ? `${((row as DigitalHumanVersion).md5 || '').substring(0, 12)}...` : '—' }}</span>
</template>
</el-table-column>
<el-table-column label="更新说明" min-width="180">
<template #default="{ row }">
<span :title="(row as DigitalHumanVersion).changelog || ''">{{ digitalHumanChangelogShort((row as DigitalHumanVersion).changelog) }}</span>
</template>
</el-table-column>
<el-table-column label="发布时间" min-width="170">
<template #default="{ row }">{{ formatDateTime((row as DigitalHumanVersion).releasedAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="240" fixed="right">
<template #default="{ row }">
<el-button v-if="canReleaseDigitalHumanVersion((row as DigitalHumanVersion).status)" text type="warning" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="release(row as DigitalHumanVersion)">发布</el-button>
<el-button v-if="!canReleaseDigitalHumanVersion((row as DigitalHumanVersion).status) && canSetLatestDigitalHumanVersion((row as DigitalHumanVersion).status) && !(row as DigitalHumanVersion).isLatest" text type="primary" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="setLatest(row as DigitalHumanVersion)">设为最新</el-button>
<el-button v-if="canSetLatestDigitalHumanVersion((row as DigitalHumanVersion).status)" text type="success" size="small" :loading="downloadingVersion === (row as DigitalHumanVersion).version" @click="download(row as DigitalHumanVersion)">下载</el-button>
<el-button v-if="canDelete(row as DigitalHumanVersion)" text type="danger" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="remove(row as DigitalHumanVersion)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> {{ items.length.toLocaleString() }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
:total="items.length"
:page-size="pageSize"
:current-page="page"
@current-change="(p: number) => { page = p }"
/>
<div class="table-scroll digital-human-version-table-scroll">
<table>
<thead>
<tr>
<th style="width: 80px">版本号</th>
<th style="width: 60px">状态</th>
<th style="width: 60px">最新</th>
<th style="width: 80px">文件大小</th>
<th style="width: 120px">MD5</th>
<th>更新日志</th>
<th style="width: 140px">发布时间</th>
<th style="width: 240px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="pagedItems.length">
<tr v-for="row in pagedItems" :key="row.version + row.id">
<td><span class="mono-cell">{{ row.version }}</span></td>
<td>
<span class="st-pill" :class="`is-${statusOf(row).tag}`">{{ statusOf(row).text }}</span>
</td>
<td><span v-if="row.isLatest" class="star" title="最新版本">★</span><span v-else class="dim"></span></td>
<td>{{ digitalHumanVersionFileSizeText(row.fileSize) }}</td>
<td><span :title="row.md5 || ''" class="mono-cell">{{ row.md5 ? `${row.md5.substring(0, 12)}...` : '—' }}</span></td>
<td>
<span :title="row.changelog || ''" class="changelog-cell">{{ digitalHumanChangelogShort(row.changelog) }}</span>
</td>
<td>{{ formatDateTime(row.releasedAt) }}</td>
<td class="ops-cell">
<button v-if="canReleaseDigitalHumanVersion(row.status)" class="btn btn-sm" type="button" :disabled="confirming === row.version" @click="release(row)">发布</button>
<button
v-if="!canReleaseDigitalHumanVersion(row.status) && canSetLatestDigitalHumanVersion(row.status) && !row.isLatest"
class="btn btn-sm"
type="button"
:disabled="confirming === row.version"
@click="setLatest(row)"
>
设为最新
</button>
<button v-if="canSetLatestDigitalHumanVersion(row.status)" class="btn btn-sm btn-secondary" type="button" :disabled="downloadingVersion === row.version" @click="download(row)">下载</button>
<button v-if="canDelete(row)" class="btn btn-sm btn-danger" type="button" :disabled="confirming === row.version" @click="remove(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">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
</tr>
</tbody>
</table>
</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">
<p class="upload-desc">上传数字人程序 ZIP 系统会自动计算 MD5 并存储到 OSS上传后状态为草稿需要手动发布</p>
@@ -270,11 +302,267 @@ onMounted(load)
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
.actions { display: flex; align-items: center; gap: 10px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.star { color: #e6a23c; font-weight: 700; }
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
/* 像素复刻旧版 admin.html panel-digital-human-version(蓝白末层)。 */
.dh-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;
}
.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>
@@ -1,29 +1,40 @@
<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 { 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 { HistoryRecordItem } from './history-dto.ts'
import { historyEmptyText, historyErrorText } from './history-feedback.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 userLoading = ref(false)
const rows = ref<HistoryRecordItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const pageSize = ref(10)
const userOptions = ref<HistoryUserOption[]>([])
const errorText = ref('')
const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
const previewVisible = ref(false)
const previewItem = ref<HistoryRecordItem | null>(null)
const jumpPage = ref('')
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
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])。 */
function previewUrls(item: HistoryRecordItem): string[] {
@@ -46,10 +57,10 @@ async function load() {
try {
const result = await fetchHistoryList({
page: page.value,
pageSize,
pageSize: pageSize.value,
userId: filter.userId ? Number(filter.userId) : undefined,
timeStart: filter.timeStart || undefined,
timeEnd: filter.timeEnd || undefined,
timeStart: toApiTime(filter.timeStart),
timeEnd: toApiTime(filter.timeEnd),
})
rows.value = result.items
total.value = result.total
@@ -77,54 +88,37 @@ function apply() {
load()
}
function reset() {
filter.userId = ''
filter.timeStart = ''
filter.timeEnd = ''
page.value = 1
load()
}
function retry() {
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()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(n)
}
function openPreview(item: HistoryRecordItem) {
previewItem.value = item
previewVisible.value = true
}
const clearing = ref(false)
/** 清空全部生成记录:二次确认后调后端 DELETE /api/admin/history。 */
async function clearAll() {
try {
await ElMessageBox.confirm('确定清空全部生成记录吗?该操作会删除所有历史数据,无法恢复。', '清理生成记录', {
confirmButtonText: '确认清空',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
clearing.value = true
try {
const removed = await clearHistory()
ElMessage.success(`已清空 ${removed} 条生成记录`)
page.value = 1
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清理失败')
} finally {
clearing.value = false
}
}
onMounted(() => {
load()
loadUserOptions()
@@ -132,143 +126,443 @@ onMounted(() => {
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>生成记录</h2>
<p>查看各用户在后台生成的历史记录与结果图</p>
<div class="history-view">
<section class="form-box">
<h3>筛选条件</h3>
<div class="form-row">
<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 class="heading-actions">
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
</section>
<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>
<el-card shadow="never" class="filter-card">
<div class="filter-grid">
<div class="f-item">
<label>指定用户</label>
<el-select v-model="filter.userId" clearable filterable placeholder="全部用户" style="width: 100%" :loading="userLoading">
<el-option label="全部用户" value="" />
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
</el-select>
</div>
<div class="f-item">
<label>开始时间</label>
<el-date-picker
v-model="filter.timeStart"
type="datetime"
placeholder="开始时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
<div class="f-item">
<label>结束时间</label>
<el-date-picker
v-model="filter.timeEnd"
type="datetime"
placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
</div>
</el-card>
<el-card shadow="never">
<el-alert
v-if="errorText"
:title="errorText"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
>
<template #default>
<el-button link type="primary" @click="retry">重试</el-button>
</template>
</el-alert>
<el-table v-loading="loading" :data="rows" stripe :empty-text="emptyText">
<el-table-column prop="id" label="ID" min-width="90" />
<el-table-column prop="username" label="用户" min-width="140" />
<el-table-column label="类型" min-width="150">
<template #default="{ row }">
{{ historyPanelTypeLabel((row as HistoryRecordItem).panelType) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDateTime((row as HistoryRecordItem).createdAt) }}</template>
</el-table-column>
<el-table-column label="结果预览" min-width="220">
<template #default="{ row }">
<div v-if="previewUrls(row as HistoryRecordItem).length" class="thumbs">
<el-image
v-for="(url, index) in previewUrls(row as HistoryRecordItem).slice(0, 3)"
:key="url"
:src="url"
:preview-src-list="previewUrls(row as HistoryRecordItem)"
:initial-index="index"
preview-teleported
fit="cover"
class="thumb"
/>
<span v-if="previewUrls(row as HistoryRecordItem).length > 3" class="dim">+{{ previewUrls(row as HistoryRecordItem).length - 3 }}</span>
</div>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="110" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openPreview(row as HistoryRecordItem)">查看大图</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span> <b>{{ total.toLocaleString() }}</b> </span>
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
</div>
</el-card>
<el-dialog v-model="previewVisible" :title="previewItem ? `记录 #${previewItem.id} 结果图` : '结果图'" width="820px" top="6vh">
<div v-if="previewItem" class="preview-meta">
<el-tag size="small">{{ previewItem.username }}</el-tag>
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
</div>
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
<el-image
v-for="(url, index) in previewUrls(previewItem)"
:key="url"
:src="url"
:preview-src-list="previewUrls(previewItem)"
:initial-index="index"
preview-teleported
fit="contain"
class="preview-img"
/>
</div>
<el-empty v-else description="无结果图片" />
</el-dialog>
</div>
</template>
<style scoped>
.filter-card { margin-bottom: 14px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.thumbs { display: flex; align-items: center; gap: 6px; }
.thumb { width: 56px; height: 56px; border-radius: 4px; border: 1px solid var(--el-border-color-lighter); flex: none; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.table-footer b { color: var(--el-text-color-primary); }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.preview-meta { display: flex; align-items: center; gap: 12px; color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 12px; }
.preview-grid { display: flex; flex-direction: column; gap: 12px; }
.preview-img { width: 100%; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
/* 像素复刻旧版 admin.html 蓝白末层样式:form-box/panel-box 双卡 + table-scroll 表格 + 旧式分页 + thumb 缩略图。 */
.history-view {
font-family: inherit;
color: var(--c-text, #24384d);
display: flex;
flex-direction: column;
gap: 18px;
}
.form-box,
.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 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>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 软件版本管理页:客户端版本列表 + 上传新版本(成功留驻弹窗含链接,对齐 admin panel-version)。 */
import { computed, onMounted, ref } from 'vue'
/** 软件版本管理页 · 像素复刻旧版 admin.html panel-version(自绘:面板头+上传新版本、版本列表、全量无分页同旧版)。
* script 逻辑沿用现有 Vue 实现(上传弹窗成功留驻含链接)。 */
import { computed, onMounted, ref, watch } from 'vue'
import OldPagination from '@/components/OldPagination.vue'
import { ElMessage } from 'element-plus'
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts'
import type { SoftwareVersionItem } from './version-dto.ts'
@@ -9,15 +11,23 @@ import { isNonEmptyVersion } from './version-dto.ts'
const loading = ref(false)
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 pageSize = 10
const total = computed(() => items.value.length)
const pagedItems = computed(() => items.value.slice((page.value - 1) * pageSize, page.value * pageSize))
function changePage(p: number) {
page.value = p
}
const pageSize = ref(10)
const pagedVersions = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
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)))
})
const uploadVisible = ref(false)
const uploading = ref(false)
@@ -87,40 +97,52 @@ onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>软件版本管理</h2>
<p>管理客户端更新包版本用于 /api/version/latest 下发</p>
<div class="version-view">
<section class="panel-box">
<div class="version-head">
<h3>版本列表</h3>
<div class="version-head-tools">
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
<button class="btn" type="button" @click="openUpload">上传新版本</button>
</div>
</div>
<div class="actions">
<el-button type="primary" @click="openUpload">上传新版本</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无版本记录">
<el-table-column prop="version" label="版本号" min-width="180" />
<el-table-column label="下载链接" min-width="240">
<template #default="{ row }">
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" target="_blank" rel="noopener">{{ (row as SoftwareVersionItem).fileUrl }}</a>
<span v-else class="dim"></span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="90" fixed="right">
<template #default="{ row }">
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" download>下载</a>
<span v-else class="dim"></span>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
<div class="table-scroll version-table-scroll">
<table>
<thead>
<tr>
<th style="width: 120px">版本号</th>
<th>下载链接</th>
<th style="width: 160px">创建时间</th>
<th style="width: 120px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredItems.length">
<tr v-for="row in pagedVersions" :key="row.version + row.id">
<td>{{ row.version }}</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>{{ formatDateTime(row.createdAt) }}</td>
<td>
<a v-if="row.fileUrl" class="btn btn-sm btn-secondary dl-btn" :href="row.fileUrl" download>下载</a>
<span v-else class="dim"></span>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="4" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="4" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
</tr>
</tbody>
</table>
</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">
<p class="upload-desc">上传软件 ZIP 系统会自动计算 MD5 并存储到 OSS</p>
@@ -146,11 +168,180 @@ onMounted(load)
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
.actions { display: flex; align-items: center; gap: 10px; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
.zip-hint { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
.upload-msg { margin-bottom: 8px; }
.upload-msg :deep(.el-alert__title) { word-break: break-all; }
/* 像素复刻旧版 admin.html panel-version(蓝白末层)。 */
.version-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;
}
.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: 760px;
}
.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;
}
.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 } })
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
* snake 契约对齐;历史页与后台版本/公开版本接口分离。纯逻辑。 */
export const HISTORY_DEFAULT_PAGE_SIZE = 15
export const HISTORY_DEFAULT_PAGE_SIZE = 10
export const HISTORY_MIN_PAGE = 1
export const HISTORY_MAX_PAGE_SIZE = 200
@@ -1,18 +1,31 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 店铺密钥管理页:分页 + 新增/编辑/删除;紫鸟令牌掩码展示。 */
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
/** 店铺密钥管理页 · 像素复刻旧版 admin.html panel-shop-keys(自绘:面板头+新增店铺密钥、序号列、令牌掩码、白名单状态药丸+悬停详情、旧式分页)。
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } from './shop-key-api.ts'
import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts'
import type { ShopKeyItem } from './shop-dto.ts'
import { whitelistStatusMeta } from './shop-key-model.ts'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false)
const rows = ref<ShopKeyItem[]>([])
const total = ref(0)
const page = ref(1)
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 editingId = ref<number | null>(null)
@@ -20,7 +33,7 @@ const saving = ref(false)
const form = reactive(emptyShopKeyForm())
function maskToken(token: string): string {
const value = (token || '')
const value = token || ''
if (value.length <= 8) return '••••••••'
return `${value.slice(0, 4)}••••••••${value.slice(-4)}`
}
@@ -97,11 +110,8 @@ async function submit() {
}
async function remove(row: ShopKeyItem) {
try {
await ElMessageBox.confirm(`确定删除店铺密钥“${row.ziniaoAccountName}”吗?`, '删除确认', { type: 'warning' })
} catch {
return
}
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除店铺密钥“${row.ziniaoAccountName}”吗?`)) return
try {
await deleteShopKey(row.id)
ElMessage.success('已删除')
@@ -111,61 +121,101 @@ 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)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>店铺密钥管理</h2>
<p>管理各店铺的紫鸟浏览器账号与令牌</p>
<div class="keys-view">
<section class="panel-box">
<div class="keys-head">
<h3>店铺密钥列表</h3>
<button class="btn" type="button" @click="openCreate">新增店铺密钥</button>
</div>
</div>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增密钥</el-button>
<div class="form-row keys-filter-row">
<div class="form-group" style="min-width: 220px">
<label>备注名 / 紫鸟账号</label>
<input v-model="filterKeyword" type="text" placeholder="模糊搜索备注名或紫鸟账号" />
</div>
</div>
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="remarkName" label="备注名" min-width="140">
<template #default="{ row }">{{ (row as ShopKeyItem).remarkName || '—' }}</template>
</el-table-column>
<el-table-column prop="ziniaoAccountName" label="紫鸟账号名称" min-width="160" />
<el-table-column label="紫鸟令牌" min-width="200">
<template #default="{ row }">
<el-tooltip :content="(row as ShopKeyItem).ziniaoToken" placement="top">
<code class="mono-mask">{{ maskToken((row as ShopKeyItem).ziniaoToken) }}</code>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="白名单状态" min-width="150" align="center">
<template #default="{ row }">
<el-tooltip :content="whitelistTooltip(row as ShopKeyItem)" placement="top">
<el-tag :type="whitelistStatusMeta((row as ShopKeyItem).ipWhitelistStatus).tag" size="small">
{{ whitelistStatusMeta((row as ShopKeyItem).ipWhitelistStatus).label }}
</el-tag>
</el-tooltip>
</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="160" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="openEdit(row as ShopKeyItem)">编辑</el-button>
<el-button text type="danger" size="small" @click="remove(row as ShopKeyItem)">删除</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 class="shop-key-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 150px">备注名</th>
<th style="width: 160px">紫鸟账号名称</th>
<th style="width: 220px">紫鸟令牌</th>
<th style="width: 150px">白名单状态 <span class="table-help" aria-hidden="true">?</span></th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">修改时间</th>
<th style="width: 160px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredRows.length">
<tr v-for="(row, index) in filteredRows" :key="row.id">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td>{{ row.remarkName || '—' }}</td>
<td>{{ row.ziniaoAccountName }}</td>
<td>
<span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
</td>
<td>
<span
class="wh-pill"
:class="{
'is-allowed': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'success',
'is-blocked': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'danger',
'is-unknown': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'info',
}"
: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-danger" type="button" @click="remove(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">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
</tr>
</tbody>
</table>
</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-form label-width="110px">
@@ -193,9 +243,259 @@ onMounted(load)
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.table-footer b { color: var(--el-text-color-primary); }
.mono-mask { font-family: Consolas, monospace; font-size: 12px; background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 4px; }
/* 像素复刻旧版 admin.html panel-shop-keys(蓝白末层)。 */
.keys-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;
}
.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-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: 1000px;
}
.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>
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 店铺管理页:分组/关键字筛选 + 分页 + 新增/编辑/凭证明文/删除。 */
/** 店铺管理页 · 像素复刻旧版 admin.html panel-shop-manage(自绘:面板头+新增店铺、筛选、密码列 ******+眼睛实时读明文、旧式表格/分页、原生 confirm 删除)。
* script 逻辑沿用现有 Vue 实现(凭证明文 dialog/编辑弹窗/分组筛选)。 */
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 {
deleteShopManage,
@@ -14,6 +15,7 @@ import {
} from './shop-manage-api.ts'
import { emptyShopManageForm } from './shop-manage-form-model.ts'
import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false)
@@ -21,6 +23,8 @@ const rows = ref<ShopSummary[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const groups = ref<ShopGroupOption[]>([])
const filter = reactive({ shopName: '', groupId: null as number | null })
@@ -94,17 +98,26 @@ function apply() {
load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
void load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function reset() {
filter.shopName = ''
filter.groupId = null
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 openCreate() {
@@ -166,11 +179,8 @@ async function showCredential(row: ShopSummary) {
}
async function remove(row: ShopSummary) {
try {
await ElMessageBox.confirm(`确定删除店铺“${row.shopName}”吗?`, '删除确认', { type: 'warning' })
} catch {
return
}
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除店铺“${row.shopName}”吗?`)) return
try {
await deleteShopManage(row.id)
ElMessage.success('已删除')
@@ -196,83 +206,88 @@ onMounted(() => {
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>店铺管理</h2>
<p>管理店铺账号所属分组与自动化账号</p>
<div class="shop-view">
<section class="panel-box">
<div class="shop-head">
<h3>店铺列表</h3>
<button class="btn" type="button" @click="openCreate">新增店铺</button>
</div>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-grid">
<div class="f-item">
<div class="form-row shop-filter-row">
<div class="form-group" style="min-width: 180px">
<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-select>
</div>
<div class="f-item">
<div class="form-group" style="min-width: 220px">
<label>店铺名</label>
<el-input v-model="filter.shopName" placeholder="模糊搜索店铺名" clearable @keyup.enter="apply" />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
</div>
</el-card>
<el-card shadow="never">
<div class="table-toolbar">
<el-button type="primary" @click="openCreate">新增店铺</el-button>
<div class="table-scroll shop-manage-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th 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 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="10" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="10" class="empty-tip">暂无店铺</td>
</tr>
</tbody>
</table>
</div>
<el-table v-loading="loading" :data="rows" stripe border>
<el-table-column prop="groupName" label="分组" min-width="120" />
<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>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
<el-form label-width="120px">
<el-form-item label="所属分组" required>
<el-select v-model="form.groupId" placeholder="选择分组">
<el-select filterable v-model="form.groupId" placeholder="选择分组">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
@@ -316,14 +331,239 @@ onMounted(() => {
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
.table-footer b { color: var(--el-text-color-primary); }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
.pwd-cell { font-family: Consolas, monospace; font-size: 12px; margin-right: 6px; }
/* 像素复刻旧版 admin.html panel-shop-manage(蓝白末层)。 */
.shop-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;
}
.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>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
<script setup lang="ts">
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 { ElMessage } from 'element-plus'
import { requestVideoZipDownload, fetchImageVideoTasks, fetchImageVideoTaskDetail } from './image-video-api.ts'
@@ -16,12 +17,17 @@ import {
} from './image-video-selection.ts'
import { grantedUserIds, type TaskPermissionItem } from './task-permission.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 tasks = ref<ImageVideoRow[]>([])
const totalTasks = ref(0)
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 selection = ref<Set<string>>(new Set())
@@ -34,21 +40,47 @@ const permissionVisible = ref(false)
const permissionItems = ref<TaskPermissionItem[]>([])
const permissionInitial = ref<number[]>([])
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 downloadingKey = ref('')
/** 旧版状态 label 映射(admin.js 前缀兜底:RUN/WAIT/PROCESS/POLL → 处理中)。 */
function statusLabel(status: string): string {
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' }
return map[status] || status
const map: Record<string, string> = {
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' {
if (status === 'SUCCESS') return 'success'
if (status === 'RUNNING') return 'warning'
if (status === 'FAILED') return 'danger'
if (status === 'CANCELLED') return 'info'
return 'info'
function statusType(status: string): 'pending' | 'running' | 'success' | 'failed' | 'cancelled' {
const key = (status || '').toUpperCase()
if (key === 'SUCCESS' || key === 'COMPLETED' || key === 'DONE') return 'success'
if (key === 'FAILED' || key === 'ERROR') return 'failed'
if (key === 'CANCELLED' || key === 'CANCELED') return 'cancelled'
if (key === 'PENDING' || key === 'WAITING') return 'pending'
return 'running'
}
const currentVideoKeys = computed(() => {
@@ -78,7 +110,7 @@ function toFilter(): ImageVideoFilter {
async function load() {
loading.value = true
try {
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize)
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize.value)
tasks.value = result.items
totalTasks.value = result.total
page.value = result.page
@@ -101,6 +133,29 @@ function reset() {
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() {
selection.value = allSelected.value ? clearVideoSelection() : new Set(currentVideoKeys.value)
}
@@ -195,6 +250,7 @@ async function openPermission() {
try {
permissionItems.value = await fetchImageVideoPermissionUsers()
permissionInitial.value = grantedUserIds(permissionItems.value)
permPage.value = 1
permissionVisible.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
@@ -221,7 +277,8 @@ async function savePermission() {
}
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 '视频生成中或暂无结果'
}
@@ -233,97 +290,89 @@ onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>视频任务记录</h2>
<p>查看图生视频任务卡片在线预览与下载视频配置数据范围授权</p>
<div class="iv-view">
<section class="form-box">
<h3>视频任务记录筛选</h3>
<div class="form-row">
<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 class="actions">
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选当前页</el-checkbox>
<el-button :disabled="selectionCount === 0" :loading="downloading" @click="downloadBatch">
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
</el-button>
<el-button @click="openPermission">权限配置</el-button>
</section>
<section class="panel-box iv-panel">
<div class="iv-toolbar">
<div class="iv-toolbar-main">
<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>
<el-card shadow="never" style="margin-bottom: 14px">
<div class="filter-grid">
<div class="f-item">
<label>用户名模糊搜索</label>
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
</div>
<div class="f-item wide">
<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 />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
<div v-loading="loading" class="task-card-grid">
<template v-if="tasks.length">
<section v-for="task in tasks" :key="String(task.taskId)" class="iv-card">
<header class="iv-card-head">
<input type="checkbox" :checked="cardSelected(task)" :disabled="!hasVideos(task)" @change="toggleCard(task)" />
<span class="iv-card-title">任务 {{ task.taskId }} · 视频 {{ task.videos.length }}</span>
<span class="st-pill" :class="`is-${statusType(task.status)}`">{{ statusLabel(task.status) }}</span>
</header>
<div class="iv-card-info">
<div class="iv-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
<div class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
<div class="iv-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></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>
</el-card>
<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="暂无视频任务记录" />
<OldPagination :total="totalTasks" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-drawer v-model="detailVisible" title="任务明细" size="720px">
<div v-loading="detailLoading">
@@ -335,26 +384,16 @@ onMounted(load)
<el-descriptions-item label="任务模式">{{ detail.mode || '—' }}</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="调试链接">
<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="错误">{{ detail.errorMessage || '—' }}</el-descriptions-item>
</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>
</div>
</el-drawer>
<el-dialog v-model="permissionVisible" title="视频任务数据范围授权" width="620px">
<p class="dim">勾选允许查看/操作视频任务的用户(仅数据范围)。当前已授权 {{ permissionGrantedCount }} 人。</p>
<el-table :data="permissionItems" border size="small" max-height="420">
<el-dialog v-model="permissionVisible" title="视频任务记录权限配置" width="620px">
<p class="dim">勾选允许查看视频任务记录的用户仅数据范围当前已授权 {{ permissionGrantedCount }} </p>
<el-table :data="pagedPermissionItems" border size="small" max-height="420">
<el-table-column prop="id" label="ID" min-width="80" />
<el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column label="授权" min-width="90" align="center">
@@ -363,6 +402,7 @@ onMounted(load)
</template>
</el-table-column>
</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>
<el-button @click="permissionVisible = false">取消</el-button>
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
@@ -372,30 +412,315 @@ onMounted(load)
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; align-items: center; gap: 12px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.wide { width: 340px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; margin-left: auto; flex: none; }
.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; }
.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; }
.task-card-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.task-card-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.task-id { font-weight: 600; color: var(--el-text-color-primary); }
.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); }
.task-info-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; min-width: 0; }
.task-info-row label { flex: none; color: var(--el-text-color-secondary); min-width: 60px; }
.task-info-row span { color: var(--el-text-color-regular); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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); }
.video-row:last-child { border-bottom: 0; }
.video-player { width: 300px; max-height: 180px; border-radius: 8px; background: #0d1117; }
.video-no-preview { color: var(--el-text-color-secondary); font-size: 13px; }
.video-index { color: var(--el-text-color-secondary); font-size: 12.5px; }
.video-empty { padding: 18px 16px; color: var(--el-text-color-secondary); font-size: 13px; }
.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; }
/* 像素复刻旧版 admin.html panel-image-video-tasks(蓝白末层)。 */
.iv-view {
font-family: inherit;
color: #24384d;
display: flex;
flex-direction: column;
gap: 18px;
}
.form-box,
.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 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.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>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 店铺数据任务/记录页:每条任务一张卡片(店铺名/任务号/状态/分组/最新/国家/文件 + 下载文件/删除)
* + 筛选 + 批量下载 + 数据范围授权(对齐 admin.js renderShopDataRecordTable)。 */
/** 店铺数据记录页 · 像素复刻旧版 admin.html panel-shop-data-crawl-tasks(自绘:form-box 筛选[h4 筛选条件] + panel-box toolbar[全选/批量下载/权限配置/汇总] + 每店卡片网格 + 旧式分页(店铺级+卡片级 9/页))。
* script 逻辑沿用现有 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 { createShopDataFilter, type ShopDataFilter } from './shop-data-filter.ts'
import type { ShopDataResultRow, ShopDataTaskGroup } from './shop-data-model.ts'
@@ -24,15 +24,21 @@ import {
createShopDataSelection,
toggleShopDataSelection,
} 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 groups = ref<ShopDataTaskGroup[]>([])
const totalShops = ref(0)
const page = ref(1)
const pageSize = 20
// 卡片宫格分页:一行 3 个、一页 9 张卡片(在店铺分页的结果文件内翻页)。
// 店铺级一次全载(>200 家店铺时后端按页截断,可上调或恢复底部店铺级分页)。
const pageSize = ref(200)
// 卡片宫格分页:一行 3 个、一页 10 张卡片(在店铺分页的结果文件内翻页)。
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 selection = ref<Set<string>>(new Set())
@@ -40,20 +46,42 @@ const selection = ref<Set<string>>(new Set())
const permissionVisible = ref(false)
const permissionItems = ref<TaskPermissionItem[]>([])
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 deletingKey = ref('')
/** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */
function statusLabel(status: string): string {
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' }
return map[status] || status
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): 'info' | 'warning' | 'success' | 'danger' {
if (status === 'SUCCESS') return 'success'
if (status === 'RUNNING') return 'warning'
if (status === 'FAILED') return 'danger'
return 'info'
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 {
@@ -69,12 +97,12 @@ function toFilter(): ShopDataFilter {
async function load() {
loading.value = true
try {
const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize)
const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize.value)
groups.value = result.items
totalShops.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '店铺数据任务加载失败')
ElMessage.error(error instanceof Error ? error.message : '店铺数据记录加载失败')
} finally {
loading.value = false
}
@@ -94,7 +122,7 @@ function reset() {
const allResultRows = computed(() => groups.value.flatMap((group) => group.results))
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))
@@ -102,6 +130,20 @@ function changeCardPage(p: number) {
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 {
return groups.value.reduce((sum, group) => sum + group.results.length, 0)
}
@@ -127,6 +169,16 @@ function rowSelected(row: ShopDataResultRow): boolean {
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) {
if (!row.fileReady) {
ElMessage.warning('文件未就绪,无法下载')
@@ -164,15 +216,8 @@ async function downloadBatch() {
}
async function removeRowByRow(row: ShopDataResultRow) {
try {
await ElMessageBox.confirm(`确认删除店铺“${row.shopName}”的任务 ${row.taskNo || row.resultId} 及结果文件?`, '删除前确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
// 对齐旧版:原生 confirm。
if (!window.confirm(`确认删除店铺“${row.shopName}”的任务 ${row.taskNo || row.resultId} 及结果文件?`)) return
deletingKey.value = row.resultId
try {
await deleteShopDataResultHistory(row.resultId)
@@ -197,6 +242,7 @@ function saveBlob(blob: Blob, filename: string) {
async function openPermission() {
try {
permissionItems.value = await fetchShopDataTaskPermissionUsers()
permPage.value = 1
permissionVisible.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
@@ -227,139 +273,120 @@ onMounted(load)
</script>
<template>
<div class="page-stack">
<div class="page-heading">
<div>
<h2>店铺数据任务/记录</h2>
<p>按店铺查看数据抓取结果文件支持筛选下载删除与数据范围授权</p>
</div>
<div class="actions">
<el-checkbox
: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 class="sd-view">
<section class="form-box">
<h3>店铺数据记录</h3>
<h4 class="sd-filter-title">筛选条件</h4>
<div class="form-row">
<div class="form-group" style="min-width: 160px">
<label>店铺</label>
<input v-model="filter.shopName" type="text" placeholder="模糊搜索店铺名" @keyup.enter="apply" />
</div>
<div class="f-item">
<label>分组(模糊搜索)</label>
<el-input v-model="filter.groupName" placeholder="输入分组关键字" clearable @keyup.enter="apply" />
<div class="form-group" style="min-width: 150px">
<label>分组</label>
<input v-model="filter.groupName" type="text" placeholder="模糊搜索分组名" @keyup.enter="apply" />
</div>
<div class="f-item">
<div class="form-group" style="min-width: 130px">
<label>国家</label>
<el-select v-model="filter.country" placeholder="全部" clearable>
<el-option v-for="code in ['UK', 'DE', 'FR', 'ES', 'IT']" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
<select v-model="filter.country">
<option value="">全部</option>
<option v-for="code in ['UK', 'DE', 'FR', 'ES', 'IT']" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="f-item wide">
<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 />
<div class="form-group" style="min-width: 176px">
<label>创建开始</label>
<input v-model="filter.dateRange[0]" type="datetime-local" />
</div>
<div class="f-item btn-row">
<el-button type="primary" @click="apply">查询</el-button>
<el-button @click="reset">重置</el-button>
<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>
</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>
</el-card>
<div v-loading="loading" class="shop-summary-row">
<span>共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
<el-pagination
background
layout="prev, pager, next, jumper"
:total="totalShops"
: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)"
<div v-loading="loading" class="shop-card-grid">
<template v-if="allResultRows.length">
<article
v-for="row in pagedResultRows"
:key="row.resultId || `${row.taskNo}-${row.filename}`"
class="sd-card"
>
下载文件
</el-button>
<el-button
v-if="isTerminalStatus(row.status) && !!row.resultId"
text
type="danger"
size="small"
:loading="deletingKey === row.resultId"
@click="removeRowByRow(row)"
>
删除
</el-button>
</div>
</article>
</div>
<div v-if="allResultRows.length > cardPageSize" class="table-footer">
<el-pagination
background
layout="prev, pager, next, jumper"
:total="allResultRows.length"
:page-size="cardPageSize"
:current-page="cardPage"
@current-change="changeCardPage"
/>
</div>
<header class="sd-card-head">
<span class="sd-shop-name" :title="row.shopName || '-'">{{ row.shopName || '-' }}</span>
</header>
<div class="sd-card-select-row">
<input type="checkbox" :checked="rowSelected(row)" :disabled="!isUsableShopDataResult(row)" @change="toggleRow(row)" />
<span class="shop-card-task-no">任务 {{ row.taskNo || row.resultId }}</span>
<span class="st-pill" :class="`is-${statusType(row.status)}`">{{ statusLabel(row.status) }}</span>
</div>
<div class="sd-card-row" v-if="row.groupName">
<span class="sd-card-label">分组</span>
<span class="sd-card-value">{{ row.groupName }}</span>
</div>
<div class="sd-card-row">
<span class="sd-card-label">最新</span>
<span class="sd-card-value">{{ formatDateTime(row.updatedAt || row.createdAt) || '-' }}</span>
</div>
<div class="sd-card-row">
<span class="sd-card-label">国家</span>
<span class="sd-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="sd-card-row">
<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">
<p class="dim">勾选允许查看/操作店铺数据任务的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p>
<el-table :data="permissionItems" border size="small" max-height="420">
<el-dialog v-model="permissionVisible" title="店铺数据记录数据范围授权" width="620px">
<p class="dim">勾选允许查看/操作店铺数据记录的用户仅数据范围当前已授权 {{ grantedCount }} </p>
<el-table :data="pagedPermissionItems" border size="small" max-height="420">
<el-table-column prop="id" label="ID" min-width="80" />
<el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column label="授权" min-width="90" align="center">
@@ -368,6 +395,7 @@ onMounted(load)
</template>
</el-table-column>
</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>
<el-button @click="permissionVisible = false">取消</el-button>
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
@@ -377,25 +405,343 @@ onMounted(load)
</template>
<style scoped>
.page-heading { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; align-items: center; gap: 12px; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
.f-item.wide { width: 340px; }
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
.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; }
.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; }
.shop-card-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.shop-name { font-weight: 600; color: var(--el-text-color-primary); }
.shop-card-select-row { display: flex; align-items: center; gap: 10px; padding: 10px 16px 0; }
.shop-card-task-no { font-weight: 600; font-size: 13px; color: var(--el-text-color-primary); }
.shop-card-row { display: flex; align-items: flex-start; gap: 10px; padding: 6px 16px; font-size: 13px; }
.shop-card-label { flex: none; width: 52px; color: var(--el-text-color-secondary); }
.shop-card-value { color: var(--el-text-color-regular); min-width: 0; }
.shop-card-file { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.shop-card-actions { display: flex; gap: 6px; padding: 4px 16px 12px; }
.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; }
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
/* 像素复刻旧版 admin.html panel-shop-data-crawl-tasks(蓝白末层)。 */
.sd-view {
font-family: inherit;
color: #24384d;
display: flex;
flex-direction: column;
gap: 18px;
}
.form-box,
.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 0 14px;
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(3, 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;
}
.sd-task-no {
font-weight: 600;
font-size: 13px;
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;
}
.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: 1100px) {
.shop-card-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 760px) {
.shop-card-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -55,8 +55,8 @@ export async function fetchDuplicateLedger(
return parseLedgerPage(data)
}
/** 导出 CSV(撞款 view=monitor / 台账 view=all)GET /duplicate-check-export → Blob。 */
export async function requestDuplicateCsvBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
/** 导出 Excel(撞款 view=monitor / 台账 view=all)GET /duplicate-check-export → xlsx Blob。 */
export async function requestDuplicateExportBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
responseType: 'blob',
@@ -1,6 +1,6 @@
/** 重复检查明细筛选与分页(任务 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_MAX_PAGE_SIZE = 100
export const DUPLICATE_PAGE_MIN = 1
@@ -1,7 +1,7 @@
/** 视频任务筛选与分页(任务 102):图生视频任务列表筛选/分页归一,与 Java AdminImageVideoTaskController
* 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_MAX_PAGE_SIZE = 100
export const IMAGE_VIDEO_PAGE_MIN = 1
@@ -1,6 +1,6 @@
/** 店铺数据任务筛选与分页(任务 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_MAX_PAGE_SIZE = 100
export const SHOP_DATA_PAGE_MIN = 1
+1 -1
View File
@@ -24,7 +24,7 @@ export const adminPages: AdminPageDef[] = [
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.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: '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/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') },
+18
View File
@@ -184,6 +184,24 @@ button, input, textarea, select { font: inherit; }
.el-button { border-radius: 9px; }
.el-message-box { border-radius: 14px; }
/* 分组筛选下拉(可模糊搜索):对齐旧版控件观感(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) {
.admin-sidebar { flex-basis: 64px; }
.admin-brand-copy, .admin-menu .el-sub-menu__title span, .admin-menu .el-menu-item { display: none; }
@@ -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', () => {
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
assert.match(page, /expandedIds/, '客户端展开状态集合')
assert.match(page, /flatRows|treeRows/, '按展开状态扁平化行集')
for (const col of ['层级路径', '排序', '来源', '说明']) {
assert.match(page, new RegExp(col), `树形表格含「${col}」列`)
assert.match(page, /treeRows/, '按展开状态扁平化行集')
for (const col of ['层级路径', '排序', '来源', '备注']) {
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', () => {
// 视觉复刻旧版:20px 圆角方块标记、缩进宽度变量、名称加粗。
// 视觉复刻旧版:圆角方块标记、层级缩进(24px/级)、名称加粗。
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
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 不属于表格列。
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/ShopDataTasksPage.vue',
]
@@ -47,9 +34,40 @@ test('align_column_width_normal_variant_input', () => {
})
test('align_column_width_boundary_empty_input', () => {
// 例外页 DuplicateCheckPage 保持自身实现不动
// 例外页 DuplicateCheckPage 已整页复刻 reference 撞款控制台(原生 ledger 表格),不参与 el-table 列宽约定
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', () => {
@@ -53,11 +53,20 @@ test('align_dedupe_group_option_parses_leader', () => {
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')
assert.match(page, /数据权限分组/, '页顶数据权限分组汇总卡')
assert.match(page, /dedupeGroupSummaryOf/, '汇总走纯模型')
assert.doesNotMatch(page, /dedupe-group-access|dedupeGroupSummaryOf|groupSummary/, '页面不再有分组摘要块与接线')
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', () => {
const page = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
assert.match(page, /pageSize\s*=\s*20|:page-size="20"/, '分页大小 20')
assert.match(page, /el-pagination/, '分页组件')
assert.match(page, /pageSize\s*=\s*ref\(10\)/, '分页大小默认 10(全站统一)')
assert.match(page, /page-nums/, '数字页码分页(像素复刻旧版)')
assert.match(page, /设为最新/, '按钮名对齐“设为最新”')
assert.match(page, /上传(草稿状态)/, '上传按钮名对齐')
assert.match(page, /上传新版本/, '页头上传按钮名对齐')
assert.match(page, /上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布/, '上传弹窗描述对齐')
assert.match(page, /正在上传/, '上传进度文案')
assert.match(page, /digitalHumanDeleteConfirmText/, '删除确认文案走对齐模型')
assert.match(page, /downloadUrl|fetchDigitalHumanDownloadUrl/, 'RELEASED 下载按钮点击取链接')
assert.match(page, /已废弃/, '状态三态接入')
assert.match(page, /fetchDigitalHumanDownloadUrl/, 'RELEASED 下载按钮点击取链接')
assert.match(page, /digitalHumanStatusDisplay/, '状态走展示层模型')
const display = readSource('src/pages/records/digitalhuman-status-display.ts')
assert.match(display, /已废弃/, '状态三态接入(草稿/已发布/已废弃)')
assert.match(page, /digitalHumanChangelogShort/, '更新说明 50 字符截断')
})
@@ -42,7 +42,20 @@ test('align_dup_console_drawer_template', () => {
assert.match(page, /drawerAggRows/, '抽屉渲染聚合行')
assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章')
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, /v-if="dupRows\.length > dupCardPageSize"/, '撞款详情卡片有分页器')
assert.match(page, /v-for="group in pagedGroupStats"/, '分组说明抽屉表绑定分页切片')
assert.match(page, /:total="groupStats\.length"/, '分组说明抽屉表有分页器')
})
test('align_dup_console_ledger_total_preload', () => {
@@ -56,3 +69,30 @@ test('align_dup_console_matrix_sticky', () => {
assert.match(page, /table\.matrix th\s*\{[^}]*position:\s*sticky/, '矩阵表头吸顶')
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\)/, '筛选下拉站点显示中文')
})
@@ -2,29 +2,23 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
// 验收反馈:生成记录的数据要可清理
// 契约:页面有「清理数据」入口(二次确认)、前端 DELETE /api/admin/history、后端清空端点与日志
// 用户反馈(round2 像素复刻样办):生成记录页还原旧版交互——「清理数据」为旧版没有的新增入口,从页面移除
// 契约:页面无清理入口、无二次确认文案;前端适配层不再暴露 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')
assert.match(page, /清理数据/, '页头有清理数据按钮')
assert.match(page, /确定清空全部生成记录吗/, '清理前需二次确认')
assert.match(page, /clearHistory\(\)/, '页面调用清理 API')
assert.doesNotMatch(page, /清理数据/, '页面不再有「清理数据按钮')
assert.doesNotMatch(page, /clearAll|clearHistory/, '页面不再调用清理逻辑')
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')
assert.match(api, /http\.delete<unknown>\(HISTORY_ENDPOINT\)/, '清理走 DELETE /api/admin/history')
assert.match(api, /clearHistory/, '暴露 clearHistory 适配函数')
assert.doesNotMatch(api, /clearHistory/, '前端适配层不再暴露 clearHistory')
})
test('align_history_clear_boundary_empty_input', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /已清空 \$\{removed\} 条生成记录/, '成功提示含清理条数')
})
test('align_history_clear_dependency_failure_returns_actionable_message', () => {
// 后端契约:DELETE /history 端点 + service 清空 + 中文排查日志。
test('align_history_clear_backend_endpoint_kept', () => {
// 后端契约保留:DELETE /history 端点 + service 清空 + 中文排查日志(从前端摘除入口,不动后端)。
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, /admin_history/, '清理端点沿用生成记录模块权限')
@@ -7,8 +7,8 @@ import { historyEmptyText } from '../src/pages/records/history-feedback.ts'
test('align_history_column_labels', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /label="类型"/, '列名对齐参考「类型」')
assert.match(page, /label="创建时间"/, '列名对齐参考「创建时间」')
assert.match(page, /<th[^>]*>类型<\/th>/, '列名对齐参考「类型」(自绘 th)')
assert.match(page, /<th[^>]*>创建时间<\/th>/, '列名对齐参考「创建时间」(自绘 th)')
assert.doesNotMatch(page, /记录类型/, '去掉“记录类型”列名')
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: 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')
})
@@ -61,3 +61,11 @@ test('align_image_video_page_card_info_wiring', () => {
assert.match(page, /copyLink\(task\.debugUrl\)/, '调试链接复制接线')
assert.match(page, /:disabled="!video\.displayUrl"|!hasDisplayUrl/, '下载按钮无视频禁用')
})
test('align_image_video_permission_table_pagination', () => {
// 权限配置弹窗用户表全量渲染无分页:补共享 OldPagination10/20/50/100)。
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /import OldPagination from '@\/components\/OldPagination\.vue'/, '分页组件已 import(曾缺失导致分页器不渲染)')
assert.match(page, /:data="pagedPermissionItems"/, '权限表绑定分页切片')
assert.match(page, /:total="permissionItems\.length"/, '权限表有分页器')
})
@@ -0,0 +1,73 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import { adminPages } from '../src/router/routes.ts'
import {
OPERATION_GUIDES,
OPERATION_GUIDE_FALLBACK,
OPERATION_GUIDE_STORAGE_KEY,
operationGuideFor,
} from '../src/layout/operation-guides.ts'
/** 对齐旧版 admin-interactions.js guides 表:每业务菜单页顶部展示可折叠「当前页面操作提示」,文案沿用旧版原文;店铺撞款监控(例外)不展示。 */
const EXCEPTED_KEY = 'admin_shop_data_duplicate_check'
test('align_guide_users_matches_reference_text', () => {
const g = operationGuideFor('admin_users')
assert.ok(g)
assert.equal(g.text, '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。')
assert.deepEqual(g.steps, ['筛选账号', '编辑权限', '确认保存'])
})
test('align_guide_history_and_version_reference_text', () => {
assert.match(operationGuideFor('admin_history')!.text, /生成记录可按用户和时间范围回溯/)
assert.deepEqual(operationGuideFor('admin_version')!.steps, ['上传压缩包', '检查版本记录', '维护历史版本'])
})
test('align_guide_group_manage_falls_back', () => {
// 旧版 group-manage 无独立 guide,用通用 fallback。
assert.deepEqual(operationGuideFor('admin_group_manage'), OPERATION_GUIDE_FALLBACK)
})
test('align_guide_duplicate_check_not_present', () => {
// 例外页:不展示提示条(不参与本轮对齐)。
assert.equal(operationGuideFor(EXCEPTED_KEY), null)
assert.equal(EXCEPTED_KEY in OPERATION_GUIDES, false)
})
test('align_guide_unknown_key_null', () => {
assert.equal(operationGuideFor(undefined), null)
assert.equal(operationGuideFor(null), null)
assert.equal(operationGuideFor('not-a-menu'), null)
assert.equal(operationGuideFor(''), null)
})
test('align_guide_every_router_page_has_guide_except_duplicate_check', () => {
// 路由注册表里的业务页除例外页外都应有独立/兜底提示。
const without = adminPages.filter((p) => p.menuKey !== EXCEPTED_KEY)
assert.ok(without.length >= 15, '业务页至少 15')
for (const page of without) {
assert.ok(operationGuideFor(page.menuKey), `缺少 guide: ${page.menuKey}`)
}
})
test('align_guide_storage_key_matches_legacy', () => {
assert.equal(OPERATION_GUIDE_STORAGE_KEY, 'shufuAdminGuideCollapsed')
})
test('align_guide_component_in_layout_headline', () => {
const layout = readSource('src/layout/AdminLayout.vue')
assert.match(layout, /OperationGuide/, '壳层引用 OperationGuide 组件')
const comp = readSource('src/layout/OperationGuide.vue')
assert.match(comp, /当前页面操作提示/, '标题对齐旧版 adminOperationGuide 语义')
assert.match(comp, /收起提示/, '折叠按钮文案')
assert.match(comp, /展开提示/, '展开按钮文案')
assert.match(comp, /operationGuideFor\(route\.meta\.menuKey\)/, '按菜单 key 查文案')
assert.match(comp, /OPERATION_GUIDE_STORAGE_KEY/, '折叠态读写同一 localStorage key')
})
test('align_guide_fallback_shape', () => {
assert.equal(OPERATION_GUIDE_FALLBACK.steps.length, 3)
assert.ok(OPERATION_GUIDE_FALLBACK.text.length > 10)
})
@@ -7,35 +7,26 @@ import { readSource } from './helpers.ts'
// 菜单管理是树结构(非列表),商品类目树用懒加载+加载更多节点,均豁免。
test('align_pagination_normal_primary_path', () => {
for (const page of [
'src/pages/account/GroupsPage.vue',
'src/pages/records/RecordsSoftwareVersionPage.vue',
'src/pages/asin/ProductCategoryPage.vue',
]) {
const src = readSource(page)
assert.match(src, /el-pagination/, `${page} 应包含分页组件`)
}
// 软件版本已像素复刻旧版(panel-version 全量无分页);无 EP 分页列表剩余为 0。
const dh = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
assert.match(dh, /page-nums/, '数字人版本用数字页码分页(像素复刻)')
})
test('align_pagination_normal_variant_input', () => {
// 分页组件需含上一页/下一页(prev/next),中文文案由全局 zh-cn locale 渲染
for (const page of [
'src/pages/account/GroupsPage.vue',
'src/pages/records/RecordsSoftwareVersionPage.vue',
'src/pages/asin/ProductCategoryPage.vue',
]) {
const src = readSource(page)
assert.match(src, /el-pagination[^>]*layout="[^"]*prev[^"]*next/, `${page} 分页 layout 需含 prev/next`)
}
// 数字人版本分页升级为共享 OldPagination(含上一页/下一页/条数选择)
const dh = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
assert.match(dh, /OldPagination/, '数字人分页用共享组件')
const comp = readSource('src/components/OldPagination.vue')
assert.match(comp, />上一页<\/button>/, '共享分页含上一页')
assert.match(comp, />下一页<\/button>/, '共享分页含下一页')
assert.match(comp, /10, 20, 50, 100|sizes/, '共享分页默认 10/20/50/100')
})
test('align_pagination_boundary_empty_input', () => {
// 分组/软件版本为全量拉取,走客户端切片分页(computed 按 page/pageSize 切)。
for (const page of ['src/pages/account/GroupsPage.vue', 'src/pages/records/RecordsSoftwareVersionPage.vue']) {
const src = readSource(page)
assert.match(src, /pagedRows|pageRows|pageItems|pagedItems/, `${page} 应有客户端分页切片`)
assert.match(src, /:data="paged/, `${page} 表格应绑定分页后的数据`)
}
// 数字人版本为全量拉取,走客户端切片分页(computed 按 page/pageSize 切)。
const dh = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
assert.match(dh, /pagedItems/, '数字人版本应有客户端分页切片')
assert.match(dh, /v-for="row in pagedItems/, '表格应绑定分页后的数据')
})
test('align_pagination_boundary_single_item', () => {
@@ -46,13 +37,20 @@ test('align_pagination_boundary_single_item', () => {
})
test('align_pagination_repeated_operation_is_idempotent', () => {
// 已有分页的页面(12 页)保持原样,不被破坏
// 已像素复刻(自绘旧式分页/原生表格)的页单独断言
for (const page of [
'src/pages/account/UsersPage.vue',
'src/pages/asin/AsinInvalidPage.vue',
'src/pages/asin/DedupeRegistryPage.vue',
'src/pages/shop/ShopManagePage.vue',
'src/pages/shop/ShopKeysPage.vue',
'src/pages/tasks/ImageVideoTasksPage.vue',
]) {
const src = readSource(page)
assert.match(src, /el-pagination/, `${page} 既有分页保留`)
assert.match(src, /OldPagination/, `${page} 用共享旧式分页`)
assert.doesNotMatch(src, /el-pagination/, `${page} 不再用 EP 分页`)
}
const groups = readSource('src/pages/account/GroupsPage.vue')
assert.doesNotMatch(groups, /el-pagination/, '分组管理全量渲染无分页')
assert.doesNotMatch(groups, /pagedRows/, '分组管理全量渲染无客户端切片')
})
@@ -80,7 +80,7 @@ test('align_query_asin_create_api_wiring', () => {
test('align_query_asin_page_layout_wiring', () => {
const page = readSource('src/pages/asin/QueryAsinPage.vue')
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
assert.match(page, /span-method/, '表格用 span-method 做 rowspan 合并')
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型')
@@ -15,12 +15,13 @@ test('align_shop_keys_columns', () => {
test('align_shop_manage_columns_order_and_labels', () => {
const page = readSource('src/pages/shop/ShopManagePage.vue')
assert.doesNotMatch(page, /label="序号"/, '不再展示序号列')
// 像素复刻旧版 panel-shop-manage:序号列保留(admin.html:5339)。
assert.match(page, /序号/, '按旧版展示序号列')
assert.doesNotMatch(page, /label="ID"/, '不展示 ID 列')
// 列序:分组 → 店铺名 → 店铺商城名 → 自动化账号 → 账号 → 密码(对齐 admin.html:5339-5348)。
const table = page.slice(page.indexOf('<el-table'))
const orderMatch = /label="分组"[\s\S]*?label="店铺名"[\s\S]*?label="店铺商城名"[\s\S]*?label="自动化账号"[\s\S]*?label="账号"[\s\S]*?label="密码"/.exec(table)
// 列序:序号 → 分组 → 店铺名 → 店铺商城名 → 自动化账号 → 账号 → 密码(对齐 admin.html:5339-5348)。
const table = page.slice(page.indexOf('<table'))
const orderMatch = /序号[\s\S]*?分组[\s\S]*?店铺名[\s\S]*?店铺商城名[\s\S]*?自动化账号[\s\S]*?账号[\s\S]*?密码/.exec(table)
assert.ok(orderMatch, '店铺管理列序对齐参考')
assert.doesNotMatch(table, /店铺名称/, '列名对齐「店铺名」')
assert.doesNotMatch(table, /label="商城"/, '列名对齐「店铺商城名」')
assert.doesNotMatch(table, />商城</, '列名对齐「店铺商城名」')
})
@@ -28,5 +28,12 @@ test('align_shop_data_cards_selection_semantics', () => {
test('align_shop_data_cards_empty_text', () => {
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案对齐参考')
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案对齐参考')
})
test('align_shop_data_permission_table_pagination', () => {
// 权限配置弹窗用户表全量渲染无分页:补共享 OldPagination10/20/50/100)。
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
assert.match(page, /:data="pagedPermissionItems"/, '权限表绑定分页切片')
assert.match(page, /:total="permissionItems\.length"/, '权限表有分页器')
})
@@ -13,17 +13,17 @@ test('align_shop_data_grid_normal_primary_path', () => {
test('align_shop_data_grid_normal_variant_input', () => {
const src = readSource(PAGE)
assert.match(src, /cardPageSize\s*=\s*9|pageSize\s*=\s*9/, '卡片分页每页 9 张')
assert.match(src, /cardPageSize = ref\(10\)|cardPageSize\.value/, '卡片分页默认 10 张且可调')
assert.match(src, /cardPage\b/, '应有卡片页状态')
assert.match(src, /pagedResultRows|cardPageRows/, '卡片应绑定按 9 张切片的数据')
})
test('align_shop_data_grid_boundary_empty_input', () => {
// 卡片区下方有翻页组件(prev/next),与店铺分页并存。
// 卡片区下方有翻页组件(prev/next),与店铺分页并存(均为旧式分页)
const src = readSource(PAGE)
const count = (src.match(/el-pagination/g) || []).length
assert.ok(count >= 2, '页面应含店铺分页 + 卡片分页两处 el-pagination')
assert.match(src, /layout="prev, pager, next, jumper"/, '卡片分页含上一页/下一页')
const count = (src.match(/class="pagination"/g) || []).length + (src.match(/sd-shop-pager/g) || []).length
assert.ok(count >= 2, '页面应含店铺分页 + 卡片分页两处分页')
assert.match(src, /cardPage/, '卡片分页状态')
})
test('align_shop_data_grid_boundary_single_item', () => {
@@ -0,0 +1,55 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
/** 对齐旧版 .btn-smshop/records/任务 域主操作按钮(编辑/凭证/删除/查看/发布/设最新/下载/明细)统一实心 small;纯复制链接辅助仍可 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_shop_manage_row_buttons_old_btn', () => {
const s = readSource('src/pages/shop/ShopManagePage.vue')
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
assert.match(s, /class="btn btn-sm btn-secondary"[^>]*@click="showCredential\(row\)"/, '凭证用旧版 btn-sm btn-secondary')
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="remove\(row\)"/, '删除用旧版 btn-sm btn-danger')
assert.doesNotMatch(s, /el-button[^>]*@click="openEdit/, '行内不再用 EP 按钮')
assert.match(s, /window\.confirm\(`确定删除店铺/, '删除确认原生 confirm 对齐旧版')
})
test('align_shop_keys_row_buttons_old_btn', () => {
const s = readSource('src/pages/shop/ShopKeysPage.vue')
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="remove\(row\)"/, '删除用旧版 btn-sm btn-danger')
assert.doesNotMatch(s, /el-button[^>]*@click="openEdit/, '行内不再用 EP 按钮')
})
test('align_records_history_thumb_click_opens_preview', () => {
const s = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(s, /class="thumb"[^>]*@click="openPreview\(h\)"/, '点击缩略图直接放大预览')
assert.doesNotMatch(s, /查看大图/, '无行内「查看大图」按钮')
})
test('align_digital_human_row_buttons_old_btn', () => {
const s = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
assert.match(s, /class="btn btn-sm"[^>]*@click="release\(row\)"/, '发布用旧版 btn-sm')
assert.match(s, /class="btn btn-sm"[^>]*@click="setLatest\(row\)"/, '设为最新用旧版 btn-sm')
assert.match(s, /class="btn btn-sm btn-secondary"[^>]*@click="download\(row\)"/, '下载用旧版 btn-sm btn-secondary')
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="remove\(row\)"/, '删除用旧版 btn-sm btn-danger')
assert.doesNotMatch(s, /el-button[^>]*@click="release/, '行内不再用 EP 按钮')
assert.match(s, /window\.confirm\(text\)/, '确认走原生 confirm 对齐旧版')
})
test('align_image_video_detail_and_download_old_btn', () => {
const s = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(s, /class="btn btn-sm btn-secondary"[^>]*@click="downloadVideo\(task, index, video\)"/, '视频下载为旧版 btn-sm btn-secondary')
assert.match(s, /class="btn btn-sm"[^>]*@click="copyLink\(video\.displayUrl\)"/, '复制链接为旧版 btn-sm')
})
test('align_copy_link_aux_old_btn', () => {
// 辅助"复制链接"为旧版 btn-sm 小按钮。
const s = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(s, /class="btn btn-sm"[^>]*@click="copyLink/, '复制链接用 btn-sm')
})
@@ -80,7 +80,7 @@ test('align_skip_price_create_api_wiring', () => {
test('align_skip_price_page_layout_wiring', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
assert.match(page, /span-method/, '表格用 span-method 做 rowspan 合并')
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
@@ -93,7 +93,7 @@ test('align_skip_price_filter_price_range_same_row', () => {
// 验收反馈:筛选条件「最低价」的 ≥/≤ 输入并排同一排(price-range-row),不换行拆开。
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /price-range-row/, '最低价范围筛选并排容器存在')
assert.match(page, /placeholder="≥ 最低值"/, '下限占位文案')
assert.match(page, /placeholder="≤ 最高值"/, '上限占位文案')
assert.match(page, /placeholder="最低价 ≥"/, '下限占位文案(旧版)')
assert.match(page, /placeholder="最高价 ≤"/, '上限占位文案(旧版)')
assert.ok(!page.includes('label="最低价 ≥"'), '不再有独立的最低价 ≥ 筛选项')
})
@@ -0,0 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
ADMIN_MENU_TYPE,
APP_MENU_TYPE,
parseMenuOptionList,
parsePermissionMenuItem,
} from '../src/pages/account/user-menu-auth.ts'
// 验收反馈:用户创建/编辑的菜单权限配置要同时包含 后台(admin) 与 前端客户端(app) 两类菜单,
// 并按类型分区落库(internal 不纳入)。
test('align_user_menu_auth_both_types_tree_nodes', () => {
// 授权节点携带 type:admin/app 两类菜单都能进同一棵树。
assert.equal(APP_MENU_TYPE, 'app')
const admin = parsePermissionMenuItem({ id: 1, name: '账号权限', parent_id: null, sort_order: 1 }, ADMIN_MENU_TYPE)
assert.equal(admin?.type, ADMIN_MENU_TYPE)
const app = parseMenuOptionList(
[{ id: 2, name: '视频', parent_id: null, sort_order: 1, column_key: 'wb' }],
APP_MENU_TYPE,
)
assert.equal(app[0].type, APP_MENU_TYPE)
})
test('align_user_menu_auth_both_types_fetch_and_submit_source', () => {
const api = readSource('src/pages/account/user-menu-auth-api.ts')
assert.match(api, /APP_MENU_TYPE/, '拉取/回显含客户端类型')
assert.match(api, /Promise\.all/, '两类菜单并行拉取')
assert.match(api, /normalizeMenuIds/, '两类已授权 id 合并去重')
// 提交仍由后端把勾选 id 按类型分区写(user PUT),前端不再限定单类型。
const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java')
assert.match(service, /resolveMenuTypes/, '后端按菜单 id 解析类型')
assert.match(service, /MENU_TYPE_ADMIN/, 'admin 分区写')
assert.match(service, /MENU_TYPE_APP/, 'app 分区写')
const auth = readSource('src/pages/account/user-menu-auth.ts')
assert.match(auth, /type: string/, '授权节点携带 type')
})
@@ -64,7 +64,7 @@ test('test_datetime_sweep_boundary_single_item', () => {
// 含时间展示的页:时间标签仍存在(列还在),只是改成走格式化;无时间列页面豁免。
for (const page of PAGES) {
const src = readSource(page)
if (TIME_CONTENT.test(src)) assert.match(src, /时间/, `${page} 仍应含“时间”列/文案`)
if (TIME_CONTENT.test(src)) assert.match(src, /时间|最新/, `${page} 仍应含“时间/最新”列/文案`)
}
})
+1 -1
View File
@@ -64,7 +64,7 @@ test('test_task_103_video_list_load_boundary_empty_input', () => {
const page = parseImageVideoPage({})
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.pageSize, 20)
assert.equal(page.pageSize, 10)
})
test('test_task_103_video_list_load_boundary_single_item', () => {
+1 -1
View File
@@ -79,7 +79,7 @@ test('test_task_110_shop_data_list_load_boundary_empty_input', () => {
const page = parseShopDataTaskPage({})
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.pageSize, 20)
assert.equal(page.pageSize, 10)
})
test('test_task_110_shop_data_list_load_boundary_single_item', () => {
+4 -2
View File
@@ -39,8 +39,10 @@ test('test_task_250_users_list_boundary_empty_input', () => {
test('test_task_250_users_list_boundary_single_item', () => {
// 边界:分页/合计形态为“共 N 条”,查询后回第一页。
const page = readSource('src/pages/account/UsersPage.vue')
assert.match(page, /共 .*total.*条/, '分页页脚应有“共 N 条”合计')
assert.match(page, /page\s*=\s*1|filters\.page\s*=\s*USER_PAGE_MIN_PAGE/, '查询需重置回第一页')
assert.match(page, /OldPagination/, '分页走共享组件')
const comp = readSource('src/components/OldPagination.vue')
assert.match(comp, /共 \{\{ total \}\} 条/, '共享分页含“共 N 条”合计')
assert.match(page, /filters\.page = 1/, '查询需重置回第一页')
})
test('test_task_250_users_list_boundary_limit_or_missing_field', () => {
+1 -1
View File
@@ -9,7 +9,7 @@ test('test_task_255_invalidasin_normal_primary_path', () => {
const page = readSource('src/pages/asin/AsinInvalidPage.vue')
assert.match(page, /分组/, '筛选需含分组下拉')
assert.match(page, /recordSourceLabel/, '来源列需用 recordSourceLabel 文案')
assert.match(page, /el-tag/, '来源宜用 tag 形态')
assert.match(page, /source-text/, '来源用旧版源样式(自绘)')
})
test('test_task_255_invalidasin_normal_variant_input', () => {
+3 -3
View File
@@ -17,9 +17,9 @@ test('test_task_258_skipprice_normal_variant_input', () => {
const page = readSource('src/pages/asin/SkipPricePage.vue')
assert.match(page, /填写最低价时必须填写 ASIN/, '低价必带 ASIN 校验文案')
assert.match(page, /最低价格式不正确/, '低价格式校验文案')
assert.match(page, /导入添加/, '页头导入添加')
assert.match(page, /导入删除/, '页头删除导入')
assert.match(page, /导出/, '页头导出')
assert.match(page, /导入文件新增/, '页头导入文件新增(旧版文案)')
assert.match(page, /导入文件删除/, '页头导入文件删除(旧版文案)')
assert.match(page, /导出 XLSX/, '页头导出 XLSX(旧版文案)')
})
test('test_task_258_skipprice_normal_repeated_operation_is_idempotent', () => {
+1 -1
View File
@@ -58,7 +58,7 @@ test('test_task_259_history_boundary_single_item', () => {
test('test_task_259_history_boundary_limit_or_missing_field', () => {
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
assert.match(page, /指定用户/, '筛选需有“指定用户”标签(admin 语义)')
assert.match(page, /el-date-picker/, '需用日期时间选择器')
assert.match(page, /type="datetime-local"/, '自绘复刻用原生日历控件(对齐 admin datetime-local')
assert.match(page, /historyPanelTypeLabel|panelTypeLabel/, '类型列需走中文映射')
assert.match(page, /formatDateTime/, '时间统一格式化')
})
+1 -1
View File
@@ -36,7 +36,7 @@ test('test_task_264_view_boundary_empty_input', () => {
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /视频生成中或暂无结果/)
assert.match(page, /任务失败,未生成视频/)
assert.match(page, /暂无视频任务记录/)
assert.match(page, /暂无符合条件的视频任务/)
})
test('test_task_264_view_boundary_single_item', () => {
+4 -3
View File
@@ -7,8 +7,9 @@ import { normalizeImageVideoFilter } from '../src/pages/tasks/image-video-filter
test('test_task_265_filter_normal_primary_path', () => {
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
assert.match(page, /用户名(模糊搜索)/, '需用户名模糊筛选')
assert.match(page, /提交时间/, '需提交时间范围')
assert.match(page, /用户名/, '需用户名模糊筛选')
assert.match(page, /提交开始/, '需提交开始时间')
assert.match(page, /提交结束/, '需提交结束时间')
assert.match(page, /查询/, '需查询按钮')
assert.match(page, /reset/, '需重置')
})
@@ -43,7 +44,7 @@ test('test_task_265_permission_normal_primary_path', () => {
assert.match(page, /权限配置/, '权限入口')
assert.match(page, /fetchImageVideoPermissionUsers/, '权限用户加载')
assert.match(page, /saveImageVideoPermissions/, '权限保存')
assert.match(page, /数据范围授权/, '权限弹窗语义')
assert.match(page, /数据范围/, '权限弹窗语义')
})
test('test_task_265_permission_boundary_limit_or_missing_field', () => {
+2 -2
View File
@@ -12,7 +12,7 @@ test('test_task_266_view_normal_primary_path', () => {
assert.match(page, /shop-card-grid/, '需任务卡片网格')
assert.match(page, /v-for="row in pagedResultRows"/, '逐任务渲染卡片')
assert.match(page, /shop-card-task-no/, '卡片含任务号')
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案')
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案')
})
test('test_task_266_view_normal_variant_input', () => {
@@ -56,5 +56,5 @@ test('test_task_266_delete_boundary_limit_or_missing_field', () => {
test('test_task_266_dependency_failure_returns_actionable_message', () => {
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
assert.equal(/<el-table[^>]*:data="(rows|allResultRows)"/.test(page), false, '结果文件不再用整表渲染')
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案')
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案')
})
+4 -3
View File
@@ -8,10 +8,11 @@ import { toShopDataZipRequest } from '../src/pages/tasks/shop-data-download.ts'
test('test_task_267_filter_normal_primary_path', () => {
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
assert.match(page, /店铺(模糊搜索)/, '需店铺模糊筛选')
assert.match(page, /分组(模糊搜索)/, '需分组模糊筛选')
assert.match(page, /店铺/, '需店铺模糊筛选')
assert.match(page, /分组/, '需分组模糊筛选')
assert.match(page, /国家/, '需国家筛选')
assert.match(page, /创建时间/, '需创建时间范围')
assert.match(page, /创建开始/, '需创建开始时间')
assert.match(page, /创建结束/, '需创建结束时间')
assert.match(page, /查询/, '需查询')
assert.match(page, /reset/, '需重置')
})
+2 -2
View File
@@ -75,7 +75,7 @@ test('test_task_059_data_group_list_dependency_failure_returns_actionable_messag
assert.match(api, /shop-manages\/groups/)
assert.match(api, /parseShopGroupList/)
const page = readSource('src/pages/account/GroupsPage.vue')
assert.match(page, /shop-group-api|fetchShopGroups/)
assert.match(page, /shopGroupSummary|summary/)
assert.match(page, /shop-group-api|fetchShopGroups/, '页面走分组适配接口')
assert.match(page, /filterKeyword|filterLeader/, '分组列表已加名称/组长筛选')
assert.equal(/http\.(get|post)|unwrap\(/.test(page), false, '页面不内联请求')
})
+2 -2
View File
@@ -63,7 +63,7 @@ test('test_task_062_dedupe_list_boundary_empty_input', () => {
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.page, 1)
assert.equal(page.pageSize, 15)
assert.equal(page.pageSize, 10)
})
test('test_task_062_dedupe_list_boundary_single_item', () => {
@@ -84,7 +84,7 @@ test('test_task_062_dedupe_list_boundary_limit_or_missing_field', () => {
// 边界上限/缺字段:缺 page/pageSize 用默认;空 dataValue 保留空串。
const page = parseDedupeTotalPage({ items: [{ id: 9, dataValue: '' }], total: 0, page: 1 })
assert.equal(page.items[0].dataValue, '')
assert.equal(page.pageSize, 15)
assert.equal(page.pageSize, 10)
assert.equal(toDedupeTotalItem({ id: 10, dataValue: 'v' })?.dataValue, 'v')
})
+1 -1
View File
@@ -56,7 +56,7 @@ test('test_task_068_brand_db_list_boundary_empty_input', () => {
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.page, 1)
assert.equal(page.pageSize, 15)
assert.equal(page.pageSize, 10)
})
test('test_task_068_brand_db_list_boundary_single_item', () => {
+1 -1
View File
@@ -95,7 +95,7 @@ test('test_task_071_query_asin_list_boundary_empty_input', () => {
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.page, 1)
assert.equal(page.pageSize, 15)
assert.equal(page.pageSize, 10)
})
test('test_task_071_query_asin_list_boundary_single_item', () => {
+13 -1
View File
@@ -10,6 +10,7 @@ import {
} from '../src/pages/asin/skip-price-model.ts'
import { ASIN_COUNTRY_CODES, asinCountryLabel } from '../src/pages/asin/asin-country.ts'
import {
normalizePriceInput,
skipPriceFilterActive,
toSkipPriceParams,
toSkipPriceQuery,
@@ -84,7 +85,7 @@ test('test_task_075_skip_price_list_boundary_empty_input', () => {
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.page, 1)
assert.equal(page.pageSize, 15)
assert.equal(page.pageSize, 10)
})
test('test_task_075_skip_price_list_boundary_single_item', () => {
@@ -136,6 +137,17 @@ test('test_task_075_skip_price_filter_normal_primary_and_price', () => {
})
})
test('test_task_075_skip_price_filter_number_input_compat', () => {
// 原生 number input v-model 会把输入值转成 numbernormalizePriceInput 需兼容(修复 "(value||'').trim is not a function")。
const state: SkipPriceFilterState = { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: 12.34, minimumPriceTo: 56.78 }
const query = toSkipPriceQuery(toSkipPriceParams(state, 1, 15))
assert.equal(query.minimum_price_from, 12.34)
assert.equal(query.minimum_price_to, 56.78)
assert.equal(normalizePriceInput(0), 0)
assert.equal(normalizePriceInput(-5), null)
assert.equal(normalizePriceInput(null as unknown as string), null)
})
test('test_task_075_skip_price_filter_invalid_input_rejected', () => {
// 异常输入:非法价格/负数/非数值价格丢弃;success=false 抛 message;垃圾行不入列表。
const state: SkipPriceFilterState = {
+1 -1
View File
@@ -102,7 +102,7 @@ test('test_task_078_product_category_search_boundary_empty_and_default', () => {
assert.deepEqual(page.items, [])
assert.equal(page.total, 0)
assert.equal(page.hasMore, false)
assert.equal(page.pageSize, 20)
assert.equal(page.pageSize, 10)
})
test('test_task_078_product_category_invalid_input_rejected', () => {
@@ -38,11 +38,12 @@ public class AdminUserController {
@RequestParam(required = false) String username,
@RequestParam(name = "search", required = false) String search,
@RequestParam(name = "created_by_id", required = false) Long createdById,
@RequestParam(name = "admin_id", required = false) Long adminId) {
@RequestParam(name = "admin_id", required = false) Long adminId,
@RequestParam(name = "role", required = false) String role) {
AdminUserEntity currentUser = adminAuthSupport.requireAdminOrInternal(request);
String kw = (username == null || username.isBlank()) ? search : username;
Long filterCreatedBy = createdById != null ? createdById : adminId;
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy);
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy, role);
return ApiResponse.success(vo);
}
@@ -42,7 +42,7 @@ public class AdminUserService {
private final PermissionMenuService permissionMenuService;
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
String username, Long createdById) {
String username, Long createdById, String roleFilter) {
String role = adminAuthSupport.currentRole(currentUser);
if (role == null) {
throw new BusinessException(403, "需要管理员权限");
@@ -60,6 +60,10 @@ public class AdminUserService {
Long filterCreatedBy = "super_admin".equals(role) ? createdById : null;
LambdaQueryWrapper<AdminUserEntity> query = new LambdaQueryWrapper<>();
String roleFilterValue = roleFilter == null ? "" : roleFilter.trim();
if (!roleFilterValue.isEmpty()) {
query.eq(AdminUserEntity::getRole, roleFilterValue);
}
if ("super_admin".equals(role)) {
if (!kw.isEmpty()) {
query.like(AdminUserEntity::getUsername, kw);
@@ -230,11 +234,38 @@ public class AdminUserService {
}
}
/**
* 用户管理授权树现在同时展示并勾选 后台(admin) + 前端客户端(app) 菜单:
* 把勾选 id 按类型拆分后分区整树替换(internal 类型不在树里,出现即按无效处理)。
*/
private void replaceDirectPermissions(AdminUserEntity operator, Long userId, List<Long> columnIds) {
List<Long> raw = columnIds == null ? List.of() : columnIds;
Map<Long, String> typeById = permissionMenuService.resolveMenuTypes(raw);
List<Long> adminIds = new ArrayList<>();
List<Long> appIds = new ArrayList<>();
for (Long id : raw) {
if (id == null) {
continue;
}
String type = typeById.getOrDefault(id, "");
if (PermissionMenuService.MENU_TYPE_ADMIN.equals(type)) {
adminIds.add(id);
} else if (PermissionMenuService.MENU_TYPE_APP.equals(type)) {
appIds.add(id);
} else {
throw new BusinessException("存在无效或不属于当前类型的菜单权限项");
}
}
replaceDirectPermissionsByType(operator, userId, adminIds, PermissionMenuService.MENU_TYPE_ADMIN);
replaceDirectPermissionsByType(operator, userId, appIds, PermissionMenuService.MENU_TYPE_APP);
}
private void replaceDirectPermissionsByType(AdminUserEntity operator, Long userId, List<Long> columnIds,
String menuType) {
UserColumnPermissionUpdateRequest permissionRequest = new UserColumnPermissionUpdateRequest();
permissionRequest.setColumnIds(columnIds == null ? List.of() : columnIds);
permissionRequest.setColumnIds(columnIds);
permissionMenuService.updateUserColumnPermissions(
operator, userId, permissionRequest, PermissionMenuService.MENU_TYPE_APP);
operator, userId, permissionRequest, menuType);
}
public void deleteUser(AdminUserEntity currentUser, Long uid) {
@@ -291,7 +322,9 @@ public class AdminUserService {
vo.setAdmin(entity.getIsAdmin() != null && entity.getIsAdmin() == 1);
vo.setRole(entity.getRole() == null || entity.getRole().isEmpty() ? "normal" : entity.getRole());
vo.setCreatedById(entity.getCreatedById());
vo.setCreatorUsername(creatorMap.getOrDefault(entity.getCreatedById(), ""));
// 不可变 Map(Map.of) 的 getOrDefault 不允许 null keycreatedById 为 null 时直接空串。
Long creatorId = entity.getCreatedById();
vo.setCreatorUsername(creatorId == null ? "" : creatorMap.getOrDefault(creatorId, ""));
LocalDateTime createdAt = entity.getCreatedAt();
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
vo.setPinyinAbbr(PinyinAbbrUtil.abbr(entity.getUsername()));
@@ -78,16 +78,16 @@ public class DedupeTotalDataController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataPageVo.class)))
})
public ApiResponse<DedupeTotalDataPageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "数据值模糊搜索关键字") @RequestParam(required = false) String keyword,
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username,
@Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
@Parameter(description = "数据值模糊搜索关键字") @RequestParam(name = "keyword", required = false) String keyword,
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(name = "username", required = false) String username,
@Parameter(description = "开始日期(包含)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(name = "start_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "结束日期(包含)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "国家代码(如 DE、UK") @RequestParam(required = false) String country,
@RequestParam(name = "end_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "国家代码(如 DE、UK") @RequestParam(name = "country", required = false) String country,
HttpServletRequest request) {
RequestOperator operator = requireDedupeTotalDataAccess(request);
return ApiResponse.success(dedupeTotalDataService.page(
@@ -97,13 +97,13 @@ public class DedupeTotalDataController {
@GetMapping("/export")
@Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据;跨自然月时按月生成 XLSX 并打包为 ZIP。")
public ResponseEntity<StreamingResponseBody> export(
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username,
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(name = "username", required = false) String username,
@Parameter(description = "开始日期(包含)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(name = "start_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@Parameter(description = "结束日期(包含)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "国家代码(如 DE、UK") @RequestParam(required = false) String country,
@RequestParam(name = "end_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "国家代码(如 DE、UK") @RequestParam(name = "country", required = false) String country,
HttpServletRequest request) {
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
throw new BusinessException(400, "invalid export date range");
@@ -60,12 +60,12 @@ public class InvalidAsinDataController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = InvalidAsinDataPageVo.class)))
})
public ApiResponse<InvalidAsinDataPageVo> page(
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "模糊搜索关键字") @RequestParam(required = false) String keyword,
@Parameter(description = "ASIN 模糊搜索") @RequestParam(required = false) String dataValue,
@Parameter(description = "品牌模糊搜索") @RequestParam(required = false) String brand,
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page,
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
@Parameter(description = "模糊搜索关键字") @RequestParam(name = "keyword", required = false) String keyword,
@Parameter(description = "ASIN 模糊搜索") @RequestParam(name = "data_value", required = false) String dataValue,
@Parameter(description = "品牌模糊搜索") @RequestParam(name = "brand", required = false) String brand,
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
HttpServletRequest request) {
RequestOperator operator = requireInvalidAsinDataAccess(request);
return ApiResponse.success(invalidAsinDataService.page(
@@ -61,6 +61,25 @@ public class PermissionMenuService {
return toItemVos(menus, menus);
}
/**
* 按菜单(column) id 解析所属类型;未知/无效 id 不在返回中。供用户创建/编辑把
* 勾选的 后台(admin)+客户端(app) 菜单 id 拆分后分别落库。
*/
public Map<Long, String> resolveMenuTypes(List<Long> columnIds) {
List<Long> ids = normalizeColumnIds(columnIds);
if (ids.isEmpty()) {
return Map.of();
}
List<PermissionMenuEntity> rows = permissionMenuMapper.selectBatchIds(ids);
Map<Long, String> typeById = new HashMap<>();
for (PermissionMenuEntity row : rows) {
if (row != null && row.getId() != null) {
typeById.put(row.getId(), row.getMenuType() == null ? "" : row.getMenuType());
}
}
return typeById;
}
@Transactional
public PermissionMenuItemVo create(PermissionMenuCreateRequest request) {
return create(null, request);
@@ -52,9 +52,9 @@ public class ProductCategoryController {
@Operation(summary = "分页查询指定父级下的商品类目")
public ApiResponse<ProductCategoryListVo> children(
HttpServletRequest request,
@RequestParam(value = "parentId", required = false) Long parentId,
@RequestParam(value = "parent_id", required = false) Long parentId,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
@RequestParam(value = "page_size", defaultValue = "20") Long pageSize) {
adminAuthSupport.requireAdminOrInternal(request);
return ApiResponse.success(productCategoryService.children(parentId, page, pageSize));
}
@@ -65,7 +65,7 @@ public class ProductCategoryController {
HttpServletRequest request,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
@RequestParam(value = "page_size", defaultValue = "20") Long pageSize) {
adminAuthSupport.requireAdminOrInternal(request);
return ApiResponse.success(productCategoryService.search(keyword, page, pageSize));
}
@@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlAdminTasksService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -38,6 +39,7 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
@@ -75,6 +77,7 @@ public class AdminShopDataCrawlTasksController {
private final AdminAuthSupport adminAuthSupport;
private final PermissionMenuService permissionMenuService;
private final ShopDataCrawlAdminTasksService adminTasksService;
private final ShopDuplicateCheckSourceMapper duplicateCheckSourceMapper;
@GetMapping
@Operation(summary = "店铺数据抓取记录:按店铺分组分页列表(每店最新结果文件)")
@@ -87,12 +90,14 @@ public class AdminShopDataCrawlTasksController {
@RequestParam(name = "country", required = false) String country,
@RequestParam(name = "created_from", required = false) String createdFrom,
@RequestParam(name = "created_to", required = false) String createdTo) {
requireShopDataCrawlTaskAccess(request);
AdminUserEntity operator = requireShopDataCrawlTaskAccess(request);
int safePage = Math.max(1, parseInt(page, 1, "page 参数不合法"));
int safePageSize = Math.min(PAGE_SIZE_MAX,
Math.max(10, parseInt(pageSize, PAGE_SIZE_DEFAULT, "page_size 参数不合法")));
boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator));
List<String> visibleShopKeys = superAdmin ? null : resolveVisibleShopNameKeys(operator.getId());
Map<String, Object> payload = adminTasksService.listShopGroups(
safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo);
safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo, visibleShopKeys);
return ApiResponse.success(payload);
}
@@ -235,7 +240,7 @@ public class AdminShopDataCrawlTasksController {
return new ArrayList<>(ids);
}
private void requireShopDataCrawlTaskAccess(HttpServletRequest request) {
private AdminUserEntity requireShopDataCrawlTaskAccess(HttpServletRequest request) {
AdminUserEntity operator;
try {
operator = adminAuthSupport.requireAdmin(request);
@@ -246,6 +251,20 @@ public class AdminShopDataCrawlTasksController {
}
}
permissionMenuService.requireShopDataCrawlTaskAccess(operator);
return operator;
}
/** 非超管返回其可管店铺名(trim+小写去重,与撞款可见口径一致);超管调用方传 null 表示全量。 */
private List<String> resolveVisibleShopNameKeys(Long operatorId) {
List<String> names = duplicateCheckSourceMapper.selectManagedShopNames(operatorId);
if (names == null || names.isEmpty()) {
return List.of();
}
return names.stream()
.filter(name -> name != null && !name.isBlank())
.map(name -> name.trim().toLowerCase(Locale.ROOT))
.distinct()
.toList();
}
private AdminUserEntity resolveInternalOperator(HttpServletRequest request) {
@@ -163,6 +163,15 @@ public interface ShopDataCrawlAdminTasksMapper {
if (p.get("createdTo") != null) {
sql.append(" AND t.created_at <= #{createdTo}");
}
Object visibleShopKeys = p.get("visibleShopKeys");
if (visibleShopKeys instanceof List<?> keys) {
if (keys.isEmpty()) {
sql.append(" AND 1 = 0");
} else {
sql.append(" AND LOWER(TRIM(COALESCE(r.source_filename, ''))) IN (")
.append(inPlaceholders(p, "visibleShopKeys", "lv")).append(')');
}
}
return sql.toString();
}
@@ -0,0 +1,18 @@
package com.nanri.aiimage.modules.shopdatacrawl.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlItemEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface ShopDataCrawlItemMapper extends BaseMapper<ShopDataCrawlItemEntity> {
/** 删除某店某批次(每日快照替换语义:先删后插)。 */
@Delete("""
DELETE FROM biz_shop_data_crawl_item
WHERE shop_name = #{shopName} AND business_date = #{businessDate}
""")
int deleteBatch(@Param("shopName") String shopName, @Param("businessDate") java.time.LocalDate businessDate);
}
@@ -0,0 +1,32 @@
package com.nanri.aiimage.modules.shopdatacrawl.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("biz_shop_data_crawl_item")
public class ShopDataCrawlItemEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long shopId;
private String shopName;
private String groupName;
private LocalDate businessDate;
private String country;
private String asin;
private String brand;
private String price;
private String itemDate;
private String unitsSold;
private String itemJson;
private Long resultId;
private Long taskId;
private Long dailyFileId;
private LocalDateTime createdAt;
}

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