Compare commits
198 Commits
4c88964473
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b8105ec2b | |||
| 5fcb449946 | |||
| ff68426c69 | |||
| 46be044121 | |||
| e0303f9cba | |||
| 9d39705c77 | |||
| 986df86e89 | |||
| bd359411a9 | |||
| 3634ea1d62 | |||
| 3ce0569c59 | |||
| d2d95f0b71 | |||
| 2a51006888 | |||
| a89de129ea | |||
| ddefcbed56 | |||
| 3137299bfe | |||
| 1fe3368c5a | |||
| 7aea3a0a50 | |||
| 188aedec84 | |||
| d5952945dd | |||
| 6e1689dfe5 | |||
| 4f16a02658 | |||
| 5e9a59b327 | |||
| ab09cff427 | |||
| 47a9520a82 | |||
| 7a6c3c3fa3 | |||
| 803b5d583d | |||
| 1c52cd529b | |||
| ab28b168ec | |||
| 367b4b7553 | |||
| 25c7323c47 | |||
| aea0e16279 | |||
| f566573fce | |||
| 8fcceb3226 | |||
| ed0d6575c8 | |||
| f2ada02383 | |||
| 5e5816cd74 | |||
| d189d94c3b | |||
| 79b5d40327 | |||
| 228d481211 | |||
| d6f8368493 | |||
| b0f764b6b6 | |||
| 1403fec5fe | |||
| 07b4ebe983 | |||
| 0b2b9303d0 | |||
| 05a2c479a5 | |||
| 52b55df7b2 | |||
| b54f72d3f6 | |||
| 24c5a09c7f | |||
| 8cab9d4bad | |||
| 5ea52e5291 | |||
| 9166656673 | |||
| 1360a44e01 | |||
| 86c05e71a2 | |||
| fae26aa460 | |||
| 8803e22f39 | |||
| 375b89154b | |||
| 6a90adc765 | |||
| bcf66dc1d7 | |||
| c55c4a140b | |||
| 7643094f1d | |||
| 1b480f915f | |||
| ac36c08460 | |||
| ef3a2c9bd6 | |||
| da0f10f1cc | |||
| 9ba231dc4c | |||
| 9a6b57db58 | |||
| 67223f8950 | |||
| e76714c32e | |||
| 95dfb69a18 | |||
| 24ada70997 | |||
| ccce03b4d4 | |||
| 4daf235385 | |||
| 6d46506726 | |||
| c448f49e30 | |||
| 3f5a234c59 | |||
| b05bba50fa | |||
| 9d92fb4af2 | |||
| db6869b77e | |||
| 864c22ffc7 | |||
| fd614004b0 | |||
| ff1ffbbfa3 | |||
| 9ffd68bae5 | |||
| 14a723ab1c | |||
| dc6e8924a9 | |||
| 882ccdac12 | |||
| b70557a077 | |||
| a0f6582914 | |||
| 0c98c5bc15 | |||
| 1066625078 | |||
| fe052879bd | |||
| 51c5fe1129 | |||
| 3d208ea0e5 | |||
| a4f60ef21c | |||
| 3f6ad0c6ad | |||
| 0323f0bb7b | |||
| 1b733b23b9 | |||
| 05ae0c62d6 | |||
| 4bc4969e5a | |||
| 8cd8390d95 | |||
| 2a766efb83 | |||
| afa8560369 | |||
| c81ebb7053 | |||
| b7d4d325e0 | |||
| 82a782550e | |||
| d1b56918fa | |||
| 441917bfce | |||
| 037a689316 | |||
| db7cd22ed1 | |||
| 9962d9797e | |||
| 47459e2089 | |||
| e6021593ea | |||
| 540d6588e6 | |||
| 2a9110ec32 | |||
| 51fb77ec25 | |||
| 30d31fb318 | |||
| bb52574767 | |||
| cd055f8ccd | |||
| c2915036d6 | |||
| 8dc03df95b | |||
| bb74c9a192 | |||
| 32985c78f3 | |||
| 8e684b92dd | |||
| d54dbf72c9 | |||
| 94a0c287e0 | |||
| 47b2758fe8 | |||
| dd45ffe34c | |||
| 48ee76d60d | |||
| 5af4e1c23e | |||
| b9768aac5e | |||
| 08e78ebe55 | |||
| 6fe67b648f | |||
| 96e93944d9 | |||
| 0cf20527a4 | |||
| 83cb3ff84e | |||
| 9a487ae9a7 | |||
| 02441565a0 | |||
| b3f7b81b9c | |||
| 11d1951c7f | |||
| ffd8c718dd | |||
| 45fdd8a294 | |||
| 5fad0ae027 | |||
| 3f599547e6 | |||
| 7c1c13526e | |||
| 15222be2b5 | |||
| 3cc5a64609 | |||
| 197643a8fa | |||
| ba581ed095 | |||
| 630bfc3eee | |||
| b12acf7793 | |||
| 4ac8f8b472 | |||
| 7b6f62f1bc | |||
| f2906e8d6f | |||
| d9e3f34d37 | |||
| e248b6e43a | |||
| abcfa5bef7 | |||
| 83e38d4278 | |||
| 50d0d110a2 | |||
| c086d402a7 | |||
| 30b8c910e8 | |||
| c5982dd900 | |||
| 0bd4843b8e | |||
| e8f0dead9e | |||
| b575059726 | |||
| 6d6cfcb6ff | |||
| 4994142a67 | |||
| e6cf4c2461 | |||
| d2301db292 | |||
| f6c44d1b92 | |||
| 23e749c973 | |||
| 5f8c469b8e | |||
| 678605e9fb | |||
| 056abcd473 | |||
| e820811d98 | |||
| cd6beef388 | |||
| d3f5a371e3 | |||
| f00fdcd42a | |||
| 970affec95 | |||
| 4a9ddcab64 | |||
| 3ef7cefe07 | |||
| b0c461d347 | |||
| f324c6bd3d | |||
| 35ecceacb4 | |||
| c5c103b287 | |||
| 35bed42b73 | |||
| 66082af52a | |||
| c200fa4f32 | |||
| e81aa9e02e | |||
| 4276a90236 | |||
| 7adef149d8 | |||
| e793f3b72d | |||
| fc4eff2f01 | |||
| 2691472a5e | |||
| 4742a2e3b5 | |||
| 26e062d32c | |||
| 898fae4cb3 | |||
| 8034d916bc | |||
| 191e5f9102 | |||
| 1da4511dd9 |
@@ -43,6 +43,6 @@ jobs:
|
|||||||
- name: Type check (vue-tsc)
|
- name: Type check (vue-tsc)
|
||||||
working-directory: frontend-vue
|
working-directory: frontend-vue
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Frontend contract tests (node --test)
|
- name: Frontend contract tests (npm test → node --test)
|
||||||
working-directory: frontend-vue
|
working-directory: frontend-vue
|
||||||
run: node --test tests/*.test.ts
|
run: npm test
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ ipython_config.py
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# ===== Misc =====
|
# ===== Misc =====
|
||||||
|
.playwright-cli/
|
||||||
desktop/
|
desktop/
|
||||||
ERP-Demo/
|
ERP-Demo/
|
||||||
xlsx/
|
xlsx/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { expect, test, type Page } from '@playwright/test'
|
|||||||
|
|
||||||
async function openAdmin(page: Page) {
|
async function openAdmin(page: Page) {
|
||||||
await page.goto('/admin-vue/')
|
await page.goto('/admin-vue/')
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
}
|
}
|
||||||
|
|
||||||
test('test_task_249_shell_light_normal_primary_path', async ({ page }) => {
|
test('test_task_249_shell_light_normal_primary_path', async ({ page }) => {
|
||||||
@@ -40,8 +40,8 @@ test('test_task_249_shell_light_invalid_input_rejected', async ({ page }) => {
|
|||||||
page.on('pageerror', (error) => errors.push(error.message))
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
await openAdmin(page)
|
await openAdmin(page)
|
||||||
await page.goto('/admin-vue/account/menus')
|
await page.goto('/admin-vue/account/menus')
|
||||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||||
await page.goto('/admin-vue/account/groups')
|
await page.goto('/admin-vue/account/groups')
|
||||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||||
expect(errors).toEqual([])
|
expect(errors).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
// 撞款控制台(DuplicateCheckPage)像素级复刻 reference asin-store-console 的真浏览器验收:
|
||||||
|
// mock 后端跑真实数据渲染,无 pageerror;并截图本页与 reference 离线页供人工比对。
|
||||||
|
|
||||||
|
const REFERENCE = 'D:\\todesk\\副业\\数富AI\\crawler-plugin\\reference\\asin-store-console\\asin-store-console\\index.html'
|
||||||
|
|
||||||
|
test('test_dup_console_render_dup_tab_normal_primary_path', async ({ page }) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
|
|
||||||
|
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||||
|
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
||||||
|
// KPI + 矩阵行 + 撞款卡片都渲染出真实 mock 数据
|
||||||
|
await expect(page.locator('.kpi')).toHaveCount(7)
|
||||||
|
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||||
|
await expect(page.locator('.dup-card').first()).toBeVisible()
|
||||||
|
// 分组撞款:切到「分组撞款」出现分组 chips
|
||||||
|
await page.getByRole('button', { name: /分组撞款/ }).click()
|
||||||
|
await expect(page.locator('.g-chip').first()).toBeVisible()
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
await page.screenshot({ path: 'test-results/dup-console-admin-dup.jpg' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page }) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
|
|
||||||
|
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||||
|
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
||||||
|
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
||||||
|
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||||
|
// 台账 tab 下页面同时有「店铺上架分布」分页器(常显,见 tests/align-dup-console.test.ts)与台账分页器,
|
||||||
|
// 全页 .old-pagination 会命中 2 个元素触发 strict mode;限定到台账表格(.table-wrap)紧邻的分页器。
|
||||||
|
await expect(page.locator('.table-wrap + .old-pagination')).toBeVisible()
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_dup_console_palette_matches_reference_tokens', async ({ page }) => {
|
||||||
|
// reference 设计令牌在页面落定:主蓝 #2E5BE6、红 #D63A4A、弱红底、单元底色等(按钮蓝白统一为 #4f78a5)。
|
||||||
|
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||||
|
await expect(page.locator('.kpi')).toHaveCount(7)
|
||||||
|
const got = await page.evaluate(() => {
|
||||||
|
const cs = (sel, prop) => {
|
||||||
|
const el = document.querySelector(sel)
|
||||||
|
return el ? getComputedStyle(el)[prop] : null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
btnPrimary: cs('.btn-primary', 'backgroundColor'),
|
||||||
|
btnPrimaryColor: cs('.btn-primary', 'color'),
|
||||||
|
kpiDanger: cs('.kpi.danger .k-value', 'color'),
|
||||||
|
kpiPrimary: cs('.kpi.primary .k-value', 'color'),
|
||||||
|
mCell: cs('.m-cell', 'backgroundColor'),
|
||||||
|
mCellText: cs('.m-cell', 'color'),
|
||||||
|
badgeDup: cs('.badge.dup', 'backgroundColor'),
|
||||||
|
badgeSingle: cs('.badge.single', 'backgroundColor'),
|
||||||
|
kSub: cs('.kpi .k-sub', 'color'),
|
||||||
|
selectBorder: cs('.f-group select', 'borderColor'),
|
||||||
|
kDotDanger: cs('.kpi.danger .k-dot', 'backgroundColor'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(got.btnPrimary).toBe('rgb(79, 120, 165)') // 全站按钮主色统一 #4f78a5(d385d999 起)
|
||||||
|
expect(got.btnPrimaryColor).toBe('rgb(255, 255, 255)')
|
||||||
|
expect(got.kpiDanger).toBe('rgb(214, 58, 74)')
|
||||||
|
expect(got.kpiPrimary).toBe('rgb(46, 91, 230)')
|
||||||
|
expect(got.mCell).toBe('rgb(234, 240, 255)')
|
||||||
|
expect(got.mCellText).toBe('rgb(46, 91, 230)')
|
||||||
|
expect(got.badgeDup).toBe('rgb(253, 235, 237)')
|
||||||
|
expect(got.badgeSingle).toBe('rgb(231, 247, 239)')
|
||||||
|
expect(got.kSub).toBe('rgb(152, 161, 177)')
|
||||||
|
expect(got.selectBorder).toBe('rgb(213, 219, 230)')
|
||||||
|
expect(got.kDotDanger).toBe('rgb(214, 58, 74)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_dup_console_structure_matches_reference', async ({ page }) => {
|
||||||
|
// 结构对齐:矩阵列=可见店铺数、台账列=reference 10 列、分组说明抽屉内 4 列。
|
||||||
|
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||||
|
await expect(page.locator('.kpi')).toHaveCount(7)
|
||||||
|
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||||
|
const matrixHeaders = await page.locator('table.matrix thead th').count()
|
||||||
|
expect(matrixHeaders).toBe(8) // ASIN + 品牌 + 6 家店
|
||||||
|
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
||||||
|
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||||
|
const ledgerHeaders = await page.locator('table.ledger thead th').count()
|
||||||
|
expect(ledgerHeaders).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_dup_console_reference_screenshot', async ({ page }) => {
|
||||||
|
// reference 离线原型截图:仅供人工像素比对(不接入应用路由)。
|
||||||
|
test.skip(!existsSync(REFERENCE), 'reference 原型不在本机,跳过比对')
|
||||||
|
await page.goto(pathToFileURL(REFERENCE).href)
|
||||||
|
await expect(page.locator('.kpi').first()).toBeVisible({ timeout: 15000 })
|
||||||
|
await page.screenshot({ path: 'test-results/dup-console-reference.jpg' })
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// 后台管理列表「创建时间 + 更新时间」两列验收(2026-09-19 需求):
|
||||||
|
// 8 个实体管理列表统一展示创建/更新时间,数据取自各列表 VO。
|
||||||
|
// mock 夹具统一给 createdAt=2026-03-01 09:15:00、updatedAt=2026-09-18 16:42:30,
|
||||||
|
// 页面统一经 formatDateTime 归一为「YYYY-MM-DD HH:mm:ss」。
|
||||||
|
|
||||||
|
const CREATED = '2026-03-01 09:15:00'
|
||||||
|
const UPDATED = '2026-09-18 16:42:30'
|
||||||
|
|
||||||
|
const PAGES: Array<{ title: string; path: string; heading: string }> = [
|
||||||
|
{ title: '用户管理', path: '/admin-vue/account/users', heading: '用户列表' },
|
||||||
|
{ title: '菜单管理', path: '/admin-vue/account/menus', heading: '菜单' },
|
||||||
|
{ title: '密钥管理', path: '/admin-vue/account/user-secrets', heading: '用户密钥列表' },
|
||||||
|
{ title: '不符合ASIN数据', path: '/admin-vue/asin-center/invalid', heading: '品牌数据列表' },
|
||||||
|
{ title: '数据去重总数据', path: '/admin-vue/asin-center/registry', heading: '数据去重总数据' },
|
||||||
|
{ title: '查询ASIN', path: '/admin-vue/asin-center/query', heading: '查询' },
|
||||||
|
{ title: '最低价ASIN', path: '/admin-vue/asin-center/skip-price', heading: '最低价' },
|
||||||
|
{ title: '商品类目', path: '/admin-vue/asin-center/categories', heading: '商品类目' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function openPage(page: Page, path: string) {
|
||||||
|
await page.goto(path)
|
||||||
|
await expect(page.locator('.panel-box table tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const spec of PAGES) {
|
||||||
|
test(`${spec.title}:表格展示创建时间与更新时间`, async ({ page }) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
|
|
||||||
|
await openPage(page, spec.path)
|
||||||
|
|
||||||
|
const table = page.locator('.panel-box table').first()
|
||||||
|
// 表头出现两列
|
||||||
|
await expect(table.locator('thead')).toContainText('创建时间')
|
||||||
|
await expect(table.locator('thead')).toContainText('更新时间')
|
||||||
|
// 数据行渲染出真实时间值(非空、非占位符)
|
||||||
|
await expect(table.locator('tbody')).toContainText(CREATED)
|
||||||
|
await expect(table.locator('tbody')).toContainText(UPDATED)
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// 站内通知铃铛面板验收:关键字/年月日区间搜索、按天分组、分页。
|
||||||
|
// 依赖 scripts/mock-admin-server.mjs 的通知夹具(14 条:09-13 六条、09-12 四条、09-10 四条)。
|
||||||
|
|
||||||
|
async function openBell(page: Page) {
|
||||||
|
await page.goto('/admin-vue/')
|
||||||
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
|
await page.locator('.admin-notification-bell .bell-trigger').click()
|
||||||
|
await expect(page.locator('.bell-panel')).toBeVisible()
|
||||||
|
await expect(page.locator('.bell-item').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test_notification_panel_day_group_and_pagination', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
const panel = page.locator('.bell-panel')
|
||||||
|
const nextPage = panel.getByRole('button', { name: '下一页' })
|
||||||
|
|
||||||
|
// 每页 10 条,跨两个日期分组,页脚显示页码与总数
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||||
|
await expect(page.locator('.bell-day-head')).toHaveCount(2)
|
||||||
|
await expect(page.locator('.bell-page-info')).toHaveText('1 / 2')
|
||||||
|
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
|
||||||
|
await expect(page.locator('.bell-day-head').first()).toHaveText(/^(今天|昨天|\d{4}年\d{1,2}月\d{1,2}日)$/)
|
||||||
|
|
||||||
|
const page2Request = page.waitForRequest(
|
||||||
|
(request) => request.url().includes('/api/admin/notifications?') && request.url().includes('page=2'),
|
||||||
|
)
|
||||||
|
await nextPage.click()
|
||||||
|
await page2Request
|
||||||
|
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(4)
|
||||||
|
await expect(page.locator('.bell-page-info')).toHaveText('2 / 2')
|
||||||
|
await expect(page.locator('.bell-day-head')).toHaveCount(1)
|
||||||
|
await expect(nextPage).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_notification_panel_search_by_keyword', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
const keywordRequest = page.waitForRequest((request) =>
|
||||||
|
decodeURIComponent(request.url()).includes('keyword=余额不足'),
|
||||||
|
)
|
||||||
|
await page.locator('.bell-search-input').fill('余额不足')
|
||||||
|
await keywordRequest
|
||||||
|
|
||||||
|
// 关键字命中标题/内容:夹具里 5 条含「余额不足」
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(5)
|
||||||
|
await expect(page.locator('.bell-page-total')).toHaveText('共 5 条')
|
||||||
|
await expect(page.locator('.bell-search-reset')).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('.bell-search-reset').click()
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||||
|
await expect(page.locator('.bell-search-reset')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_notification_panel_search_by_day_range', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
const rangeRequest = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.url().includes('startDate=2026-09-12') && request.url().includes('endDate=2026-09-12'),
|
||||||
|
)
|
||||||
|
await page.locator('.bell-date-input').first().fill('2026-09-12')
|
||||||
|
await page.locator('.bell-date-input').nth(1).fill('2026-09-12')
|
||||||
|
await rangeRequest
|
||||||
|
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(4)
|
||||||
|
await expect(page.locator('.bell-day-head')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.bell-page-total')).toHaveText('共 4 条')
|
||||||
|
await expect(page.locator('.bell-empty')).toHaveCount(0)
|
||||||
|
|
||||||
|
// 起止倒挂自动交换:09-13 起 / 09-10 止 → 实际按 09-10 ~ 09-13 查询(全量 14 条)
|
||||||
|
await page.locator('.bell-date-input').first().fill('2026-09-13')
|
||||||
|
await page.locator('.bell-date-input').nth(1).fill('2026-09-10')
|
||||||
|
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_notification_panel_not_covered_by_page_content', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
// 回归:面板挂在顶栏内时曾被页面 el-select 压住(提 z-index 无效),现改为 Teleport 到 body。
|
||||||
|
// 这里在面板内取多点做命中测试,最上层元素必须属于面板自身。
|
||||||
|
const covered = await page.evaluate(() => {
|
||||||
|
const panel = document.querySelector('.bell-panel') as HTMLElement
|
||||||
|
const rect = panel.getBoundingClientRect()
|
||||||
|
const points: Array<[number, number]> = [
|
||||||
|
[rect.left + 30, rect.top + 130],
|
||||||
|
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||||
|
[rect.right - 30, rect.bottom - 60],
|
||||||
|
]
|
||||||
|
return points
|
||||||
|
.map(([x, y]) => document.elementsFromPoint(x, y)[0])
|
||||||
|
.filter((el) => !!el && el !== panel && !panel.contains(el))
|
||||||
|
.map((el) => `${el!.tagName}.${String((el as HTMLElement).className).slice(0, 40)}`)
|
||||||
|
})
|
||||||
|
expect(covered, '面板被页面元素遮挡').toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_notification_panel_reference_screenshot', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
// 同日多条不得重叠:逐条校验纵向排布严格递增(截图伪影与真实布局问题的分界)
|
||||||
|
const tops = await page
|
||||||
|
.locator('.bell-item')
|
||||||
|
.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top))
|
||||||
|
for (let i = 1; i < tops.length; i += 1) {
|
||||||
|
expect(tops[i], `第 ${i + 1} 条应排在第 ${i} 条下方`).toBeGreaterThan(tops[i - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/notification-panel-admin-page.jpg', animations: 'disabled' })
|
||||||
|
await page.locator('.bell-panel').screenshot({ path: 'test-results/notification-panel-admin.jpg', animations: 'disabled' })
|
||||||
|
|
||||||
|
await page.locator('.bell-search-input').fill('余额不足')
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(5)
|
||||||
|
await page.locator('.bell-panel').screenshot({
|
||||||
|
path: 'test-results/notification-panel-admin-search.jpg',
|
||||||
|
animations: 'disabled',
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// 教程管理页验收(module 记录与版本):列表新增「版本号」列 + 上传时间列,
|
||||||
|
// 默认按上传时间降序;版本号/上传时间表头可点击切换升降序;空版本历史行沉底。
|
||||||
|
// 依赖 scripts/mock-admin-server.mjs 的教程包夹具(4 条,其中 1 条无版本号、1 条 09-01 历史行)。
|
||||||
|
|
||||||
|
async function openTutorial(page: Page) {
|
||||||
|
await page.goto('/admin-vue/records/tutorial')
|
||||||
|
await expect(page.locator('.admin-topbar h1')).toHaveText('教程管理')
|
||||||
|
await expect(page.locator('.panel-box tbody tr').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionTexts = (page: Page) =>
|
||||||
|
page.locator('.panel-box tbody tr td:nth-child(3)').allTextContents()
|
||||||
|
|
||||||
|
const fileTexts = (page: Page) =>
|
||||||
|
page.locator('.panel-box tbody tr td:nth-child(2) .file-name').allTextContents()
|
||||||
|
|
||||||
|
test('test_tutorial_list_default_desc_by_upload_time', async ({ page }) => {
|
||||||
|
await openTutorial(page)
|
||||||
|
|
||||||
|
// 表头:版本号、上传时间(均带排序标记)
|
||||||
|
await expect(page.locator('.sort-version')).toHaveText(/版本号/)
|
||||||
|
await expect(page.locator('.sort-time')).toHaveText(/上传时间/)
|
||||||
|
// 默认排序状态:上传时间降序(▼),版本号未激活(▲▼)
|
||||||
|
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
|
||||||
|
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
|
||||||
|
|
||||||
|
// 默认按上传时间降序:09-14 → 09-12 → 09-10 → 09-01;空版本行沉底
|
||||||
|
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
|
||||||
|
expect(await fileTexts(page)).toEqual([
|
||||||
|
'数富AI-教学客户端-v4.zip',
|
||||||
|
'数富AI-教学客户端-v3.zip',
|
||||||
|
'数富AI-教学客户端-v2.zip',
|
||||||
|
'数富AI-教学客户端.zip',
|
||||||
|
])
|
||||||
|
// 当前生效 = 最新上传(夹具里 09-14 那条),不随列表排序变化
|
||||||
|
await expect(page.locator('.tag-active')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.tag-active').locator('xpath=..')).toHaveText(/数富AI-教学客户端-v4\.zip/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_tutorial_sort_toggle_by_version_and_time', async ({ page }) => {
|
||||||
|
await openTutorial(page)
|
||||||
|
|
||||||
|
// 点「版本号」:首次为降序(v3.10.0 按自然序大于 v3.2.0),空版本行仍在末尾
|
||||||
|
await page.locator('.sort-version').click()
|
||||||
|
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▼')
|
||||||
|
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
|
||||||
|
|
||||||
|
// 再点一次切升序
|
||||||
|
await page.locator('.sort-version').click()
|
||||||
|
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲')
|
||||||
|
expect(await versionTexts(page)).toEqual(['v3.1.0', 'v3.2.0', 'v3.10.0', '—'])
|
||||||
|
|
||||||
|
// 点回「上传时间」:切换字段时回到降序默认
|
||||||
|
await page.locator('.sort-time').click()
|
||||||
|
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
|
||||||
|
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
|
||||||
|
expect(await fileTexts(page)).toEqual([
|
||||||
|
'数富AI-教学客户端-v4.zip',
|
||||||
|
'数富AI-教学客户端-v3.zip',
|
||||||
|
'数富AI-教学客户端-v2.zip',
|
||||||
|
'数富AI-教学客户端.zip',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_tutorial_upload_dialog_has_version_field', async ({ page }) => {
|
||||||
|
await openTutorial(page)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '上传教程包' }).click()
|
||||||
|
const dialog = page.locator('.el-dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.locator('.el-form-item').first()).toContainText('版本号')
|
||||||
|
await expect(dialog.locator('input').first()).toHaveAttribute('maxlength', '64')
|
||||||
|
|
||||||
|
// 不选文件直接提交:提示仍以 zip 为必填(版本号可空)
|
||||||
|
await dialog.getByRole('button', { name: '上传教程包' }).click()
|
||||||
|
await expect(dialog.locator('.el-alert')).toContainText('请选择 zip 压缩包')
|
||||||
|
})
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test'
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// 响应式壳层对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
|
||||||
|
// 断言主题改为「窄屏不破版、核心地标与标题可用」。
|
||||||
|
|
||||||
async function open(page: Page, width: number, path = '/admin-vue/') {
|
async function open(page: Page, width: number, path = '/admin-vue/') {
|
||||||
await page.setViewportSize({ width, height: 900 })
|
await page.setViewportSize({ width, height: 900 })
|
||||||
await page.goto(path)
|
await page.goto(path)
|
||||||
@@ -7,60 +10,51 @@ async function open(page: Page, width: number, path = '/admin-vue/') {
|
|||||||
|
|
||||||
test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => {
|
test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => {
|
||||||
await open(page, 1440)
|
await open(page, 1440)
|
||||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
const shell = page.locator('.admin-shell')
|
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||||
await expect(shell).not.toHaveClass(/is-collapsed/)
|
|
||||||
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
|
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
|
||||||
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => {
|
test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => {
|
||||||
await open(page, 1280)
|
await open(page, 1280)
|
||||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||||
await open(page, 1440)
|
await open(page, 1440)
|
||||||
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true')
|
await page.reload()
|
||||||
const toggle = page.locator('.sidebar-collapse')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await toggle.click()
|
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
|
||||||
await toggle.click()
|
|
||||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
|
||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => {
|
test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => {
|
||||||
await open(page, 640)
|
await open(page, 640)
|
||||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||||
await expect(page.locator('.brand-copy, .admin-brand-copy')).toHaveCount(0)
|
// 窄屏侧栏自适应收缩(当前 85px 窄栏),不遮主内容标题。
|
||||||
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'false')
|
await expect(page.locator('.admin-topbar h1')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => {
|
test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => {
|
||||||
await open(page, 700)
|
await open(page, 700)
|
||||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await page.locator('.sidebar-collapse').click()
|
|
||||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
|
||||||
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
|
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => {
|
test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => {
|
||||||
await open(page, 900)
|
await open(page, 900)
|
||||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await open(page, 901, '/admin-vue/account/users')
|
await open(page, 901, '/admin-vue/account/users')
|
||||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => {
|
test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => {
|
||||||
// 手动折叠偏好持久化,刷新后仍保持(不因页面重载而回弹)。
|
// 无折叠偏好了;刷新后壳层保持正常渲染(不因重载变样)。
|
||||||
await open(page, 1440)
|
await open(page, 1440)
|
||||||
await page.locator('.sidebar-collapse').click()
|
|
||||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
|
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => {
|
test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test'
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// 壳层可访问性对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
|
||||||
|
// 断言主题改为「跳转链接、地标唯一性、标题层级、键盘可达」。
|
||||||
|
|
||||||
async function openUsers(page: Page) {
|
async function openUsers(page: Page) {
|
||||||
await page.goto('/admin-vue/')
|
await page.goto('/admin-vue/')
|
||||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
}
|
}
|
||||||
|
|
||||||
test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => {
|
test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => {
|
||||||
@@ -12,19 +15,16 @@ test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) =
|
|||||||
|
|
||||||
test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => {
|
test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => {
|
||||||
await openUsers(page)
|
await openUsers(page)
|
||||||
const toggle = page.locator('.sidebar-collapse')
|
const skip = page.locator('.skip-link')
|
||||||
await expect(toggle).toHaveAttribute('aria-label', '切换侧边栏')
|
await expect(skip).toBeVisible()
|
||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
await expect(skip).toHaveAttribute('href', '#admin-content')
|
||||||
await expect(toggle).toHaveAttribute('type', 'button')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||||
await openUsers(page)
|
await openUsers(page)
|
||||||
const toggle = page.locator('.sidebar-collapse')
|
await page.reload()
|
||||||
await toggle.click()
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||||
await toggle.click()
|
|
||||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => {
|
test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => {
|
||||||
@@ -35,11 +35,12 @@ test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page })
|
|||||||
|
|
||||||
test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => {
|
test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => {
|
||||||
await openUsers(page)
|
await openUsers(page)
|
||||||
// 键盘可聚焦并可回车触发折叠切换。
|
// 键盘可达:Tab 到跳转链接,回车跳到主内容(hash 定位)。
|
||||||
await page.locator('.sidebar-collapse').focus()
|
await page.locator('.skip-link').focus()
|
||||||
await expect(page.locator('.sidebar-collapse')).toBeFocused()
|
await expect(page.locator('.skip-link')).toBeFocused()
|
||||||
await page.keyboard.press('Enter')
|
await page.keyboard.press('Enter')
|
||||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
await expect(page).toHaveURL(/#admin-content/)
|
||||||
|
await expect(page.locator('#admin-content')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => {
|
test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => {
|
||||||
@@ -50,16 +51,16 @@ test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async
|
|||||||
|
|
||||||
test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => {
|
test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => {
|
||||||
await openUsers(page)
|
await openUsers(page)
|
||||||
// 折叠后品牌文案不应留在可感知/可聚焦树中。
|
// 导航后无可见错误遮罩/弹层残留。
|
||||||
await page.locator('.sidebar-collapse').click()
|
await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
|
||||||
await expect(page.locator('.admin-brand-copy')).toHaveCount(0)
|
await expect(page.locator('.el-alert--error')).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
|
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||||
await openUsers(page)
|
await openUsers(page)
|
||||||
// 标题层级:顶栏 h1(页面标题) + 页面 h2;键盘焦点样式已定义。
|
// 标题层级:顶栏唯一 h1(页面标题);页面面板标题用 h3 递进。
|
||||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('h1')).toHaveCount(1)
|
||||||
const hasFocusStyle = await page.evaluate(() => {
|
const hasFocusStyle = await page.evaluate(() => {
|
||||||
const rules: string[] = []
|
const rules: string[] = []
|
||||||
for (const sheet of document.styleSheets) {
|
for (const sheet of document.styleSheets) {
|
||||||
@@ -71,7 +72,7 @@ test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_me
|
|||||||
// 跨域样式表忽略
|
// 跨域样式表忽略
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rules.some((t) => t.includes('sidebar-collapse'))
|
return rules.length > 0
|
||||||
})
|
})
|
||||||
expect(hasFocusStyle).toBe(true)
|
expect(hasFocusStyle).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,35 +6,35 @@ async function gotoRoute(page: Page, path: string) {
|
|||||||
|
|
||||||
test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => {
|
test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => {
|
||||||
await gotoRoute(page, '/admin-vue/account/users')
|
await gotoRoute(page, '/admin-vue/account/users')
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => {
|
test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => {
|
||||||
await gotoRoute(page, '/admin-vue/account/menus')
|
await gotoRoute(page, '/admin-vue/account/menus')
|
||||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||||
await gotoRoute(page, '/admin-vue/account/groups')
|
await gotoRoute(page, '/admin-vue/account/groups')
|
||||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||||
await gotoRoute(page, '/admin-vue/account/users')
|
await gotoRoute(page, '/admin-vue/account/users')
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await gotoRoute(page, '/admin-vue/account/groups')
|
await gotoRoute(page, '/admin-vue/account/groups')
|
||||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => {
|
test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => {
|
||||||
// 根路由跳转到首个可见页面(用户管理)。
|
// 根路由跳转到首个可见页面(用户管理)。
|
||||||
await gotoRoute(page, '/admin-vue/')
|
await gotoRoute(page, '/admin-vue/')
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await expect(page).toHaveURL(/\/account\/users$/)
|
await expect(page).toHaveURL(/\/account\/users$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => {
|
test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => {
|
||||||
await gotoRoute(page, '/admin-vue/account/menus')
|
await gotoRoute(page, '/admin-vue/account/menus')
|
||||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||||
const active = page.locator('.el-menu-item.is-active')
|
const active = page.locator('.el-menu-item.is-active')
|
||||||
await expect(active).toHaveText('菜单管理')
|
await expect(active).toHaveText('菜单管理')
|
||||||
})
|
})
|
||||||
@@ -42,11 +42,11 @@ test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) =>
|
|||||||
test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => {
|
test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => {
|
||||||
// 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。
|
// 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。
|
||||||
await gotoRoute(page, '/admin-vue/account/users')
|
await gotoRoute(page, '/admin-vue/account/users')
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await page.getByText('菜单管理', { exact: true }).first().click()
|
await page.getByText('菜单管理', { exact: true }).first().click()
|
||||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||||
await page.getByText('数据权限分组', { exact: true }).first().click()
|
await page.getByText('数据权限分组', { exact: true }).first().click()
|
||||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => {
|
test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from '@playwright/test'
|
|||||||
|
|
||||||
async function openAdmin(page: Page) {
|
async function openAdmin(page: Page) {
|
||||||
await page.goto('/admin-vue/')
|
await page.goto('/admin-vue/')
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
}
|
}
|
||||||
|
|
||||||
test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => {
|
test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => {
|
||||||
@@ -15,16 +15,16 @@ test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) =
|
|||||||
|
|
||||||
test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => {
|
test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => {
|
||||||
await openAdmin(page)
|
await openAdmin(page)
|
||||||
await page.locator('.sidebar-collapse').click()
|
// 侧边栏菜单导航到「数据权限分组」,标题与面包屑跟随(对齐现状:无收起按钮)。
|
||||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
await page.getByRole('menuitem', { name: '数据权限分组' }).click()
|
||||||
await page.locator('.sidebar-collapse').click()
|
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
await expect(page.locator('.admin-breadcrumb')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||||
await openAdmin(page)
|
await openAdmin(page)
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
await expect(page.locator('.global-error-stack')).toHaveCount(0)
|
await expect(page.locator('.global-error-stack')).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ test('test_task_020_shell_browser_smoke_boundary_single_item', async ({ page })
|
|||||||
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
|
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
|
||||||
await expect(page.locator('.el-message-box')).toHaveCount(0)
|
await expect(page.locator('.el-message-box')).toHaveCount(0)
|
||||||
await expect(page).toHaveURL(/\/admin-vue\//)
|
await expect(page).toHaveURL(/\/admin-vue\//)
|
||||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => {
|
test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => {
|
||||||
@@ -58,9 +58,9 @@ test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }
|
|||||||
page.on('pageerror', (error) => errors.push(error.message))
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
await openAdmin(page)
|
await openAdmin(page)
|
||||||
await page.goto('/admin-vue/account/menus')
|
await page.goto('/admin-vue/account/menus')
|
||||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||||
await page.goto('/admin-vue/account/groups')
|
await page.goto('/admin-vue/account/groups')
|
||||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||||
expect(errors).toEqual([])
|
expect(errors).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// 样办校验:生成记录页旧版样式落地实测(本地真实栈)。
|
||||||
|
import { chromium } from '@playwright/test'
|
||||||
|
const BASE = 'http://localhost:5174/admin-vue'
|
||||||
|
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||||
|
await page.locator('#loginUsername').fill('admin')
|
||||||
|
await page.locator('#loginPassword').fill('admin123')
|
||||||
|
await page.locator('.btn-login').click()
|
||||||
|
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
}
|
||||||
|
await page.goto(`${BASE}/records/history`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.locator('.form-box').first().waitFor({ state: 'visible', timeout: 15000 })
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {})
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
const out = await page.evaluate(() => {
|
||||||
|
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
|
||||||
|
const fb = document.querySelector('.form-box')
|
||||||
|
const pb = document.querySelector('.panel-box')
|
||||||
|
const th = document.querySelector('.table-scroll th')
|
||||||
|
const td = document.querySelector('.table-scroll td')
|
||||||
|
const btn = document.querySelector('.form-box .btn')
|
||||||
|
const inp = document.querySelector('.form-group input, .form-group select')
|
||||||
|
return {
|
||||||
|
formBox: { bg: cs(fb, 'backgroundColor'), radius: cs(fb, 'borderRadius'), border: cs(fb, 'borderTopColor') },
|
||||||
|
panelBox: { bg: cs(pb, 'backgroundColor') },
|
||||||
|
h3: cs(document.querySelector('.panel-box h3'), 'fontSize'),
|
||||||
|
input: { minH: inp && cs(inp, 'minHeight'), bg: inp && cs(inp, 'backgroundColor'), radius: inp && cs(inp, 'borderRadius') },
|
||||||
|
btn: { minH: cs(btn, 'minHeight'), bgImage: btn && getComputedStyle(btn).backgroundImage.slice(0, 60), radius: cs(btn, 'borderRadius'), color: cs(btn, 'color') },
|
||||||
|
th: { bg: cs(th, 'backgroundColor'), color: cs(th, 'color'), fontSize: cs(th, 'fontSize'), weight: cs(th, 'fontWeight') },
|
||||||
|
td: { color: td && cs(td, 'color'), fontSize: td && cs(td, 'fontSize') },
|
||||||
|
tableScroll: { border: cs(document.querySelector('.table-scroll'), 'borderTopColor'), radius: cs(document.querySelector('.table-scroll'), 'borderRadius') },
|
||||||
|
rowCount: document.querySelectorAll('.table-scroll tbody tr').length,
|
||||||
|
thumbb: !!document.querySelector('.thumb'),
|
||||||
|
pagination: (document.querySelector('.pagination')?.textContent || '').trim().slice(0, 60),
|
||||||
|
modal: document.querySelector('.modal-mask') === null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(JSON.stringify(out, null, 2))
|
||||||
|
await browser.close()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API。
|
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API + 撞款控制台夹具。
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
|
|
||||||
const PORT = Number(process.env.MOCK_PORT || 18100)
|
const PORT = Number(process.env.MOCK_PORT || 18100)
|
||||||
@@ -15,6 +15,123 @@ const MENU_TREE = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/* ===================== 撞款控制台夹具(对齐前端 parse 的 snake 契约) ===================== */
|
||||||
|
const STORE_GROUP = { 店A1: '陈新辉', 店A2: '陈新辉', 店B1: '魏振峰', 店B2: '魏振峰', 店B3: '魏振峰', 店C1: '郭亚芳' }
|
||||||
|
const GROUP_DEFS = [
|
||||||
|
{ name: '陈新辉', shops: ['店A1', '店A2'] },
|
||||||
|
{ name: '魏振峰', shops: ['店B1', '店B2', '店B3'] },
|
||||||
|
{ name: '郭亚芳', shops: ['店C1'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
function occ(asin, brand, shop, country, day) {
|
||||||
|
return { asin, date: `2026-08-${String(day).padStart(2, '0')} 09:00:00`, price: '9.90', brand, shop_name: shop, group_name: STORE_GROUP[shop], country_codes: [country], country }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撞款种子(≥2 店在售)与单店种子
|
||||||
|
const DUP_SEEDS = [
|
||||||
|
{ asin: 'E0000001', brand: '品牌甲', occ: [occ('E0000001', '品牌甲', '店A1', 'UK', 20), occ('E0000001', '品牌甲', '店A1', 'UK', 24), occ('E0000001', '品牌甲', '店A2', 'DE', 22), occ('E0000001', '品牌甲', '店B1', 'FR', 21)] },
|
||||||
|
{ asin: 'E0000002', brand: '品牌乙', occ: [occ('E0000002', '品牌乙', '店B1', 'UK', 10), occ('E0000002', '品牌乙', '店B2', 'DE', 12)] },
|
||||||
|
{ asin: 'E0000003', brand: '品牌丙', occ: [occ('E0000003', '品牌丙', '店A2', 'DE', 15), occ('E0000003', '品牌丙', '店B3', 'IT', 16)] },
|
||||||
|
]
|
||||||
|
const SINGLE_SEEDS = [
|
||||||
|
{ asin: 'E0000004', brand: '品牌甲', occ: [occ('E0000004', '品牌甲', '店A1', 'UK', 1)] },
|
||||||
|
{ asin: 'E0000005', brand: '品牌丁', occ: [occ('E0000005', '品牌丁', '店B2', 'UK', 2), occ('E0000005', '品牌丁', '店B2', 'UK', 3)] },
|
||||||
|
{ asin: 'E0000006', brand: '品牌戊', occ: [occ('E0000006', '品牌戊', '店C1', 'FR', 5)] },
|
||||||
|
{ asin: 'E0000007', brand: '品牌己', occ: [occ('E0000007', '品牌己', '店B3', 'DE', 6)] },
|
||||||
|
{ asin: 'E0000008', brand: '品牌庚', occ: [occ('E0000008', '品牌庚', '店A1', 'ES', 7)] },
|
||||||
|
]
|
||||||
|
|
||||||
|
function finalizeRow(seed) {
|
||||||
|
const stores = []
|
||||||
|
const storeSet = new Set()
|
||||||
|
const countries = new Set()
|
||||||
|
let earliest = ''
|
||||||
|
let latest = ''
|
||||||
|
for (const o of seed.occ) {
|
||||||
|
if (!storeSet.has(o.shop_name)) {
|
||||||
|
storeSet.add(o.shop_name)
|
||||||
|
stores.push(o.shop_name)
|
||||||
|
}
|
||||||
|
countries.add(o.country)
|
||||||
|
if (!earliest || o.date < earliest) earliest = o.date
|
||||||
|
if (o.date > latest) latest = o.date
|
||||||
|
}
|
||||||
|
const groups = [...new Set(stores.map((s) => STORE_GROUP[s] || ''))].filter(Boolean)
|
||||||
|
return {
|
||||||
|
asin: seed.asin,
|
||||||
|
brand: seed.brand,
|
||||||
|
store_count: stores.length,
|
||||||
|
stores,
|
||||||
|
groups,
|
||||||
|
countries: [...countries],
|
||||||
|
record_count: seed.occ.length,
|
||||||
|
earliest,
|
||||||
|
latest,
|
||||||
|
occurrences: seed.occ,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_ROWS = [...DUP_SEEDS, ...SINGLE_SEEDS].map(finalizeRow)
|
||||||
|
const DUP_ROWS = ALL_ROWS.filter((row) => row.store_count >= 2)
|
||||||
|
|
||||||
|
const SHOPS = Object.keys(STORE_GROUP).map((name) => {
|
||||||
|
const codes = new Set()
|
||||||
|
let recordCount = 0
|
||||||
|
let asinCount = 0
|
||||||
|
for (const row of ALL_ROWS) {
|
||||||
|
if (row.stores.includes(name)) {
|
||||||
|
asinCount += 1
|
||||||
|
for (const o of row.occurrences) {
|
||||||
|
if (o.shop_name === name) {
|
||||||
|
recordCount += 1
|
||||||
|
codes.add(o.country)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { shop_name: name, group_name: STORE_GROUP[name], country_codes: [...codes], asin_count: asinCount, record_count: recordCount }
|
||||||
|
})
|
||||||
|
|
||||||
|
function computeGroups() {
|
||||||
|
return GROUP_DEFS.map((group) => {
|
||||||
|
const members = group.shops
|
||||||
|
const hit = DUP_ROWS.filter((row) => members.filter((shop) => row.stores.includes(shop)).length >= 2)
|
||||||
|
const asinUnique = ALL_ROWS.filter((row) => row.stores.some((shop) => members.includes(shop))).length
|
||||||
|
const recordCount = ALL_ROWS.reduce((sum, row) => {
|
||||||
|
if (row.stores.some((shop) => members.includes(shop))) sum += row.recordCount
|
||||||
|
return sum
|
||||||
|
}, 0)
|
||||||
|
return { name: group.name, shop_count: members.length, asin_unique: asinUnique, record_count: recordCount, dup_count: hit.length }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS = computeGroups()
|
||||||
|
const RECORD_TOTAL = ALL_ROWS.reduce((sum, row) => sum + row.recordCount, 0)
|
||||||
|
|
||||||
|
function consolePayload() {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
pending: false,
|
||||||
|
scanned_at: '2026-09-05 08:30:00',
|
||||||
|
summary: {
|
||||||
|
shop_count: SHOPS.length,
|
||||||
|
asin_total: ALL_ROWS.length,
|
||||||
|
record_total: RECORD_TOTAL,
|
||||||
|
duplicate_asin_total: DUP_ROWS.length,
|
||||||
|
duplicate_shop_count: DUP_ROWS.length ? 6 : 0,
|
||||||
|
site_count: 5,
|
||||||
|
asin_per_shop: 4.8,
|
||||||
|
source: 'job',
|
||||||
|
},
|
||||||
|
shops: SHOPS,
|
||||||
|
dup: DUP_ROWS,
|
||||||
|
groups: GROUPS,
|
||||||
|
total_dup: DUP_ROWS.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function json(res, payload, status = 200) {
|
function json(res, payload, status = 200) {
|
||||||
const body = JSON.stringify(payload)
|
const body = JSON.stringify(payload)
|
||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
@@ -25,6 +142,66 @@ function json(res, payload, status = 200) {
|
|||||||
res.end(body)
|
res.end(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== 教程包夹具(教程管理页:版本号列 + 上传时间排序验收用) ===================== */
|
||||||
|
// 故意乱序给出,且含一条无版本号的历史行:页面默认应按上传时间降序、空版本行沉底。
|
||||||
|
const TUTORIAL_PACKAGES = [
|
||||||
|
{ id: 3, file_name: '数富AI-教学客户端-v3.zip', version: 'v3.2.0', object_key: 'tutorial/20260912090000-数富AI-教学客户端-v3.zip', file_size: 1048576, file_url: 'https://oss.aishufu.top/client/tutorial/t3.zip', created_at: '2026-09-12 09:00' },
|
||||||
|
{ id: 1, file_name: '数富AI-教学客户端.zip', version: '', object_key: 'tutorial/数富AI-教学客户端.zip', file_size: 0, file_url: 'https://oss.aishufu.top/client/tutorial/legacy.zip', created_at: '2026-09-01 08:00' },
|
||||||
|
{ id: 4, file_name: '数富AI-教学客户端-v4.zip', version: 'v3.10.0', object_key: 'tutorial/20260914103000-数富AI-教学客户端-v4.zip', file_size: 2097152, file_url: 'https://oss.aishufu.top/client/tutorial/t4.zip', created_at: '2026-09-14 10:30' },
|
||||||
|
{ id: 2, file_name: '数富AI-教学客户端-v2.zip', version: 'v3.1.0', object_key: 'tutorial/20260910120000-数富AI-教学客户端-v2.zip', file_size: 524288, file_url: 'https://oss.aishufu.top/client/tutorial/t2.zip', created_at: '2026-09-10 12:00' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ===================== 站内通知夹具(铃铛面板:搜索/按天分组/分页验收用) ===================== */
|
||||||
|
const NOTIFICATIONS = [
|
||||||
|
...[6, 5, 4, 3, 2, 1].map((seq) => notif(seq, '2026-09-13', 22, seq, seq <= 3)),
|
||||||
|
...[4, 3, 2, 1].map((seq) => notif(20 + seq, '2026-09-12', 20, seq, true)),
|
||||||
|
...[4, 3, 2, 1].map((seq) => notif(30 + seq, '2026-09-10', 18, seq, true)),
|
||||||
|
]
|
||||||
|
|
||||||
|
function notif(id, day, hour, seq, read) {
|
||||||
|
const minutes = String(seq * 7).padStart(2, '0')
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
scene: id % 3 === 0 ? 'secret_balance' : 'task_failed',
|
||||||
|
level: id % 3 === 0 ? 'error' : 'warning',
|
||||||
|
title: id % 3 === 0 ? `用户密钥异常:测试用户${seq}` : `跟价任务失败`,
|
||||||
|
content: id % 3 === 0 ? `用户 测试用户${seq}(uid=${1100 + seq})的代理设置对应服务商余额不足` : `用户 测试用户${seq} 有 ${seq} 个跟价任务失败`,
|
||||||
|
read,
|
||||||
|
readAt: read ? `${day} ${hour}:${minutes}:00` : null,
|
||||||
|
createdAt: `${day} ${hour}:${minutes}:00`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知列表:关键字匹配标题/内容,日期按年月日区间(与 Java 侧同语义)。 */
|
||||||
|
function notificationPage(searchParams) {
|
||||||
|
const keyword = (searchParams.get('keyword') || '').trim()
|
||||||
|
const startDate = searchParams.get('startDate') || ''
|
||||||
|
const endDate = searchParams.get('endDate') || ''
|
||||||
|
const page = Math.max(1, Number(searchParams.get('page') || 1))
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('pageSize') || 20)))
|
||||||
|
let rows = NOTIFICATIONS
|
||||||
|
if (keyword) {
|
||||||
|
rows = rows.filter((item) => `${item.title}${item.content}`.includes(keyword))
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
rows = rows.filter((item) => item.createdAt.slice(0, 10) >= startDate)
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
rows = rows.filter((item) => item.createdAt.slice(0, 10) <= endDate)
|
||||||
|
}
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: rows.slice(start, start + pageSize),
|
||||||
|
total: rows.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
unreadCount: rows.filter((item) => !item.read).length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
const url = (req.url || '').split('?')[0]
|
const url = (req.url || '').split('?')[0]
|
||||||
const method = req.method || 'GET'
|
const method = req.method || 'GET'
|
||||||
@@ -43,17 +220,213 @@ const server = createServer((req, res) => {
|
|||||||
return json(res, {
|
return json(res, {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', createdAt: '2026-01-01 00:00:00' }],
|
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', created_at: '2026-03-01 09:15:00', updated_at: '2026-09-18 16:42:30' }],
|
||||||
total: 1,
|
total: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/admin/permission-menus') {
|
if (url === '/api/admin/permission-menus') {
|
||||||
return json(res, { success: true, data: { items: [] } })
|
// 菜单管理列表:一条根节点,带创建/更新时间(验收时间列)。
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '用户管理',
|
||||||
|
column_key: 'admin_users',
|
||||||
|
parent_id: null,
|
||||||
|
menu_type: 'admin',
|
||||||
|
route_path: 'account/users',
|
||||||
|
sort_order: 10,
|
||||||
|
created_at: '2026-03-01T09:15:00',
|
||||||
|
updated_at: '2026-09-18T16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/invalid-asin-data') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dataValue: 'B0INVALID1',
|
||||||
|
brand: '测试品牌',
|
||||||
|
groupId: 1,
|
||||||
|
groupName: '测试分组',
|
||||||
|
recordSource: 'MANUAL',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/dedupe-total-data') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dataValue: 'B0DEDUPE01',
|
||||||
|
country: 'DE',
|
||||||
|
groupId: 1,
|
||||||
|
groupName: '测试分组',
|
||||||
|
uploaderUserId: 1,
|
||||||
|
username: 'admin',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/query-asins') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
groupId: 1,
|
||||||
|
groupName: '测试分组',
|
||||||
|
shopName: '测试店铺',
|
||||||
|
asinDe: 'B0QUERY001',
|
||||||
|
asinUk: '',
|
||||||
|
asinFr: '',
|
||||||
|
asinIt: '',
|
||||||
|
asinEs: '',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/skip-price-asins') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
groupId: 1,
|
||||||
|
groupName: '测试分组',
|
||||||
|
shopName: '测试店铺',
|
||||||
|
asinDe: 'B0SKIP0001',
|
||||||
|
minimumPriceDe: 9.99,
|
||||||
|
asinUk: '',
|
||||||
|
minimumPriceUk: null,
|
||||||
|
asinFr: '',
|
||||||
|
minimumPriceFr: null,
|
||||||
|
asinIt: '',
|
||||||
|
minimumPriceIt: null,
|
||||||
|
asinEs: '',
|
||||||
|
minimumPriceEs: null,
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/product-categories/children') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
parentId: null,
|
||||||
|
name: '护肤品',
|
||||||
|
categoryKey: 'skincare',
|
||||||
|
sortOrder: 10,
|
||||||
|
description: '测试备注',
|
||||||
|
isBuiltin: true,
|
||||||
|
childCount: 0,
|
||||||
|
level: 0,
|
||||||
|
path: '护肤品',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/user-secrets') {
|
||||||
|
const emptyModule = { moduleKey: '', moduleLabel: '', masked: '', full: '', exists: false, checkStatus: 'unknown', checkCode: '', checkMessage: '', checkLatencyMs: null, checkedAt: null, updatedAt: null }
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
userId: 1,
|
||||||
|
username: 'admin',
|
||||||
|
groups: [],
|
||||||
|
leaderUsername: '',
|
||||||
|
similarAsin: { ...emptyModule, moduleKey: 'similar-asin', moduleLabel: '货源查询密钥' },
|
||||||
|
appearancePatent: { ...emptyModule, moduleKey: 'appearance-patent', moduleLabel: '外观专利密钥' },
|
||||||
|
proxy: { ...emptyModule, moduleKey: 'proxy', moduleLabel: '代理设置' },
|
||||||
|
status: 'unknown',
|
||||||
|
statusMessage: '',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 15,
|
||||||
|
groupOptions: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/admin/shop-manage-groups') {
|
if (url === '/api/admin/shop-manage-groups') {
|
||||||
return json(res, { success: true, data: { items: [] } })
|
return json(res, { success: true, data: { items: [] } })
|
||||||
}
|
}
|
||||||
|
if (url === '/api/admin/shop-data-crawl/duplicate-check-console') {
|
||||||
|
return json(res, consolePayload())
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/shop-data-crawl/duplicate-check-ledger') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/notifications/summary') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
unreadCount: NOTIFICATIONS.filter((item) => !item.read).length,
|
||||||
|
latestId: NOTIFICATIONS.reduce((max, item) => Math.max(max, item.id), 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/notifications') {
|
||||||
|
return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams))
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/tutorials') {
|
||||||
|
// 与 Java 侧同契约:created_at 降序返回(页面"当前生效"取第一条)。
|
||||||
|
const items = [...TUTORIAL_PACKAGES].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
||||||
|
return json(res, { success: true, data: { items } })
|
||||||
|
}
|
||||||
if (url.startsWith('/api/')) {
|
if (url.startsWith('/api/')) {
|
||||||
return json(res, { success: true, data: { items: [], total: 0 } })
|
return json(res, { success: true, data: { items: [], total: 0 } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// round2 DOM 审计:登录本地真实栈后,对 15 页提取结构化观感事实(颜色/卡壳/分页坐标/按钮形态/标题/列头/空态)。
|
||||||
|
// 用法:cd admin-frontend-vue && node scripts/round2-audit.mjs
|
||||||
|
import { chromium } from '@playwright/test'
|
||||||
|
|
||||||
|
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
|
||||||
|
|
||||||
|
const PAGES = [
|
||||||
|
['account-users', 'account/users'],
|
||||||
|
['account-menus', 'account/menus'],
|
||||||
|
['account-groups', 'account/groups'],
|
||||||
|
['asin-registry', 'asin-center/registry'],
|
||||||
|
['asin-invalid', 'asin-center/invalid'],
|
||||||
|
['asin-query', 'asin-center/query'],
|
||||||
|
['asin-categories', 'asin-center/categories'],
|
||||||
|
['asin-skip-price', 'asin-center/skip-price'],
|
||||||
|
['shop-keys', 'shop-center/keys'],
|
||||||
|
['shop-shops', 'shop-center/shops'],
|
||||||
|
['shop-data-tasks', 'shop-center/data-tasks'],
|
||||||
|
['records-history', 'records/history'],
|
||||||
|
['records-software-version', 'records/software-version'],
|
||||||
|
['records-digital-human-version', 'records/digital-human-version'],
|
||||||
|
['records-image-video-tasks', 'records/image-video-tasks'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
|
||||||
|
const page = await context.newPage()
|
||||||
|
|
||||||
|
async function ensureLoggedIn() {
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||||
|
await page.locator('#loginUsername').fill('admin')
|
||||||
|
await page.locator('#loginPassword').fill('admin123')
|
||||||
|
await page.locator('.btn-login').click()
|
||||||
|
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ensureLoggedIn()
|
||||||
|
|
||||||
|
const out = {}
|
||||||
|
for (const [name, path] of PAGES) {
|
||||||
|
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {})
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
|
||||||
|
const fact = await page.evaluate(() => {
|
||||||
|
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
|
||||||
|
const root = getComputedStyle(document.documentElement)
|
||||||
|
const q = (sel) => document.querySelectorAll(sel)
|
||||||
|
const text = (el) => (el ? (el.textContent || '').trim() : '')
|
||||||
|
const heading = document.querySelector('.page-heading h1, .page-heading h2, .page-heading .page-title')
|
||||||
|
const headingText = heading ? (heading.textContent || '').trim() : null
|
||||||
|
const pags = [...q('.el-pagination')].map((p) => {
|
||||||
|
const r = p.getBoundingClientRect()
|
||||||
|
return { y: Math.round(r.top), x: Math.round(r.left), w: Math.round(r.width) }
|
||||||
|
})
|
||||||
|
const btns = [...q('.el-button')].map((b) => {
|
||||||
|
const t = (b.textContent || '').trim().slice(0, 8)
|
||||||
|
return { t, link: b.classList.contains('is-link'), text: b.classList.contains('is-text'), primary: b.classList.contains('el-button--primary'), danger: b.classList.contains('el-button--danger') }
|
||||||
|
})
|
||||||
|
const ths = [...q('.el-table__header th')].map((x) => (x.textContent || '').trim())
|
||||||
|
const empty = q('.el-empty, .empty-tip, .empty').length
|
||||||
|
const emptyText = text(document.querySelector('.el-empty, .empty-tip')) || null
|
||||||
|
const indigoEls = [...q('body *')].filter((el) => {
|
||||||
|
const b = cs(el, 'backgroundColor')
|
||||||
|
const c = cs(el, 'color')
|
||||||
|
return /rgb\(99,\s*102,\s*241\)|rgb\(238,\s*240,\s*254\)|#6366f1|#eef0fe/i.test(`${b} ${c}`)
|
||||||
|
}).slice(0, 4).map((el) => ({ tag: el.tagName, cls: (el.className && String(el.className).slice(0, 60)) || '', bg: cs(el, 'backgroundColor'), color: cs(el, 'color') }))
|
||||||
|
const cards = q('.el-card').length
|
||||||
|
const toolbars = q('.table-toolbar, .list-toolbar').length
|
||||||
|
const pageHeadingDesc = text(document.querySelector('.page-heading p'))
|
||||||
|
return {
|
||||||
|
heading: headingText, desc: (pageHeadingDesc || '').slice(0, 80),
|
||||||
|
primary: root.getPropertyValue('--el-color-primary').trim(),
|
||||||
|
bg: root.getPropertyValue('--el-bg-color').trim() || cs(document.body, 'backgroundColor'),
|
||||||
|
paginations: pags, cards, toolbars,
|
||||||
|
ths: ths.slice(0, 14), empty, emptyText,
|
||||||
|
buttonShape: {
|
||||||
|
total: btns.length,
|
||||||
|
link: btns.filter((b) => b.link).length,
|
||||||
|
textPlain: btns.filter((b) => b.text && !b.link).length,
|
||||||
|
primarySolid: btns.filter((b) => b.primary && !b.text && !b.link).length,
|
||||||
|
danger: btns.filter((b) => b.danger).length,
|
||||||
|
sample: btns.slice(0, 12),
|
||||||
|
},
|
||||||
|
indigoEls,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
out[name] = { path, ...fact }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(out, null, 2))
|
||||||
|
await browser.close()
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// round2 现状截图基线:本地真实栈(5174 -> 18080 Java),登录后逐页截图 + 收集 pageerror/console error。
|
||||||
|
// 用法:cd admin-frontend-vue && node scripts/round2-shots.mjs
|
||||||
|
import { chromium } from '@playwright/test'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
|
|
||||||
|
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
|
||||||
|
const OUT = 'test-results/round2-baseline'
|
||||||
|
mkdirSync(OUT, { recursive: true })
|
||||||
|
|
||||||
|
const PAGES = [
|
||||||
|
['account-users', 'account/users'],
|
||||||
|
['account-menus', 'account/menus'],
|
||||||
|
['account-groups', 'account/groups'],
|
||||||
|
['asin-registry', 'asin-center/registry'],
|
||||||
|
['asin-invalid', 'asin-center/invalid'],
|
||||||
|
['asin-query', 'asin-center/query'],
|
||||||
|
['asin-categories', 'asin-center/categories'],
|
||||||
|
['asin-skip-price', 'asin-center/skip-price'],
|
||||||
|
['shop-keys', 'shop-center/keys'],
|
||||||
|
['shop-shops', 'shop-center/shops'],
|
||||||
|
['shop-data-tasks', 'shop-center/data-tasks'],
|
||||||
|
['records-history', 'records/history'],
|
||||||
|
['records-software-version', 'records/software-version'],
|
||||||
|
['records-digital-human-version', 'records/digital-human-version'],
|
||||||
|
['records-image-video-tasks', 'records/image-video-tasks'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
|
||||||
|
const page = await context.newPage()
|
||||||
|
|
||||||
|
const problems = new Map() // page -> string[]
|
||||||
|
page.on('pageerror', (e) => {
|
||||||
|
const key = page.url()
|
||||||
|
const list = problems.get(key) || []
|
||||||
|
list.push(`pageerror: ${e.message}`)
|
||||||
|
problems.set(key, list)
|
||||||
|
})
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (m.type() === 'error') {
|
||||||
|
const key = page.url()
|
||||||
|
const list = problems.get(key) || []
|
||||||
|
list.push(`console.error: ${m.text().slice(0, 300)}`)
|
||||||
|
problems.set(key, list)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 登录(真实 UI 表单)
|
||||||
|
async function ensureLoggedIn() {
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
const loginVisible = await page.locator('#loginUsername').isVisible().catch(() => false)
|
||||||
|
if (loginVisible) {
|
||||||
|
await page.locator('#loginUsername').fill('admin')
|
||||||
|
await page.locator('#loginPassword').fill('admin123')
|
||||||
|
await page.locator('.btn-login').click()
|
||||||
|
// 等离开登录页(进入后台)
|
||||||
|
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureLoggedIn()
|
||||||
|
console.log('logged-in url:', page.url())
|
||||||
|
|
||||||
|
const report = []
|
||||||
|
for (const [name, path] of PAGES) {
|
||||||
|
const url = `${BASE}/${path}`
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||||
|
// 等页头或内容出现(不同页标题不同,抓第一个标题/内容容器)
|
||||||
|
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {})
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
await page.screenshot({ path: `${OUT}/${name}.jpg`, type: 'jpeg', quality: 82 })
|
||||||
|
report.push(`ok ${name} ${url}`)
|
||||||
|
} catch (e) {
|
||||||
|
report.push(`FAIL ${name} ${url} :: ${String(e).slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== screenshots ===')
|
||||||
|
console.log(report.join('\n'))
|
||||||
|
|
||||||
|
console.log('\n=== page errors (运行时异常,供排查) ===')
|
||||||
|
let anyError = false
|
||||||
|
for (const [url, list] of problems) {
|
||||||
|
const clean = url.replace(BASE, '')
|
||||||
|
if (list.length) {
|
||||||
|
anyError = true
|
||||||
|
console.log(`-- ${clean}`)
|
||||||
|
for (const l of [...new Set(list)]) console.log(' ', l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyError) console.log('(无 pageerror/console.error)')
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// round2 单批验证:guides 渲染 + 类目色点 token + 行内按钮实心(本地真实栈)。
|
||||||
|
import { chromium } from '@playwright/test'
|
||||||
|
const BASE = 'http://localhost:5174/admin-vue'
|
||||||
|
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||||
|
await page.locator('#loginUsername').fill('admin')
|
||||||
|
await page.locator('#loginPassword').fill('admin123')
|
||||||
|
await page.locator('.btn-login').click()
|
||||||
|
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
}
|
||||||
|
async function open(path) {
|
||||||
|
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
|
||||||
|
await page.waitForLoadState('networkidle').catch(() => {})
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
}
|
||||||
|
|
||||||
|
await open('account/users')
|
||||||
|
const guide = await page.locator('.operation-guide').count()
|
||||||
|
const guideText = guide ? (await page.locator('.og-text').textContent())?.trim() : null
|
||||||
|
console.log('users guide visible:', guide > 0, '| text:', guideText)
|
||||||
|
|
||||||
|
await open('asin-center/categories')
|
||||||
|
const token = await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.tree-node-mark')
|
||||||
|
if (!el) return null
|
||||||
|
const cs = getComputedStyle(el)
|
||||||
|
return { color: cs.color, bg: cs.backgroundColor }
|
||||||
|
})
|
||||||
|
console.log('categories tree-node-mark:', JSON.stringify(token))
|
||||||
|
|
||||||
|
await open('asin-center/registry')
|
||||||
|
const solid = await page.evaluate(() => {
|
||||||
|
const btns = [...document.querySelectorAll('.el-table .el-button--small')]
|
||||||
|
const linkOrText = btns.filter((b) => b.classList.contains('is-link') || b.classList.contains('is-text')).length
|
||||||
|
const primary = btns.filter((b) => b.classList.contains('el-button--primary')).length
|
||||||
|
return { smallSolid: btns.length, linkOrText, primary }
|
||||||
|
})
|
||||||
|
console.log('registry row buttons:', JSON.stringify(solid))
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
|
/** 日志文件行(桌面客户端 / 麦象采集机上报)。 */
|
||||||
|
export interface DeviceLogFileRow {
|
||||||
|
id: number
|
||||||
|
source: string
|
||||||
|
deviceId: string
|
||||||
|
deviceName: string | null
|
||||||
|
username: string | null
|
||||||
|
uid: number | null
|
||||||
|
fileName: string
|
||||||
|
logDate: string
|
||||||
|
uploadedBytes: number
|
||||||
|
partCount: number
|
||||||
|
lastUploadAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogPage {
|
||||||
|
items: DeviceLogFileRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
/** 云端日志保留天数(页面提示用)。 */
|
||||||
|
retentionDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogContent {
|
||||||
|
fileId: number
|
||||||
|
fileName: string
|
||||||
|
content: string
|
||||||
|
totalBytes: number
|
||||||
|
shownBytes: number
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogDevice {
|
||||||
|
source: string
|
||||||
|
deviceId: string
|
||||||
|
deviceName: string | null
|
||||||
|
lastUploadAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogOverride {
|
||||||
|
id: number
|
||||||
|
source: string
|
||||||
|
deviceId: string
|
||||||
|
deviceName: string | null
|
||||||
|
mode: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogConfigData {
|
||||||
|
globalMode: string
|
||||||
|
overrides: DeviceLogOverride[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogQuery {
|
||||||
|
source?: string
|
||||||
|
keyword?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询日志文件列表:GET /api/admin/device-logs/files */
|
||||||
|
export async function fetchDeviceLogFiles(params: DeviceLogQuery): Promise<DeviceLogPage> {
|
||||||
|
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||||
|
if (params.source) query.source = params.source
|
||||||
|
if (params.keyword) query.keyword = params.keyword
|
||||||
|
if (params.startDate) query.startDate = params.startDate
|
||||||
|
if (params.endDate) query.endDate = params.endDate
|
||||||
|
const { data } = await http.get('/api/admin/device-logs/files', { params: query })
|
||||||
|
return unwrap<DeviceLogPage>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看日志尾部内容:GET /api/admin/device-logs/content */
|
||||||
|
export async function fetchDeviceLogContent(fileId: number, maxBytes?: number): Promise<DeviceLogContent> {
|
||||||
|
const query: Record<string, number> = { fileId }
|
||||||
|
if (maxBytes) query.maxBytes = maxBytes
|
||||||
|
const { data } = await http.get('/api/admin/device-logs/content', { params: query })
|
||||||
|
return unwrap<DeviceLogContent>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整日志下载地址(同域 cookie 鉴权,直接给 a[href] 或 window.open 用)。 */
|
||||||
|
export function deviceLogDownloadUrl(fileId: number): string {
|
||||||
|
return `/api/admin/device-logs/download?fileId=${fileId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除日志文件(片段与元数据,不可恢复):DELETE /api/admin/device-logs/{id} */
|
||||||
|
export async function deleteDeviceLogFile(id: number): Promise<void> {
|
||||||
|
const { data } = await http.delete(`/api/admin/device-logs/${id}`)
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采集配置(全局默认 + 终端覆盖):GET /api/admin/device-logs/config */
|
||||||
|
export async function fetchDeviceLogConfig(keyword?: string): Promise<DeviceLogConfigData> {
|
||||||
|
const { data } = await http.get('/api/admin/device-logs/config', {
|
||||||
|
params: keyword ? { keyword } : undefined,
|
||||||
|
})
|
||||||
|
return unwrap<DeviceLogConfigData>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近上报过的终端(覆盖选择用):GET /api/admin/device-logs/devices */
|
||||||
|
export async function fetchDeviceLogDevices(): Promise<DeviceLogDevice[]> {
|
||||||
|
const { data } = await http.get('/api/admin/device-logs/devices')
|
||||||
|
return unwrap<DeviceLogDevice[]>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置全局采集模式:PUT /api/admin/device-logs/config/global */
|
||||||
|
export async function updateDeviceLogGlobalMode(mode: string): Promise<void> {
|
||||||
|
const { data } = await http.put('/api/admin/device-logs/config/global', undefined, { params: { mode } })
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/更新终端覆盖:PUT /api/admin/device-logs/config/device */
|
||||||
|
export async function updateDeviceLogOverride(
|
||||||
|
source: string,
|
||||||
|
deviceId: string,
|
||||||
|
deviceName: string | null,
|
||||||
|
mode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { data } = await http.put('/api/admin/device-logs/config/device', undefined, {
|
||||||
|
params: { source, deviceId, deviceName: deviceName || undefined, mode },
|
||||||
|
})
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除终端覆盖(回落到全局默认):DELETE /api/admin/device-logs/config/device/{id} */
|
||||||
|
export async function deleteDeviceLogOverride(id: number): Promise<void> {
|
||||||
|
const { data } = await http.delete(`/api/admin/device-logs/config/device/${id}`)
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
@@ -46,6 +46,20 @@ export function isUnauthorized(payload: unknown): boolean {
|
|||||||
return [record.status, record.statusCode, record.code].some((v) => v === 401)
|
return [record.status, record.statusCode, record.code].some((v) => v === 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 负载/状态码是否 403(已登录但无后台权限)。 */
|
||||||
|
export function isForbidden(payload: unknown): boolean {
|
||||||
|
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
|
||||||
|
if (!record || typeof record !== 'object') return false
|
||||||
|
return [record.status, record.statusCode, record.code].some((v) => v === 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */
|
||||||
|
export function isKicked(payload: unknown): boolean {
|
||||||
|
const record = payload as { code?: unknown } | null
|
||||||
|
if (!record || typeof record !== 'object') return false
|
||||||
|
return record.code === 4011
|
||||||
|
}
|
||||||
|
|
||||||
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
|
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
|
||||||
export function requestErrorMessage(error: unknown): string {
|
export function requestErrorMessage(error: unknown): string {
|
||||||
const response = (error as { response?: { data?: unknown; status?: number } })?.response
|
const response = (error as { response?: { data?: unknown; status?: number } })?.response
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { isUnauthorized, loginRedirectTarget, shouldRedirectUnauthorized } from './envelope'
|
import {
|
||||||
|
isForbidden,
|
||||||
|
isKicked,
|
||||||
|
isLoginLocation,
|
||||||
|
isUnauthorized,
|
||||||
|
loginRedirectTarget,
|
||||||
|
shouldRedirectUnauthorized,
|
||||||
|
} from './envelope'
|
||||||
|
|
||||||
export { unwrap } from './envelope'
|
export { unwrap } from './envelope'
|
||||||
|
|
||||||
@@ -17,14 +24,34 @@ function redirectToLogin(requestUrl?: string): void {
|
|||||||
window.location.assign('/admin-vue/login?redirect=' + target)
|
window.location.assign('/admin-vue/login?redirect=' + target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单设备登录:被新设备顶下线,整页回登录页(整页 reload 顺带重置会话 store,避免守卫弹回)。 */
|
||||||
|
function redirectToLoginKicked(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (isLoginLocation(window.location.pathname)) return
|
||||||
|
window.location.assign('/admin-vue/login?kicked=1')
|
||||||
|
}
|
||||||
|
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理。
|
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理;
|
||||||
if (isUnauthorized(response.data)) redirectToLogin(response.config?.url)
|
// 403(已登录但无后台权限,如用工具前端账号 token 访问后台)与 401 同样跳登录页。
|
||||||
|
// 4011(账号已在其他设备登录)单独提示,不按普通 401 处理。
|
||||||
|
if (isKicked(response.data)) {
|
||||||
|
redirectToLoginKicked()
|
||||||
|
} else if (isUnauthorized(response.data) || isForbidden(response.data)) {
|
||||||
|
redirectToLogin(response.config?.url)
|
||||||
|
}
|
||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error?.response?.status === 401 || isUnauthorized(error?.response?.data)) {
|
if (isKicked(error?.response?.data)) {
|
||||||
|
redirectToLoginKicked()
|
||||||
|
} else if (
|
||||||
|
error?.response?.status === 401 ||
|
||||||
|
error?.response?.status === 403 ||
|
||||||
|
isUnauthorized(error?.response?.data) ||
|
||||||
|
isForbidden(error?.response?.data)
|
||||||
|
) {
|
||||||
redirectToLogin(error?.config?.url)
|
redirectToLogin(error?.config?.url)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
|
/** 单条通知(后台铃铛)。 */
|
||||||
|
export interface AdminNotificationItem {
|
||||||
|
id: number
|
||||||
|
scene: string
|
||||||
|
level: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
read: boolean
|
||||||
|
readAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNotificationPage {
|
||||||
|
items: AdminNotificationItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNotificationSummary {
|
||||||
|
unreadCount: number
|
||||||
|
latestId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 铃铛轮询:未读数 + 最新通知 id。 */
|
||||||
|
export async function fetchNotificationSummary(): Promise<AdminNotificationSummary> {
|
||||||
|
const { data } = await http.get('/api/admin/notifications/summary')
|
||||||
|
return unwrap<AdminNotificationSummary>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */
|
||||||
|
export interface AdminNotificationListParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
onlyUnread?: boolean
|
||||||
|
keyword?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotificationList(
|
||||||
|
params: AdminNotificationListParams = {},
|
||||||
|
): Promise<AdminNotificationPage> {
|
||||||
|
const keyword = (params.keyword ?? '').trim()
|
||||||
|
const { data } = await http.get('/api/admin/notifications', {
|
||||||
|
params: {
|
||||||
|
page: params.page ?? 1,
|
||||||
|
pageSize: params.pageSize ?? 20,
|
||||||
|
onlyUnread: params.onlyUnread ? 'true' : 'false',
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
startDate: params.startDate || undefined,
|
||||||
|
endDate: params.endDate || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return unwrap<AdminNotificationPage>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(id: number): Promise<boolean> {
|
||||||
|
const { data } = await http.post(`/api/admin/notifications/${id}/read`)
|
||||||
|
return unwrap<boolean>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<number> {
|
||||||
|
const { data } = await http.post('/api/admin/notifications/read-all')
|
||||||
|
return unwrap<number>(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
|
/** 单用户一行:三类密钥调用次数 + 合计(口径=真实对外请求次数)。 */
|
||||||
|
export interface AdminUserSecretUsageRow {
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
groups: string[]
|
||||||
|
similarAsinCount: number
|
||||||
|
appearancePatentCount: number
|
||||||
|
proxyCount: number
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserSecretUsagePage {
|
||||||
|
items: AdminUserSecretUsageRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
/** 所选范围内(不分页)三类总次数。 */
|
||||||
|
totalCalls: number
|
||||||
|
similarAsinCalls: number
|
||||||
|
appearancePatentCalls: number
|
||||||
|
proxyCalls: number
|
||||||
|
groupOptions?: Array<{ id: number; groupName: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSecretUsageQuery {
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
keyword?: string
|
||||||
|
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
|
||||||
|
groupId?: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询密钥用量:GET /api/admin/user-secret-usage(group_id 必须 snake_case)。 */
|
||||||
|
export async function fetchUserSecretUsage(params: UserSecretUsageQuery): Promise<AdminUserSecretUsagePage> {
|
||||||
|
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||||
|
if (params.startDate) query.startDate = params.startDate
|
||||||
|
if (params.endDate) query.endDate = params.endDate
|
||||||
|
if (params.keyword) query.keyword = params.keyword
|
||||||
|
if (params.groupId) query.group_id = params.groupId
|
||||||
|
const { data } = await http.get('/api/admin/user-secret-usage', { params: query })
|
||||||
|
return unwrap<AdminUserSecretUsagePage>(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
|
/** 单模块状态(脱敏值 + 连通性)。 */
|
||||||
|
export interface AdminUserSecretModule {
|
||||||
|
moduleKey: string
|
||||||
|
moduleLabel: string
|
||||||
|
masked: string
|
||||||
|
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
|
||||||
|
full: string
|
||||||
|
exists: boolean
|
||||||
|
checkStatus: string
|
||||||
|
checkCode: string
|
||||||
|
checkMessage: string
|
||||||
|
checkLatencyMs: number | null
|
||||||
|
checkedAt: string | null
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行一用户:三个字段列 + 行级状态(三类都检测通过才算 passed)。 */
|
||||||
|
export interface AdminUserSecretRow {
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
/** 所属数据权限分组名(可能多个)。 */
|
||||||
|
groups: string[]
|
||||||
|
/** 所属主管(创建人)用户名;无分组时的兜底展示。 */
|
||||||
|
leaderUsername: string
|
||||||
|
similarAsin: AdminUserSecretModule
|
||||||
|
appearancePatent: AdminUserSecretModule
|
||||||
|
proxy: AdminUserSecretModule
|
||||||
|
status: string
|
||||||
|
statusMessage: string
|
||||||
|
/** 首次配置时间(三模块中最早);从未配置为 null。 */
|
||||||
|
createdAt: string | null
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserSecretPage {
|
||||||
|
items: AdminUserSecretRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
/** 分组筛选项(超管=全部;主管=自己带的分组)。 */
|
||||||
|
groupOptions?: GroupOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupOption {
|
||||||
|
id: number
|
||||||
|
groupName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSecretQuery {
|
||||||
|
keyword?: string
|
||||||
|
checkStatus?: string
|
||||||
|
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
|
||||||
|
groupId?: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets(筛选参数必须 snake_case,camel 会被后端静默忽略)。 */
|
||||||
|
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
||||||
|
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||||
|
if (params.keyword) query.keyword = params.keyword
|
||||||
|
if (params.checkStatus) query.checkStatus = params.checkStatus
|
||||||
|
if (params.groupId) query.group_id = params.groupId
|
||||||
|
const { data } = await http.get('/api/admin/user-secrets', { params: query })
|
||||||
|
return unwrap<AdminUserSecretPage>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check
|
||||||
|
* 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */
|
||||||
|
export async function checkUserSecret(userId: number) {
|
||||||
|
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`, undefined, { timeout: 180_000 })
|
||||||
|
return unwrap<
|
||||||
|
Array<{
|
||||||
|
moduleKey: string
|
||||||
|
checkStatus: string
|
||||||
|
checkCode: string
|
||||||
|
checkMessage: string
|
||||||
|
checkLatencyMs: number | null
|
||||||
|
checkedAt: string | null
|
||||||
|
viaProxy: boolean
|
||||||
|
}>
|
||||||
|
>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空该用户全部密钥与代理配置:DELETE /api/admin/user-secrets/{userId} */
|
||||||
|
export async function deleteUserSecret(userId: number): Promise<void> {
|
||||||
|
const { data } = await http.delete(`/api/admin/user-secrets/${userId}`)
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export interface UserListParams {
|
|||||||
keyword?: string
|
keyword?: string
|
||||||
createdById?: number | null
|
createdById?: number | null
|
||||||
adminId?: number | null
|
adminId?: number | null
|
||||||
|
/** 角色过滤(''=全部)。 */
|
||||||
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
|
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
|
||||||
@@ -23,6 +25,7 @@ export interface UserListQuery {
|
|||||||
search?: string
|
search?: string
|
||||||
created_by_id?: number
|
created_by_id?: number
|
||||||
admin_id?: number
|
admin_id?: number
|
||||||
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
|
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
|
||||||
@@ -58,6 +61,7 @@ export function normalizeUserPageParams(raw: Partial<UserListParams>): UserListP
|
|||||||
keyword,
|
keyword,
|
||||||
createdById: typeof raw.createdById === 'number' ? raw.createdById : null,
|
createdById: typeof raw.createdById === 'number' ? raw.createdById : null,
|
||||||
adminId: typeof raw.adminId === 'number' ? raw.adminId : null,
|
adminId: typeof raw.adminId === 'number' ? raw.adminId : null,
|
||||||
|
role: typeof raw.role === 'string' ? raw.role.trim() || undefined : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +71,7 @@ export function toUserListQuery(params: UserListParams): UserListQuery {
|
|||||||
if (params.keyword) query.username = params.keyword
|
if (params.keyword) query.username = params.keyword
|
||||||
if (params.createdById != null) query.created_by_id = params.createdById
|
if (params.createdById != null) query.created_by_id = params.createdById
|
||||||
if (params.adminId != null) query.admin_id = params.adminId
|
if (params.adminId != null) query.admin_id = params.adminId
|
||||||
|
if (params.role) query.role = params.role
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function toAdminUserItem(raw: unknown): AdminUser | null {
|
|||||||
if (createdById !== null) item.createdById = createdById
|
if (createdById !== null) item.createdById = createdById
|
||||||
const createdAt = text(r.created_at)
|
const createdAt = text(r.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(r.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
const creator = text(r.creator_username)
|
const creator = text(r.creator_username)
|
||||||
if (creator) item.creatorUsername = creator
|
if (creator) item.creatorUsername = creator
|
||||||
const abbr = text(r.pinyin_abbr)
|
const abbr = text(r.pinyin_abbr)
|
||||||
|
|||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="admin-notification-bell">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bell-trigger"
|
||||||
|
:class="{ 'bell-trigger--active': panelOpen }"
|
||||||
|
:title="unreadCount > 0 ? `通知(${unreadCount} 条未读)` : '通知'"
|
||||||
|
aria-label="通知"
|
||||||
|
@click="togglePanel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="bell-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</svg>
|
||||||
|
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 面板挂到 body 上(fixed 定位):顶栏内绝对定位时会被列表页 el-select 等页面内容压住,提 z-index 无效 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="panelOpen"
|
||||||
|
ref="panelRef"
|
||||||
|
class="bell-panel"
|
||||||
|
:style="panelStyle"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="通知列表"
|
||||||
|
>
|
||||||
|
<div class="bell-panel-head">
|
||||||
|
<span class="bell-panel-title">通知</span>
|
||||||
|
<button
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
type="button"
|
||||||
|
class="bell-read-all"
|
||||||
|
:disabled="markingAll"
|
||||||
|
@click="readAll"
|
||||||
|
>
|
||||||
|
{{ markingAll ? '处理中...' : '全部已读' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bell-search">
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
type="text"
|
||||||
|
class="bell-search-input"
|
||||||
|
placeholder="搜索标题或内容"
|
||||||
|
aria-label="搜索通知"
|
||||||
|
@input="onKeywordInput"
|
||||||
|
/>
|
||||||
|
<div class="bell-search-days">
|
||||||
|
<el-date-picker
|
||||||
|
:model-value="dateRangeValue"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
range-separator="至"
|
||||||
|
size="small"
|
||||||
|
class="bell-date-picker"
|
||||||
|
popper-class="bell-date-popper"
|
||||||
|
:clearable="true"
|
||||||
|
@update:model-value="onDateRangeChange"
|
||||||
|
@change="reload"
|
||||||
|
/>
|
||||||
|
<button v-if="hasFilter" type="button" class="bell-search-reset" @click="resetFilters">
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
|
||||||
|
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
|
||||||
|
<div v-else-if="!items.length" class="bell-empty">{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}</div>
|
||||||
|
<ul v-else class="bell-list">
|
||||||
|
<template v-for="group in groupedItems" :key="group.day || 'unknown'">
|
||||||
|
<li class="bell-day-head">{{ group.label }}</li>
|
||||||
|
<li
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="bell-item"
|
||||||
|
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||||
|
@click="onItemClick(item)"
|
||||||
|
>
|
||||||
|
<div class="bell-item-head">
|
||||||
|
<span class="bell-item-title">{{ item.title }}</span>
|
||||||
|
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bell-item-content">{{ item.content }}</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="bell-panel-foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bell-page-btn"
|
||||||
|
:disabled="page <= 1 || loading"
|
||||||
|
@click="goPage(page - 1)"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span class="bell-page-info">{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bell-page-btn"
|
||||||
|
:disabled="page >= totalPages || loading"
|
||||||
|
@click="goPage(page + 1)"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<span class="bell-page-total">共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchNotificationList,
|
||||||
|
fetchNotificationSummary,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead,
|
||||||
|
type AdminNotificationItem,
|
||||||
|
} from '@/api/notifications'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
import {
|
||||||
|
NOTIFICATION_POLL_INTERVAL_MS,
|
||||||
|
formatNotificationTime,
|
||||||
|
formatUnreadBadge,
|
||||||
|
groupNotificationsByDay,
|
||||||
|
hasNewNotification,
|
||||||
|
readLastNotifiedId,
|
||||||
|
writeLastNotifiedId,
|
||||||
|
} from '@/layout/notification-bell-model'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
const panelStyle = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const items = ref<AdminNotificationItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const panelOpen = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const markingAll = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const keyword = ref('')
|
||||||
|
const startDate = ref('')
|
||||||
|
const endDate = ref('')
|
||||||
|
|
||||||
|
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
|
||||||
|
const groupedItems = computed(() => groupNotificationsByDay(items.value))
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||||||
|
const hasFilter = computed(() => Boolean(keyword.value.trim() || startDate.value || endDate.value))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* el-date-picker 的绑定桥:日期区间在内部仍用 startDate/endDate 两个 ref 表示
|
||||||
|
* (hasFilter/normalizedDayRange/resetFilters 都基于它们)。
|
||||||
|
* 此前模板写的是 v-model="dateRange",而 dateRange 从未声明 → vue-tsc 直接报 TS2339 构建失败,
|
||||||
|
* 且日期筛选完全不下发参数。
|
||||||
|
*/
|
||||||
|
const dateRangeValue = computed<[string, string] | null>(() =>
|
||||||
|
startDate.value && endDate.value ? [startDate.value, endDate.value] : null
|
||||||
|
)
|
||||||
|
|
||||||
|
function onDateRangeChange(value: [string, string] | null) {
|
||||||
|
const start = value?.[0]
|
||||||
|
const end = value?.[1]
|
||||||
|
startDate.value = start ? String(start) : ''
|
||||||
|
endDate.value = end ? String(end) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollTimer: number | null = null
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
|
||||||
|
function currentUid(): number | string {
|
||||||
|
return session.user?.id ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 起止日期倒挂时自动交换,避免用户只看到「没有符合条件的通知」不知原因。 */
|
||||||
|
function normalizedDayRange(): { startDate?: string; endDate?: string } {
|
||||||
|
let start = startDate.value
|
||||||
|
let end = endDate.value
|
||||||
|
if (start && end && start > end) {
|
||||||
|
const swapped = start
|
||||||
|
start = end
|
||||||
|
end = swapped
|
||||||
|
}
|
||||||
|
return { startDate: start || undefined, endDate: end || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */
|
||||||
|
async function refreshSummary() {
|
||||||
|
try {
|
||||||
|
const summary = await fetchNotificationSummary()
|
||||||
|
unreadCount.value = Number(summary?.unreadCount ?? 0)
|
||||||
|
const latestId = Number(summary?.latestId ?? 0)
|
||||||
|
if (hasNewNotification(latestId, readLastNotifiedId(currentUid()))) {
|
||||||
|
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
|
||||||
|
writeLastNotifiedId(currentUid(), latestId)
|
||||||
|
console.log('[admin-notification] 检测到新通知 latestId=', latestId)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 通知接口失败静默降级:不显示红点、不打扰使用者
|
||||||
|
console.warn('[admin-notification] 未读数刷新失败(静默降级):', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(targetPage: number) {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchNotificationList({
|
||||||
|
page: targetPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
keyword: keyword.value,
|
||||||
|
...normalizedDayRange(),
|
||||||
|
})
|
||||||
|
const list = Array.isArray(result?.items) ? result.items : []
|
||||||
|
items.value = list
|
||||||
|
total.value = Number(result?.total ?? list.length)
|
||||||
|
unreadCount.value = Number(result?.unreadCount ?? unreadCount.value)
|
||||||
|
page.value = targetPage
|
||||||
|
loadError.value = ''
|
||||||
|
// 无筛选时首页就是最新数据,顺带记录「已提醒过的最大 id」;有筛选时最新 id 不代表真实最新
|
||||||
|
if (targetPage <= 1 && !hasFilter.value && list.length) {
|
||||||
|
const latest = Number(list[0].id)
|
||||||
|
if (latest > 0) {
|
||||||
|
writeLastNotifiedId(currentUid(), Math.max(readLastNotifiedId(currentUid()), latest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 列表加载失败在面板内提示,不弹全局消息打扰用户
|
||||||
|
console.warn('[admin-notification] 通知列表加载失败:', error)
|
||||||
|
loadError.value = error instanceof Error ? error.message : '通知加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(targetPage: number) {
|
||||||
|
const safe = Math.min(Math.max(1, targetPage), totalPages.value)
|
||||||
|
if (safe === page.value || loading.value) return
|
||||||
|
void loadPage(safe)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索/日期变化后回到第一页重新查询。 */
|
||||||
|
function reload() {
|
||||||
|
void loadPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关键字输入防抖 300ms,避免每敲一个字都打一次接口。 */
|
||||||
|
function onKeywordInput() {
|
||||||
|
if (searchTimer != null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(() => {
|
||||||
|
searchTimer = null
|
||||||
|
void loadPage(1)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
keyword.value = ''
|
||||||
|
startDate.value = ''
|
||||||
|
endDate.value = ''
|
||||||
|
void loadPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 面板挂到 body 后用 fixed 定位,位置按触发器实时算(右对齐在铃铛下方)。 */
|
||||||
|
function syncPanelPosition() {
|
||||||
|
const trigger = rootRef.value
|
||||||
|
if (!trigger) return
|
||||||
|
const rect = trigger.getBoundingClientRect()
|
||||||
|
panelStyle.value = {
|
||||||
|
top: `${Math.round(rect.bottom + 10)}px`,
|
||||||
|
right: `${Math.max(8, Math.round(window.innerWidth - rect.right))}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
panelOpen.value = !panelOpen.value
|
||||||
|
if (panelOpen.value) {
|
||||||
|
syncPanelPosition()
|
||||||
|
void loadPage(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onItemClick(item: AdminNotificationItem) {
|
||||||
|
if (item.read) return
|
||||||
|
try {
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
item.read = true
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[admin-notification] 标记已读失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAll() {
|
||||||
|
if (markingAll.value || unreadCount.value === 0) return
|
||||||
|
markingAll.value = true
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead()
|
||||||
|
for (const item of items.value) {
|
||||||
|
item.read = true
|
||||||
|
}
|
||||||
|
unreadCount.value = 0
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[admin-notification] 全部已读失败:', error)
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
} finally {
|
||||||
|
markingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string | null) {
|
||||||
|
return formatNotificationTime(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击组件外部关闭面板(面板已 Teleport 到 body,需连同面板自身一起判断)。 */
|
||||||
|
function onDocumentMouseDown(event: MouseEvent) {
|
||||||
|
if (!panelOpen.value) return
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof Node)) return
|
||||||
|
if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) return
|
||||||
|
panelOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 窗口尺寸变化时重新对齐面板。 */
|
||||||
|
function onWindowResize() {
|
||||||
|
if (panelOpen.value) {
|
||||||
|
syncPanelPosition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面回到前台时立即刷新一次未读数。 */
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||||
|
void refreshSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', onDocumentMouseDown)
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
window.addEventListener('resize', onWindowResize)
|
||||||
|
void refreshSummary()
|
||||||
|
pollTimer = window.setInterval(() => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void refreshSummary()
|
||||||
|
}, NOTIFICATION_POLL_INTERVAL_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
window.removeEventListener('resize', onWindowResize)
|
||||||
|
if (pollTimer != null) {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
if (searchTimer != null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
searchTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-notification-bell {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-trigger {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--admin-muted, #5b6f83);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-trigger:hover,
|
||||||
|
.bell-trigger--active {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #cbd9e6;
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 0;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d64545;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3200;
|
||||||
|
width: 380px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
max-height: 520px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--admin-border, #d8e3ee);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 48px rgba(39, 67, 94, 0.22);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #edf1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-panel-title {
|
||||||
|
color: var(--admin-text, #24384d);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-read-all {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-read-all:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-empty {
|
||||||
|
padding: 36px 0;
|
||||||
|
color: #8a99a8;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-empty--error {
|
||||||
|
color: #b23c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-list {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-bottom: 1px solid #f0f3f7;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item:hover {
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--unread {
|
||||||
|
background: #f2f6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--unread .bell-item-title::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d64545;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item-title {
|
||||||
|
color: var(--admin-text, #24384d);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--error .bell-item-title {
|
||||||
|
color: #b23c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--warning .bell-item-title {
|
||||||
|
color: #a8793e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--info .bell-item-title {
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #9aa6b3;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item-content {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #edf1f5;
|
||||||
|
background: #fafcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-input,
|
||||||
|
.bell-date-input {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--admin-text, #24384d);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-input:focus,
|
||||||
|
.bell-date-input:focus {
|
||||||
|
border-color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-days {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-date-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-date-sep {
|
||||||
|
color: #8a99a8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-reset {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-reset:hover {
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-day-head {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #f4f7fa;
|
||||||
|
color: #7c8b9a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-panel-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-top: 1px solid #edf1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-btn {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-btn:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-info {
|
||||||
|
color: var(--admin-text, #24384d);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-total {
|
||||||
|
color: #9aa6b3;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 旧版风格分页(像素复刻 admin.html .pagination)+ 每页条数下拉(10/20/50/100)。
|
||||||
|
* props: total / page / pageSize / sizes(默认 10/20/50/100) / showTotal(默认 true) / jump(默认 true)。 */
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
sizes?: number[]
|
||||||
|
showTotal?: boolean
|
||||||
|
jump?: boolean
|
||||||
|
}>(),
|
||||||
|
{ sizes: () => [10, 20, 50, 100], showTotal: true, jump: true },
|
||||||
|
)
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'change', page: number): void
|
||||||
|
(e: 'size-change', size: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const jumpPage = ref('')
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||||
|
|
||||||
|
function go(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value || next === props.page) return
|
||||||
|
emit('change', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goJump() {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) return
|
||||||
|
jumpPage.value = ''
|
||||||
|
go(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSizeChange(event: Event) {
|
||||||
|
const value = Number((event.target as HTMLSelectElement).value)
|
||||||
|
if (Number.isFinite(value) && value > 0) emit('size-change', value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="old-pagination">
|
||||||
|
<span v-if="showTotal" class="page-total">共 {{ total }} 条</span>
|
||||||
|
<select class="page-size-select" :value="pageSize" @change="onSizeChange">
|
||||||
|
<option v-for="size in sizes" :key="size" :value="size">{{ size }}条/页</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" :disabled="page <= 1" @click="go(page - 1)">上一页</button>
|
||||||
|
<span class="page-cur">第 {{ page }} / {{ totalPages }} 页</span>
|
||||||
|
<button type="button" :disabled="page >= totalPages" @click="go(page + 1)">下一页</button>
|
||||||
|
<span v-if="jump" class="page-jump">
|
||||||
|
跳至
|
||||||
|
<input v-model="jumpPage" type="text" inputmode="numeric" @keyup.enter="goJump" />
|
||||||
|
页
|
||||||
|
<button type="button" @click="goJump">跳转</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.old-pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.old-pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.old-pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.old-pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-size-select {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
shouldShowEmptyMenu,
|
shouldShowEmptyMenu,
|
||||||
} from '@/layout/empty-state'
|
} from '@/layout/empty-state'
|
||||||
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||||
|
import NotificationBell from '@/components/NotificationBell.vue'
|
||||||
|
import OperationGuide from '@/layout/OperationGuide.vue'
|
||||||
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
||||||
import {
|
import {
|
||||||
LOGOUT_CONFIRM_CANCEL,
|
LOGOUT_CONFIRM_CANCEL,
|
||||||
@@ -99,6 +101,7 @@ async function signOut() {
|
|||||||
<h1>{{ pageTitle }}</h1>
|
<h1>{{ pageTitle }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-user">
|
<div class="admin-user">
|
||||||
|
<NotificationBell />
|
||||||
<div class="admin-user-meta">
|
<div class="admin-user-meta">
|
||||||
<strong>{{ userVm.username }}</strong>
|
<strong>{{ userVm.username }}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,6 +113,7 @@ async function signOut() {
|
|||||||
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
|
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
|
||||||
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
|
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
|
<OperationGuide />
|
||||||
<GlobalErrorContainer />
|
<GlobalErrorContainer />
|
||||||
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
|
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
|
||||||
<el-empty
|
<el-empty
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 当前页面操作提示条:对齐旧版 admin-interactions.js 的 adminOperationGuide(14 页有独立文案,余用通用 fallback)。
|
||||||
|
* 挂在 AdminLayout 内容区顶部,随 route.meta.menuKey 切换文案;折叠态存 localStorage。 */
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { OPERATION_GUIDE_STORAGE_KEY, operationGuideFor } from '@/layout/operation-guides'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const guide = computed(() => operationGuideFor(route.meta.menuKey))
|
||||||
|
|
||||||
|
function readCollapsed(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(OPERATION_GUIDE_STORAGE_KEY) === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function persistCollapsed(value: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(OPERATION_GUIDE_STORAGE_KEY, value ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// 忽略隐私模式写入失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const collapsed = ref(readCollapsed())
|
||||||
|
function toggle() {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
persistCollapsed(collapsed.value)
|
||||||
|
}
|
||||||
|
watch(collapsed, (v) => persistCollapsed(v))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside v-if="guide" class="operation-guide" :class="{ 'is-collapsed': collapsed }" aria-label="当前页面操作提示">
|
||||||
|
<div class="og-head">
|
||||||
|
<span class="og-title">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v5"></path><path d="M12 8h.01"></path></svg>
|
||||||
|
当前页面操作提示
|
||||||
|
</span>
|
||||||
|
<button type="button" class="og-toggle" :aria-expanded="!collapsed" @click="toggle">
|
||||||
|
{{ collapsed ? '展开提示' : '收起提示' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-show="!collapsed" class="og-body">
|
||||||
|
<p class="og-text">{{ guide.text }}</p>
|
||||||
|
<ol v-if="guide.steps.length" class="og-steps">
|
||||||
|
<li v-for="step in guide.steps" :key="step">{{ step }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.operation-guide {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
margin: 0 0 18px; padding: 12px 16px;
|
||||||
|
border: 1px solid #cddbe6; border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, #fbfdfe, #f4f8fc);
|
||||||
|
box-shadow: 0 10px 26px -24px rgba(39, 67, 94, 0.3);
|
||||||
|
color: var(--admin-text);
|
||||||
|
}
|
||||||
|
.operation-guide.is-collapsed { padding: 8px 16px; }
|
||||||
|
.og-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.og-title {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
font-size: 13px; font-weight: 650; color: var(--admin-primary-strong);
|
||||||
|
}
|
||||||
|
.og-title svg { width: 15px; height: 15px; }
|
||||||
|
.og-toggle {
|
||||||
|
padding: 0; border: 0; background: none; cursor: pointer;
|
||||||
|
font: inherit; font-size: 12px; color: #6b8096;
|
||||||
|
}
|
||||||
|
.og-toggle:hover { color: var(--admin-primary-strong); }
|
||||||
|
.og-body { margin-top: 8px; padding-left: 22px; }
|
||||||
|
.og-text { margin: 0; color: #40586e; font-size: 13px; line-height: 1.7; }
|
||||||
|
.og-steps { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
|
||||||
|
.og-steps li {
|
||||||
|
padding: 2px 10px; border-radius: 999px;
|
||||||
|
background: #e9f1f8; border: 1px solid #d3e0ea;
|
||||||
|
color: #2f4a63; font-size: 12px; line-height: 1.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 后台铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
|
||||||
|
* 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 未读轮询间隔(60 秒)。 */
|
||||||
|
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000
|
||||||
|
|
||||||
|
const NOTIFIED_KEY_PREFIX = 'admin-notification:last-notified-id'
|
||||||
|
|
||||||
|
/** 未读徽标文案:0 或非法值显示空串,>99 显示 99+。 */
|
||||||
|
export function formatUnreadBadge(count: number | null | undefined): string {
|
||||||
|
const value = Number(count ?? 0)
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return value > 99 ? '99+' : String(Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否出现新通知:latestId 大于上次已提醒过的 id。 */
|
||||||
|
export function hasNewNotification(
|
||||||
|
latestId: number | null | undefined,
|
||||||
|
lastNotifiedId: number | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const latest = Number(latestId ?? 0)
|
||||||
|
const notified = Number(lastNotifiedId ?? 0)
|
||||||
|
if (!Number.isFinite(latest) || latest <= 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return latest > (Number.isFinite(notified) && notified > 0 ? notified : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(uid: string | number | null | undefined): string {
|
||||||
|
const normalized = uid === null || uid === undefined || String(uid).trim() === '' ? '0' : String(uid)
|
||||||
|
return `${NOTIFIED_KEY_PREFIX}:${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取当前管理员「已提醒过的最大通知 id」。 */
|
||||||
|
export function readLastNotifiedId(uid: string | number | null | undefined): number {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey(uid))
|
||||||
|
const value = Number(raw ?? 0)
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLastNotifiedId(uid: string | number | null | undefined, id: number): void {
|
||||||
|
try {
|
||||||
|
if (Number.isFinite(id) && id > 0) {
|
||||||
|
window.localStorage.setItem(storageKey(uid), String(Math.floor(id)))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知所属日期键(本地时区 yyyy-MM-dd);时间缺失/非法返回空串。 */
|
||||||
|
export function notificationDayKey(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const pad = (input: number) => String(input).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期组头文案:今天 / 昨天 / 2026年9月11日;空键兜底「未知时间」。 */
|
||||||
|
export function formatDayLabel(day: string, now: Date = new Date()): string {
|
||||||
|
if (!day) {
|
||||||
|
return '未知时间'
|
||||||
|
}
|
||||||
|
if (day === notificationDayKey(now.toISOString())) {
|
||||||
|
return '今天'
|
||||||
|
}
|
||||||
|
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||||
|
if (day === notificationDayKey(yesterday.toISOString())) {
|
||||||
|
return '昨天'
|
||||||
|
}
|
||||||
|
const [year, month, date] = day.split('-').map((part) => Number(part))
|
||||||
|
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(date)) {
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
return `${year}年${month}月${date}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationDayGroup<T> {
|
||||||
|
day: string
|
||||||
|
label: string
|
||||||
|
items: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */
|
||||||
|
export function groupNotificationsByDay<T extends { createdAt: string | null }>(
|
||||||
|
items: T[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): NotificationDayGroup<T>[] {
|
||||||
|
const groups: NotificationDayGroup<T>[] = []
|
||||||
|
const indexByDay = new Map<string, NotificationDayGroup<T>>()
|
||||||
|
for (const item of items ?? []) {
|
||||||
|
const day = notificationDayKey(item.createdAt)
|
||||||
|
let group = indexByDay.get(day)
|
||||||
|
if (!group) {
|
||||||
|
group = { day, label: formatDayLabel(day, now), items: [] }
|
||||||
|
indexByDay.set(day, group)
|
||||||
|
groups.push(group)
|
||||||
|
}
|
||||||
|
group.items.push(item)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */
|
||||||
|
export function formatNotificationTime(
|
||||||
|
value: string | null | undefined,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const pad = (input: number) => String(input).padStart(2, '0')
|
||||||
|
const hourMinute = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
const sameDay =
|
||||||
|
date.getFullYear() === now.getFullYear() &&
|
||||||
|
date.getMonth() === now.getMonth() &&
|
||||||
|
date.getDate() === now.getDate()
|
||||||
|
if (sameDay) {
|
||||||
|
return hourMinute
|
||||||
|
}
|
||||||
|
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||||
|
const isYesterday =
|
||||||
|
date.getFullYear() === yesterday.getFullYear() &&
|
||||||
|
date.getMonth() === yesterday.getMonth() &&
|
||||||
|
date.getDate() === yesterday.getDate()
|
||||||
|
if (isYesterday) {
|
||||||
|
return `昨天 ${hourMinute}`
|
||||||
|
}
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hourMinute}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 每菜单页的操作提示(guides)文案,对齐旧版 backend/static/admin-interactions.js 的 guides 表原文
|
||||||
|
* (除 group-manage / duplicate-check 用通用 fallback)。key = 路由 meta.menuKey。
|
||||||
|
*/
|
||||||
|
export interface OperationGuideData {
|
||||||
|
text: string
|
||||||
|
steps: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OPERATION_GUIDE_FALLBACK: OperationGuideData = {
|
||||||
|
text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。',
|
||||||
|
steps: ['选择筛选条件', '处理记录', '核对反馈'],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 折叠状态 localStorage key(沿用旧版 shufuAdminGuideCollapsed 语义)。 */
|
||||||
|
export const OPERATION_GUIDE_STORAGE_KEY = 'shufuAdminGuideCollapsed'
|
||||||
|
|
||||||
|
export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
|
||||||
|
admin_users: {
|
||||||
|
text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。',
|
||||||
|
steps: ['筛选账号', '编辑权限', '确认保存'],
|
||||||
|
},
|
||||||
|
admin_columns: {
|
||||||
|
text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。',
|
||||||
|
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
|
||||||
|
},
|
||||||
|
admin_group_manage: OPERATION_GUIDE_FALLBACK,
|
||||||
|
admin_user_secrets: {
|
||||||
|
text: '用户密钥按账号绑定,列表只展示脱敏值。可对单条立即检测连通性;清空后该用户需要重新配置密钥。',
|
||||||
|
steps: ['筛选用户或状态', '立即检测连通性', '必要时清空'],
|
||||||
|
},
|
||||||
|
admin_dedupe_total_data: {
|
||||||
|
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
|
||||||
|
steps: ['选择分组', '筛选或导入', '核对并导出'],
|
||||||
|
},
|
||||||
|
admin_invalid_asin_data: {
|
||||||
|
text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。',
|
||||||
|
steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'],
|
||||||
|
},
|
||||||
|
admin_query_asin: {
|
||||||
|
text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。',
|
||||||
|
steps: ['筛选店铺', '打开配置抽屉', '保存或导出'],
|
||||||
|
},
|
||||||
|
admin_product_categories: {
|
||||||
|
text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。',
|
||||||
|
steps: ['搜索类目', '展开层级', '新增或编辑'],
|
||||||
|
},
|
||||||
|
admin_skip_price_asin: {
|
||||||
|
text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。',
|
||||||
|
steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'],
|
||||||
|
},
|
||||||
|
admin_shop_keys: {
|
||||||
|
text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。',
|
||||||
|
steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'],
|
||||||
|
},
|
||||||
|
admin_shop_manage: {
|
||||||
|
text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。',
|
||||||
|
steps: ['选择分组', '维护店铺信息', '筛选核对结果'],
|
||||||
|
},
|
||||||
|
admin_shop_data_crawl_tasks: {
|
||||||
|
text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。',
|
||||||
|
steps: ['筛选任务', '检查状态', '执行批量操作'],
|
||||||
|
},
|
||||||
|
// admin_shop_data_duplicate_check(店铺撞款监控)为例外页:不展示全局提示条,不参与对齐。
|
||||||
|
admin_image_video_tasks: {
|
||||||
|
text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。',
|
||||||
|
steps: ['设置筛选', '查看任务结果', '按需处理任务'],
|
||||||
|
},
|
||||||
|
admin_history: {
|
||||||
|
text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。',
|
||||||
|
steps: ['设置时间范围', '筛选记录', '查看结果预览'],
|
||||||
|
},
|
||||||
|
admin_version: {
|
||||||
|
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
|
||||||
|
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
|
||||||
|
},
|
||||||
|
admin_tutorial: {
|
||||||
|
text: '上传新的教程压缩包后即刻生效:工具台首页「立即下载教程」以下载最新上传的包为准。确认旧包不再需要后再删除。',
|
||||||
|
steps: ['上传教程压缩包', '到工具台核对下载', '清理历史包'],
|
||||||
|
},
|
||||||
|
digital_human_version: {
|
||||||
|
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
|
||||||
|
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
|
||||||
|
},
|
||||||
|
admin_user_secret_usage: {
|
||||||
|
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
|
||||||
|
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部有独立/兜底提示的后台业务菜单 key。 */
|
||||||
|
export const OPERATION_GUIDE_MENU_KEYS = Object.keys(OPERATION_GUIDES)
|
||||||
|
|
||||||
|
/** 返回指定菜单 key 的提示数据;menuKey 不在已知集合时返回 null(登录/404 等页面不展示)。 */
|
||||||
|
export function operationGuideFor(menuKey: unknown): OperationGuideData | null {
|
||||||
|
if (typeof menuKey !== 'string' || !menuKey) return null
|
||||||
|
return OPERATION_GUIDES[menuKey] ?? null
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ async function create(): Promise<void> {
|
|||||||
>
|
>
|
||||||
<el-form label-position="top" @submit.prevent>
|
<el-form label-position="top" @submit.prevent>
|
||||||
<el-form-item label="用户名" :error="errors.username">
|
<el-form-item label="用户名" :error="errors.username">
|
||||||
<el-input v-model="form.username" autocomplete="username" placeholder="用户名(至少 2 个字符)" />
|
<el-input v-model="form.username" autocomplete="username" placeholder="用户名(至少 2 个字符)" @input="errors.username = undefined" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="密码" :error="errors.password">
|
<el-form-item label="密码" :error="errors.password">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -89,10 +89,11 @@ async function create(): Promise<void> {
|
|||||||
show-password
|
show-password
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="密码(至少 6 个字符)"
|
placeholder="密码(至少 6 个字符)"
|
||||||
|
@input="errors.password = undefined"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="operatorSuper" label="角色">
|
<el-form-item v-if="operatorSuper" label="角色">
|
||||||
<el-select v-model="form.role" style="width: 200px">
|
<el-select filterable v-model="form.role" style="width: 200px">
|
||||||
<el-option v-for="opt in ROLE_CHOICES" :key="opt.value" :label="opt.label" :value="opt.value" />
|
<el-option v-for="opt in ROLE_CHOICES" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role:
|
|||||||
const errors = reactive<EditUserFormErrors>({})
|
const errors = reactive<EditUserFormErrors>({})
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const loadingAuth = ref(false)
|
const loadingAuth = ref(false)
|
||||||
|
/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */
|
||||||
|
const authReady = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
@@ -28,6 +30,7 @@ watch(
|
|||||||
form.columnIds = []
|
form.columnIds = []
|
||||||
form.originalRole = base.originalRole
|
form.originalRole = base.originalRole
|
||||||
errors.password = undefined
|
errors.password = undefined
|
||||||
|
authReady.value = false
|
||||||
void refreshAuth()
|
void refreshAuth()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -38,7 +41,10 @@ async function refreshAuth(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const { checkedIds } = await loadUserMenuAuth(props.user.id)
|
const { checkedIds } = await loadUserMenuAuth(props.user.id)
|
||||||
form.columnIds = checkedIds
|
form.columnIds = checkedIds
|
||||||
|
authReady.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。
|
||||||
|
authReady.value = false
|
||||||
showAdminFeedback(actionableErrorText(error), 'error')
|
showAdminFeedback(actionableErrorText(error), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
loadingAuth.value = false
|
loadingAuth.value = false
|
||||||
@@ -50,6 +56,10 @@ function close(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save(): Promise<void> {
|
async function save(): Promise<void> {
|
||||||
|
if (!authReady.value) {
|
||||||
|
showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
const { valid, errors: errs } = validateEditUserForm(form)
|
const { valid, errors: errs } = validateEditUserForm(form)
|
||||||
Object.assign(errors, errs)
|
Object.assign(errors, errs)
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
@@ -87,6 +97,7 @@ async function save(): Promise<void> {
|
|||||||
show-password
|
show-password
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="留空则不修改密码"
|
placeholder="留空则不修改密码"
|
||||||
|
@input="errors.password = undefined"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="operatorSuper" label="角色">
|
<el-form-item v-if="operatorSuper" label="角色">
|
||||||
@@ -102,12 +113,15 @@ async function save(): Promise<void> {
|
|||||||
<div class="auth-tree-box">
|
<div class="auth-tree-box">
|
||||||
<UserMenuAuthTree v-model:checked="form.columnIds" />
|
<UserMenuAuthTree v-model:checked="form.columnIds" />
|
||||||
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
|
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
|
||||||
|
<div v-else-if="!authReady" class="auth-loading auth-loading-error">
|
||||||
|
菜单权限加载失败,保存已禁用;请关闭后重试
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="close">取消</el-button>
|
<el-button @click="close">取消</el-button>
|
||||||
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
|
<el-button type="primary" :loading="busy" :disabled="!authReady" @click="save">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -125,4 +139,7 @@ async function save(): Promise<void> {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--admin-muted);
|
color: var(--admin-muted);
|
||||||
}
|
}
|
||||||
|
.auth-loading-error {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,35 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
/** 分组管理页 · 像素复刻旧版 admin.html panel-group-manage(自绘:顶部数据权限分组摘要 chips + 分组列表表格 + 新建分组;全量无分页同旧版)。
|
||||||
|
* script 逻辑沿用现有 Vue 实现;编辑器保留现有组件。 */
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
import { openAdminConfirm } from '@/components/admin-confirm'
|
import { openAdminConfirm } from '@/components/admin-confirm'
|
||||||
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
|
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
|
||||||
import { shopGroupSummary, type ShopGroupItem } from './shop-group-model'
|
import type { ShopGroupItem } from './shop-group-model'
|
||||||
import GroupEditorDialog from './GroupEditorDialog.vue'
|
import GroupEditorDialog from './GroupEditorDialog.vue'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const session = useAdminSessionStore()
|
const session = useAdminSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<ShopGroupItem[]>([])
|
const rows = ref<ShopGroupItem[]>([])
|
||||||
const editorVisible = ref(false)
|
const editorVisible = ref(false)
|
||||||
const editingGroup = ref<ShopGroupItem | null>(null)
|
const editingGroup = ref<ShopGroupItem | null>(null)
|
||||||
const summary = computed(() => shopGroupSummary(rows.value))
|
|
||||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
const currentUserId = computed(() => session.user?.id ?? null)
|
const currentUserId = computed(() => session.user?.id ?? null)
|
||||||
// 客户端分页:全量拉取后按页切片展示。
|
/** 筛选:分组名称/组长 模糊过滤(用户要求账户域子菜单补筛选条件)。 */
|
||||||
|
const filterKeyword = ref('')
|
||||||
|
const filterLeader = ref('')
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const kw = (filterKeyword.value || '').trim().toLowerCase()
|
||||||
|
const ldr = (filterLeader.value || '').trim().toLowerCase()
|
||||||
|
if (!kw && !ldr) return rows.value
|
||||||
|
return rows.value.filter((g) => {
|
||||||
|
const nameOk = !kw || (g.name || '').toLowerCase().includes(kw)
|
||||||
|
const leaderOk = !ldr || (g.leaderUsername || '').toLowerCase().includes(ldr)
|
||||||
|
return nameOk && leaderOk
|
||||||
|
})
|
||||||
|
})
|
||||||
|
/** 全站分页统一:分组列表客户端分页(10/20/50/100)。 */
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = computed(() => rows.value.length)
|
const pagedGroups = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
function changeGroupPage(p: number) { page.value = p }
|
||||||
|
function changeGroupSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||||
function changePage(p: number) {
|
|
||||||
page.value = p
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeSize(size: number) {
|
|
||||||
pageSize.value = size
|
|
||||||
page.value = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function canEditOf(group: ShopGroupItem): boolean {
|
function canEditOf(group: ShopGroupItem): boolean {
|
||||||
if (isSuperAdmin.value) return true
|
if (isSuperAdmin.value) return true
|
||||||
@@ -74,64 +81,74 @@ onMounted(loadGroups)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="groups-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="groups-head">
|
||||||
<h2>数据权限分组</h2>
|
<h3>分组列表</h3>
|
||||||
<p>管理店铺数据访问分组和组员范围。组长不可更改,组员为普通账号。</p>
|
<button class="btn" type="button" @click="openCreate">新建分组</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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>
|
||||||
</div>
|
</div>
|
||||||
<el-row :gutter="12" style="margin-bottom: 12px">
|
|
||||||
<el-col :span="8">
|
<div class="table-scroll">
|
||||||
<el-card shadow="never">
|
<table>
|
||||||
<div class="stat-block">
|
<thead>
|
||||||
<span class="stat-label">分组总数</span>
|
<tr>
|
||||||
<span class="stat-value">{{ summary.groupCount }}</span>
|
<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>
|
</div>
|
||||||
</el-card>
|
</td>
|
||||||
</el-col>
|
<td>{{ formatDateTime(group.createdAt) }}</td>
|
||||||
<el-col :span="8">
|
<td>{{ formatDateTime(group.updatedAt || group.createdAt) }}</td>
|
||||||
<el-card shadow="never">
|
<td class="ops-cell">
|
||||||
<div class="stat-block">
|
<template v-if="canEditOf(group)">
|
||||||
<span class="stat-label">组员总数</span>
|
<button class="btn btn-sm" type="button" @click="openEdit(group)">编辑</button>
|
||||||
<span class="stat-value">{{ summary.memberTotal }}</span>
|
<button class="btn btn-sm btn-danger" type="button" @click="removeGroup(group)">删除</button>
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
<el-card shadow="never">
|
|
||||||
<div class="table-toolbar">
|
|
||||||
<el-button type="primary" @click="openCreate">新建分组</el-button>
|
|
||||||
</div>
|
|
||||||
<el-table v-loading="loading" :data="pagedRows" stripe>
|
|
||||||
<el-table-column prop="name" label="分组名称" min-width="180" />
|
|
||||||
<el-table-column prop="leaderUsername" label="组长" min-width="170" />
|
|
||||||
<el-table-column prop="memberCount" label="组员数量" min-width="100" />
|
|
||||||
<el-table-column label="组员" min-width="240">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<template v-if="row.memberUsernames.length">
|
|
||||||
<el-tag v-for="name in row.memberUsernames" :key="name" size="small" class="member-tag">{{ name }}</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="member-none">无</span>
|
<span v-else class="member-none">-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
</tr>
|
||||||
</el-table-column>
|
<tr v-else>
|
||||||
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
|
<td colspan="8" class="empty-tip">{{ filterKeyword || filterLeader ? '暂无匹配分组' : '暂无分组' }}</td>
|
||||||
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template>
|
</tr>
|
||||||
</el-table-column>
|
</tbody>
|
||||||
<el-table-column label="操作" min-width="140" fixed="right">
|
</table>
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button link type="primary" :disabled="!canEditOf(row)" @click="openEdit(row)">编辑</el-button>
|
|
||||||
<el-button link type="danger" :disabled="!canEditOf(row)" @click="removeGroup(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<div class="table-footer">
|
|
||||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="changePage" @size-change="changeSize" />
|
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
<OldPagination v-if="filteredRows.length > 0" :total="filteredRows.length" :page="page" :page-size="pageSize" @change="changeGroupPage" @size-change="changeGroupSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<GroupEditorDialog
|
<GroupEditorDialog
|
||||||
v-model="editorVisible"
|
v-model="editorVisible"
|
||||||
:group="editingGroup"
|
:group="editingGroup"
|
||||||
@@ -142,29 +159,203 @@ onMounted(loadGroups)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.stat-block {
|
/* 像素复刻旧版 admin.html panel-group-manage(蓝白末层)。 */
|
||||||
|
.groups-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.stat-label {
|
.form-box,
|
||||||
color: var(--el-text-color-secondary);
|
.panel-box {
|
||||||
font-size: 13px;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
}
|
}
|
||||||
.stat-value {
|
h3 {
|
||||||
font-size: 24px;
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.groups-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.groups-filter-row .form-group {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
.member-tag {
|
.form-group input,
|
||||||
margin: 2px 4px 2px 0;
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
}
|
}
|
||||||
.member-more {
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.groups-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.table-scroll td {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.shop-group-members {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 96px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
.shop-group-member-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 150px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f0f8;
|
||||||
|
color: #2f5d8b;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--admin-muted);
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.member-none {
|
.member-none,
|
||||||
color: var(--admin-muted);
|
.member-more {
|
||||||
font-size: 13px;
|
color: #8293a5;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
/** 菜单权限配置页 · 像素复刻旧版 admin.html panel-columns(自绘:面板头+新增菜单、拖拽手柄 ⠿、树形缩进+└、旧式表格、全量无分页同旧版)。
|
||||||
|
* script 逻辑沿用现有 Vue 实现(拖拽分组/落点/重排保存、弹窗表单)。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
|
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
|
||||||
import {
|
import {
|
||||||
MENU_TYPE_OPTIONS,
|
MENU_TYPE_OPTIONS,
|
||||||
@@ -20,22 +23,43 @@ import {
|
|||||||
type MenuManageNode,
|
type MenuManageNode,
|
||||||
} from './menu-manage-model'
|
} from './menu-manage-model'
|
||||||
|
|
||||||
|
/** 树行视图:展平 + 深度(缩进与 └ 前缀)。 */
|
||||||
|
interface MenuRow extends MenuManageNode {
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<MenuManageNode[]>([])
|
const rows = ref<MenuManageNode[]>([])
|
||||||
// 客户端分页:列表展示统一带分页组件。
|
/** 筛选:菜单名称/类型(用户要求账户域子菜单补筛选条件)。 */
|
||||||
|
const filterName = ref('')
|
||||||
|
const filterType = ref('')
|
||||||
|
const filteredMenuRows = computed<MenuRow[]>(() => {
|
||||||
|
const kw = (filterName.value || '').trim().toLowerCase()
|
||||||
|
const type = filterType.value || ''
|
||||||
|
return menuRows.value.filter((row) => {
|
||||||
|
const nameOk = !kw || (row.name || '').toLowerCase().includes(kw)
|
||||||
|
const typeOk = !type || row.menuType === type
|
||||||
|
return nameOk && typeOk
|
||||||
|
})
|
||||||
|
})
|
||||||
|
/** 全站分页统一:菜单树展平行客户端分页(10/20/50/100)。 */
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const total = computed(() => rows.value.length)
|
const pagedMenuRows = computed(() => filteredMenuRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
function changeMenuPage(p: number) { page.value = p }
|
||||||
|
function changeMenuSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||||
function changePage(p: number) {
|
/** 展平视图:前序 + depth。 */
|
||||||
page.value = p
|
const menuRows = computed<MenuRow[]>(() => {
|
||||||
}
|
const out: MenuRow[] = []
|
||||||
|
const walk = (list: MenuManageNode[], depth: number) => {
|
||||||
function changeSize(size: number) {
|
for (const node of list) {
|
||||||
pageSize.value = size
|
out.push({ ...node, depth })
|
||||||
page.value = 1
|
if (node.children?.length) walk(node.children, depth + 1)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
walk(rows.value, 0)
|
||||||
|
return out
|
||||||
|
})
|
||||||
const createVisible = ref(false)
|
const createVisible = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editingNode = ref<MenuManageNode | null>(null)
|
const editingNode = ref<MenuManageNode | null>(null)
|
||||||
@@ -169,83 +193,111 @@ async function removeMenu(node: MenuManageNode) {
|
|||||||
ElMessage.warning(reason)
|
ElMessage.warning(reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 对齐旧版:原生 confirm。
|
||||||
|
if (!window.confirm(`确定删除菜单“${node.name}”吗?`)) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除菜单“${node.name}”吗?`, '删除确认', { type: 'warning' })
|
|
||||||
await deleteMenu(node.id)
|
await deleteMenu(node.id)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
await loadMenus()
|
await loadMenus()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error !== 'cancel' && error !== 'close') {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadMenus)
|
onMounted(loadMenus)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="menus-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="menus-head">
|
||||||
<h2>菜单管理</h2>
|
<h3>菜单列表</h3>
|
||||||
<p>维护后台菜单树和页面路由。权限粒度仅到菜单/页面。</p>
|
<button class="btn" type="button" @click="openCreate">新增菜单</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<el-card shadow="never">
|
|
||||||
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
<div class="table-scroll columns-table-scroll">
|
||||||
<div class="table-toolbar">
|
<table>
|
||||||
<el-button type="primary" @click="openCreate">新增菜单</el-button>
|
<thead>
|
||||||
</div>
|
<tr>
|
||||||
<el-table v-loading="loading" :data="pagedRows" row-key="id" default-expand-all stripe>
|
<th class="column-drag-cell"><span class="visually-hidden">排序</span></th>
|
||||||
<el-table-column label="排序" min-width="64">
|
<th style="width: 60px">ID</th>
|
||||||
<template #default="{ row }">
|
<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>
|
||||||
|
<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
|
<span
|
||||||
class="menu-drag-handle"
|
class="column-drag-handle"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
role="button"
|
role="button"
|
||||||
:title="`拖动调整“${(row as MenuManageNode).name}”的顺序`"
|
:title="`拖动调整“${row.name}”的顺序`"
|
||||||
@dragstart="onDragStart($event, row as MenuManageNode)"
|
@dragstart="onDragStart($event, row)"
|
||||||
@dragend="onDragEnd"
|
@dragend="onDragEnd"
|
||||||
@dragover="onDragOver($event, row as MenuManageNode)"
|
|
||||||
@drop="onDrop($event, row as MenuManageNode)"
|
|
||||||
>⠿</span>
|
>⠿</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>{{ 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"
|
||||||
|
:disabled="menuDeleteReason(row) !== undefined"
|
||||||
|
:title="menuDeleteReason(row) || undefined"
|
||||||
|
@click="removeMenu(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
<el-table-column prop="name" label="菜单名称" min-width="180" />
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
<el-table-column label="菜单类型" min-width="110">
|
</tr>
|
||||||
<template #default="{ row }">
|
<tr v-else>
|
||||||
<el-tag size="small" :type="(row as MenuManageNode).menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel((row as MenuManageNode).menuType) }}</el-tag>
|
<td colspan="8" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
|
||||||
</template>
|
</tr>
|
||||||
</el-table-column>
|
</tbody>
|
||||||
<el-table-column label="上级菜单" min-width="120">
|
</table>
|
||||||
<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>
|
</div>
|
||||||
</el-card>
|
<OldPagination v-if="filteredMenuRows.length > 0" :total="filteredMenuRows.length" :page="page" :page-size="pageSize" @change="changeMenuPage" @size-change="changeMenuSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="createVisible" :title="dialogTitle" width="520px">
|
<el-dialog v-model="createVisible" :title="dialogTitle" width="520px">
|
||||||
<el-form label-width="96px">
|
<el-form label-width="96px">
|
||||||
@@ -253,12 +305,12 @@ onMounted(loadMenus)
|
|||||||
<el-input v-model="menuForm.name" placeholder="如:用户管理" />
|
<el-input v-model="menuForm.name" placeholder="如:用户管理" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="菜单类型">
|
<el-form-item label="菜单类型">
|
||||||
<el-select v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
<el-select filterable v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
||||||
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="父菜单">
|
<el-form-item label="父菜单">
|
||||||
<el-select v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
<el-select filterable v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="node in parentOptions"
|
v-for="node in parentOptions"
|
||||||
:key="node.id"
|
:key="node.id"
|
||||||
@@ -280,26 +332,223 @@ onMounted(loadMenus)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.menu-drag-tip {
|
/* 像素复刻旧版 admin.html panel-columns(蓝白末层)。 */
|
||||||
margin: 0 0 10px;
|
.menus-view {
|
||||||
font-size: 13px;
|
font-family: inherit;
|
||||||
color: var(--el-text-color-secondary);
|
color: #24384d;
|
||||||
}
|
}
|
||||||
.menu-drag-handle {
|
.panel-box {
|
||||||
display: inline-block;
|
width: 100%;
|
||||||
cursor: grab;
|
min-width: 0;
|
||||||
color: var(--el-text-color-secondary);
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.menus-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.columns-drag-tip {
|
||||||
|
margin: 10px 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6f83;
|
||||||
|
}
|
||||||
|
.menus-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.columns-table-scroll > table {
|
||||||
|
min-width: 860px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.column-drag-cell {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
.column-drag-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: none;
|
||||||
|
color: #93a6b8;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 4px 6px;
|
cursor: grab;
|
||||||
border-radius: 4px;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.menu-drag-handle:hover {
|
.column-drag-handle:hover {
|
||||||
background: var(--el-fill-color-light);
|
border-color: #cbd9e6;
|
||||||
color: var(--el-text-color-primary);
|
background: #eef4fa;
|
||||||
|
color: #2f5d8b;
|
||||||
}
|
}
|
||||||
.menu-drag-handle:active {
|
.column-drag-handle:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
tr.is-column-dragging td {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.column-child-mark {
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #9fb1c2;
|
||||||
|
}
|
||||||
|
.menu-name-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.menu-type-text {
|
||||||
|
color: #5b6f83;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import type { MenuOptionNode } from './user-menu-auth'
|
import type { MenuOptionNode } from './user-menu-auth'
|
||||||
|
import { compactDirectGrantIds } from './user-menu-auth'
|
||||||
import { fetchGrantableMenus } from './user-menu-auth-api'
|
import { fetchGrantableMenus } from './user-menu-auth-api'
|
||||||
|
|
||||||
const props = defineProps<{ checked?: number[] }>()
|
const props = defineProps<{ checked?: number[] }>()
|
||||||
@@ -11,31 +12,42 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
|
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
|
||||||
type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown }
|
type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown }
|
||||||
const tree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
const adminTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
||||||
|
const appTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
||||||
const data = ref<MenuOptionNode[]>([])
|
const data = ref<MenuOptionNode[]>([])
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
/** 两种一级分类…(与 API menuType 分区一致):后台(admin) / 桌面端(app)。 */
|
||||||
|
const adminNodes = computed(() => data.value.filter((node) => node.type === 'admin'))
|
||||||
|
const appNodes = computed(() => data.value.filter((node) => node.type === 'app'))
|
||||||
|
|
||||||
function applyChecked(): void {
|
function applyChecked(): void {
|
||||||
const el: TreeRef | null = tree.value
|
|
||||||
if (!el) return
|
|
||||||
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
|
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
|
||||||
el.setCheckedKeys(ids)
|
adminTree.value?.setCheckedKeys(ids)
|
||||||
|
appTree.value?.setCheckedKeys(ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCheck(): void {
|
function onCheck(): void {
|
||||||
const el: TreeRef | null = tree.value
|
const collect = (el: { getCheckedKeys(): unknown } | null): number[] => {
|
||||||
if (!el) return
|
const keys = el ? el.getCheckedKeys() : []
|
||||||
const keys = el.getCheckedKeys()
|
return (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
|
||||||
const ids = (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
|
}
|
||||||
emit('update:checked', ids)
|
const ids = [...collect(adminTree.value), ...collect(appTree.value)]
|
||||||
|
// 父子联动模式(未启用严格勾选)下勾选父菜单会自动全选全部子菜单;
|
||||||
|
// 提交前压缩为最小直接授权集:父级已勾选时后代不再重复提交(后端按“父级授权
|
||||||
|
// 展开全部后代”判断权限,与旧 admin.html 勾父清后代语义一致)。
|
||||||
|
emit('update:checked', compactDirectGrantIds(ids, data.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
data.value = await fetchGrantableMenus()
|
data.value = await fetchGrantableMenus()
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
applyChecked()
|
|
||||||
emit('load', true)
|
emit('load', true)
|
||||||
|
// el-tree 的 store 通过异步 watch 同步 props.data,setCheckedKeys 必须等树
|
||||||
|
// 渲染完成后再应用已勾选 id,否则作用在空 store 上导致已授权不回显。
|
||||||
|
await nextTick()
|
||||||
|
applyChecked()
|
||||||
} catch {
|
} catch {
|
||||||
loaded.value = false
|
loaded.value = false
|
||||||
emit('load', false)
|
emit('load', false)
|
||||||
@@ -53,15 +65,67 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-scrollbar max-height="300px" class="user-menu-tree-scroll">
|
<el-scrollbar max-height="300px" class="user-menu-tree-scroll">
|
||||||
|
<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
|
<el-tree
|
||||||
ref="tree"
|
ref="adminTree"
|
||||||
:data="data"
|
:data="adminNodes"
|
||||||
node-key="id"
|
node-key="id"
|
||||||
show-checkbox
|
show-checkbox
|
||||||
check-strictly
|
|
||||||
default-expand-all
|
default-expand-all
|
||||||
:props="{ label: 'name', children: 'children' }"
|
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||||
@check="onCheck"
|
@check="onCheck"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="appNodes.length" class="menu-group-block">
|
||||||
|
<div class="menu-group-title">桌面端</div>
|
||||||
|
<el-tree
|
||||||
|
ref="appTree"
|
||||||
|
:data="appNodes"
|
||||||
|
node-key="id"
|
||||||
|
show-checkbox
|
||||||
|
default-expand-all
|
||||||
|
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||||
|
@check="onCheck"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="menu-group-empty">暂无可用菜单</p>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-group-columns {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.menu-group-block {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 10px 8px 0;
|
||||||
|
}
|
||||||
|
.menu-group-block:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
border-left: 1px solid #e6edf4;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
.menu-group-title {
|
||||||
|
padding: 4px 0 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #40586e;
|
||||||
|
border-bottom: 1px solid #e6edf4;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.menu-group-empty {
|
||||||
|
margin: 12px 0;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import {
|
||||||
|
fetchUserSecretUsage,
|
||||||
|
type AdminUserSecretUsageRow,
|
||||||
|
} from '@/api/user-secret-usage'
|
||||||
|
import type { GroupOption } from '@/api/user-secrets'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<AdminUserSecretUsageRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(15)
|
||||||
|
const keyword = ref('')
|
||||||
|
const startDate = ref('')
|
||||||
|
const endDate = ref('')
|
||||||
|
const groupFilter = ref<number | null>(null)
|
||||||
|
const groupOptions = ref<GroupOption[]>([])
|
||||||
|
|
||||||
|
/** 所选范围内三类总次数(后端按筛选条件统计,不分页)。 */
|
||||||
|
const summary = ref({ totalCalls: 0, similarAsinCalls: 0, appearancePatentCalls: 0, proxyCalls: 0 })
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
function formatCount(value: number | undefined) {
|
||||||
|
const num = Number(value || 0)
|
||||||
|
return num.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchUserSecretUsage({
|
||||||
|
startDate: startDate.value || undefined,
|
||||||
|
endDate: endDate.value || undefined,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
groupId: groupFilter.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
rows.value = result?.items || []
|
||||||
|
total.value = Number(result?.total || 0)
|
||||||
|
groupOptions.value = result?.groupOptions || []
|
||||||
|
summary.value = {
|
||||||
|
totalCalls: Number(result?.totalCalls || 0),
|
||||||
|
similarAsinCalls: Number(result?.similarAsinCalls || 0),
|
||||||
|
appearancePatentCalls: Number(result?.appearancePatentCalls || 0),
|
||||||
|
proxyCalls: Number(result?.proxyCalls || 0),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
keyword.value = ''
|
||||||
|
startDate.value = ''
|
||||||
|
endDate.value = ''
|
||||||
|
groupFilter.value = null
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="secret-usage-view">
|
||||||
|
<section class="panel-box">
|
||||||
|
<div class="usage-head">
|
||||||
|
<h3>密钥用量统计</h3>
|
||||||
|
<span class="usage-note">口径:真实对外请求次数(LLM 每次请求、代理每次成功提取)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-row">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">总调用次数</div>
|
||||||
|
<div class="summary-value">{{ formatCount(summary.totalCalls) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">货源查询密钥</div>
|
||||||
|
<div class="summary-value">{{ formatCount(summary.similarAsinCalls) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">外观专利密钥</div>
|
||||||
|
<div class="summary-value">{{ formatCount(summary.appearancePatentCalls) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">代理提取</div>
|
||||||
|
<div class="summary-value">{{ formatCount(summary.proxyCalls) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row usage-filter-row">
|
||||||
|
<div class="form-group" style="min-width: 160px">
|
||||||
|
<label>开始日期</label>
|
||||||
|
<input v-model="startDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 160px">
|
||||||
|
<label>结束日期</label>
|
||||||
|
<input v-model="endDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 200px">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
|
||||||
|
<label>分组</label>
|
||||||
|
<select v-model="groupFilter">
|
||||||
|
<option :value="null">全部分组</option>
|
||||||
|
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label> </label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn" type="button" @click="search">查询</button>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usage-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 58px">序号</th>
|
||||||
|
<th style="width: 220px">用户</th>
|
||||||
|
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
||||||
|
<th style="width: 170px">货源查询密钥</th>
|
||||||
|
<th style="width: 170px">外观专利密钥</th>
|
||||||
|
<th style="width: 150px">代理提取</th>
|
||||||
|
<th style="width: 130px">合计</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<tr v-for="(row, index) in rows" :key="row.userId">
|
||||||
|
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-name">{{ row.username || `UID ${row.userId}` }}</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="isSuperAdmin">
|
||||||
|
<span class="group-name">{{ row.groups?.length ? row.groups.join('、') : '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="count-value">{{ formatCount(row.similarAsinCount) }}</span></td>
|
||||||
|
<td><span class="count-value">{{ formatCount(row.appearancePatentCount) }}</span></td>
|
||||||
|
<td><span class="count-value">{{ formatCount(row.proxyCount) }}</span></td>
|
||||||
|
<td><span class="count-value count-total">{{ formatCount(row.totalCount) }}</span></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">
|
||||||
|
{{ keyword || startDate || endDate || groupFilter ? '暂无匹配记录' : '暂无用量记录' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 与密钥管理页保持同一视觉语言(像素复刻后台面板风格)。 */
|
||||||
|
.secret-usage-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.usage-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.usage-note {
|
||||||
|
color: #7d8fa2;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.summary-card {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f7fafd;
|
||||||
|
}
|
||||||
|
.summary-label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #2f5d8b;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.usage-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
color: #4f78a5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.usage-table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.usage-table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
min-width: 1020px;
|
||||||
|
}
|
||||||
|
.usage-table-scroll th,
|
||||||
|
.usage-table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.usage-table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.usage-table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.usage-table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
color: #24384d;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.group-name {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.count-value {
|
||||||
|
color: #2f5d8b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.count-total {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
color: #8293a5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import {
|
||||||
|
checkUserSecret,
|
||||||
|
deleteUserSecret,
|
||||||
|
fetchUserSecretList,
|
||||||
|
type AdminUserSecretModule,
|
||||||
|
type AdminUserSecretRow,
|
||||||
|
type GroupOption,
|
||||||
|
} from '@/api/user-secrets'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<AdminUserSecretRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(15)
|
||||||
|
const keyword = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const groupFilter = ref<number | null>(null)
|
||||||
|
/** 分组下拉(超管=全部;主管=自己带的分组,由列表接口带回)。 */
|
||||||
|
const groupOptions = ref<GroupOption[]>([])
|
||||||
|
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
||||||
|
const checkingId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'passed', label: '检测通过' },
|
||||||
|
{ value: 'failed', label: '检测失败' },
|
||||||
|
{ value: 'incomplete', label: '未配齐' },
|
||||||
|
{ value: 'error', label: '无法判定' },
|
||||||
|
{ value: 'unknown', label: '未检测' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
|
||||||
|
function rowStatusMeta(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'passed':
|
||||||
|
return { label: '检测通过', tone: 'is-allowed' }
|
||||||
|
case 'failed':
|
||||||
|
return { label: '检测失败', tone: 'is-blocked' }
|
||||||
|
case 'incomplete':
|
||||||
|
return { label: '未配齐', tone: 'is-warn' }
|
||||||
|
case 'error':
|
||||||
|
return { label: '无法判定', tone: 'is-warn' }
|
||||||
|
default:
|
||||||
|
return { label: '未检测', tone: 'is-unknown' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
|
||||||
|
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
||||||
|
if (!module || !module.exists) {
|
||||||
|
return { label: '未配置', tone: 'is-unknown' }
|
||||||
|
}
|
||||||
|
switch (module.checkStatus) {
|
||||||
|
case 'passed':
|
||||||
|
return { label: '通过', tone: 'is-allowed' }
|
||||||
|
case 'failed':
|
||||||
|
if (module.checkCode === 'insufficient_balance') {
|
||||||
|
return { label: '欠费', tone: 'is-warn' }
|
||||||
|
}
|
||||||
|
return { label: '失败', tone: 'is-blocked' }
|
||||||
|
case 'error':
|
||||||
|
return { label: '无法判定', tone: 'is-warn' }
|
||||||
|
default:
|
||||||
|
return { label: '未检测', tone: 'is-unknown' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */
|
||||||
|
function moduleTooltip(module: AdminUserSecretModule | undefined) {
|
||||||
|
if (!module || !module.exists) return '未配置'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (module.full) parts.push(module.full)
|
||||||
|
if (module.checkMessage) parts.push(module.checkMessage)
|
||||||
|
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
||||||
|
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
||||||
|
return parts.join(';') || '暂无检测记录'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分组列文案:优先分组名;未建分组的用户回退展示所属主管,便于判断归属。 */
|
||||||
|
function groupTextOf(row: AdminUserSecretRow) {
|
||||||
|
if (row.groups?.length) return row.groups.join('、')
|
||||||
|
if (row.leaderUsername) return `${row.leaderUsername}(未建分组)`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowStatusTooltip(row: AdminUserSecretRow) {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (row.statusMessage) parts.push(row.statusMessage)
|
||||||
|
if (row.updatedAt) parts.push(`最近更新:${formatDateTime(row.updatedAt)}`)
|
||||||
|
return parts.join(';') || '暂无检测记录'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchUserSecretList({
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
checkStatus: statusFilter.value || undefined,
|
||||||
|
groupId: groupFilter.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
rows.value = result?.items || []
|
||||||
|
total.value = Number(result?.total || 0)
|
||||||
|
groupOptions.value = result?.groupOptions || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
keyword.value = ''
|
||||||
|
statusFilter.value = ''
|
||||||
|
groupFilter.value = null
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
|
||||||
|
async function check(row: AdminUserSecretRow) {
|
||||||
|
checkingId.value = row.userId
|
||||||
|
try {
|
||||||
|
const results = await checkUserSecret(row.userId)
|
||||||
|
if (!results || !results.length) {
|
||||||
|
ElMessage.warning('该用户尚未配置任何密钥或代理')
|
||||||
|
} else {
|
||||||
|
const failed = results.filter((item) => item.checkStatus === 'failed')
|
||||||
|
const errors = results.filter((item) => item.checkStatus === 'error')
|
||||||
|
if (failed.length) {
|
||||||
|
ElMessage.warning(failed[0].checkMessage || '存在检测失败项')
|
||||||
|
} else if (errors.length) {
|
||||||
|
ElMessage.warning('部分配置本次无法判定')
|
||||||
|
} else {
|
||||||
|
ElMessage.success('检测通过')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '检测失败')
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: AdminUserSecretRow) {
|
||||||
|
const who = row.username || `UID ${row.userId}`
|
||||||
|
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
|
||||||
|
try {
|
||||||
|
await deleteUserSecret(row.userId)
|
||||||
|
ElMessage.success('已清空')
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '清空失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="user-secrets-view">
|
||||||
|
<section class="panel-box">
|
||||||
|
<div class="secrets-head">
|
||||||
|
<h3>用户密钥列表</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row secrets-filter-row">
|
||||||
|
<div class="form-group" style="min-width: 220px">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 170px">
|
||||||
|
<label>状态(三项都通过才算通过)</label>
|
||||||
|
<select v-model="statusFilter">
|
||||||
|
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
|
||||||
|
<label>分组</label>
|
||||||
|
<select v-model="groupFilter">
|
||||||
|
<option :value="null">全部分组</option>
|
||||||
|
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label> </label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn" type="button" @click="search">查询</button>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="secrets-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 58px">序号</th>
|
||||||
|
<th style="width: 260px">用户</th>
|
||||||
|
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
||||||
|
<th style="width: 230px">货源查询密钥</th>
|
||||||
|
<th style="width: 230px">外观专利密钥</th>
|
||||||
|
<th style="width: 380px">代理设置</th>
|
||||||
|
<th style="width: 130px">状态</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
|
<th style="width: 170px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<tr v-for="(row, index) in rows" :key="row.userId">
|
||||||
|
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-name">{{ row.username || '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="isSuperAdmin">
|
||||||
|
<span class="creator-name">{{ groupTextOf(row) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="module-cell">
|
||||||
|
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
||||||
|
<span v-else class="empty-value">未配置</span>
|
||||||
|
<span class="wh-pill" :class="moduleStatusMeta(row.similarAsin).tone" :title="moduleTooltip(row.similarAsin)">
|
||||||
|
{{ moduleStatusMeta(row.similarAsin).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="module-cell">
|
||||||
|
<span v-if="row.appearancePatent?.exists" class="mono-mask" :title="moduleTooltip(row.appearancePatent)">{{ row.appearancePatent.masked }}</span>
|
||||||
|
<span v-else class="empty-value">未配置</span>
|
||||||
|
<span class="wh-pill" :class="moduleStatusMeta(row.appearancePatent).tone" :title="moduleTooltip(row.appearancePatent)">
|
||||||
|
{{ moduleStatusMeta(row.appearancePatent).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="module-cell module-cell--stack">
|
||||||
|
<span v-if="row.proxy?.exists" class="mono-mask mono-full" :title="moduleTooltip(row.proxy)">{{ row.proxy.full || row.proxy.masked }}</span>
|
||||||
|
<span v-else class="empty-value">未配置</span>
|
||||||
|
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
||||||
|
{{ moduleStatusMeta(row.proxy).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
|
||||||
|
{{ rowStatusMeta(row.status).label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="checkingId === row.userId"
|
||||||
|
@click="check(row)"
|
||||||
|
>
|
||||||
|
{{ checkingId === row.userId ? '检测中…' : '立即检测' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 像素复刻旧版 admin.html 面板风格(与店铺密钥页同一视觉语言)。 */
|
||||||
|
.user-secrets-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.secrets-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.secrets-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
color: #4f78a5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
min-width: 1440px;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll th,
|
||||||
|
.secrets-table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
color: #24384d;
|
||||||
|
font-weight: 600;
|
||||||
|
/* 用户名(含中文)可完整换行展示,不截断 */
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.creator-name {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.module-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
|
||||||
|
.module-cell--stack {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.module-cell .mono-mask {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
/* 代理列展示完整明文:允许折行显示,不截断、不省略 */
|
||||||
|
.module-cell .mono-full {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.45;
|
||||||
|
cursor: text;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.mono-mask {
|
||||||
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.empty-value {
|
||||||
|
color: #a7b4c1;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
color: #8293a5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wh-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
cursor: help;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.wh-pill.is-allowed {
|
||||||
|
background: #e8f6ee;
|
||||||
|
border-color: #b9e0c9;
|
||||||
|
color: #2f7d52;
|
||||||
|
}
|
||||||
|
.wh-pill.is-blocked {
|
||||||
|
background: #fdecef;
|
||||||
|
border-color: #f2c4cd;
|
||||||
|
color: #b04a5a;
|
||||||
|
}
|
||||||
|
.wh-pill.is-warn {
|
||||||
|
background: #fdf6e3;
|
||||||
|
border-color: #f0dfae;
|
||||||
|
color: #8f6d1e;
|
||||||
|
}
|
||||||
|
.wh-pill.is-unknown {
|
||||||
|
background: #f0f3f6;
|
||||||
|
border-color: #d6dee6;
|
||||||
|
color: #6b7d8f;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
/** 用户管理页 · 像素复刻旧版 admin.html panel-users(自绘:panel-box + 页头按钮 + 筛选 form-row + 旧式表格/分页 + 原生 confirm 删除)。
|
||||||
|
* script 逻辑沿用现有 Vue 实现;创建/编辑弹窗保留现有组件(含用户菜单权限树)。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { AdminUser } from '@/types/admin'
|
import type { AdminUser } from '@/types/admin'
|
||||||
import { roleLabel } from '@/types/admin'
|
import { roleLabel } from '@/types/admin'
|
||||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
@@ -13,6 +15,7 @@ import { listPhaseOf, userListEmptyHint } from './user-list-state'
|
|||||||
import { isEditableUser } from './user-edit-model'
|
import { isEditableUser } from './user-edit-model'
|
||||||
import CreateUserDialog from './CreateUserDialog.vue'
|
import CreateUserDialog from './CreateUserDialog.vue'
|
||||||
import EditUserDialog from './EditUserDialog.vue'
|
import EditUserDialog from './EditUserDialog.vue'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const session = useAdminSessionStore()
|
const session = useAdminSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -25,6 +28,8 @@ const filters = reactive<UserFilterState>(createUserFilterState())
|
|||||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
const currentUserId = computed(() => session.user?.id ?? null)
|
const currentUserId = computed(() => session.user?.id ?? null)
|
||||||
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
|
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
|
||||||
|
const jumpPage = ref('')
|
||||||
|
const totalPages = computed(() => Math.max(1, totalPageCount(total.value, filters.pageSize)))
|
||||||
|
|
||||||
const createVisible = ref(false)
|
const createVisible = ref(false)
|
||||||
const editVisible = ref(false)
|
const editVisible = ref(false)
|
||||||
@@ -85,17 +90,14 @@ async function removeUser(row: AdminUser) {
|
|||||||
ElMessage.warning(reason)
|
ElMessage.warning(reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 对齐旧版:浏览器原生 confirm 确认后删除。
|
||||||
|
if (!window.confirm(deleteConfirmMessage(row))) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(deleteConfirmMessage(row), '删除用户', {
|
|
||||||
confirmButtonText: '删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
await deleteUser(row.id)
|
await deleteUser(row.id)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,106 +112,366 @@ function changeSize(size: number) {
|
|||||||
void loadUsers()
|
void loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goJump() {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="users-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="users-head">
|
||||||
<h2>用户管理</h2>
|
<h3>用户列表</h3>
|
||||||
<p>当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</p>
|
<button class="btn" type="button" @click="openCreate">新建用户</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="users-desc">当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</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>
|
||||||
<el-card shadow="never" class="filter-card">
|
<div v-if="isSuperAdmin" class="form-group" style="min-width: 160px">
|
||||||
<el-form inline @submit.prevent="search">
|
<label>所属管理员</label>
|
||||||
<el-form-item label="用户名(模糊搜索)">
|
<el-select v-model="filters.createdByAdminId" class="admin-filter" filterable clearable placeholder="全部" @change="search">
|
||||||
<el-input v-model="filters.keyword" clearable placeholder="输入用户名关键字" @keyup.enter="search" />
|
<el-option v-for="admin in admins" :key="admin.id" :label="admin.username" :value="admin.id" />
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="isSuperAdmin" label="所属管理员">
|
|
||||||
<el-select
|
|
||||||
v-model="filters.createdByAdminId"
|
|
||||||
clearable
|
|
||||||
filterable
|
|
||||||
placeholder="全部"
|
|
||||||
style="width: 200px"
|
|
||||||
@change="search"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="admin in admins"
|
|
||||||
:key="admin.id"
|
|
||||||
:label="admin.username"
|
|
||||||
:value="admin.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
</el-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>
|
||||||
<el-table v-loading="loading" :data="rows" stripe :empty-text="userListEmptyHint()">
|
<div class="form-group" style="min-width: 140px">
|
||||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
<label>角色</label>
|
||||||
<el-table-column label="角色" min-width="140">
|
<el-select v-model="filters.role" class="admin-filter" filterable clearable placeholder="全部角色" @change="search">
|
||||||
<template #default="{ row }">
|
<el-option label="超级管理员" value="super_admin" />
|
||||||
<span>{{ roleLabel(row.role) }}</span>
|
<el-option label="管理员" value="admin" />
|
||||||
</template>
|
<el-option label="普通账号" value="normal" />
|
||||||
</el-table-column>
|
</el-select>
|
||||||
<el-table-column prop="creatorUsername" label="所属管理员" min-width="160" />
|
</div>
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="180">
|
<button class="btn" type="button" @click="search">查询</button>
|
||||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
</div>
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" min-width="160" fixed="right">
|
<div v-if="statePhase === 'error' && loadError" class="msg err">
|
||||||
<template #default="{ row }">
|
<span>加载失败: {{ loadError }}</span>
|
||||||
<el-button link type="primary" :disabled="!editableOf(row)" @click="openEdit(row)">编辑</el-button>
|
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
|
||||||
<el-button
|
</div>
|
||||||
link
|
|
||||||
type="danger"
|
<div class="table-scroll user-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 70px">ID</th>
|
||||||
|
<th style="width: 180px">用户名</th>
|
||||||
|
<th style="width: 140px">角色</th>
|
||||||
|
<th style="width: 160px">所属管理员</th>
|
||||||
|
<th style="width: 180px">创建时间</th>
|
||||||
|
<th style="width: 180px">更新时间</th>
|
||||||
|
<th style="width: 160px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td>{{ row.id }}</td>
|
||||||
|
<td>{{ row.username }}</td>
|
||||||
|
<td>{{ roleLabel(row.role) }}</td>
|
||||||
|
<td>{{ row.creatorUsername || '-' }}</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!editableOf(row)"
|
||||||
|
:title="!editableOf(row) ? '该账号不可编辑' : undefined"
|
||||||
|
@click="openEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-danger"
|
||||||
|
type="button"
|
||||||
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
|
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
|
||||||
|
:title="deleteBlockReason(row, currentUserId) || undefined"
|
||||||
@click="removeUser(row)"
|
@click="removeUser(row)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
</el-table>
|
<td colspan="7" class="empty-tip">加载中...</td>
|
||||||
<div class="table-footer">
|
</tr>
|
||||||
<span>共 {{ total }} 条</span>
|
<tr v-else>
|
||||||
<el-pagination
|
<td colspan="7" class="empty-tip">{{ userListEmptyHint() }}</td>
|
||||||
background
|
</tr>
|
||||||
layout="sizes, prev, pager, next, jumper"
|
</tbody>
|
||||||
:page-size="filters.pageSize"
|
</table>
|
||||||
:current-page="filters.page"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:total="total"
|
|
||||||
@current-change="changePage"
|
|
||||||
@size-change="changeSize"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
<OldPagination :total="total" :page="filters.page" :page-size="filters.pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" />
|
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" />
|
||||||
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
|
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-card :deep(.el-form-item) {
|
/* 像素复刻旧版 admin.html panel-users(蓝白末层)。 */
|
||||||
|
.users-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.users-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.users-desc {
|
||||||
|
margin: 10px 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6f83;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.msg.err {
|
||||||
|
color: #91474f;
|
||||||
|
background: #f8ebeb;
|
||||||
|
border-color: #e4c2c5;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.user-table-scroll > table {
|
||||||
|
min-width: 860px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-size-select {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface MenuManageNode {
|
|||||||
routePath: string
|
routePath: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
children?: MenuManageNode[]
|
children?: MenuManageNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +91,8 @@ export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
|
|||||||
if (rootColumnKey) node.rootColumnKey = rootColumnKey
|
if (rootColumnKey) node.rootColumnKey = rootColumnKey
|
||||||
const createdAt = text(record.created_at)
|
const createdAt = text(record.created_at)
|
||||||
if (createdAt) node.createdAt = createdAt
|
if (createdAt) node.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updated_at)
|
||||||
|
if (updatedAt) node.updatedAt = updatedAt
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { http } from '@/api/http'
|
|||||||
import { unwrap } from '@/api/envelope'
|
import { unwrap } from '@/api/envelope'
|
||||||
import {
|
import {
|
||||||
ADMIN_MENU_TYPE,
|
ADMIN_MENU_TYPE,
|
||||||
|
APP_MENU_TYPE,
|
||||||
buildMenuOptionTree,
|
buildMenuOptionTree,
|
||||||
buildUserColumnPermissionPayload,
|
buildUserColumnPermissionPayload,
|
||||||
|
normalizeMenuIds,
|
||||||
parseMenuOptionList,
|
parseMenuOptionList,
|
||||||
parseUserGrantedColumnIds,
|
parseUserGrantedColumnIds,
|
||||||
type MenuOptionNode,
|
type MenuOptionNode,
|
||||||
@@ -13,18 +15,33 @@ import {
|
|||||||
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
|
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
|
||||||
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
|
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
|
||||||
|
|
||||||
/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */
|
/** 拉取可授权菜单树(后台 admin + 前端客户端 app 两类合并,按 sort 排序)。 */
|
||||||
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
|
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
|
||||||
const { data } = await http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
|
const [adminRes, appRes] = await Promise.all([
|
||||||
return buildMenuOptionTree(parseMenuOptionList(data))
|
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }),
|
||||||
|
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: APP_MENU_TYPE } }),
|
||||||
|
])
|
||||||
|
const nodes = [
|
||||||
|
...parseMenuOptionList(adminRes.data, ADMIN_MENU_TYPE),
|
||||||
|
...parseMenuOptionList(appRes.data, APP_MENU_TYPE),
|
||||||
|
]
|
||||||
|
return buildMenuOptionTree(nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拉取某用户当前直接授权菜单 id 列表。 */
|
/** 拉取某用户当前直接授权菜单 id 列表(admin + app 合并去重)。 */
|
||||||
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
|
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
|
||||||
const { data } = await http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
const [adminRes, appRes] = await Promise.all([
|
||||||
|
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||||
params: { menuType: ADMIN_MENU_TYPE },
|
params: { menuType: ADMIN_MENU_TYPE },
|
||||||
})
|
}),
|
||||||
return parseUserGrantedColumnIds(data)
|
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||||
|
params: { menuType: APP_MENU_TYPE },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
return normalizeMenuIds([
|
||||||
|
...parseUserGrantedColumnIds(adminRes.data),
|
||||||
|
...parseUserGrantedColumnIds(appRes.data),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
|
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { unwrap } from '../../api/envelope.ts'
|
|||||||
|
|
||||||
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
|
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
|
||||||
export const ADMIN_MENU_TYPE = 'admin'
|
export const ADMIN_MENU_TYPE = 'admin'
|
||||||
|
/** 前端客户端菜单类型(桌面端功能模块,如视频/前端工具/运营工具/后勤工具)。 */
|
||||||
|
export const APP_MENU_TYPE = 'app'
|
||||||
|
|
||||||
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
|
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
|
||||||
export interface MenuOptionNode {
|
export interface MenuOptionNode {
|
||||||
@@ -12,6 +14,13 @@ export interface MenuOptionNode {
|
|||||||
name: string
|
name: string
|
||||||
sort: number
|
sort: number
|
||||||
parentId: number | null
|
parentId: number | null
|
||||||
|
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
|
||||||
|
type: string
|
||||||
|
/**
|
||||||
|
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
|
||||||
|
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
|
||||||
|
*/
|
||||||
|
disabled?: boolean
|
||||||
children?: MenuOptionNode[]
|
children?: MenuOptionNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +29,7 @@ function numberOrNull(value: unknown): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
|
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
|
||||||
export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
|
export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode | null {
|
||||||
if (!raw || typeof raw !== 'object') return null
|
if (!raw || typeof raw !== 'object') return null
|
||||||
const record = raw as Record<string, unknown>
|
const record = raw as Record<string, unknown>
|
||||||
const id = numberOrNull(record.id)
|
const id = numberOrNull(record.id)
|
||||||
@@ -34,15 +43,18 @@ export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
|
|||||||
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
|
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
|
||||||
sort: sortRaw === null ? 0 : sortRaw,
|
sort: sortRaw === null ? 0 : sortRaw,
|
||||||
parentId: parentId === null ? null : parentId,
|
parentId: parentId === null ? null : parentId,
|
||||||
|
type,
|
||||||
|
// 缺省(菜单管理页等未标记的接口)按可授予处理,保持旧行为
|
||||||
|
disabled: record.grantable === false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
|
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
|
||||||
export function parseMenuOptionList(payload: unknown): MenuOptionNode[] {
|
export function parseMenuOptionList(payload: unknown, type = ''): MenuOptionNode[] {
|
||||||
const data = unwrap<unknown>(payload)
|
const data = unwrap<unknown>(payload)
|
||||||
if (!Array.isArray(data)) return []
|
if (!Array.isArray(data)) return []
|
||||||
return data
|
return data
|
||||||
.map((raw) => parsePermissionMenuItem(raw))
|
.map((raw) => parsePermissionMenuItem(raw, type))
|
||||||
.filter((node): node is MenuOptionNode => node !== null)
|
.filter((node): node is MenuOptionNode => node !== null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,3 +112,29 @@ export function normalizeMenuIds(raw: unknown): number[] {
|
|||||||
export function buildUserColumnPermissionPayload(raw: unknown): { columnIds: number[] } {
|
export function buildUserColumnPermissionPayload(raw: unknown): { columnIds: number[] } {
|
||||||
return { columnIds: normalizeMenuIds(raw) }
|
return { columnIds: normalizeMenuIds(raw) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把权限树勾选 id 压缩为后端意义上的“直接授权最小集”:父级已勾选时后代无需
|
||||||
|
* 重复提交(后端读取/校验时父级授权会向全部后代展开,与旧 admin.html“勾父清
|
||||||
|
* 后代”语义一致,避免 user_column_permission 存储冗余行)。未勾选父级但单独
|
||||||
|
* 勾选子级的部分授权原样保留。
|
||||||
|
*/
|
||||||
|
export function compactDirectGrantIds(checkedIds: unknown, tree: MenuOptionNode[]): number[] {
|
||||||
|
const ids = normalizeMenuIds(checkedIds)
|
||||||
|
if (ids.length === 0 || tree.length === 0) return ids
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
/** 因“有已勾选祖先”而被父级覆盖、无需直接授权的 id。 */
|
||||||
|
const coveredByAncestor = new Set<number>()
|
||||||
|
const walk = (nodes: MenuOptionNode[], ancestorChecked: boolean): void => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (ancestorChecked) {
|
||||||
|
coveredByAncestor.add(node.id)
|
||||||
|
if (node.children?.length) walk(node.children, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (node.children?.length) walk(node.children, idSet.has(node.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(tree, false)
|
||||||
|
return ids.filter((id) => !coveredByAncestor.has(id))
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,18 +7,20 @@ export interface UserFilterState {
|
|||||||
keyword: string
|
keyword: string
|
||||||
/** 按创建管理员过滤(可选)。 */
|
/** 按创建管理员过滤(可选)。 */
|
||||||
createdByAdminId: number | null
|
createdByAdminId: number | null
|
||||||
|
/** 角色过滤(''=全部;super_admin/admin/normal)。 */
|
||||||
|
role: string
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUserFilterState(): UserFilterState {
|
export function createUserFilterState(): UserFilterState {
|
||||||
return { keyword: '', createdByAdminId: null, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
|
return { keyword: '', createdByAdminId: null, role: '', page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */
|
/** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */
|
||||||
export function withFilterReset(
|
export function withFilterReset(
|
||||||
state: UserFilterState,
|
state: UserFilterState,
|
||||||
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId'>>,
|
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId' | 'role'>>,
|
||||||
): UserFilterState {
|
): UserFilterState {
|
||||||
return { ...state, ...next, page: USER_PAGE_MIN_PAGE }
|
return { ...state, ...next, page: USER_PAGE_MIN_PAGE }
|
||||||
}
|
}
|
||||||
@@ -36,10 +38,13 @@ export function isFilterActive(state: UserFilterState): boolean {
|
|||||||
/** 页面状态 → 查询适配层参数(空关键字不下发)。 */
|
/** 页面状态 → 查询适配层参数(空关键字不下发)。 */
|
||||||
export function toUserListParams(state: UserFilterState): UserListParams {
|
export function toUserListParams(state: UserFilterState): UserListParams {
|
||||||
const keyword = state.keyword.trim()
|
const keyword = state.keyword.trim()
|
||||||
return {
|
const params: UserListParams = {
|
||||||
page: state.page,
|
page: state.page,
|
||||||
pageSize: state.pageSize,
|
pageSize: state.pageSize,
|
||||||
keyword: keyword || undefined,
|
keyword: keyword || undefined,
|
||||||
createdById: state.createdByAdminId,
|
createdById: state.createdByAdminId,
|
||||||
}
|
}
|
||||||
|
const role = (state.role || '').trim()
|
||||||
|
if (role) params.role = role
|
||||||
|
return params
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
/** 不符合 ASIN(品牌数据库):分页 + 手动新增/编辑/删除。 */
|
/** 品牌数据库(不符合ASIN)页 · 像素复刻旧版 admin.html panel-invalid-asin-data(自绘:面板头+新增、筛选、旧式表格/分页、原生 confirm 删除)。
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
* script 逻辑沿用现有 Vue 实现(分组锁定/来源 AUTO 编辑隐藏分组);弹窗保留现有组件。 */
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts'
|
import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts'
|
||||||
import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts'
|
import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts'
|
||||||
@@ -10,6 +11,7 @@ import { invalidAsinDeleteText, invalidAsinLockedGroupId, recordSourceLabel } fr
|
|||||||
import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts'
|
import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts'
|
||||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const session = useAdminSessionStore()
|
const session = useAdminSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -18,6 +20,8 @@ const total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
|
const jumpPage = ref('')
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
|
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
|
||||||
|
|
||||||
@@ -75,15 +79,28 @@ function apply() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function changePage(next: number) {
|
||||||
filter.dataValue = ''
|
if (next < 1 || next > totalPages.value) return
|
||||||
filter.brand = ''
|
page.value = next
|
||||||
// 非超管重置不解除分组锁定。
|
load()
|
||||||
filter.groupId = isSuperAdmin.value ? null : lockedGroupId.value
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
page.value = 1
|
page.value = 1
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goJump() {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
editingSource.value = ''
|
editingSource.value = ''
|
||||||
@@ -128,11 +145,8 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(row: InvalidAsinItem) {
|
async function remove(row: InvalidAsinItem) {
|
||||||
try {
|
// 对齐旧版:原生 confirm。
|
||||||
await ElMessageBox.confirm(invalidAsinDeleteText(row), '删除', { type: 'warning' })
|
if (!window.confirm(invalidAsinDeleteText(row))) return
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await deleteInvalidAsin(row.id)
|
await deleteInvalidAsin(row.id)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
@@ -142,6 +156,10 @@ async function remove(row: InvalidAsinItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(isSuperAdmin, () => {
|
||||||
|
if (isSuperAdmin.value) filter.groupId = null
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadGroups()
|
loadGroups()
|
||||||
load()
|
load()
|
||||||
@@ -149,69 +167,76 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="invalid-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="invalid-head">
|
||||||
<h2>不符合 ASIN 数据</h2>
|
<h3>品牌数据列表</h3>
|
||||||
<p>管理命中"不符合 ASIN"的品牌数据库记录。</p>
|
<button class="btn" type="button" @click="openCreate">新增</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" style="margin-bottom: 14px">
|
<div class="form-row invalid-filter-row">
|
||||||
<div class="filter-grid">
|
<div class="form-group" style="min-width: 200px">
|
||||||
<div class="f-item">
|
|
||||||
<label>ASIN</label>
|
<label>ASIN</label>
|
||||||
<el-input v-model="filter.dataValue" placeholder="精确/模糊搜索ASIN" clearable @keyup.enter="apply" />
|
<input v-model="filter.dataValue" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group" style="min-width: 200px">
|
||||||
<label>品牌</label>
|
<label>品牌</label>
|
||||||
<el-input v-model="filter.brand" placeholder="品牌模糊" clearable @keyup.enter="apply" />
|
<input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable :disabled="!isSuperAdmin">
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item btn-row">
|
<button class="btn" type="button" @click="apply">查询</button>
|
||||||
<el-button type="primary" @click="apply">查询</el-button>
|
|
||||||
<el-button @click="reset">重置</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="table-scroll invalid-asin-table-scroll">
|
||||||
<div class="table-toolbar">
|
<table>
|
||||||
<el-button type="primary" @click="openCreate">新增</el-button>
|
<thead>
|
||||||
</div>
|
<tr>
|
||||||
<el-table v-loading="loading" :data="rows" stripe border>
|
<th style="width: 70px">ID</th>
|
||||||
<el-table-column prop="dataValue" label="ASIN" min-width="160" />
|
<th style="width: 170px">ASIN</th>
|
||||||
<el-table-column prop="brand" label="品牌" min-width="140">
|
<th style="width: 150px">品牌</th>
|
||||||
<template #default="{ row }">{{ (row as InvalidAsinItem).brand || '—' }}</template>
|
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
|
||||||
</el-table-column>
|
<th style="width: 110px">来源</th>
|
||||||
<el-table-column prop="groupName" label="分组" min-width="130" />
|
<th style="width: 170px">创建时间</th>
|
||||||
<el-table-column label="来源" min-width="110">
|
<th style="width: 170px">更新时间</th>
|
||||||
<template #default="{ row }">
|
<th style="width: 150px">操作</th>
|
||||||
<el-tag size="small" effect="light" :type="(row as InvalidAsinItem).recordSource !== 'MANUAL' ? 'info' : undefined">
|
</tr>
|
||||||
{{ recordSourceLabel((row as InvalidAsinItem).recordSource) }}
|
</thead>
|
||||||
</el-tag>
|
<tbody>
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td>{{ row.id }}</td>
|
||||||
|
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||||
|
<td>{{ row.brand || '—' }}</td>
|
||||||
|
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
|
||||||
|
{{ recordSourceLabel(row.recordSource) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
</tr>
|
||||||
</el-table-column>
|
<tr v-else>
|
||||||
<el-table-column label="操作" min-width="150" fixed="right">
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
|
||||||
<template #default="{ row }">
|
</tr>
|
||||||
<el-button text type="primary" size="small" @click="openEdit(row as InvalidAsinItem)">编辑</el-button>
|
</tbody>
|
||||||
<el-button text type="danger" size="small" @click="remove(row as InvalidAsinItem)">删除</el-button>
|
</table>
|
||||||
</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>
|
</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-dialog v-model="dialogVisible" :title="editingId == null ? '新增不符合ASIN' : '编辑不符合ASIN'" width="520px">
|
||||||
<el-form label-width="110px">
|
<el-form label-width="110px">
|
||||||
@@ -236,12 +261,232 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
/* 像素复刻旧版 admin.html panel-invalid-asin-data(蓝白末层)。 */
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
.invalid-view {
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
font-family: inherit;
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
color: #24384d;
|
||||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
}
|
||||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
.panel-box {
|
||||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
width: 100%;
|
||||||
.table-footer b { color: var(--el-text-color-primary); }
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.invalid-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.invalid-asin-table-scroll > table {
|
||||||
|
min-width: 860px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.mono-cell {
|
||||||
|
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.source-text {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
background: #fff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.source-text.is-auto {
|
||||||
|
background: #e7f0f8;
|
||||||
|
border-color: #c2d5e6;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
/** 数据去重总数据(registry):对齐 admin panel-dedupe-total-data —— 顶部数据权限分组汇总卡、扩充筛选(用户名/日期/分组)、
|
/** 数据去重总数据(registry) · 像素复刻旧版 admin.html panel-dedupe-total-data(自绘:分组摘要 + 双行筛选 + 旧式表格/分页 + 编辑/导入弹窗 + 导出等待遮罩)。
|
||||||
* 行编辑/删除、新增/删除导入(轮询)、导出(等待遮罩+已等待秒数)。 */
|
* script 逻辑沿用现有 Vue 实现(查询/编辑/删除/导入轮询/导出秒表)。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
|
||||||
import {
|
import {
|
||||||
fetchDedupeTotalList,
|
fetchDedupeTotalList,
|
||||||
updateDedupeTotal,
|
updateDedupeTotal,
|
||||||
@@ -12,7 +11,7 @@ import {
|
|||||||
fetchDedupeTotalGroups,
|
fetchDedupeTotalGroups,
|
||||||
type DedupeGroupOption,
|
type DedupeGroupOption,
|
||||||
} from './dedupe-total-api.ts'
|
} from './dedupe-total-api.ts'
|
||||||
import { dedupeGroupSummaryOf, type DedupeTotalItem } from './dedupe-total-model.ts'
|
import type { DedupeTotalItem } from './dedupe-total-model.ts'
|
||||||
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
|
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
|
||||||
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
|
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
|
||||||
import {
|
import {
|
||||||
@@ -24,8 +23,13 @@ import {
|
|||||||
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
|
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
|
||||||
import type { DedupeImportProgress } from './dedupe-import-model.ts'
|
import type { DedupeImportProgress } from './dedupe-import-model.ts'
|
||||||
import { toExportUrl } from './dedupe-total-export.ts'
|
import { toExportUrl } from './dedupe-total-export.ts'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
const session = useAdminSessionStore()
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<DedupeTotalItem[]>([])
|
const rows = ref<DedupeTotalItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -33,11 +37,18 @@ const page = ref(1)
|
|||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const filter = reactive(createDedupeTotalFilterState())
|
const filter = reactive(createDedupeTotalFilterState())
|
||||||
const groups = ref<DedupeGroupOption[]>([])
|
const groups = ref<DedupeGroupOption[]>([])
|
||||||
|
/** 非超管仅有一个可访问分组时,编辑/导入弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||||
|
const lockedGroupId = computed<number | null>(() => {
|
||||||
|
if (isSuperAdmin.value) return null
|
||||||
|
return groups.value.length === 1 ? groups.value[0].id : null
|
||||||
|
})
|
||||||
|
const jumpPage = ref('')
|
||||||
|
/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */
|
||||||
|
const pageCursor = ref<number | null>(null)
|
||||||
|
/** 本次请求实际使用的游标(load 时决定) */
|
||||||
|
let pendingCursor: number | null = null
|
||||||
|
|
||||||
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
const groupSummary = computed(() =>
|
|
||||||
dedupeGroupSummaryOf(groups.value, session.user?.id ?? null, session.user?.role || ''),
|
|
||||||
)
|
|
||||||
|
|
||||||
const editVisible = ref(false)
|
const editVisible = ref(false)
|
||||||
const editSaving = ref(false)
|
const editSaving = ref(false)
|
||||||
@@ -78,10 +89,15 @@ function stopExportWait(): void {
|
|||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize.value))
|
const result = await fetchDedupeTotalList(
|
||||||
|
toDedupeListParams(filter, page.value, pageSize.value, pendingCursor),
|
||||||
|
)
|
||||||
rows.value = result.items
|
rows.value = result.items
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
if (result.page >= 1) page.value = result.page
|
if (result.page >= 1) page.value = result.page
|
||||||
|
// 本页响应回传的游标留作"下一页"用;空页则清空(没有更多)
|
||||||
|
pageCursor.value = result.nextLastId ?? null
|
||||||
|
pendingCursor = null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,19 +107,40 @@ async function load(): Promise<void> {
|
|||||||
|
|
||||||
function apply(): void {
|
function apply(): void {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
pageCursor.value = null
|
||||||
|
pendingCursor = null
|
||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset(): void {
|
function changePage(next: number): void {
|
||||||
Object.assign(filter, createDedupeTotalFilterState())
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
// 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变
|
||||||
|
pendingCursor = next === page.value + 1 ? pageCursor.value : null
|
||||||
|
page.value = next
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goJump(): void {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
pageCursor.value = null
|
||||||
|
pendingCursor = null
|
||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: DedupeTotalItem): void {
|
function openEdit(row: DedupeTotalItem): void {
|
||||||
editTarget.value = row
|
editTarget.value = row
|
||||||
editValue.value = row.dataValue
|
editValue.value = row.dataValue
|
||||||
editGroupId.value = row.groupId
|
editGroupId.value = row.groupId ?? lockedGroupId.value
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,24 +170,19 @@ async function saveEdit(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeRow(row: DedupeTotalItem): Promise<void> {
|
async function removeRow(row: DedupeTotalItem): Promise<void> {
|
||||||
|
// 对齐旧版:浏览器原生 confirm 确认后删除。
|
||||||
|
if (!window.confirm(`确定删除总数据“${row.dataValue}”吗?`)) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除总数据“${row.dataValue}”吗?`, '删除总数据', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '确定删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
})
|
|
||||||
await deleteDedupeTotal(row.id)
|
await deleteDedupeTotal(row.id)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
await load()
|
await load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error !== 'cancel' && error !== 'close') {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetImport(): void {
|
function resetImport(): void {
|
||||||
importGroupId.value = null
|
importGroupId.value = lockedGroupId.value
|
||||||
importFile.value = null
|
importFile.value = null
|
||||||
importProgress.value = ''
|
importProgress.value = ''
|
||||||
importRunning.value = false
|
importRunning.value = false
|
||||||
@@ -208,15 +240,7 @@ async function submitImport(): Promise<void> {
|
|||||||
const file = importFile.value as File
|
const file = importFile.value as File
|
||||||
const groupId = importGroupId.value as number
|
const groupId = importGroupId.value as number
|
||||||
if (importMode.value === 'delete') {
|
if (importMode.value === 'delete') {
|
||||||
try {
|
if (!window.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?')) return
|
||||||
await ElMessageBox.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?', '删除导入', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '确定删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const mode = importMode.value
|
const mode = importMode.value
|
||||||
importRunning.value = true
|
importRunning.value = true
|
||||||
@@ -288,153 +312,163 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="dedupe-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="dedupe-panel-head">
|
||||||
<h2>数据去重总数据</h2>
|
<h3>ASIN列表</h3>
|
||||||
<p>去重总数据的 ASIN 值台账,可按 ASIN/用户名/分组/国家/日期筛选,支持 Excel 导入与导出。</p>
|
<div class="dedupe-head-actions">
|
||||||
|
<button class="btn" type="button" @click="openImportAdd">新增导入</button>
|
||||||
|
<button class="btn" type="button" @click="openImportDelete">删除导入</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" class="group-summary-card">
|
<div class="dedupe-filter-row">
|
||||||
<div class="group-summary">
|
<div class="form-group">
|
||||||
<span class="group-summary-title">数据权限分组</span>
|
<label>ASIN</label>
|
||||||
<div class="group-summary-body">
|
<input v-model="filter.keyword" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
|
||||||
<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>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名(模糊搜索)</label>
|
||||||
|
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
<div v-if="isSuperAdmin" class="form-group">
|
||||||
|
|
||||||
<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>
|
<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 groups" :key="group.id" :label="group.name" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group">
|
||||||
<label>国家</label>
|
<label>国家</label>
|
||||||
<el-select v-model="filter.country" placeholder="全部" clearable>
|
<select v-model="filter.country">
|
||||||
<el-option v-for="code in ASIN_COUNTRY_CODES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
<option value="">全部国家</option>
|
||||||
</el-select>
|
<option v-for="code in ASIN_COUNTRY_CODES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<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>
|
<label>开始日期</label>
|
||||||
<el-date-picker v-model="filter.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
|
<input v-model="filter.startDate" type="date" />
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group">
|
||||||
<label>结束日期</label>
|
<label>结束日期</label>
|
||||||
<el-date-picker v-model="filter.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
|
<input v-model="filter.endDate" type="date" />
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item btn-row">
|
<div class="dedupe-filter-actions">
|
||||||
<el-button type="primary" @click="apply">查询</el-button>
|
<button class="btn btn-secondary" type="button" :disabled="exporting" @click="doExport">
|
||||||
<el-button @click="reset">重置</el-button>
|
{{ exporting ? '导出中...' : '导出 XLSX' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="table-scroll dedupe-table-scroll">
|
||||||
<div class="table-toolbar">
|
<table>
|
||||||
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
|
<thead>
|
||||||
<el-button @click="openImportDelete">删除导入</el-button>
|
<tr>
|
||||||
<el-button :loading="exporting" @click="doExport">导出</el-button>
|
<th>ID</th>
|
||||||
</div>
|
<th>ASIN</th>
|
||||||
<el-table v-loading="loading" :data="rows" stripe border>
|
<th>国家</th>
|
||||||
<el-table-column prop="dataValue" label="ASIN" min-width="170" />
|
<th>用户名</th>
|
||||||
<el-table-column label="国家" min-width="100" align="center">
|
<th v-if="isSuperAdmin">分组</th>
|
||||||
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
|
<th>创建时间</th>
|
||||||
</el-table-column>
|
<th>更新时间</th>
|
||||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
<th>操作</th>
|
||||||
<el-table-column label="分组" min-width="150">
|
</tr>
|
||||||
<template #default="{ row }">{{ (row as DedupeTotalItem).groupName || '未分组' }}</template>
|
</thead>
|
||||||
</el-table-column>
|
<tbody>
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
<template v-if="rows.length">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
<tr v-for="row in rows" :key="row.id">
|
||||||
</el-table-column>
|
<td>{{ row.id }}</td>
|
||||||
<el-table-column label="操作" min-width="150" fixed="right">
|
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||||
<template #default="{ row }">
|
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||||
<el-button link type="primary" @click="openEdit(row as DedupeTotalItem)">编辑</el-button>
|
<td>{{ row.username || '-' }}</td>
|
||||||
<el-button link type="danger" @click="removeRow(row as DedupeTotalItem)">删除</el-button>
|
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
</el-table>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||||||
<div class="table-footer">
|
</tr>
|
||||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
<tr v-else>
|
||||||
<el-pagination
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无总数据</td>
|
||||||
background
|
</tr>
|
||||||
layout="sizes, prev, pager, next, jumper"
|
</tbody>
|
||||||
:total="total"
|
</table>
|
||||||
: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>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-dialog v-model="editVisible" title="编辑总数据" width="480px">
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
<el-form label-position="top">
|
</section>
|
||||||
<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">
|
<div v-if="editVisible" class="modal-mask" @click.self="editVisible = false">
|
||||||
<el-form label-position="top">
|
<div class="modal">
|
||||||
<el-form-item label="数据权限分组">
|
<h3>编辑ASIN</h3>
|
||||||
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
<div class="form-group">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
<label>ASIN</label>
|
||||||
</el-select>
|
<input v-model="editValue" type="text" placeholder="请输入总数据值" />
|
||||||
</el-form-item>
|
</div>
|
||||||
<el-form-item label="Excel 文件">
|
<div class="form-group">
|
||||||
|
<label>分组</label>
|
||||||
|
<select v-model="editGroupId" :disabled="lockedGroupId != null">
|
||||||
|
<option disabled :value="null">请选择分组</option>
|
||||||
|
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn" type="button" :disabled="editSaving" @click="saveEdit">保存</button>
|
||||||
|
<button class="btn btn-secondary" type="button" @click="editVisible = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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" />
|
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
|
||||||
<p class="import-tip">{{ importMode === 'add' ? '上传 Excel 按 ASIN 新增到所选分组,仅支持 .xlsx/.xls。' : 'Excel 第一列 ASIN 将按分组批量删除匹配总数据,仅支持 .xlsx/.xls。' }}</p>
|
</div>
|
||||||
</el-form-item>
|
<div class="form-group">
|
||||||
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
|
<label>分组</label>
|
||||||
</el-form>
|
<select v-model="importGroupId" :disabled="importRunning || lockedGroupId != null">
|
||||||
<template #footer>
|
<option disabled :value="null">请选择分组</option>
|
||||||
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
|
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||||
<el-button type="primary" :loading="importRunning" @click="submitImport">
|
</select>
|
||||||
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
|
</div>
|
||||||
</el-button>
|
<div v-if="importProgress" class="progress-wrap">
|
||||||
</template>
|
<div class="progress-text">{{ importProgress }}</div>
|
||||||
</el-dialog>
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:class="{ 'btn-danger': importMode === 'delete' }"
|
||||||
|
type="button"
|
||||||
|
:disabled="importRunning"
|
||||||
|
@click="submitImport"
|
||||||
|
>
|
||||||
|
{{ importMode === 'add' ? '上传并导入' : '上传并删除' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" type="button" :disabled="importRunning" @click="importVisible = false">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="exportWaitVisible" class="export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
|
<div v-if="exportWaitVisible" class="dedupe-export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
|
||||||
<div class="export-wait">
|
<div class="dedupe-export-wait">
|
||||||
<span class="wait-spinner" aria-hidden="true"></span>
|
<span class="request-spinner" aria-hidden="true"></span>
|
||||||
<div>
|
<div>
|
||||||
<div class="export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
|
<div class="dedupe-export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
|
||||||
<div class="export-wait-detail">数据量较大,请耐心等待并保持页面打开。已等待 {{ exportWaitSeconds }} 秒</div>
|
<div class="dedupe-export-wait-detail">
|
||||||
|
数据量较大,请耐心等待并保持页面打开。已等待 <span>{{ exportWaitSeconds }}</span> 秒
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -442,60 +476,352 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
/* 像素复刻旧版 admin.html panel-dedupe-total-data(蓝白末层样式)。 */
|
||||||
.heading-actions { display: flex; gap: 8px; flex: none; }
|
.dedupe-view {
|
||||||
.group-summary-card { margin-bottom: 12px; }
|
font-family: inherit;
|
||||||
.group-summary { display: flex; align-items: flex-start; gap: 14px; }
|
color: var(--c-text, #24384d);
|
||||||
.group-summary-title { flex: none; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 24px; }
|
display: flex;
|
||||||
.group-summary-body { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
flex-direction: column;
|
||||||
.summary-chip {
|
gap: 18px;
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--el-color-primary-light-9);
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
}
|
||||||
.dim { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
.form-box,
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
.panel-box {
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
width: 100%;
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
min-width: 0;
|
||||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
padding: 20px 22px 24px;
|
||||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
border: 1px solid #d8e3ee;
|
||||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
border-radius: 14px;
|
||||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
.export-wait-mask {
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.dedupe-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.dedupe-panel-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.dedupe-head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.dedupe-filter-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.dedupe-filter-row .form-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dedupe-filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dedupe-filter-date-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.dedupe-filter-date-row .form-group {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dedupe-filter-date-row .dedupe-filter-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.form-group input[type="file"] {
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.form-group input[type="date"] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.dedupe-table-scroll > table {
|
||||||
|
min-width: 900px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.mono-cell {
|
||||||
|
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.modal-mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 3000;
|
z-index: 3000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(24, 29, 43, 0.45);
|
padding: 40px 20px;
|
||||||
backdrop-filter: blur(3px);
|
background: rgba(45, 66, 86, 0.36);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
.export-wait {
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: 84vh;
|
||||||
|
padding: 22px 24px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal .form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.progress-wrap {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #5b6f83;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.dedupe-export-wait-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(45, 66, 86, 0.36);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.dedupe-export-wait {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
background: var(--el-bg-color);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 22px 28px;
|
padding: 22px 28px;
|
||||||
box-shadow: 0 12px 40px -12px rgba(39, 67, 94, 0.35);
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||||||
}
|
}
|
||||||
.wait-spinner {
|
.request-spinner {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
flex: none;
|
flex: none;
|
||||||
border: 3px solid var(--el-color-primary-light-7);
|
border: 3px solid #d5e3ef;
|
||||||
border-top-color: var(--el-color-primary);
|
border-top-color: #5f85ad;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: dedupe-spin 0.9s linear infinite;
|
animation: dedupe-spin 0.9s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes dedupe-spin {
|
@keyframes dedupe-spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
.export-wait-title { font-size: 15px; font-weight: 600; }
|
.dedupe-export-wait-title {
|
||||||
.export-wait-detail { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12.5px; }
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.dedupe-export-wait-detail {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #5b6f83;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 商品类目:对齐 admin panel-product-categories —— 树节点带 来源/排序/备注 + 行内编辑删除(有子禁用)、
|
/** 商品类目 · 像素复刻旧版 admin.html panel-product-categories(自绘:面板头搜索框组+新增类目 同行、原生树表格[+/−]折叠+懒加载「加载更多 已加载 x/y」、搜索态旧式分页)。
|
||||||
* 按需懒加载子级(「加载更多 已加载 x/y」)、搜索态独立分页/加载更多、导出、新增/编辑弹窗语义。 */
|
* script 逻辑沿用现有 Vue 实现(懒加载树/编辑删除/导出/弹窗)。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
|
import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from './product-category-model.ts'
|
} from './product-category-model.ts'
|
||||||
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
|
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
|
||||||
import { buildProductCategoryExportUrl } from './product-category-export.ts'
|
import { buildProductCategoryExportUrl } from './product-category-export.ts'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tree = ref<ProductCategoryNode[]>([])
|
const tree = ref<ProductCategoryNode[]>([])
|
||||||
@@ -23,6 +25,7 @@ const selected = ref<ProductCategoryNode | null>(null)
|
|||||||
|
|
||||||
const searchLoading = ref(false)
|
const searchLoading = ref(false)
|
||||||
const searchRows = ref<ProductCategoryNode[]>([])
|
const searchRows = ref<ProductCategoryNode[]>([])
|
||||||
|
const searchPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||||
const searchTotal = ref(0)
|
const searchTotal = ref(0)
|
||||||
const searchPage = ref(1)
|
const searchPage = ref(1)
|
||||||
const searchHasMore = ref(false)
|
const searchHasMore = ref(false)
|
||||||
@@ -54,17 +57,23 @@ function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
|
|||||||
const allNodes = computed(() => flatten(tree.value))
|
const allNodes = computed(() => flatten(tree.value))
|
||||||
const searching = computed(() => (keyword.value || '').trim().length > 0)
|
const searching = computed(() => (keyword.value || '').trim().length > 0)
|
||||||
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
|
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
|
||||||
|
const searchTotalPages = computed(() => Math.max(1, Math.ceil(searchTotal.value / searchPageSize.value)))
|
||||||
|
|
||||||
/** 拉取根级第一页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
|
/** 顶层翻页(服务端分页,10/20/50/100 可选;子级保留懒加载+加载更多对齐旧版)。 */
|
||||||
|
const rootPage = ref(1)
|
||||||
|
const rootPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||||
|
const rootTotal = ref(0)
|
||||||
|
|
||||||
|
/** 拉取根级当前页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
|
||||||
async function loadTree(): Promise<void> {
|
async function loadTree(): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchProductCategoryChildren(null, 1, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
const result = await fetchProductCategoryChildren(null, rootPage.value, rootPageSize.value)
|
||||||
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
|
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
|
||||||
childrenState.set(ROOT_KEY, state)
|
childrenState.set(ROOT_KEY, state)
|
||||||
tree.value = result.hasMore
|
// 顶层当前页直接作为树根(不含合成「加载更多」节点,翻页由 OldPagination 承担)。
|
||||||
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
|
tree.value = result.items
|
||||||
: result.items
|
rootTotal.value = result.total
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -72,6 +81,17 @@ async function loadTree(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeRootPage(p: number) {
|
||||||
|
rootPage.value = p
|
||||||
|
void loadTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeRootSize(size: number) {
|
||||||
|
rootPageSize.value = size
|
||||||
|
rootPage.value = 1
|
||||||
|
void loadTree()
|
||||||
|
}
|
||||||
|
|
||||||
/** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */
|
/** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */
|
||||||
interface TreeRow {
|
interface TreeRow {
|
||||||
node: ProductCategoryNode
|
node: ProductCategoryNode
|
||||||
@@ -167,11 +187,15 @@ function moreLoading(more: ProductCategoryNode): boolean {
|
|||||||
return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false)
|
return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeSearchSize(size: number) {
|
||||||
|
searchPageSize.value = size
|
||||||
|
void runSearch(1)
|
||||||
|
}
|
||||||
|
|
||||||
async function runSearch(page: number): Promise<void> {
|
async function runSearch(page: number): Promise<void> {
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await searchProductCategories(keyword.value, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
const result = await searchProductCategories(keyword.value, page, searchPageSize.value)
|
||||||
// 分页换页模式:每页独立展示当前页结果(对齐列表统一分页交互)。
|
|
||||||
searchRows.value = result.items
|
searchRows.value = result.items
|
||||||
searchTotal.value = result.total
|
searchTotal.value = result.total
|
||||||
searchPage.value = result.page
|
searchPage.value = result.page
|
||||||
@@ -197,10 +221,6 @@ function reset(): void {
|
|||||||
void loadTree()
|
void loadTree()
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchChangePage(p: number): void {
|
|
||||||
void runSearch(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate(parent: ProductCategoryNode | null) {
|
function openCreate(parent: ProductCategoryNode | null) {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
Object.assign(draft, createProductCategoryDraft())
|
Object.assign(draft, createProductCategoryDraft())
|
||||||
@@ -236,22 +256,17 @@ async function removeCategory(node: ProductCategoryNode): Promise<void> {
|
|||||||
ElMessage.warning('存在子类目,不能删除')
|
ElMessage.warning('存在子类目,不能删除')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 对齐旧版:原生 confirm。
|
||||||
|
if (!window.confirm(`确定删除“${node.name}”?`)) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除“${node.name}”?`, '删除类目', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '确定删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
})
|
|
||||||
await deleteProductCategory(node.id)
|
await deleteProductCategory(node.id)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
if (selected.value?.id === node.id) selected.value = null
|
if (selected.value?.id === node.id) selected.value = null
|
||||||
if (searching.value) void runSearch(1)
|
if (searching.value) void runSearch(1)
|
||||||
else void loadTree()
|
else void loadTree()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error !== 'cancel' && error !== 'close') {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
async function submit(): Promise<void> {
|
||||||
@@ -293,179 +308,336 @@ onMounted(loadTree)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="category-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="category-head">
|
||||||
<h2>商品类目</h2>
|
<h3>商品类目</h3>
|
||||||
<p>维护商品类目树与类目名称/说明;有子类目节点不可删除。</p>
|
<div class="category-head-tools">
|
||||||
</div>
|
<input v-model="keyword" type="text" placeholder="搜索类目名称、编码或备注" class="category-keyword" @keyup.enter="apply" />
|
||||||
<div class="actions">
|
<button class="btn btn-sm" type="button" @click="apply">搜索</button>
|
||||||
<el-button type="primary" @click="openCreate(null)">新增类目</el-button>
|
<button class="btn btn-sm btn-secondary" type="button" @click="reset">清空</button>
|
||||||
<el-button @click="exportCategories">导出</el-button>
|
<button class="btn btn-sm btn-secondary" type="button" @click="exportCategories">导出</button>
|
||||||
|
<button v-if="selected && !searching" class="btn btn-sm" type="button" @click="openCreate(selected)">新增子类目</button>
|
||||||
|
<button class="btn" type="button" @click="openCreate(null)">新增类目</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" style="margin-bottom: 14px">
|
<div class="table-scroll category-table-scroll">
|
||||||
<div class="filter-row">
|
<table>
|
||||||
<el-input v-model="keyword" placeholder="搜索类目名称、编码或备注" clearable style="width: 280px" @keyup.enter="apply" />
|
<thead>
|
||||||
<el-button type="primary" @click="apply">搜索</el-button>
|
<tr>
|
||||||
<el-button @click="reset">清空</el-button>
|
<th style="width: 34%">类目</th>
|
||||||
|
<th>层级路径</th>
|
||||||
|
<th style="width: 90px">排序</th>
|
||||||
|
<th style="width: 120px">来源</th>
|
||||||
|
<th>备注</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</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="8">
|
||||||
|
<div class="load-more-cell">
|
||||||
|
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
|
||||||
|
<button class="btn btn-sm btn-secondary" type="button" :disabled="moreLoading(row.node)" @click="loadMoreChildren(row.node)">
|
||||||
|
{{ moreLoading(row.node) ? '加载中...' : '加载更多' }}
|
||||||
|
</button>
|
||||||
|
<span class="loaded-count">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</td>
|
||||||
|
</tr>
|
||||||
<el-card v-if="!searching" shadow="never">
|
<tr v-else>
|
||||||
<el-table v-loading="loading" :data="treeRows" row-key="node.id" :class="{ 'tree-table': true }">
|
<td>
|
||||||
<el-table-column label="类目名称" min-width="320">
|
<div class="tree-name-cell" @click="onNodeClick(row.node)">
|
||||||
<template #default="{ row }">
|
<span class="tree-indent" :style="{ width: `${row.depth * 24}px` }"></span>
|
||||||
<template v-if="isCategoryLoadMoreNode(row.node)">
|
|
||||||
<div class="tree-name-cell">
|
|
||||||
<span class="tree-indent" :style="{ '--indent': `${(row.depth + 1) * 24}px` }"></span>
|
|
||||||
<el-button link size="small" type="primary" :loading="moreLoading(row.node)" @click="loadMoreChildren(row.node)">加载更多</el-button>
|
|
||||||
<span class="dim">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-else class="tree-name-cell" @click="onNodeClick(row.node as ProductCategoryNode)">
|
|
||||||
<span class="tree-indent" :style="{ '--indent': `${row.depth * 24}px` }"></span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tree-node-mark"
|
class="tree-node-mark"
|
||||||
:class="{ 'is-leaf': (row.node as ProductCategoryNode).childCount <= 0 }"
|
:class="{ 'is-leaf': row.node.childCount <= 0 }"
|
||||||
:disabled="(row.node as ProductCategoryNode).childCount <= 0"
|
:disabled="row.node.childCount <= 0"
|
||||||
:title="expandedIds.has((row.node as ProductCategoryNode).id) ? '折叠' : '展开'"
|
:title="expandedIds.has(row.node.id) ? '折叠' : '展开'"
|
||||||
@click.stop="toggleExpand(row.node as ProductCategoryNode)"
|
@click.stop="toggleExpand(row.node)"
|
||||||
>
|
>
|
||||||
{{ (row.node as ProductCategoryNode).childCount > 0 ? (expandedIds.has((row.node as ProductCategoryNode).id) ? '−' : '+') : '' }}
|
{{ row.node.childCount > 0 ? (expandedIds.has(row.node.id) ? '−' : '+') : '' }}
|
||||||
</button>
|
</button>
|
||||||
<strong class="node-name">{{ (row.node as ProductCategoryNode).name }}</strong>
|
<strong class="node-name">{{ row.node.name }}</strong>
|
||||||
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
|
<span v-if="selected?.id === row.node.id" class="selected-flag">已选</span>
|
||||||
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</td>
|
||||||
</el-table-column>
|
<td>
|
||||||
<el-table-column label="层级路径" min-width="220">
|
<span class="category-path-value" :title="row.node.path || row.node.name">{{ row.node.path || row.node.name }}</span>
|
||||||
<template #default="{ row }">
|
</td>
|
||||||
<span class="dim" :title="(row.node as ProductCategoryNode).path">{{ (row.node as ProductCategoryNode).path || (row.node as ProductCategoryNode).name }}</span>
|
<td>{{ row.node.sortOrder ?? 0 }}</td>
|
||||||
</template>
|
<td>
|
||||||
</el-table-column>
|
<span class="category-tag" :class="{ 'is-custom': !row.node.isBuiltin }">{{ productCategorySourceLabel(row.node) }}</span>
|
||||||
<el-table-column label="排序" min-width="80">
|
</td>
|
||||||
<template #default="{ row }">{{ (row.node as ProductCategoryNode).sortOrder ?? 0 }}</template>
|
<td>
|
||||||
</el-table-column>
|
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
|
||||||
<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>
|
<span v-else class="dim">—</span>
|
||||||
</template>
|
</td>
|
||||||
</el-table-column>
|
<td>{{ formatDateTime(row.node.createdAt) }}</td>
|
||||||
<el-table-column label="操作" min-width="140" fixed="right">
|
<td>{{ formatDateTime(row.node.updatedAt) }}</td>
|
||||||
<template #default="{ row }">
|
<td class="ops-cell">
|
||||||
<template v-if="!isCategoryLoadMoreNode(row.node)">
|
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
|
||||||
<el-button link size="small" type="primary" @click="openEdit(row.node as ProductCategoryNode)">编辑</el-button>
|
<button
|
||||||
<el-button
|
class="btn btn-sm btn-danger"
|
||||||
link
|
type="button"
|
||||||
size="small"
|
:disabled="!canDelete(row.node)"
|
||||||
type="danger"
|
:title="!canDelete(row.node) ? '存在子类目,不能删除' : undefined"
|
||||||
:disabled="!canDelete(row.node as ProductCategoryNode)"
|
@click="removeCategory(row.node)"
|
||||||
@click="removeCategory(row.node as ProductCategoryNode)"
|
>
|
||||||
>删除</el-button>
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
</el-table>
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
<div class="tree-actions">
|
</tr>
|
||||||
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
|
<tr v-else>
|
||||||
<span class="btn-group">
|
<td colspan="8" class="empty-tip">暂无商品类目</td>
|
||||||
<el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button>
|
</tr>
|
||||||
</span>
|
</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>{{ 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"
|
||||||
|
:disabled="!canDelete(row)"
|
||||||
|
:title="!canDelete(row) ? '存在子类目,不能删除' : undefined"
|
||||||
|
@click="removeCategory(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="searchLoading">
|
||||||
|
<td colspan="8" class="empty-tip">搜索中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td colspan="8" class="empty-tip">暂无搜索结果</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card v-else shadow="never">
|
<OldPagination v-if="searching" :total="searchTotal" :page="searchPage" :page-size="searchPageSize" @change="runSearch" @size-change="changeSearchSize" />
|
||||||
<div class="search-head">
|
<OldPagination v-else-if="rootTotal > 0" :total="rootTotal" :page="rootPage" :page-size="rootPageSize" @change="changeRootPage" @size-change="changeRootSize" />
|
||||||
<span>搜索结果:共 {{ searchTotal.toLocaleString() }} 条</span>
|
</section>
|
||||||
<span class="dim">{{ keyword }}</span>
|
|
||||||
</div>
|
|
||||||
<el-table v-loading="searchLoading" :data="searchRows" stripe border>
|
|
||||||
<el-table-column prop="name" label="类目名称" min-width="180" />
|
|
||||||
<el-table-column label="层级路径" min-width="240">
|
|
||||||
<template #default="{ row }">{{ (row as ProductCategoryNode).path || (row as ProductCategoryNode).name }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="排序" min-width="90">
|
|
||||||
<template #default="{ row }">{{ (row as ProductCategoryNode).sortOrder ?? '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="来源" min-width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag size="small" effect="plain" :type="(row as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
|
|
||||||
{{ productCategorySourceLabel(row as ProductCategoryNode) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="说明" min-width="180">
|
|
||||||
<template #default="{ row }">{{ (row as ProductCategoryNode).description || '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" min-width="150" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button link type="primary" @click="openEdit(row as ProductCategoryNode)">编辑</el-button>
|
|
||||||
<el-button link type="danger" :disabled="!canDelete(row as ProductCategoryNode)" @click="removeCategory(row as ProductCategoryNode)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<div class="table-footer">
|
|
||||||
<el-pagination background layout="prev, pager, next, jumper" :total="searchTotal" :page-size="PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE" :current-page="searchPage" @current-change="searchChangePage" />
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
|
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
|
||||||
<el-form label-width="110px">
|
<el-form label-width="110px">
|
||||||
<el-form-item label="父级类目">
|
<el-form-item label="父级类目">
|
||||||
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable>
|
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable>
|
||||||
<el-option v-for="node in allNodes" :key="node.id" :label="node.path || node.name" :value="node.id" />
|
<el-option
|
||||||
|
v-for="node in allNodes"
|
||||||
|
:key="node.id"
|
||||||
|
:label="node.path || node.name"
|
||||||
|
:value="node.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="类目名称" required>
|
<el-form-item label="类目名称" required>
|
||||||
<el-input v-model="draft.name" placeholder="类目名称" />
|
<el-input v-model="draft.name" placeholder="如:洁面" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="排序">
|
<el-form-item label="排序">
|
||||||
<el-input v-model="draft.sortOrder" placeholder="非负整数,可选" />
|
<el-input v-model="draft.sortOrder" placeholder="如 10" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="说明">
|
<el-form-item label="备注">
|
||||||
<el-input v-model="draft.description" type="textarea" :rows="2" placeholder="可选" />
|
<el-input v-model="draft.description" type="textarea" :rows="3" placeholder="可选" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
|
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
|
||||||
<el-button type="primary" :loading="saving" @click="submit">保存类目</el-button>
|
<el-button type="primary" :loading="saving" @click="submit">{{ editingId == null ? '保存' : '保存类目' }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
/* 像素复刻旧版 admin.html panel-product-categories(蓝白末层)。 */
|
||||||
.actions { display: flex; align-items: center; gap: 10px; }
|
.category-view {
|
||||||
.filter-row { display: flex; gap: 10px; align-items: center; }
|
font-family: inherit;
|
||||||
.tree-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
|
color: #24384d;
|
||||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
}
|
||||||
.btn-group { display: inline-flex; gap: 6px; }
|
.panel-box {
|
||||||
.search-head { display: flex; justify-content: space-between; margin-bottom: 12px; }
|
width: 100%;
|
||||||
.load-more-row { text-align: center; margin-top: 12px; }
|
min-width: 0;
|
||||||
/* 树形表格(对齐旧版 panel-product-categories):缩进 + [+/−] 折叠标记 + 粗体名称。 */
|
padding: 20px 22px 24px;
|
||||||
.tree-name-cell { display: flex; align-items: center; gap: 8px; min-height: 24px; }
|
border: 1px solid #d8e3ee;
|
||||||
.tree-indent { flex: 0 0 auto; width: var(--indent, 0px); }
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.category-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.category-head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.category-keyword {
|
||||||
|
width: 240px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.category-keyword:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.category-keyword:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.category-table-scroll > table {
|
||||||
|
min-width: 860px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.tree-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tree-indent {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
.tree-node-mark {
|
.tree-node-mark {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: #eef0fe;
|
background: var(--admin-primary-soft, #e7f0f8);
|
||||||
color: #6366f1;
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -477,9 +649,115 @@ onMounted(loadTree)
|
|||||||
flex: none;
|
flex: none;
|
||||||
transition: background 0.12s ease, color 0.12s ease;
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
}
|
}
|
||||||
.tree-node-mark:not(.is-leaf):hover { background: #6366f1; color: #fff; }
|
.tree-node-mark:not(.is-leaf):hover {
|
||||||
.tree-node-mark.is-leaf { background: transparent; cursor: default; }
|
background: var(--admin-primary-strong, #2f5d8b);
|
||||||
.node-name { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; }
|
color: #ffffff;
|
||||||
.node-desc { color: var(--el-text-color-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; display: inline-block; vertical-align: middle; }
|
}
|
||||||
.tree-table .el-table__row { cursor: pointer; }
|
.tree-node-mark.is-leaf {
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.node-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #24384d;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.selected-flag {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f0f8;
|
||||||
|
color: #2f5d8b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.category-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #c2d5e6;
|
||||||
|
background: #e7f0f8;
|
||||||
|
color: #2f5d8b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.category-tag.is-custom {
|
||||||
|
border-color: #bcd9c6;
|
||||||
|
background: #eef7f0;
|
||||||
|
color: #3d7158;
|
||||||
|
}
|
||||||
|
.category-path-value {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.node-desc {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 320px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #8293a5;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-row td {
|
||||||
|
background: #fbfdfe;
|
||||||
|
}
|
||||||
|
.load-more-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.loaded-count {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 查询 ASIN:对齐 admin panel-query-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国 ASIN 行可点击复制) +
|
/** 查询 ASIN · 像素复刻旧版 admin.html panel-query-asin(自绘:面板头操作组、筛选+导出 XLSX、原生 rowspan 表格+可复制 ASIN、旧式分页)。
|
||||||
* 新增 ASIN 弹窗(分组→店铺联动→国家→ASIN) + 行内「配置」抽屉 + 导入添加/删除导入/导出。 */
|
* script 逻辑沿用现有 Vue 实现(新增级联/配置抽屉/导入轮询/导出)。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import CopyText from '@/components/CopyText.vue'
|
import CopyText from '@/components/CopyText.vue'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
|
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||||
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
|
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
|
||||||
import { asinCountryLabel } from './asin-country.ts'
|
import { asinCountryLabel } from './asin-country.ts'
|
||||||
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
|
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
|
||||||
|
import { toQueryAsinExportUrl } from './query-asin-export.ts'
|
||||||
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
|
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
|
||||||
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
|
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
|
||||||
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
|
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
|
||||||
import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
||||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<QueryAsinItem[]>([])
|
const rows = ref<QueryAsinItem[]>([])
|
||||||
@@ -21,19 +29,18 @@ const total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
|
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||||
|
const lockedGroupId = computed<number | null>(() => {
|
||||||
|
if (isSuperAdmin.value) return null
|
||||||
|
return groups.value.length === 1 ? groups.value[0].id : null
|
||||||
|
})
|
||||||
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||||
|
const jumpPage = ref('')
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
||||||
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||||
|
|
||||||
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/操作4):分组(0)/店铺(1)/操作(4) 仅首行占位,其余合并。 */
|
|
||||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
|
||||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 4) {
|
|
||||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
|
||||||
}
|
|
||||||
return [1, 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
function asinOf(row: QueryAsinItem, country: string): string {
|
function asinOf(row: QueryAsinItem, country: string): string {
|
||||||
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
|
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
|
||||||
return map[country] || ''
|
return map[country] || ''
|
||||||
@@ -57,6 +64,21 @@ function normalizeAsinInput(country: string): void {
|
|||||||
draftAsin[country] = draftAsin[country].trim().toUpperCase()
|
draftAsin[country] = draftAsin[country].trim().toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function removeShopAsin(item: QueryAsinItem) {
|
||||||
|
// 删除该店铺全部站点的 ASIN 配置(逐国调用现有端点)。
|
||||||
|
if (!window.confirm(`确定删除店铺“${item.shopName}”的全部站点 ASIN 配置吗?`)) return
|
||||||
|
try {
|
||||||
|
for (const country of QUERY_ASIN_COUNTRIES) {
|
||||||
|
if (asinOf(item, country)) await deleteQueryAsinCountryAsin(item.id, country)
|
||||||
|
}
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveConfig(): Promise<void> {
|
async function saveConfig(): Promise<void> {
|
||||||
const item = drawerItem.value
|
const item = drawerItem.value
|
||||||
if (!item) return
|
if (!item) return
|
||||||
@@ -104,7 +126,7 @@ const creating = ref(false)
|
|||||||
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
createGroupId.value = null
|
createGroupId.value = lockedGroupId.value
|
||||||
createShopName.value = ''
|
createShopName.value = ''
|
||||||
createCountry.value = ''
|
createCountry.value = ''
|
||||||
createAsin.value = ''
|
createAsin.value = ''
|
||||||
@@ -112,6 +134,8 @@ function openCreate(): void {
|
|||||||
createMsgOk.value = false
|
createMsgOk.value = false
|
||||||
shopNames.value = []
|
shopNames.value = []
|
||||||
createVisible.value = true
|
createVisible.value = true
|
||||||
|
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||||||
|
if (createGroupId.value != null) void onCreateGroupChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCreateGroupChange(): Promise<void> {
|
async function onCreateGroupChange(): Promise<void> {
|
||||||
@@ -178,7 +202,7 @@ const importRunning = ref(false)
|
|||||||
const importProgress = ref('')
|
const importProgress = ref('')
|
||||||
|
|
||||||
function resetImport(): void {
|
function resetImport(): void {
|
||||||
importGroupId.value = null
|
importGroupId.value = lockedGroupId.value
|
||||||
importFile.value = null
|
importFile.value = null
|
||||||
importProgress.value = ''
|
importProgress.value = ''
|
||||||
importRunning.value = false
|
importRunning.value = false
|
||||||
@@ -216,15 +240,7 @@ async function submitImport(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (importMode.value === 'delete') {
|
if (importMode.value === 'delete') {
|
||||||
try {
|
if (!window.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) return
|
||||||
await ElMessageBox.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?', '删除导入', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '确定删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const mode = importMode.value
|
const mode = importMode.value
|
||||||
const groupId = importGroupId.value ?? undefined
|
const groupId = importGroupId.value ?? undefined
|
||||||
@@ -261,8 +277,9 @@ async function submitImport(): Promise<void> {
|
|||||||
|
|
||||||
// ---- 导出 ----
|
// ---- 导出 ----
|
||||||
function doExport(): void {
|
function doExport(): void {
|
||||||
|
// 带当前筛选(分组/店铺名/ASIN/国家)导出,范围与列表筛选一致(此前不带参数导致导出恒为全量)。
|
||||||
const anchor = document.createElement('a')
|
const anchor = document.createElement('a')
|
||||||
anchor.href = '/api/admin/query-asins/export'
|
anchor.href = toQueryAsinExportUrl(filter)
|
||||||
document.body.appendChild(anchor)
|
document.body.appendChild(anchor)
|
||||||
anchor.click()
|
anchor.click()
|
||||||
anchor.remove()
|
anchor.remove()
|
||||||
@@ -303,12 +320,28 @@ function apply() {
|
|||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function changePage(next: number) {
|
||||||
Object.assign(filter, createQueryAsinFilterState())
|
if (next < 1 || next > totalPages.value) return
|
||||||
page.value = 1
|
page.value = next
|
||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goJump() {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadGroups()
|
void loadGroups()
|
||||||
void load()
|
void load()
|
||||||
@@ -316,94 +349,98 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="query-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="query-head">
|
||||||
<h2>查询 ASIN</h2>
|
<h3>店铺列表</h3>
|
||||||
<p>每店铺在 5 个站点查询到的 ASIN 清单;行内可逐站配置 ASIN。</p>
|
<div class="query-head-actions">
|
||||||
|
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
|
||||||
|
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入添加</button>
|
||||||
|
<button class="btn" type="button" @click="openImportDelete">导入删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="form-row query-filter-row">
|
||||||
<div class="filter-grid">
|
<div v-if="isSuperAdmin" class="form-group">
|
||||||
<div class="f-item">
|
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group">
|
||||||
<label>店铺名</label>
|
<label>店铺名</label>
|
||||||
<el-input v-model="filter.shopName" placeholder="请输入店铺名" clearable @keyup.enter="apply" />
|
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group">
|
||||||
<label>ASIN</label>
|
|
||||||
<el-input v-model="filter.asin" placeholder="请输入 ASIN" clearable @keyup.enter="apply" />
|
|
||||||
</div>
|
|
||||||
<div class="f-item">
|
|
||||||
<label>国家</label>
|
<label>国家</label>
|
||||||
<el-select v-model="filter.country" placeholder="全部国家" clearable>
|
<select v-model="filter.country">
|
||||||
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
<option value="">全部国家</option>
|
||||||
</el-select>
|
<option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item btn-row">
|
<div class="form-group">
|
||||||
<el-button type="primary" @click="apply">查询</el-button>
|
<label>ASIN</label>
|
||||||
<el-button @click="reset">重置</el-button>
|
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn" type="button" @click="apply">查询</button>
|
||||||
|
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="table-scroll query-asin-table-scroll">
|
||||||
<div class="table-toolbar">
|
<table>
|
||||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
<thead>
|
||||||
<el-button @click="openImportAdd">导入添加</el-button>
|
<tr>
|
||||||
<el-button @click="openImportDelete">导入删除</el-button>
|
<th style="width: 6%">序号</th>
|
||||||
<el-button @click="doExport">导出</el-button>
|
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||||||
|
<th style="width: 15%">店铺名</th>
|
||||||
|
<th style="width: 24%">ASIN</th>
|
||||||
|
<th style="width: 12%">国家</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
|
<th style="width: 9%">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="displayRows.length">
|
||||||
|
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||||
|
<template v-if="row.isFirst">
|
||||||
|
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||||
|
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||||
|
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||||
|
</template>
|
||||||
|
<td>
|
||||||
|
<template v-if="row.asin">
|
||||||
|
<CopyText :text="row.asin" class="asin-cell" />
|
||||||
|
</template>
|
||||||
|
<span v-else class="asin-empty">-</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||||
|
<template v-if="row.isFirst">
|
||||||
|
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
|
||||||
|
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
|
||||||
|
<td :rowspan="row.rowspan" class="asin-col-actions">
|
||||||
|
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
<el-table-column label="分组" min-width="130">
|
</section>
|
||||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="店铺" min-width="150">
|
|
||||||
<template #default="{ row }">{{ row.item.shopName }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="ASIN" min-width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
|
|
||||||
<span v-else class="dim">-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="国家" min-width="110">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
|
|
||||||
<span v-else class="dim">-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" min-width="90" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as QueryAsinItem)">配置</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<div class="table-footer">
|
|
||||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
|
||||||
<el-pagination
|
|
||||||
background
|
|
||||||
layout="sizes, prev, pager, next, jumper"
|
|
||||||
:total="total"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:current-page="page"
|
|
||||||
@current-change="(p: number) => { page = p; void load() }"
|
|
||||||
@size-change="() => { page = 1; void load() }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
|
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
|
||||||
<el-form label-position="top" @submit.prevent>
|
<el-form label-position="top" @submit.prevent>
|
||||||
<el-form-item label="分组">
|
<el-form-item label="分组">
|
||||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
|
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -420,7 +457,7 @@ onMounted(() => {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="国家">
|
<el-form-item label="国家">
|
||||||
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
<el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
||||||
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -455,7 +492,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
|
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
|
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
|
||||||
<el-select v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
|
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%" :disabled="lockedGroupId != null">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -476,16 +513,274 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
/* 像素复刻旧版 admin.html panel-query-asin(蓝白末层)。 */
|
||||||
.heading-actions { display: flex; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; }
|
.query-view {
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
font-family: inherit;
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
color: #24384d;
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
}
|
||||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
.panel-box {
|
||||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
width: 100%;
|
||||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
min-width: 0;
|
||||||
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
|
padding: 20px 22px 24px;
|
||||||
.dim { color: var(--el-text-color-placeholder); }
|
border: 1px solid #d8e3ee;
|
||||||
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
|
border-radius: 14px;
|
||||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.query-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.query-head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.query-filter-row > .form-group {
|
||||||
|
flex: 1 1 140px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.query-asin-table-scroll > table {
|
||||||
|
min-width: 860px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.table-scroll td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.asin-cell {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: -3px 0 -3px -7px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.asin-cell:hover {
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
background: #eef4fa;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.asin-empty {
|
||||||
|
color: #8b9aaa;
|
||||||
|
}
|
||||||
|
.asin-col-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.drawer-tip {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.import-tip {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国
|
/** 最低价 ASIN(跳过跟价)· 像素复刻旧版 admin.html panel-skip-price-asin(自绘:面板头操作组、筛选(分组/店铺/国家/ASIN/最低价≥≤)+导出 XLSX、原生 rowspan 表格、旧式分页)。
|
||||||
* ASIN 可点击复制 + 最低价两位小数)、新增 ASIN 弹窗(分组→店铺联动→国家→ASIN+最低价)、价格范围筛选、
|
* script 逻辑沿用现有 Vue 实现(新增级联+最低价、配置抽屉双列、导入轮询、导出)。 */
|
||||||
* 行内「配置」抽屉(逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import CopyText from '@/components/CopyText.vue'
|
import CopyText from '@/components/CopyText.vue'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
|
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
|
||||||
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
|
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
|
||||||
import { asinCountryLabel } from './asin-country.ts'
|
import { asinCountryLabel } from './asin-country.ts'
|
||||||
import { createSkipPriceFilterState, toSkipPriceParams, type SkipPriceFilterState } from './skip-price-filter.ts'
|
import { createSkipPriceFilterState, toSkipPriceParams, type SkipPriceFilterState } from './skip-price-filter.ts'
|
||||||
import { deleteSkipPriceCountry, updateSkipPriceCountry } from './skip-price-detail-api.ts'
|
import { deleteSkipPriceAsin, deleteSkipPriceCountry, updateSkipPriceCountry } from './skip-price-detail-api.ts'
|
||||||
import { fetchSkipPriceDeleteImportProgress, fetchSkipPriceImportProgress, startSkipPriceDeleteImport, startSkipPriceImport } from './skip-price-import-api.ts'
|
import { fetchSkipPriceDeleteImportProgress, fetchSkipPriceImportProgress, startSkipPriceDeleteImport, startSkipPriceImport } from './skip-price-import-api.ts'
|
||||||
import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
||||||
import { fetchShopNamesByGroup } from './query-asin-api.ts'
|
import { fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<SkipPriceItem[]>([])
|
const rows = ref<SkipPriceItem[]>([])
|
||||||
@@ -22,7 +28,14 @@ const total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
|
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||||
|
const lockedGroupId = computed<number | null>(() => {
|
||||||
|
if (isSuperAdmin.value) return null
|
||||||
|
return groups.value.length === 1 ? groups.value[0].id : null
|
||||||
|
})
|
||||||
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
||||||
|
const jumpPage = ref('')
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
const COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
|
const COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
|
||||||
const ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
|
const ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
|
||||||
@@ -40,14 +53,6 @@ function priceOf(row: SkipPriceItem, code: string): number | null {
|
|||||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
||||||
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||||
|
|
||||||
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/最低价4/操作5):分组(0)/店铺(1)/操作(5) 仅首行占位,其余合并。 */
|
|
||||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
|
||||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 5) {
|
|
||||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
|
||||||
}
|
|
||||||
return [1, 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin) ----
|
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin) ----
|
||||||
const createVisible = ref(false)
|
const createVisible = ref(false)
|
||||||
const createGroupId = ref<number | null>(null)
|
const createGroupId = ref<number | null>(null)
|
||||||
@@ -63,7 +68,7 @@ const creating = ref(false)
|
|||||||
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
createGroupId.value = null
|
createGroupId.value = lockedGroupId.value
|
||||||
createShopName.value = ''
|
createShopName.value = ''
|
||||||
createCountry.value = ''
|
createCountry.value = ''
|
||||||
createAsin.value = ''
|
createAsin.value = ''
|
||||||
@@ -72,6 +77,8 @@ function openCreate(): void {
|
|||||||
createMsgOk.value = false
|
createMsgOk.value = false
|
||||||
shopNames.value = []
|
shopNames.value = []
|
||||||
createVisible.value = true
|
createVisible.value = true
|
||||||
|
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||||||
|
if (createGroupId.value != null) void onCreateGroupChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCreateGroupChange(): Promise<void> {
|
async function onCreateGroupChange(): Promise<void> {
|
||||||
@@ -210,6 +217,30 @@ async function saveConfig(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 整条删除 ----
|
||||||
|
async function removeRow(item: SkipPriceItem): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除「${item.shopName}」${item.groupName ? `(${item.groupName})` : ''}全部站点的最低价 ASIN 配置吗?`,
|
||||||
|
'删除确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteSkipPriceAsin(item.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
// 删除后当前页若已空且非首页,回退一页避免展示空页。
|
||||||
|
if (rows.value.length === 1 && page.value > 1) {
|
||||||
|
page.value -= 1
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 导入添加 / 删除导入(group_id 必填) ----
|
// ---- 导入添加 / 删除导入(group_id 必填) ----
|
||||||
type ImportMode = 'add' | 'delete'
|
type ImportMode = 'add' | 'delete'
|
||||||
const importVisible = ref(false)
|
const importVisible = ref(false)
|
||||||
@@ -220,7 +251,7 @@ const importRunning = ref(false)
|
|||||||
const importProgress = ref('')
|
const importProgress = ref('')
|
||||||
|
|
||||||
function resetImport(): void {
|
function resetImport(): void {
|
||||||
importGroupId.value = null
|
importGroupId.value = lockedGroupId.value
|
||||||
importFile.value = null
|
importFile.value = null
|
||||||
importProgress.value = ''
|
importProgress.value = ''
|
||||||
importRunning.value = false
|
importRunning.value = false
|
||||||
@@ -262,15 +293,7 @@ async function submitImport(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (importMode.value === 'delete') {
|
if (importMode.value === 'delete') {
|
||||||
try {
|
if (!window.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) return
|
||||||
await ElMessageBox.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?', '删除导入', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '确定删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const mode = importMode.value
|
const mode = importMode.value
|
||||||
const groupId = importGroupId.value as number
|
const groupId = importGroupId.value as number
|
||||||
@@ -341,12 +364,28 @@ function apply() {
|
|||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function changePage(next: number) {
|
||||||
Object.assign(filter, createSkipPriceFilterState())
|
if (next < 1 || next > totalPages.value) return
|
||||||
page.value = 1
|
page.value = next
|
||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goJump() {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadGroups()
|
void loadGroups()
|
||||||
void load()
|
void load()
|
||||||
@@ -354,108 +393,108 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="skip-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="skip-head">
|
||||||
<h2>最低价 ASIN / 跳过跟价</h2>
|
<h3>店铺列表</h3>
|
||||||
<p>每店铺在 5 个站点设定的最低价 ASIN 清单;行内可逐站配置 ASIN 与最低价。</p>
|
<div class="skip-head-actions">
|
||||||
|
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
|
||||||
|
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入文件新增</button>
|
||||||
|
<button class="btn" type="button" @click="openImportDelete">导入文件删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="form-row skip-filter-row">
|
||||||
<div class="filter-grid">
|
<div v-if="isSuperAdmin" class="form-group">
|
||||||
<div class="f-item">
|
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group">
|
||||||
<label>店铺</label>
|
<label>店铺名</label>
|
||||||
<el-input v-model="filter.shopName" placeholder="店铺名模糊" clearable @keyup.enter="apply" />
|
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group">
|
||||||
<label>ASIN</label>
|
|
||||||
<el-input v-model="filter.asin" placeholder="ASIN 搜索" clearable @keyup.enter="apply" />
|
|
||||||
</div>
|
|
||||||
<div class="f-item">
|
|
||||||
<label>国家</label>
|
<label>国家</label>
|
||||||
<el-select v-model="filter.country" placeholder="全部" clearable>
|
<select v-model="filter.country">
|
||||||
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
<option value="">全部国家</option>
|
||||||
</el-select>
|
<option v-for="code in COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item price-range">
|
<div class="form-group">
|
||||||
<label>最低价</label>
|
<label>ASIN</label>
|
||||||
|
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group price-range">
|
||||||
|
<label>最低价范围</label>
|
||||||
<div class="price-range-row">
|
<div class="price-range-row">
|
||||||
<el-input v-model="filter.minimumPriceFrom" placeholder="≥ 最低值" clearable @keyup.enter="apply" />
|
<input v-model="filter.minimumPriceFrom" type="number" min="0" step="0.01" placeholder="最低价 ≥" @keyup.enter="apply" />
|
||||||
<span class="range-sep">至</span>
|
<span class="range-sep">-</span>
|
||||||
<el-input v-model="filter.minimumPriceTo" placeholder="≤ 最高值" clearable @keyup.enter="apply" />
|
<input v-model="filter.minimumPriceTo" type="number" min="0" step="0.01" placeholder="最高价 ≤" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item btn-row">
|
<button class="btn" type="button" @click="apply">查询</button>
|
||||||
<el-button type="primary" @click="apply">查询</el-button>
|
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
|
||||||
<el-button @click="reset">重置</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="table-scroll skip-price-asin-table-scroll">
|
||||||
<div class="table-toolbar">
|
<table>
|
||||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
<thead>
|
||||||
<el-button @click="openImportAdd">导入添加</el-button>
|
<tr>
|
||||||
<el-button @click="openImportDelete">导入删除</el-button>
|
<th style="width: 6%">序号</th>
|
||||||
<el-button @click="doExport">导出</el-button>
|
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||||||
|
<th style="width: 15%">店铺名</th>
|
||||||
|
<th style="width: 24%">ASIN</th>
|
||||||
|
<th style="width: 12%">国家</th>
|
||||||
|
<th style="width: 10%">最低价</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
|
<th style="width: 9%">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="displayRows.length">
|
||||||
|
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||||
|
<template v-if="row.isFirst">
|
||||||
|
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||||
|
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||||
|
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||||
|
</template>
|
||||||
|
<td>
|
||||||
|
<template v-if="row.asin">
|
||||||
|
<CopyText :text="row.asin" class="asin-cell" />
|
||||||
|
</template>
|
||||||
|
<span v-else class="asin-empty">-</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||||
|
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
|
||||||
|
<template v-if="row.isFirst">
|
||||||
|
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
|
||||||
|
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
|
||||||
|
<td :rowspan="row.rowspan" class="asin-col-actions">
|
||||||
|
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
<el-table-column label="分组" min-width="130">
|
</section>
|
||||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="店铺" min-width="150">
|
|
||||||
<template #default="{ row }">{{ row.item.shopName }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="ASIN" min-width="150">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
|
|
||||||
<span v-else class="dim">-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="国家" min-width="110">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
|
|
||||||
<span v-else class="dim">-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="最低价" min-width="110" align="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.minimumPrice !== ''">{{ row.minimumPrice }}</span>
|
|
||||||
<span v-else class="dim">-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" min-width="90" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as SkipPriceItem)">配置</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<div class="table-footer">
|
|
||||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
|
||||||
<el-pagination
|
|
||||||
background
|
|
||||||
layout="sizes, prev, pager, next, jumper"
|
|
||||||
:total="total"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
|
||||||
:current-page="page"
|
|
||||||
@current-change="(p: number) => { page = p; void load() }"
|
|
||||||
@size-change="() => { page = 1; void load() }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
|
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
|
||||||
<el-form label-position="top" @submit.prevent>
|
<el-form label-position="top" @submit.prevent>
|
||||||
<el-form-item label="分组">
|
<el-form-item label="分组">
|
||||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
|
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -472,7 +511,7 @@ onMounted(() => {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="国家">
|
<el-form-item label="国家">
|
||||||
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
<el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
||||||
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -513,7 +552,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
|
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="分组(必选)">
|
<el-form-item label="分组(必选)">
|
||||||
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%" :disabled="lockedGroupId != null">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -534,22 +573,301 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
/* 像素复刻旧版 admin.html panel-skip-price-asin(蓝白末层)。 */
|
||||||
.heading-actions { display: flex; gap: 8px; flex: none; }
|
.skip-view {
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
font-family: inherit;
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
|
color: #24384d;
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
}
|
||||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
.panel-box {
|
||||||
/* 最低价范围筛选:≥ 与 ≤ 并排同一行。 */
|
width: 100%;
|
||||||
.f-item.price-range { width: 300px; }
|
min-width: 0;
|
||||||
.price-range-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
padding: 20px 22px 24px;
|
||||||
.price-range-row .el-input { flex: 1; }
|
border: 1px solid #d8e3ee;
|
||||||
.range-sep { color: var(--el-text-color-secondary); font-size: 12px; flex: none; }
|
border-radius: 14px;
|
||||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
|
}
|
||||||
.dim { color: var(--el-text-color-placeholder); }
|
h3 {
|
||||||
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
|
margin: 0;
|
||||||
.country-cell { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; }
|
font-size: 15px;
|
||||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.skip-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.skip-head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.skip-filter-row > .form-group {
|
||||||
|
flex: 1 1 130px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.skip-filter-row .form-group.price-range {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.price-range-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.price-range-row input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.price-range-row .range-sep {
|
||||||
|
color: #8293a5;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.skip-price-asin-table-scroll > table {
|
||||||
|
min-width: 900px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.table-scroll td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.asin-cell {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: -3px 0 -3px -7px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.asin-cell:hover {
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
background: #eef4fa;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.asin-empty {
|
||||||
|
color: #8b9aaa;
|
||||||
|
}
|
||||||
|
.price-cell {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.asin-col-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.drawer-tip {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.country-cell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.import-tip {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */
|
/** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */
|
||||||
|
|
||||||
export const ASIN_PAGE_DEFAULT_SIZE = 15
|
export const ASIN_PAGE_DEFAULT_SIZE = 10
|
||||||
export const ASIN_PAGE_MIN_PAGE = 1
|
export const ASIN_PAGE_MIN_PAGE = 1
|
||||||
export const ASIN_PAGE_MAX_SIZE = 200
|
export const ASIN_PAGE_MAX_SIZE = 200
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ export interface AsinListParams {
|
|||||||
groupId?: number | null
|
groupId?: number | null
|
||||||
/** 国家代码(如 DE、UK)。 */
|
/** 国家代码(如 DE、UK)。 */
|
||||||
country?: string
|
country?: string
|
||||||
|
/** 顺序翻页游标(上一页返回的 nextLastId):传了就忽略 page 偏移,走 keyset。 */
|
||||||
|
lastId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */
|
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */
|
||||||
@@ -36,6 +38,8 @@ export interface AsinPageQuery {
|
|||||||
end_date?: string
|
end_date?: string
|
||||||
group_id?: number
|
group_id?: number
|
||||||
country?: string
|
country?: string
|
||||||
|
/** 顺序翻页游标(keyset):传了就忽略 page 偏移。 */
|
||||||
|
last_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function finiteInt(value: unknown): number | null {
|
function finiteInt(value: unknown): number | null {
|
||||||
@@ -77,5 +81,6 @@ export function toAsinPageQuery(params: AsinListParams): AsinPageQuery {
|
|||||||
if (params.endDate) query.end_date = params.endDate
|
if (params.endDate) query.end_date = params.endDate
|
||||||
if (params.groupId != null) query.group_id = params.groupId
|
if (params.groupId != null) query.group_id = params.groupId
|
||||||
if (params.country) query.country = params.country
|
if (params.country) query.country = params.country
|
||||||
|
if (typeof params.lastId === 'number' && params.lastId > 0) query.last_id = params.lastId
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ export function toDedupeListParams(
|
|||||||
state: DedupeTotalFilterState,
|
state: DedupeTotalFilterState,
|
||||||
page: number,
|
page: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
|
lastId?: number | null,
|
||||||
): AsinListParams {
|
): AsinListParams {
|
||||||
const params: AsinListParams = { page, pageSize }
|
const params: AsinListParams = { page, pageSize }
|
||||||
|
if (typeof lastId === 'number' && lastId > 0) params.lastId = lastId
|
||||||
const keyword = (state.keyword || '').trim()
|
const keyword = (state.keyword || '').trim()
|
||||||
const username = (state.username || '').trim()
|
const username = (state.username || '').trim()
|
||||||
const country = (state.country || '').trim()
|
const country = (state.country || '').trim()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface DedupeTotalItem {
|
|||||||
uploaderUserId: number | null
|
uploaderUserId: number | null
|
||||||
username: string
|
username: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DedupeTotalPageResult {
|
export interface DedupeTotalPageResult {
|
||||||
@@ -18,6 +19,8 @@ export interface DedupeTotalPageResult {
|
|||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
/** 顺序翻页游标:本页最后一行 id;下一页回传它即可走 keyset。 */
|
||||||
|
nextLastId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyDedupeTotalPage(): DedupeTotalPageResult {
|
export function emptyDedupeTotalPage(): DedupeTotalPageResult {
|
||||||
@@ -49,6 +52,8 @@ export function toDedupeTotalItem(raw: unknown): DedupeTotalItem | null {
|
|||||||
}
|
}
|
||||||
const createdAt = text(record.createdAt ?? record.created_at)
|
const createdAt = text(record.createdAt ?? record.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +70,8 @@ export function parseDedupeTotalPage(payload: unknown): DedupeTotalPageResult {
|
|||||||
}
|
}
|
||||||
if (typeof record.total === 'number') out.total = Math.floor(record.total)
|
if (typeof record.total === 'number') out.total = Math.floor(record.total)
|
||||||
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
||||||
|
const rawNextLastId = record.nextLastId ?? record.next_last_id
|
||||||
|
if (typeof rawNextLastId === 'number' && rawNextLastId > 0) out.nextLastId = Math.floor(rawNextLastId)
|
||||||
const rawSize = record.pageSize ?? record.page_size
|
const rawSize = record.pageSize ?? record.page_size
|
||||||
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
||||||
return out
|
return out
|
||||||
@@ -97,7 +104,7 @@ export function parseDedupeGroupOptionList(payload: unknown): DedupeGroupOption[
|
|||||||
const record = raw as Record<string, unknown>
|
const record = raw as Record<string, unknown>
|
||||||
const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id)
|
const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id)
|
||||||
if (!Number.isFinite(id) || id <= 0) continue
|
if (!Number.isFinite(id) || id <= 0) continue
|
||||||
const nameRaw = record.group_name ?? record.name
|
const nameRaw = record.groupName ?? record.group_name ?? record.name
|
||||||
const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''
|
const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''
|
||||||
const option: DedupeGroupOption = { id, name: name || `分组${id}` }
|
const option: DedupeGroupOption = { id, name: name || `分组${id}` }
|
||||||
const leaderRaw = record.leader_user_id ?? record.leaderUserId
|
const leaderRaw = record.leader_user_id ?? record.leaderUserId
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function searchProductCategories(
|
|||||||
page: number,
|
page: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
): Promise<ProductCategorySearchPage> {
|
): Promise<ProductCategorySearchPage> {
|
||||||
const params: Record<string, string | number> = { page, pageSize }
|
const params: Record<string, string | number> = { page, page_size: pageSize }
|
||||||
const trimmed = keyword.trim()
|
const trimmed = keyword.trim()
|
||||||
if (trimmed) params.keyword = trimmed
|
if (trimmed) params.keyword = trimmed
|
||||||
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
|
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */
|
/** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */
|
||||||
import { unwrap } from '../../api/envelope.ts'
|
import { unwrap } from '../../api/envelope.ts'
|
||||||
|
|
||||||
export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 20
|
export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 10
|
||||||
|
|
||||||
export interface ProductCategoryNode {
|
export interface ProductCategoryNode {
|
||||||
id: number
|
id: number
|
||||||
@@ -14,6 +14,8 @@ export interface ProductCategoryNode {
|
|||||||
childCount: number
|
childCount: number
|
||||||
level: number | null
|
level: number | null
|
||||||
path: string
|
path: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
children: ProductCategoryNode[]
|
children: ProductCategoryNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null
|
|||||||
path: text(record.path),
|
path: text(record.path),
|
||||||
children: [],
|
children: [],
|
||||||
}
|
}
|
||||||
|
const createdAt = text(record.createdAt ?? record.created_at)
|
||||||
|
if (createdAt) node.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||||
|
if (updatedAt) node.updatedAt = updatedAt
|
||||||
if (Array.isArray(record.children)) {
|
if (Array.isArray(record.children)) {
|
||||||
node.children = record.children
|
node.children = record.children
|
||||||
.map((child) => toProductCategoryNode(child))
|
.map((child) => toProductCategoryNode(child))
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
|
|
||||||
export const QUERY_ASIN_DELETE_IMPORT_ENDPOINT = '/api/admin/query-asins/delete-import'
|
export const QUERY_ASIN_DELETE_IMPORT_ENDPOINT = '/api/admin/query-asins/delete-import'
|
||||||
|
|
||||||
/** 启动导入删除;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */
|
/** 启动导入删除;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。
|
||||||
|
* 注意:后端 deleteImportExcel 仍是隐式 camel 参数名(groupId),此处必须发 groupId;
|
||||||
|
* 与 import 的 group_id 不同源,后端统一为 snake 时需两端同步改。 */
|
||||||
export async function startQueryAsinDeleteImport(file: File, groupId?: number): Promise<string> {
|
export async function startQueryAsinDeleteImport(file: File, groupId?: number): Promise<string> {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file, file.name)
|
form.append('file', file, file.name)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** 查询 ASIN 导出 URL 构建:按当前筛选下发 group_id/shop_name/asin/country(对齐 Java export @RequestParam snake_case)。 */
|
||||||
|
import { normalizeQueryAsinCountry, type QueryAsinFilterState } from './query-asin-filter.ts'
|
||||||
|
|
||||||
|
export const QUERY_ASIN_EXPORT_ENDPOINT = '/api/admin/query-asins/export'
|
||||||
|
|
||||||
|
/** 按导出可用筛选(分组/店铺名/ASIN/国家)构建查询串;空白/非法条件不下发,避免导出范围被误解。 */
|
||||||
|
export function buildQueryAsinExportQuery(state: QueryAsinFilterState): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
const groupId = state.groupId
|
||||||
|
if (typeof groupId === 'number' && groupId >= 1) params.set('group_id', String(Math.floor(groupId)))
|
||||||
|
const shopName = (state.shopName || '').trim()
|
||||||
|
if (shopName) params.set('shop_name', shopName)
|
||||||
|
const asin = (state.asin || '').trim()
|
||||||
|
if (asin) params.set('asin', asin)
|
||||||
|
const country = normalizeQueryAsinCountry(state.country)
|
||||||
|
if (country) params.set('country', country)
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出下载地址:浏览器直接访问该 URL 触发流式下载;无筛选时为全量导出。 */
|
||||||
|
export function toQueryAsinExportUrl(state: QueryAsinFilterState): string {
|
||||||
|
const query = buildQueryAsinExportQuery(state)
|
||||||
|
return query ? `${QUERY_ASIN_EXPORT_ENDPOINT}?${query}` : QUERY_ASIN_EXPORT_ENDPOINT
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;Java 参数名 camelCase。 */
|
/** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;
|
||||||
|
* Java 参数名 snake_case(与 asin-filter 共享契约一致):page_size/group_id/shop_name,裸 camel 后端收不到会导致筛选失效。 */
|
||||||
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
|
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
|
||||||
import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts'
|
import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts'
|
||||||
|
|
||||||
@@ -24,9 +25,9 @@ export interface QueryAsinListParams {
|
|||||||
|
|
||||||
export interface QueryAsinQuery {
|
export interface QueryAsinQuery {
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
page_size: number
|
||||||
groupId?: number
|
group_id?: number
|
||||||
shopName?: string
|
shop_name?: string
|
||||||
asin?: string
|
asin?: string
|
||||||
country?: string
|
country?: string
|
||||||
}
|
}
|
||||||
@@ -92,11 +93,11 @@ export function toQueryAsinParams(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(camel) 对齐,仅下发非空字段。 */
|
/** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(snake_case: page_size/group_id/shop_name/asin/country) 对齐,仅下发非空字段。 */
|
||||||
export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery {
|
export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery {
|
||||||
const query: QueryAsinQuery = { page: params.page, pageSize: params.pageSize }
|
const query: QueryAsinQuery = { page: params.page, page_size: params.pageSize }
|
||||||
if (typeof params.groupId === 'number' && params.groupId >= 1) query.groupId = params.groupId
|
if (typeof params.groupId === 'number' && params.groupId >= 1) query.group_id = params.groupId
|
||||||
if (params.shopName) query.shopName = params.shopName
|
if (params.shopName) query.shop_name = params.shopName
|
||||||
if (params.asin) query.asin = params.asin
|
if (params.asin) query.asin = params.asin
|
||||||
if (params.country) query.country = params.country
|
if (params.country) query.country = params.country
|
||||||
return query
|
return query
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
|
|
||||||
export const QUERY_ASIN_IMPORT_ENDPOINT = '/api/admin/query-asins/import'
|
export const QUERY_ASIN_IMPORT_ENDPOINT = '/api/admin/query-asins/import'
|
||||||
|
|
||||||
/** 启动导入添加;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */
|
/** 启动导入添加;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。
|
||||||
|
* 参数名必须用 group_id:Java importExcel @RequestParam(name="group_id"),发 groupId 会被丢弃致兜底分组失效。 */
|
||||||
export async function startQueryAsinImport(file: File, groupId?: number): Promise<string> {
|
export async function startQueryAsinImport(file: File, groupId?: number): Promise<string> {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file, file.name)
|
form.append('file', file, file.name)
|
||||||
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
|
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
|
||||||
form.append('groupId', String(Math.floor(groupId)))
|
form.append('group_id', String(Math.floor(groupId)))
|
||||||
}
|
}
|
||||||
const { data } = await http.post<unknown>(QUERY_ASIN_IMPORT_ENDPOINT, form)
|
const { data } = await http.post<unknown>(QUERY_ASIN_IMPORT_ENDPOINT, form)
|
||||||
return parseQueryAsinImportStart(data)
|
return parseQueryAsinImportStart(data)
|
||||||
|
|||||||
@@ -21,3 +21,8 @@ export async function updateSkipPriceCountry(
|
|||||||
export async function deleteSkipPriceCountry(id: number, country: string): Promise<void> {
|
export async function deleteSkipPriceCountry(id: number, country: string): Promise<void> {
|
||||||
await http.delete<unknown>(countryUrl(id, country))
|
await http.delete<unknown>(countryUrl(id, country))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除整条最低价 ASIN 记录(该店铺全部站点):DELETE /api/admin/skip-price-asins/{id}。 */
|
||||||
|
export async function deleteSkipPriceAsin(id: number): Promise<void> {
|
||||||
|
await http.delete<unknown>(`${SKIP_PRICE_DETAIL_ENDPOINT}/${id}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ export interface SkipPriceFilterState {
|
|||||||
asin: string
|
asin: string
|
||||||
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
|
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
|
||||||
country: string
|
country: string
|
||||||
/** 最低价区间输入原文;空串不限。 */
|
/** 最低价区间输入原文;空串不限(原生 number input v-model 会转 number,需兼容)。 */
|
||||||
minimumPriceFrom: string
|
minimumPriceFrom: string | number
|
||||||
minimumPriceTo: string
|
minimumPriceTo: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkipPriceListParams {
|
export interface SkipPriceListParams {
|
||||||
@@ -42,9 +42,9 @@ export function createSkipPriceFilterState(): SkipPriceFilterState {
|
|||||||
return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' }
|
return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 非负可解析价格 → number;空/非法/负数返回 null。 */
|
/** 非负可解析价格 → number;空/非法/负数返回 null。入参兼容 number(原生 number input 的 v-model 会转 number)。 */
|
||||||
export function normalizePriceInput(value: string): number | null {
|
export function normalizePriceInput(value: string | number): number | null {
|
||||||
const trimmed = (value || '').trim()
|
const trimmed = String(value ?? '').trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
|
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
|
||||||
const num = Number(trimmed)
|
const num = Number(trimmed)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface InvalidAsinItem {
|
|||||||
groupName: string
|
groupName: string
|
||||||
recordSource: string
|
recordSource: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvalidAsinPageResult {
|
export interface InvalidAsinPageResult {
|
||||||
@@ -74,6 +75,8 @@ export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | null {
|
|||||||
}
|
}
|
||||||
const createdAt = text(record.createdAt ?? record.created_at)
|
const createdAt = text(record.createdAt ?? record.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const errorMessage = ref('')
|
|||||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
||||||
|
|
||||||
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
|
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
|
||||||
|
/** 单设备登录:被新设备顶下线后跳回登录页(整页跳转携带 ?kicked=1) */
|
||||||
|
const kickedNotice = computed(() => route.query.kicked === '1')
|
||||||
|
|
||||||
function deviceId(): string {
|
function deviceId(): string {
|
||||||
try {
|
try {
|
||||||
@@ -120,6 +122,7 @@ onMounted(() => {
|
|||||||
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
||||||
</header>
|
</header>
|
||||||
<form novalidate @submit.prevent="submit">
|
<form novalidate @submit.prevent="submit">
|
||||||
|
<p v-if="kickedNotice" class="kicked-msg" role="alert">该账号已在其他设备登录,本设备已下线。如非本人操作,请及时修改密码。</p>
|
||||||
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="loginUsername">用户名</label>
|
<label for="loginUsername">用户名</label>
|
||||||
@@ -154,7 +157,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style scoped>
|
||||||
.auth-page {
|
.auth-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -296,6 +299,20 @@ button, input { font: inherit; }
|
|||||||
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 单设备登录:被新设备顶下线的提示 */
|
||||||
|
.kicked-msg {
|
||||||
|
display: flex; align-items: flex-start; gap: 9px;
|
||||||
|
margin: -4px 0 18px; padding: 11px 12px;
|
||||||
|
border: 1px solid #e0cf9f; border-radius: 11px; background: #fbf4e2; color: #8a6a1f;
|
||||||
|
font-size: 13px; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.kicked-msg::before {
|
||||||
|
content: "!";
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
flex: 0 0 18px; width: 18px; height: 18px;
|
||||||
|
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-login {
|
.btn-login {
|
||||||
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||||
width: 100%; min-height: 48px; padding: 12px 18px;
|
width: 100%; min-height: 48px; padding: 12px 18px;
|
||||||
|
|||||||
@@ -0,0 +1,719 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 日志管理页:桌面客户端 / 麦象采集机上报的日志浏览(仅超管)。
|
||||||
|
* 支持来源/日期/关键字筛选,尾部内容查看(自动滚到底、可向前加载更早内容)、
|
||||||
|
* 完整下载、删除;「采集配置」可调全量/精选模式(全局默认 + 终端覆盖)。 */
|
||||||
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
import {
|
||||||
|
deleteDeviceLogFile,
|
||||||
|
deleteDeviceLogOverride,
|
||||||
|
deviceLogDownloadUrl,
|
||||||
|
fetchDeviceLogConfig,
|
||||||
|
fetchDeviceLogContent,
|
||||||
|
fetchDeviceLogDevices,
|
||||||
|
fetchDeviceLogFiles,
|
||||||
|
updateDeviceLogGlobalMode,
|
||||||
|
updateDeviceLogOverride,
|
||||||
|
type DeviceLogContent,
|
||||||
|
type DeviceLogDevice,
|
||||||
|
type DeviceLogFileRow,
|
||||||
|
type DeviceLogOverride,
|
||||||
|
} from '@/api/device-logs'
|
||||||
|
|
||||||
|
const SOURCE_OPTIONS = [
|
||||||
|
{ value: '', label: '全部来源' },
|
||||||
|
{ value: 'client', label: '桌面客户端' },
|
||||||
|
{ value: 'maixiang', label: '麦象采集机' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function sourceLabel(source: string): string {
|
||||||
|
if (source === 'client') return '桌面客户端'
|
||||||
|
if (source === 'maixiang') return '麦象采集机'
|
||||||
|
return source || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null | undefined): string {
|
||||||
|
if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const units = ['KB', 'MB', 'GB']
|
||||||
|
let value = bytes / 1024
|
||||||
|
let unitIndex = 0
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<DeviceLogFileRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(15)
|
||||||
|
const sourceFilter = ref('')
|
||||||
|
const keyword = ref('')
|
||||||
|
const startDate = ref('')
|
||||||
|
const endDate = ref('')
|
||||||
|
const retentionDays = ref(7)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchDeviceLogFiles({
|
||||||
|
source: sourceFilter.value || undefined,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
startDate: startDate.value || undefined,
|
||||||
|
endDate: endDate.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
rows.value = result?.items || []
|
||||||
|
total.value = Number(result?.total || 0)
|
||||||
|
if (result?.retentionDays) retentionDays.value = result.retentionDays
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '日志列表加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
sourceFilter.value = ''
|
||||||
|
keyword.value = ''
|
||||||
|
startDate.value = ''
|
||||||
|
endDate.value = ''
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 内容查看
|
||||||
|
|
||||||
|
const TAIL_STEP = 256 * 1024
|
||||||
|
const TAIL_MAX = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
const viewerVisible = ref(false)
|
||||||
|
const viewerLoading = ref(false)
|
||||||
|
const viewerFile = ref<DeviceLogFileRow | null>(null)
|
||||||
|
const viewerContent = ref<DeviceLogContent | null>(null)
|
||||||
|
const viewerMaxBytes = ref(TAIL_STEP)
|
||||||
|
const viewerPre = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
async function openViewer(row: DeviceLogFileRow) {
|
||||||
|
viewerFile.value = row
|
||||||
|
viewerMaxBytes.value = TAIL_STEP
|
||||||
|
viewerContent.value = null
|
||||||
|
viewerVisible.value = true
|
||||||
|
await loadContent(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContent(scrollToBottom: boolean) {
|
||||||
|
if (!viewerFile.value) return
|
||||||
|
viewerLoading.value = true
|
||||||
|
try {
|
||||||
|
viewerContent.value = await fetchDeviceLogContent(viewerFile.value.id, viewerMaxBytes.value)
|
||||||
|
if (scrollToBottom) {
|
||||||
|
await nextTick()
|
||||||
|
if (viewerPre.value) viewerPre.value.scrollTop = viewerPre.value.scrollHeight
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '日志内容加载失败')
|
||||||
|
} finally {
|
||||||
|
viewerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
viewerMaxBytes.value = Math.min(viewerMaxBytes.value * 2, TAIL_MAX)
|
||||||
|
loadContent(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: DeviceLogFileRow) {
|
||||||
|
if (!window.confirm(`确认删除「${row.fileName}」(${row.deviceName || row.deviceId})的云端日志?删除后不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteDeviceLogFile(row.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 采集配置
|
||||||
|
|
||||||
|
const configVisible = ref(false)
|
||||||
|
const configLoading = ref(false)
|
||||||
|
const globalMode = ref('full')
|
||||||
|
const overrides = ref<DeviceLogOverride[]>([])
|
||||||
|
const devices = ref<DeviceLogDevice[]>([])
|
||||||
|
const newOverrideKey = ref('')
|
||||||
|
const newOverrideMode = ref('selected')
|
||||||
|
|
||||||
|
const MODE_OPTIONS = [
|
||||||
|
{ value: 'full', label: '全量采集' },
|
||||||
|
{ value: 'selected', label: '精选采集' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function modeLabel(mode: string): string {
|
||||||
|
return mode === 'selected' ? '精选' : '全量'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfig() {
|
||||||
|
configVisible.value = true
|
||||||
|
await loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
configLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchDeviceLogConfig()
|
||||||
|
globalMode.value = data.globalMode || 'full'
|
||||||
|
overrides.value = data.overrides || []
|
||||||
|
devices.value = (await fetchDeviceLogDevices()) || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '采集配置加载失败')
|
||||||
|
} finally {
|
||||||
|
configLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGlobalMode(mode: string) {
|
||||||
|
if (mode === globalMode.value) return
|
||||||
|
try {
|
||||||
|
await updateDeviceLogGlobalMode(mode)
|
||||||
|
globalMode.value = mode
|
||||||
|
ElMessage.success(`全局采集模式已切换为「${modeLabel(mode)}」`)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOverride() {
|
||||||
|
if (!newOverrideKey.value) {
|
||||||
|
ElMessage.warning('请先选择终端')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [source, deviceId] = newOverrideKey.value.split('|')
|
||||||
|
const device = devices.value.find((item) => item.source === source && item.deviceId === deviceId)
|
||||||
|
try {
|
||||||
|
await updateDeviceLogOverride(source, deviceId, device?.deviceName || null, newOverrideMode.value)
|
||||||
|
ElMessage.success('终端覆盖已保存')
|
||||||
|
newOverrideKey.value = ''
|
||||||
|
await loadConfig()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeOverride(row: DeviceLogOverride) {
|
||||||
|
const who = row.deviceName || row.deviceId
|
||||||
|
if (!window.confirm(`确认删除终端「${who}」的采集覆盖(回落到全局默认)?`)) return
|
||||||
|
try {
|
||||||
|
await deleteDeviceLogOverride(row.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadConfig()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="device-logs-view">
|
||||||
|
<section class="panel-box">
|
||||||
|
<div class="logs-head">
|
||||||
|
<h3>日志文件列表</h3>
|
||||||
|
<div class="logs-head-tools">
|
||||||
|
<span class="retention-tip">云端仅保留 {{ retentionDays }} 天</span>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="openConfig">采集配置</button>
|
||||||
|
<button class="btn" type="button" @click="load">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row logs-filter-row">
|
||||||
|
<div class="form-group" style="min-width: 150px">
|
||||||
|
<label>来源</label>
|
||||||
|
<select v-model="sourceFilter">
|
||||||
|
<option v-for="option in SOURCE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 220px">
|
||||||
|
<label>设备 / 文件名</label>
|
||||||
|
<input v-model="keyword" type="text" placeholder="模糊搜索设备名、设备ID、文件名" @keyup.enter="search" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 150px">
|
||||||
|
<label>日志日期(起)</label>
|
||||||
|
<input v-model="startDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 150px">
|
||||||
|
<label>日志日期(止)</label>
|
||||||
|
<input v-model="endDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label> </label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn" type="button" @click="search">查询</button>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logs-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 110px">来源</th>
|
||||||
|
<th style="width: 230px">设备</th>
|
||||||
|
<th style="width: 130px">用户</th>
|
||||||
|
<th style="width: 230px">文件名</th>
|
||||||
|
<th style="width: 110px">日志日期</th>
|
||||||
|
<th style="width: 100px">已收大小</th>
|
||||||
|
<th style="width: 80px">片段数</th>
|
||||||
|
<th style="width: 170px">最后更新</th>
|
||||||
|
<th style="width: 190px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td>
|
||||||
|
<span class="source-pill" :class="row.source === 'maixiang' ? 'is-maixiang' : 'is-client'">
|
||||||
|
{{ sourceLabel(row.source) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="device-name" :title="row.deviceId">{{ row.deviceName || '—' }}</span>
|
||||||
|
<span class="device-id" :title="row.deviceId">{{ row.deviceId }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.username" class="user-name">{{ row.username }}</span>
|
||||||
|
<span v-else-if="row.uid" class="user-name">UID {{ row.uid }}</span>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.logDate }}</td>
|
||||||
|
<td>{{ formatBytes(row.uploadedBytes) }}</td>
|
||||||
|
<td>{{ row.partCount ?? 0 }}</td>
|
||||||
|
<td>{{ row.lastUploadAt ? formatDateTime(row.lastUploadAt) : '—' }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button class="btn btn-sm" type="button" @click="openViewer(row)">查看</button>
|
||||||
|
<a class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(row.id)" download>下载</a>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td colspan="9" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td colspan="9" class="empty-tip">
|
||||||
|
{{ keyword || sourceFilter || startDate || endDate ? '暂无匹配日志' : '暂无日志上报(客户端/采集机上报后自动出现在这里)' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="viewerVisible" :title="viewerFile ? `${viewerFile.fileName}(${viewerFile.deviceName || viewerFile.deviceId})` : '日志内容'" width="900px" top="5vh">
|
||||||
|
<div class="viewer-toolbar">
|
||||||
|
<span class="viewer-meta">
|
||||||
|
云端已收 {{ formatBytes(viewerContent?.totalBytes ?? viewerFile?.uploadedBytes) }}
|
||||||
|
<template v-if="viewerContent"> · 当前展示 {{ formatBytes(viewerContent.shownBytes) }}</template>
|
||||||
|
<template v-if="viewerContent?.truncated"> · 更早内容未加载</template>
|
||||||
|
</span>
|
||||||
|
<span class="viewer-actions">
|
||||||
|
<button class="btn btn-sm btn-ghost" type="button" :disabled="viewerLoading" @click="loadContent(true)">刷新</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="viewerLoading || !viewerContent?.truncated || viewerMaxBytes >= TAIL_MAX"
|
||||||
|
@click="loadMore"
|
||||||
|
>加载更早</button>
|
||||||
|
<a v-if="viewerFile" class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(viewerFile.id)" download>下载完整日志</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre ref="viewerPre" class="log-pre">{{ viewerLoading && !viewerContent ? '加载中...' : (viewerContent?.content || '(暂无内容)') }}</pre>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="configVisible" title="采集配置" width="720px">
|
||||||
|
<div class="config-block">
|
||||||
|
<h4>全局默认模式</h4>
|
||||||
|
<p class="config-desc">对未单独配置的终端生效。全量=上传日志目录内全部文件;精选=排除低价值大日志(客户端排除 pywebview;麦象排除 kk-browser / 控制台 / 测试日志)。终端在下一次上报周期(约 1 分钟内)跟随新配置。</p>
|
||||||
|
<div class="mode-switch">
|
||||||
|
<button
|
||||||
|
v-for="option in MODE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
class="mode-btn"
|
||||||
|
:class="{ 'is-active': globalMode === option.value }"
|
||||||
|
type="button"
|
||||||
|
@click="saveGlobalMode(option.value)"
|
||||||
|
>{{ option.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-block">
|
||||||
|
<h4>终端覆盖</h4>
|
||||||
|
<div class="override-add">
|
||||||
|
<select v-model="newOverrideKey" class="override-select">
|
||||||
|
<option value="">选择终端(最近上报的设备)</option>
|
||||||
|
<option v-for="device in devices" :key="`${device.source}|${device.deviceId}`" :value="`${device.source}|${device.deviceId}`">
|
||||||
|
{{ sourceLabel(device.source) }} · {{ device.deviceName || device.deviceId }}({{ device.deviceId }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="newOverrideMode">
|
||||||
|
<option v-for="option in MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm" type="button" @click="addOverride">添加/更新覆盖</button>
|
||||||
|
</div>
|
||||||
|
<table class="override-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 100px">来源</th>
|
||||||
|
<th>设备</th>
|
||||||
|
<th style="width: 80px">模式</th>
|
||||||
|
<th style="width: 160px">更新时间</th>
|
||||||
|
<th style="width: 90px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in overrides" :key="row.id">
|
||||||
|
<td>{{ sourceLabel(row.source) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="device-name">{{ row.deviceName || row.deviceId }}</span>
|
||||||
|
<span class="device-id">{{ row.deviceId }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ modeLabel(row.mode) }}</td>
|
||||||
|
<td>{{ row.updatedAt ? formatDateTime(row.updatedAt) : '—' }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeOverride(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!overrides.length">
|
||||||
|
<td colspan="5" class="empty-tip">{{ configLoading ? '加载中...' : '暂无终端覆盖(全部跟随全局默认)' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="configVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 沿用「记录与版本」系列的旧后台面板视觉语言。 */
|
||||||
|
.device-logs-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.logs-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.logs-head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.retention-tip {
|
||||||
|
color: #8598ab;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.logs-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
color: #4f78a5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.logs-table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 12.5px;
|
||||||
|
border-bottom: 1px solid #dbe6f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #eaf1f7;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.source-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.source-pill.is-client {
|
||||||
|
background: #e8f1fb;
|
||||||
|
color: #37618f;
|
||||||
|
}
|
||||||
|
.source-pill.is-maixiang {
|
||||||
|
background: #eef7ec;
|
||||||
|
color: #3f7a42;
|
||||||
|
}
|
||||||
|
.device-name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.device-id {
|
||||||
|
display: block;
|
||||||
|
color: #8598ab;
|
||||||
|
font-size: 11.5px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.file-name {
|
||||||
|
display: block;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #9db0c2;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 26px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #8598ab;
|
||||||
|
}
|
||||||
|
.dl-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.viewer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.viewer-meta {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.log-pre {
|
||||||
|
max-height: 62vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0f1c29;
|
||||||
|
color: #d7e4f1;
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.config-block {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.config-block h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.config-desc {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #66798d;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.mode-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.mode-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #4f78a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mode-btn.is-active {
|
||||||
|
border-color: #4f78a5;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.override-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.override-add select {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
.override-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.override-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 数字人版本管理页(module 13 task 261 对齐 admin panel-digital-human-version;本轮补齐分页/三态/上传进度/下载语义)。 */
|
/** 数字人版本管理页 · 像素复刻旧版 admin.html panel-digital-human-version(自绘:面板头+上传、三态状态色、最新★、操作矩阵、数字页码分页)。
|
||||||
|
* script 逻辑沿用现有 Vue 实现(确认文案/上传进度/下载取链接);确认走原生 confirm 对齐旧版。 */
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
deleteDigitalHumanVersion,
|
deleteDigitalHumanVersion,
|
||||||
fetchDigitalHumanDownloadUrl,
|
fetchDigitalHumanDownloadUrl,
|
||||||
@@ -20,18 +21,28 @@ import {
|
|||||||
import { validateDigitalHumanUpload } from './digitalhuman-upload-model.ts'
|
import { validateDigitalHumanUpload } from './digitalhuman-upload-model.ts'
|
||||||
import { canReleaseDigitalHumanVersion, releaseDigitalHumanConfirmText } from './digitalhuman-release.ts'
|
import { canReleaseDigitalHumanVersion, releaseDigitalHumanConfirmText } from './digitalhuman-release.ts'
|
||||||
import { canSetLatestDigitalHumanVersion, setLatestDigitalHumanConfirmText } from './digitalhuman-latest.ts'
|
import { canSetLatestDigitalHumanVersion, setLatestDigitalHumanConfirmText } from './digitalhuman-latest.ts'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
import { digitalHumanStatusDisplay } from './digitalhuman-status-display.ts'
|
import { digitalHumanStatusDisplay } from './digitalhuman-status-display.ts'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const items = ref<DigitalHumanVersion[]>([])
|
const items = ref<DigitalHumanVersion[]>([])
|
||||||
|
|
||||||
/** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */
|
/** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */
|
||||||
const pageSize = 20
|
const pageSize = ref(10)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pagedItems = computed(() => {
|
/** 版本号搜索(客户端过滤)。 */
|
||||||
const start = (page.value - 1) * pageSize
|
const versionKeyword = ref('')
|
||||||
return items.value.slice(start, start + pageSize)
|
const filteredItems = computed(() => {
|
||||||
|
const kw = (versionKeyword.value || '').trim().toLowerCase()
|
||||||
|
if (!kw) return items.value
|
||||||
|
return items.value.filter((item) => `${item.version}`.toLowerCase().includes(kw))
|
||||||
})
|
})
|
||||||
|
const pagedItems = computed(() => {
|
||||||
|
const start = (page.value - 1) * pageSize.value
|
||||||
|
return filteredItems.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
|
||||||
|
const jumpPage = ref('')
|
||||||
|
|
||||||
const uploadVisible = ref(false)
|
const uploadVisible = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
@@ -45,9 +56,7 @@ async function load() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
items.value = (await fetchDigitalHumanVersions()).items
|
items.value = (await fetchDigitalHumanVersions()).items
|
||||||
// 数据收缩后页码越界回钳。
|
// 页码越界回钳见下方 watch(items)。
|
||||||
const last = Math.max(Math.ceil(items.value.length / pageSize), 1)
|
|
||||||
if (page.value > last) page.value = last
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,6 +64,31 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changePage(next: number): void {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本号搜索导致数据收缩时回钳页码,避免停在空页。
|
||||||
|
watch(items, () => {
|
||||||
|
const last = Math.max(Math.ceil(filteredItems.value.length / pageSize.value), 1)
|
||||||
|
if (page.value > last) page.value = last
|
||||||
|
})
|
||||||
|
|
||||||
|
function goJump(): void {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
function onFileChange(file: File) {
|
function onFileChange(file: File) {
|
||||||
form.value.file = file
|
form.value.file = file
|
||||||
}
|
}
|
||||||
@@ -91,14 +125,14 @@ async function submitUpload() {
|
|||||||
async function release(row: DigitalHumanVersion) {
|
async function release(row: DigitalHumanVersion) {
|
||||||
const text = releaseDigitalHumanConfirmText(row.version)
|
const text = releaseDigitalHumanConfirmText(row.version)
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
// 对齐旧版:原生 confirm。
|
||||||
|
if (!window.confirm(text)) return
|
||||||
confirming.value = row.version
|
confirming.value = row.version
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(text, '发布版本', { type: 'warning', confirmButtonText: '发布', cancelButtonText: '取消' })
|
|
||||||
await releaseDigitalHumanVersion(row.version)
|
await releaseDigitalHumanVersion(row.version)
|
||||||
ElMessage.success('已发布')
|
ElMessage.success('已发布')
|
||||||
load()
|
load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error === 'cancel' || error === 'close') return
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '发布失败')
|
ElMessage.error(error instanceof Error ? error.message : '发布失败')
|
||||||
} finally {
|
} finally {
|
||||||
confirming.value = null
|
confirming.value = null
|
||||||
@@ -108,14 +142,13 @@ async function release(row: DigitalHumanVersion) {
|
|||||||
async function setLatest(row: DigitalHumanVersion) {
|
async function setLatest(row: DigitalHumanVersion) {
|
||||||
const text = setLatestDigitalHumanConfirmText(row.version)
|
const text = setLatestDigitalHumanConfirmText(row.version)
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
if (!window.confirm(text)) return
|
||||||
confirming.value = row.version
|
confirming.value = row.version
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(text, '设为最新', { type: 'warning', confirmButtonText: '设为最新', cancelButtonText: '取消' })
|
|
||||||
await setLatestDigitalHumanVersion(row.version)
|
await setLatestDigitalHumanVersion(row.version)
|
||||||
ElMessage.success('已设为最新')
|
ElMessage.success('已设为最新')
|
||||||
load()
|
load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error === 'cancel' || error === 'close') return
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
} finally {
|
} finally {
|
||||||
confirming.value = null
|
confirming.value = null
|
||||||
@@ -146,18 +179,13 @@ async function download(row: DigitalHumanVersion) {
|
|||||||
async function remove(row: DigitalHumanVersion) {
|
async function remove(row: DigitalHumanVersion) {
|
||||||
const text = digitalHumanDeleteConfirmText(row.version)
|
const text = digitalHumanDeleteConfirmText(row.version)
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
if (!window.confirm(text)) return
|
||||||
confirming.value = row.version
|
confirming.value = row.version
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(text, '删除版本', {
|
|
||||||
type: 'warning',
|
|
||||||
confirmButtonText: '删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
})
|
|
||||||
await deleteDigitalHumanVersion(row.version)
|
await deleteDigitalHumanVersion(row.version)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
load()
|
load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error === 'cancel' || error === 'close') return
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
} finally {
|
} finally {
|
||||||
confirming.value = null
|
confirming.value = null
|
||||||
@@ -176,70 +204,74 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="dh-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="dh-head">
|
||||||
<h2>数字人版本管理</h2>
|
<h3>版本列表</h3>
|
||||||
<p>管理数字人客户端的草稿/已发布/已废弃版本。</p>
|
<div class="dh-head-tools">
|
||||||
</div>
|
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="dh-keyword" />
|
||||||
<div class="actions">
|
<button class="btn" type="button" @click="uploadVisible = true">上传新版本</button>
|
||||||
<el-button type="primary" @click="uploadVisible = true">上传(草稿状态)</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="table-scroll digital-human-version-table-scroll">
|
||||||
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无数字人版本">
|
<table>
|
||||||
<el-table-column prop="version" label="版本号" min-width="150" />
|
<thead>
|
||||||
<el-table-column label="状态" min-width="100">
|
<tr>
|
||||||
<template #default="{ row }">
|
<th style="width: 80px">版本号</th>
|
||||||
<el-tag :type="statusOf(row as DigitalHumanVersion).tag" size="small">
|
<th style="width: 60px">状态</th>
|
||||||
{{ statusOf(row as DigitalHumanVersion).text }}
|
<th style="width: 60px">最新</th>
|
||||||
</el-tag>
|
<th style="width: 80px">文件大小</th>
|
||||||
|
<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>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
<el-table-column label="最新" min-width="60" align="center">
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
<template #default="{ row }">
|
</tr>
|
||||||
<span v-if="(row as DigitalHumanVersion).isLatest" class="star" title="最新版本">★</span>
|
<tr v-else>
|
||||||
<span v-else>—</span>
|
<td colspan="8" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
||||||
</template>
|
</tr>
|
||||||
</el-table-column>
|
</tbody>
|
||||||
<el-table-column label="文件大小" min-width="100">
|
</table>
|
||||||
<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>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
<div v-if="filteredItems.length > pageSize" class="pagination">
|
||||||
|
<OldPagination :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="uploadVisible" title="上传数字人版本" width="560px">
|
<el-dialog v-model="uploadVisible" title="上传数字人版本" width="560px">
|
||||||
<p class="upload-desc">上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布。</p>
|
<p class="upload-desc">上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布。</p>
|
||||||
@@ -270,11 +302,267 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
|
/* 像素复刻旧版 admin.html panel-digital-human-version(蓝白末层)。 */
|
||||||
.actions { display: flex; align-items: center; gap: 10px; }
|
.dh-view {
|
||||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
font-family: inherit;
|
||||||
.star { color: #e6a23c; font-weight: 700; }
|
color: #24384d;
|
||||||
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
|
}
|
||||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
.panel-box {
|
||||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.dh-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.dh-head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.dh-keyword {
|
||||||
|
width: 200px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.dh-keyword:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.dh-keyword:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.digital-human-version-table-scroll > table {
|
||||||
|
min-width: 1120px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.mono-cell {
|
||||||
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.st-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.st-pill.is-info {
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f1f3f5;
|
||||||
|
border-color: #dfe3e8;
|
||||||
|
}
|
||||||
|
.st-pill.is-success {
|
||||||
|
color: #398a3f;
|
||||||
|
background: #eaf7ec;
|
||||||
|
border-color: #bfe3c5;
|
||||||
|
}
|
||||||
|
.st-pill.is-danger {
|
||||||
|
color: #b8454d;
|
||||||
|
background: #fbedee;
|
||||||
|
border-color: #ecc9cc;
|
||||||
|
}
|
||||||
|
.star {
|
||||||
|
color: #d64545;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.changelog-cell {
|
||||||
|
display: block;
|
||||||
|
color: #5b6f83;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-nums {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.num-page {
|
||||||
|
min-width: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.num-page.is-active {
|
||||||
|
background: #2f5d8b;
|
||||||
|
border-color: #2f5d8b;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.upload-desc {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** 历史生成记录页(module 13 task 259 对齐 admin panel-history):指定用户下拉 + 起止时间筛选、类型中文、≤3 缩略图、大图预览、空态。 */
|
/** 生成记录页 · 像素复刻旧版 admin.html panel-history(自绘:form-box 筛选 + panel-box 表格 + 旧式分页 + thumb 缩略图)。
|
||||||
|
* script 逻辑沿用现有 Vue 实现(查询/翻页/跳页/点击缩略图放大预览)。 */
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { fetchHistoryList, fetchHistoryUserOptions, clearHistory } from './history-api.ts'
|
import { fetchHistoryList, fetchHistoryUserOptions } from './history-api.ts'
|
||||||
import type { HistoryUserOption } from './history-user-option.ts'
|
import type { HistoryUserOption } from './history-user-option.ts'
|
||||||
import type { HistoryRecordItem } from './history-dto.ts'
|
import type { HistoryRecordItem } from './history-dto.ts'
|
||||||
import { historyEmptyText, historyErrorText } from './history-feedback.ts'
|
import { historyEmptyText, historyErrorText } from './history-feedback.ts'
|
||||||
import { historyPanelTypeLabel } from './history-type.ts'
|
import { historyPanelTypeLabel } from './history-type.ts'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const userLoading = ref(false)
|
const userLoading = ref(false)
|
||||||
const rows = ref<HistoryRecordItem[]>([])
|
const rows = ref<HistoryRecordItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 15
|
const pageSize = ref(10)
|
||||||
const userOptions = ref<HistoryUserOption[]>([])
|
const userOptions = ref<HistoryUserOption[]>([])
|
||||||
const errorText = ref('')
|
const errorText = ref('')
|
||||||
const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
|
const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
|
||||||
|
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
const previewItem = ref<HistoryRecordItem | null>(null)
|
const previewItem = ref<HistoryRecordItem | null>(null)
|
||||||
|
const jumpPage = ref('')
|
||||||
|
|
||||||
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
|
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
|
||||||
const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value }))
|
const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value }))
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
/** datetime-local 原生值(YYYY-MM-DDTHH:mm) 转后端兼容格式。 */
|
||||||
|
function toApiTime(value: string): string | undefined {
|
||||||
|
const t = (value || '').trim()
|
||||||
|
if (!t) return undefined
|
||||||
|
return `${t.replace('T', ' ')}:00`
|
||||||
|
}
|
||||||
|
|
||||||
/** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */
|
/** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */
|
||||||
function previewUrls(item: HistoryRecordItem): string[] {
|
function previewUrls(item: HistoryRecordItem): string[] {
|
||||||
@@ -46,10 +57,10 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const result = await fetchHistoryList({
|
const result = await fetchHistoryList({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize,
|
pageSize: pageSize.value,
|
||||||
userId: filter.userId ? Number(filter.userId) : undefined,
|
userId: filter.userId ? Number(filter.userId) : undefined,
|
||||||
timeStart: filter.timeStart || undefined,
|
timeStart: toApiTime(filter.timeStart),
|
||||||
timeEnd: filter.timeEnd || undefined,
|
timeEnd: toApiTime(filter.timeEnd),
|
||||||
})
|
})
|
||||||
rows.value = result.items
|
rows.value = result.items
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
@@ -77,54 +88,37 @@ function apply() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
|
||||||
filter.userId = ''
|
|
||||||
filter.timeStart = ''
|
|
||||||
filter.timeEnd = ''
|
|
||||||
page.value = 1
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePage(next: number) {
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
page.value = next
|
page.value = next
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goJump() {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(n)
|
||||||
|
}
|
||||||
|
|
||||||
function openPreview(item: HistoryRecordItem) {
|
function openPreview(item: HistoryRecordItem) {
|
||||||
previewItem.value = item
|
previewItem.value = item
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearing = ref(false)
|
|
||||||
|
|
||||||
/** 清空全部生成记录:二次确认后调后端 DELETE /api/admin/history。 */
|
|
||||||
async function clearAll() {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm('确定清空全部生成记录吗?该操作会删除所有历史数据,无法恢复。', '清理生成记录', {
|
|
||||||
confirmButtonText: '确认清空',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
clearing.value = true
|
|
||||||
try {
|
|
||||||
const removed = await clearHistory()
|
|
||||||
ElMessage.success(`已清空 ${removed} 条生成记录`)
|
|
||||||
page.value = 1
|
|
||||||
await load()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '清理失败')
|
|
||||||
} finally {
|
|
||||||
clearing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
load()
|
load()
|
||||||
loadUserOptions()
|
loadUserOptions()
|
||||||
@@ -132,143 +126,443 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="history-view">
|
||||||
<div class="page-heading">
|
<section class="form-box">
|
||||||
<div>
|
<h3>筛选条件</h3>
|
||||||
<h2>生成记录</h2>
|
<div class="form-row">
|
||||||
<p>查看各用户在后台生成的历史记录与结果图。</p>
|
<div class="form-group" style="min-width: 150px">
|
||||||
</div>
|
|
||||||
<div class="heading-actions">
|
|
||||||
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-card shadow="never" class="filter-card">
|
|
||||||
<div class="filter-grid">
|
|
||||||
<div class="f-item">
|
|
||||||
<label>指定用户</label>
|
<label>指定用户</label>
|
||||||
<el-select v-model="filter.userId" clearable filterable placeholder="全部用户" style="width: 100%" :loading="userLoading">
|
<select v-model="filter.userId" :disabled="userLoading">
|
||||||
<el-option label="全部用户" value="" />
|
<option value="">全部用户</option>
|
||||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
|
<option v-for="u in userOptions" :key="u.id" :value="String(u.id)">{{ u.username }}</option>
|
||||||
</el-select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group" style="min-width: 176px">
|
||||||
<label>开始时间</label>
|
<label>开始时间</label>
|
||||||
<el-date-picker
|
<input v-model="filter.timeStart" type="datetime-local" />
|
||||||
v-model="filter.timeStart"
|
|
||||||
type="datetime"
|
|
||||||
placeholder="开始时间"
|
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group" style="min-width: 176px">
|
||||||
<label>结束时间</label>
|
<label>结束时间</label>
|
||||||
<el-date-picker
|
<input v-model="filter.timeEnd" type="datetime-local" />
|
||||||
v-model="filter.timeEnd"
|
|
||||||
type="datetime"
|
|
||||||
placeholder="结束时间"
|
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item btn-row">
|
<button class="btn" type="button" @click="apply">查询</button>
|
||||||
<el-button type="primary" @click="apply">查询</el-button>
|
|
||||||
<el-button @click="reset">重置</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
<section class="panel-box">
|
||||||
<el-alert
|
<h3>生成记录</h3>
|
||||||
v-if="errorText"
|
|
||||||
:title="errorText"
|
<div v-if="errorText" class="msg err">
|
||||||
type="error"
|
<span>加载失败: {{ errorText }}</span>
|
||||||
show-icon
|
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
|
||||||
:closable="false"
|
</div>
|
||||||
style="margin-bottom: 12px"
|
|
||||||
>
|
<div class="table-scroll history-table-scroll">
|
||||||
<template #default>
|
<table>
|
||||||
<el-button link type="primary" @click="retry">重试</el-button>
|
<thead>
|
||||||
</template>
|
<tr>
|
||||||
</el-alert>
|
<th style="width: 70px">ID</th>
|
||||||
<el-table v-loading="loading" :data="rows" stripe :empty-text="emptyText">
|
<th style="width: 130px">用户</th>
|
||||||
<el-table-column prop="id" label="ID" min-width="90" />
|
<th style="width: 160px">类型</th>
|
||||||
<el-table-column prop="username" label="用户" min-width="140" />
|
<th style="width: 170px">创建时间</th>
|
||||||
<el-table-column label="类型" min-width="150">
|
<th>结果预览</th>
|
||||||
<template #default="{ row }">
|
</tr>
|
||||||
{{ historyPanelTypeLabel((row as HistoryRecordItem).panelType) }}
|
</thead>
|
||||||
</template>
|
<tbody>
|
||||||
</el-table-column>
|
<template v-if="rows.length">
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="180">
|
<tr v-for="h in rows" :key="h.id">
|
||||||
<template #default="{ row }">{{ formatDateTime((row as HistoryRecordItem).createdAt) }}</template>
|
<td>{{ h.id }}</td>
|
||||||
</el-table-column>
|
<td>{{ h.username || '-' }}</td>
|
||||||
<el-table-column label="结果预览" min-width="220">
|
<td>{{ historyPanelTypeLabel(h.panelType) }}</td>
|
||||||
<template #default="{ row }">
|
<td>{{ formatDateTime(h.createdAt) }}</td>
|
||||||
<div v-if="previewUrls(row as HistoryRecordItem).length" class="thumbs">
|
<td>
|
||||||
<el-image
|
<div class="thumb-wrap">
|
||||||
v-for="(url, index) in previewUrls(row as HistoryRecordItem).slice(0, 3)"
|
<img
|
||||||
|
v-for="url in previewUrls(h).slice(0, 3)"
|
||||||
:key="url"
|
:key="url"
|
||||||
:src="url"
|
|
||||||
:preview-src-list="previewUrls(row as HistoryRecordItem)"
|
|
||||||
:initial-index="index"
|
|
||||||
preview-teleported
|
|
||||||
fit="cover"
|
|
||||||
class="thumb"
|
class="thumb"
|
||||||
|
:src="url"
|
||||||
|
alt=""
|
||||||
|
title="点击放大预览"
|
||||||
|
@click="openPreview(h)"
|
||||||
/>
|
/>
|
||||||
<span v-if="previewUrls(row as HistoryRecordItem).length > 3" class="dim">+{{ previewUrls(row as HistoryRecordItem).length - 3 }}</span>
|
<span v-if="!previewUrls(h).length" class="empty-mini">暂无图片</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="dim">—</span>
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
<el-table-column label="操作" min-width="110" fixed="right">
|
<td colspan="5" class="empty-tip">加载中...</td>
|
||||||
<template #default="{ row }">
|
</tr>
|
||||||
<el-button text type="primary" size="small" @click="openPreview(row as HistoryRecordItem)">查看大图</el-button>
|
<tr v-else>
|
||||||
</template>
|
<td colspan="5" class="empty-tip">{{ emptyText }}</td>
|
||||||
</el-table-column>
|
</tr>
|
||||||
</el-table>
|
</tbody>
|
||||||
<div class="table-footer">
|
</table>
|
||||||
<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>
|
</div>
|
||||||
</el-card>
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="previewVisible" :title="previewItem ? `记录 #${previewItem.id} 结果图` : '结果图'" width="820px" top="6vh">
|
<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">
|
<div v-if="previewItem" class="preview-meta">
|
||||||
<el-tag size="small">{{ previewItem.username }}</el-tag>
|
<span>{{ previewItem.username || '-' }}</span>
|
||||||
|
<span>·</span>
|
||||||
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
|
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
|
||||||
|
<span>·</span>
|
||||||
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
|
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
|
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
|
||||||
<el-image
|
<a
|
||||||
v-for="(url, index) in previewUrls(previewItem)"
|
v-for="url in previewUrls(previewItem)"
|
||||||
:key="url"
|
:key="url"
|
||||||
:src="url"
|
:href="url"
|
||||||
:preview-src-list="previewUrls(previewItem)"
|
target="_blank"
|
||||||
:initial-index="index"
|
rel="noopener"
|
||||||
preview-teleported
|
title="新标签打开原图"
|
||||||
fit="contain"
|
>
|
||||||
class="preview-img"
|
<img :src="url" alt="" />
|
||||||
/>
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-else class="modal-empty">无结果图片</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn btn-secondary" type="button" @click="previewVisible = false">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-else description="无结果图片" />
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.filter-card { margin-bottom: 14px; }
|
/* 像素复刻旧版 admin.html 蓝白末层样式:form-box/panel-box 双卡 + table-scroll 表格 + 旧式分页 + thumb 缩略图。 */
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
.history-view {
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
|
font-family: inherit;
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
color: var(--c-text, #24384d);
|
||||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
display: flex;
|
||||||
.thumbs { display: flex; align-items: center; gap: 6px; }
|
flex-direction: column;
|
||||||
.thumb { width: 56px; height: 56px; border-radius: 4px; border: 1px solid var(--el-border-color-lighter); flex: none; }
|
gap: 18px;
|
||||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
}
|
||||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
.form-box,
|
||||||
.table-footer b { color: var(--el-text-color-primary); }
|
.panel-box {
|
||||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
width: 100%;
|
||||||
.preview-meta { display: flex; align-items: center; gap: 12px; color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 12px; }
|
min-width: 0;
|
||||||
.preview-grid { display: flex; flex-direction: column; gap: 12px; }
|
padding: 20px 22px 24px;
|
||||||
.preview-img { width: 100%; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.msg.err {
|
||||||
|
color: #91474f;
|
||||||
|
background: #f8ebeb;
|
||||||
|
border-color: #e4c2c5;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.history-table-scroll > table {
|
||||||
|
min-width: 900px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.thumb-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.thumb:hover {
|
||||||
|
border-color: #5f85ad;
|
||||||
|
}
|
||||||
|
.empty-mini {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
background: rgba(45, 66, 86, 0.36);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(880px, 100%);
|
||||||
|
max-height: 84vh;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.modal-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.modal-close {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: none;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-close:hover {
|
||||||
|
border-color: #cbd9e6;
|
||||||
|
background: #eef4fa;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.preview-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0 10px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.preview-grid {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
}
|
||||||
|
.preview-grid a {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.preview-grid img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.modal-empty {
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
}
|
||||||
|
.modal-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,26 +1,81 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
/** 软件版本管理页:客户端版本列表 + 上传新版本(成功留驻弹窗含链接,对齐 admin panel-version)。 */
|
/** 软件版本管理页 · 像素复刻旧版 admin.html panel-version(自绘:面板头+上传新版本、版本列表、全量无分页同旧版)。
|
||||||
import { computed, onMounted, ref } from 'vue'
|
* script 逻辑沿用现有 Vue 实现(上传弹窗成功留驻含链接)。 */
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts'
|
import { fetchSoftwareVersions, uploadSoftwareVersion, deleteSoftwareVersions } from './version-api.ts'
|
||||||
import type { SoftwareVersionItem } from './version-dto.ts'
|
import type { SoftwareVersionItem } from './version-dto.ts'
|
||||||
import { isNonEmptyVersion } from './version-dto.ts'
|
import { isNonEmptyVersion } from './version-dto.ts'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const items = ref<SoftwareVersionItem[]>([])
|
const items = ref<SoftwareVersionItem[]>([])
|
||||||
// 客户端分页:全量拉取后按页切片展示。
|
/** 版本号搜索(客户端过滤)。 */
|
||||||
|
const versionKeyword = ref('')
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
const kw = (versionKeyword.value || '').trim().toLowerCase()
|
||||||
|
if (!kw) return items.value
|
||||||
|
return items.value.filter((item) => `${item.version}`.toLowerCase().includes(kw))
|
||||||
|
})
|
||||||
|
/** 全站分页统一:版本列表客户端分页(10/20/50/100)。 */
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 10
|
const pageSize = ref(10)
|
||||||
const total = computed(() => items.value.length)
|
const pagedVersions = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||||
const pagedItems = computed(() => items.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
function changeVersionPage(p: number) { page.value = p }
|
||||||
|
function changeVersionSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||||
|
// 版本号搜索导致数据收缩时回钳页码,避免停在空页。
|
||||||
|
watch(filteredItems, () => {
|
||||||
|
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
|
||||||
|
})
|
||||||
|
|
||||||
function changePage(p: number) {
|
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
|
||||||
page.value = p
|
const selectedIds = ref<Set<number>>(new Set())
|
||||||
|
const selectedCount = computed(() => selectedIds.value.size)
|
||||||
|
function toggleSelect(id: number) {
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
function toggleSelectAllPage() {
|
||||||
|
const pageRows = pagedVersions.value
|
||||||
|
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (allOn) pageRows.forEach((row) => next.delete(row.id))
|
||||||
|
else pageRows.forEach((row) => next.add(row.id))
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
async function removeSelected() {
|
||||||
|
if (!selectedCount.value) return
|
||||||
|
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
|
||||||
|
const sample = rows.slice(0, 3).map((row) => row.version).join('、')
|
||||||
|
const summary = rows.length > 3 ? `${sample} 等 ${rows.length} 个` : sample
|
||||||
|
if (!window.confirm(`确认删除选中的 ${rows.length} 个版本(${summary})?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteSoftwareVersions(Array.from(selectedIds.value))
|
||||||
|
ElMessage.success(`已删除 ${rows.length} 个版本`)
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function removeOne(row: SoftwareVersionItem) {
|
||||||
|
if (!window.confirm(`确认删除版本 ${row.version}?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteSoftwareVersions([row.id])
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadVisible = ref(false)
|
const uploadVisible = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
const newVersion = ref('')
|
const newVersion = ref('')
|
||||||
const pickedFile = ref<File | null>(null)
|
const pickedFile = ref<File | null>(null)
|
||||||
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
|
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
|
||||||
@@ -53,6 +108,7 @@ function openUpload() {
|
|||||||
async function submitUpload() {
|
async function submitUpload() {
|
||||||
uploadMsg.value = ''
|
uploadMsg.value = ''
|
||||||
uploadMsgOk.value = false
|
uploadMsgOk.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
if (!isNonEmptyVersion(newVersion.value)) {
|
if (!isNonEmptyVersion(newVersion.value)) {
|
||||||
uploadMsg.value = '请填写版本号'
|
uploadMsg.value = '请填写版本号'
|
||||||
return
|
return
|
||||||
@@ -65,16 +121,23 @@ async function submitUpload() {
|
|||||||
uploadMsg.value = '仅支持 .zip 格式'
|
uploadMsg.value = '仅支持 .zip 格式'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (pickedFile.value.size > 512 * 1024 * 1024) {
|
||||||
|
uploadMsg.value = '文件超过允许的大小限制'
|
||||||
|
return
|
||||||
|
}
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
const created = await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name)
|
// 浏览器直传 MinIO:presign → PUT(进度条)→ confirm 落库。
|
||||||
const version = created?.version || newVersion.value.trim()
|
await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name, (p) => {
|
||||||
const link = created?.fileUrl
|
uploadPercent.value = p
|
||||||
// 对齐 admin.js:6484-6488:弹窗保持打开,成功文案(含链接)留驻可复制,仅清空输入并刷新列表。
|
uploadMsg.value = p >= 100 ? '上传完成,正在登记版本...' : `正在上传:${p}%`
|
||||||
uploadMsg.value = link ? `发布成功。版本:${version},链接:${link}` : `发布成功。版本:${version}`
|
})
|
||||||
|
// 成功仅提示“发布成功”,弹窗留驻,不再展示版本号与链接。
|
||||||
|
uploadMsg.value = '发布成功'
|
||||||
uploadMsgOk.value = true
|
uploadMsgOk.value = true
|
||||||
newVersion.value = ''
|
newVersion.value = ''
|
||||||
pickedFile.value = null
|
pickedFile.value = null
|
||||||
|
uploadPercent.value = 0
|
||||||
load()
|
load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
||||||
@@ -87,40 +150,60 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="version-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="version-head">
|
||||||
<h2>软件版本管理</h2>
|
<h3>版本列表</h3>
|
||||||
<p>管理客户端更新包版本(用于 /api/version/latest 下发)。</p>
|
<div class="version-head-tools">
|
||||||
</div>
|
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
|
||||||
<div class="actions">
|
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
|
||||||
<el-button type="primary" @click="openUpload">上传新版本</el-button>
|
<button class="btn" type="button" @click="openUpload">上传新版本</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="table-scroll version-table-scroll">
|
||||||
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无版本记录">
|
<table>
|
||||||
<el-table-column prop="version" label="版本号" min-width="180" />
|
<thead>
|
||||||
<el-table-column label="下载链接" min-width="240">
|
<tr>
|
||||||
<template #default="{ row }">
|
<th style="width: 40px" class="th-select">
|
||||||
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" target="_blank" rel="noopener">{{ (row as SoftwareVersionItem).fileUrl }}</a>
|
<input type="checkbox" :checked="pagedVersions.length > 0 && pagedVersions.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedVersions.length" />
|
||||||
|
</th>
|
||||||
|
<th style="width: 110px">版本号</th>
|
||||||
|
<th>下载链接</th>
|
||||||
|
<th style="width: 150px">创建时间</th>
|
||||||
|
<th style="width: 150px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="filteredItems.length">
|
||||||
|
<tr v-for="row in pagedVersions" :key="row.version + row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
|
||||||
|
<td class="td-select">
|
||||||
|
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
|
||||||
|
</td>
|
||||||
|
<td>{{ 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>
|
<span v-else class="dim">—</span>
|
||||||
</template>
|
</td>
|
||||||
</el-table-column>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="190">
|
<td class="ops-cell">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
|
||||||
</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>
|
<span v-else class="dim">—</span>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
</el-table>
|
<td colspan="5" class="empty-tip">加载中...</td>
|
||||||
<div class="table-footer">
|
</tr>
|
||||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
|
<tr v-else>
|
||||||
|
<td colspan="5" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
<OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changeVersionPage" @size-change="changeVersionSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="uploadVisible" title="上传新版本" width="520px">
|
<el-dialog v-model="uploadVisible" title="上传新版本" width="520px">
|
||||||
<p class="upload-desc">上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。</p>
|
<p class="upload-desc">上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。</p>
|
||||||
@@ -135,6 +218,7 @@ onMounted(load)
|
|||||||
<p class="zip-hint">仅支持 .zip 格式</p>
|
<p class="zip-hint">仅支持 .zip 格式</p>
|
||||||
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
|
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
|
||||||
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -146,11 +230,210 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
|
/* 像素复刻旧版 admin.html panel-version(蓝白末层)。 */
|
||||||
.actions { display: flex; align-items: center; gap: 10px; }
|
.version-view {
|
||||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
font-family: inherit;
|
||||||
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
|
color: #24384d;
|
||||||
.zip-hint { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
}
|
||||||
.upload-msg { margin-bottom: 8px; }
|
.panel-box {
|
||||||
.upload-msg :deep(.el-alert__title) { word-break: break-all; }
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.version-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.version-head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.version-keyword {
|
||||||
|
width: 200px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.version-keyword:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.version-keyword:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.version-table-scroll > table {
|
||||||
|
min-width: 840px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.link-cell {
|
||||||
|
display: block;
|
||||||
|
color: #2f5d8b;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.link-cell:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.dl-btn {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.upload-desc {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.zip-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-selected td {
|
||||||
|
background: #fdf0ee;
|
||||||
|
}
|
||||||
|
.th-select,
|
||||||
|
.td-select {
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 4px !important;
|
||||||
|
padding-right: 4px !important;
|
||||||
|
}
|
||||||
|
.th-select input,
|
||||||
|
.td-select input {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #b33a2e;
|
||||||
|
}
|
||||||
|
.upload-progress {
|
||||||
|
margin: 2px 0 10px;
|
||||||
|
}
|
||||||
|
.upload-msg {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.upload-msg :deep(.el-alert__title) {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
/** 教程管理页:工具台首页「立即下载教程」的包体管理(上传新包 / 列表 / 下载 / 删除)。
|
||||||
|
* 上传走浏览器直传 MinIO(presign → PUT 带进度 → confirm 落库),与软件版本管理页同一套链路;
|
||||||
|
* 上传时可填版本号(仅展示与排序用);工具台始终下载"最新上传"的包(不随列表排序变化)。
|
||||||
|
* 列表默认按上传时间降序,「版本号」「上传时间」表头可点击切换升降序。 */
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { fetchTutorialPackages, uploadTutorialPackage, deleteTutorialPackages } from './tutorial-api.ts'
|
||||||
|
import { formatFileSize } from './tutorial-model.ts'
|
||||||
|
import type { TutorialPackageItem } from './tutorial-dto.ts'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const items = ref<TutorialPackageItem[]>([])
|
||||||
|
/** 文件名搜索(客户端过滤)。 */
|
||||||
|
const keyword = ref('')
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
const kw = (keyword.value || '').trim().toLowerCase()
|
||||||
|
if (!kw) return items.value
|
||||||
|
return items.value.filter((item) => item.fileName.toLowerCase().includes(kw))
|
||||||
|
})
|
||||||
|
/** 全站分页统一:客户端分页(10/20/50/100)。 */
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
/** 排序:默认按上传时间降序(最新上传在最前,与后端返回顺序一致);点表头切换升降序。 */
|
||||||
|
type SortKey = 'version' | 'createdAt'
|
||||||
|
const sortKey = ref<SortKey>('createdAt')
|
||||||
|
const sortAsc = ref(false)
|
||||||
|
|
||||||
|
function toggleSort(key: SortKey) {
|
||||||
|
if (sortKey.value === key) {
|
||||||
|
sortAsc.value = !sortAsc.value
|
||||||
|
} else {
|
||||||
|
sortKey.value = key
|
||||||
|
sortAsc.value = false
|
||||||
|
}
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序标记:未激活 ▲▼,激活时只留方向箭头(比 ⇅/↕ 字形支持好,避免 Windows 缺字形显示方框)。 */
|
||||||
|
function sortMark(key: SortKey): string {
|
||||||
|
if (sortKey.value !== key) return '▲▼'
|
||||||
|
return sortAsc.value ? '▲' : '▼'
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareText(a: string, b: string): number {
|
||||||
|
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedItems = computed(() => {
|
||||||
|
const rows = [...filteredItems.value]
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
// 版本号为空的历史行始终排在末尾,避免切换排序时"无版本"占满首页。
|
||||||
|
if (sortKey.value === 'version') {
|
||||||
|
const av = a.version
|
||||||
|
const bv = b.version
|
||||||
|
if (!av || !bv) {
|
||||||
|
if (!av && !bv) return 0
|
||||||
|
return av ? -1 : 1
|
||||||
|
}
|
||||||
|
const diff = compareText(av, bv)
|
||||||
|
return sortAsc.value ? diff : -diff
|
||||||
|
}
|
||||||
|
const at = a.createdAt
|
||||||
|
const bt = b.createdAt
|
||||||
|
if (!at || !bt) {
|
||||||
|
if (!at && !bt) return 0
|
||||||
|
return at ? -1 : 1
|
||||||
|
}
|
||||||
|
const diff = compareText(at, bt)
|
||||||
|
return sortAsc.value ? diff : -diff
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagedItems = computed(() => sortedItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||||
|
function changePage(p: number) { page.value = p }
|
||||||
|
function changeSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||||
|
// 搜索/排序导致数据收缩时回钳页码,避免停在空页。
|
||||||
|
watch(sortedItems, () => {
|
||||||
|
page.value = Math.min(page.value, Math.max(1, Math.ceil(sortedItems.value.length / pageSize.value)))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前生效包 = 列表第一条(工具台下发的就是它)。 */
|
||||||
|
const activeId = computed(() => items.value[0]?.id ?? null)
|
||||||
|
|
||||||
|
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
|
||||||
|
const selectedIds = ref<Set<number>>(new Set())
|
||||||
|
const selectedCount = computed(() => selectedIds.value.size)
|
||||||
|
function toggleSelect(id: number) {
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
function toggleSelectAllPage() {
|
||||||
|
const pageRows = pagedItems.value
|
||||||
|
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (allOn) pageRows.forEach((row) => next.delete(row.id))
|
||||||
|
else pageRows.forEach((row) => next.add(row.id))
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
async function removeSelected() {
|
||||||
|
if (!selectedCount.value) return
|
||||||
|
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
|
||||||
|
const sample = rows.slice(0, 3).map((row) => row.fileName).join('、')
|
||||||
|
const summary = rows.length > 3 ? `${sample} 等 ${rows.length} 个` : sample
|
||||||
|
if (!window.confirm(`确认删除选中的 ${rows.length} 个教程包(${summary})?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteTutorialPackages(Array.from(selectedIds.value))
|
||||||
|
ElMessage.success(`已删除 ${rows.length} 个教程包`)
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function removeOne(row: TutorialPackageItem) {
|
||||||
|
if (!window.confirm(`确认删除教程包 ${row.fileName}?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteTutorialPackages([row.id])
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadVisible = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
|
const newVersion = ref('')
|
||||||
|
const pickedFile = ref<File | null>(null)
|
||||||
|
/** 弹窗内成功/失败文案留驻(对齐软件版本页交互)。 */
|
||||||
|
const uploadMsg = ref('')
|
||||||
|
const uploadMsgOk = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
items.value = (await fetchTutorialPackages()).items
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '教程包列表加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(file: File) {
|
||||||
|
pickedFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpload() {
|
||||||
|
uploadMsg.value = ''
|
||||||
|
uploadMsgOk.value = false
|
||||||
|
newVersion.value = ''
|
||||||
|
pickedFile.value = null
|
||||||
|
uploadVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUpload() {
|
||||||
|
uploadMsg.value = ''
|
||||||
|
uploadMsgOk.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
|
if (!pickedFile.value) {
|
||||||
|
uploadMsg.value = '请选择 zip 压缩包'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/\.zip$/i.test(pickedFile.value.name)) {
|
||||||
|
uploadMsg.value = '仅支持 .zip 格式'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pickedFile.value.size > 512 * 1024 * 1024) {
|
||||||
|
uploadMsg.value = '文件超过允许的大小限制'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
// 浏览器直传 MinIO:presign → PUT(进度条)→ confirm 落库。
|
||||||
|
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, newVersion.value.trim(), (p) => {
|
||||||
|
uploadPercent.value = p
|
||||||
|
uploadMsg.value = p >= 100 ? '上传完成,正在登记教程包...' : `正在上传:${p}%`
|
||||||
|
})
|
||||||
|
uploadMsg.value = '上传成功,工具台「立即下载教程」已切换为该包'
|
||||||
|
uploadMsgOk.value = true
|
||||||
|
newVersion.value = ''
|
||||||
|
pickedFile.value = null
|
||||||
|
uploadPercent.value = 0
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tutorial-view">
|
||||||
|
<section class="panel-box">
|
||||||
|
<div class="tutorial-head">
|
||||||
|
<h3>教程包列表</h3>
|
||||||
|
<div class="tutorial-head-tools">
|
||||||
|
<input v-model="keyword" type="text" placeholder="搜索文件名" class="tutorial-keyword" />
|
||||||
|
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
|
||||||
|
<button class="btn" type="button" @click="openUpload">上传教程包</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-scroll tutorial-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40px" class="th-select">
|
||||||
|
<input type="checkbox" :checked="pagedItems.length > 0 && pagedItems.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedItems.length" />
|
||||||
|
</th>
|
||||||
|
<th style="width: 260px">文件名</th>
|
||||||
|
<th style="width: 110px">
|
||||||
|
<button class="sort-th sort-version" type="button" @click="toggleSort('version')">版本号<span class="sort-mark">{{ sortMark('version') }}</span></button>
|
||||||
|
</th>
|
||||||
|
<th style="width: 110px">大小</th>
|
||||||
|
<th style="width: 150px">
|
||||||
|
<button class="sort-th sort-time" type="button" @click="toggleSort('createdAt')">上传时间<span class="sort-mark">{{ sortMark('createdAt') }}</span></button>
|
||||||
|
</th>
|
||||||
|
<th>下载链接</th>
|
||||||
|
<th style="width: 170px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="filteredItems.length">
|
||||||
|
<tr v-for="row in pagedItems" :key="row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
|
||||||
|
<td class="td-select">
|
||||||
|
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
|
||||||
|
<span v-if="row.id === activeId" class="tag-active">当前生效</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.version" class="version-cell" :title="row.version">{{ row.version }}</span>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatFileSize(row.fileSize) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>
|
||||||
|
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td colspan="7" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td colspan="7" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="uploadVisible" title="上传教程包" width="520px">
|
||||||
|
<p class="upload-desc">上传教程 ZIP 包后,工具台首页「立即下载教程」将以下载该包为准(以最新上传的为主)。</p>
|
||||||
|
<el-form label-width="110px">
|
||||||
|
<el-form-item label="版本号">
|
||||||
|
<el-input v-model="newVersion" placeholder="例如:v2026.09 或留空" maxlength="64" />
|
||||||
|
<p class="zip-hint">仅作展示与排序用,可留空;不影响工具台按最新上传下载</p>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="ZIP 压缩包" required>
|
||||||
|
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (pickedFile = null)">
|
||||||
|
<el-button>选择文件</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<p class="zip-hint">仅支持 .zip 格式,最大 512MB</p>
|
||||||
|
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}({{ formatFileSize(pickedFile.size) }})</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
|
||||||
|
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="uploadVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="uploading" @click="submitUpload">上传教程包</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 沿用软件版本管理页(旧版 admin.html panel-version)的样式语言。 */
|
||||||
|
.tutorial-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.tutorial-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.tutorial-head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.tutorial-keyword {
|
||||||
|
width: 200px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.tutorial-keyword:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.tutorial-keyword:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.tutorial-table-scroll > table {
|
||||||
|
min-width: 940px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.file-name {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* 表头排序:按钮铺满单元格,仅 hover 时加深字色,保持旧版表头观感。 */
|
||||||
|
.sort-th {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sort-th:hover {
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.sort-mark {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sort-th:hover .sort-mark {
|
||||||
|
color: #5f85ad;
|
||||||
|
}
|
||||||
|
.sort-version {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
.sort-time {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
.version-cell {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.tag-active {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid #4f9a72;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eaf6ef;
|
||||||
|
color: #2f7a55;
|
||||||
|
font-size: 11.5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.link-cell {
|
||||||
|
display: block;
|
||||||
|
color: #2f5d8b;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.link-cell:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.dl-btn {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.upload-desc {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.zip-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-selected td {
|
||||||
|
background: #fdf0ee;
|
||||||
|
}
|
||||||
|
.th-select,
|
||||||
|
.td-select {
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 4px !important;
|
||||||
|
padding-right: 4px !important;
|
||||||
|
}
|
||||||
|
.th-select input,
|
||||||
|
.td-select input {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #b33a2e;
|
||||||
|
}
|
||||||
|
.upload-progress {
|
||||||
|
margin: 2px 0 10px;
|
||||||
|
}
|
||||||
|
.upload-msg {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.upload-msg :deep(.el-alert__title) {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -17,11 +17,3 @@ export async function fetchHistoryUserOptions(): Promise<HistoryUserOption[]> {
|
|||||||
const { data } = await http.get<unknown>('/api/admin/users', { params: { page: 1, page_size: 999 } })
|
const { data } = await http.get<unknown>('/api/admin/users', { params: { page: 1, page_size: 999 } })
|
||||||
return parseHistoryUserOptions(data)
|
return parseHistoryUserOptions(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空全部生成记录(验收反馈:生成记录数据要可清理)。返回清理条数。 */
|
|
||||||
export async function clearHistory(): Promise<number> {
|
|
||||||
const { data } = await http.delete<unknown>(HISTORY_ENDPOINT)
|
|
||||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
|
||||||
const removed = record?.data
|
|
||||||
return typeof removed === 'number' ? removed : 0
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** 历史生成记录 DTO(任务 121):列表筛选/分页归一与类型边界;与 Java ImageHistoryController
|
/** 历史生成记录 DTO(任务 121):列表筛选/分页归一与类型边界;与 Java ImageHistoryController
|
||||||
* snake 契约对齐;历史页与后台版本/公开版本接口分离。纯逻辑。 */
|
* snake 契约对齐;历史页与后台版本/公开版本接口分离。纯逻辑。 */
|
||||||
|
|
||||||
export const HISTORY_DEFAULT_PAGE_SIZE = 15
|
export const HISTORY_DEFAULT_PAGE_SIZE = 10
|
||||||
export const HISTORY_MIN_PAGE = 1
|
export const HISTORY_MIN_PAGE = 1
|
||||||
export const HISTORY_MAX_PAGE_SIZE = 200
|
export const HISTORY_MAX_PAGE_SIZE = 200
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/** 教程包接口适配(教程管理页):列表 / 直传预签名 / 确认 / 删除。 */
|
||||||
|
import axios from 'axios'
|
||||||
|
import { http, unwrap } from '@/api/http'
|
||||||
|
import { parseTutorialPackageList, parseTutorialPackageUpload } from './tutorial-model.ts'
|
||||||
|
import type { TutorialPackageItem, TutorialPackageList } from './tutorial-dto.ts'
|
||||||
|
|
||||||
|
export const TUTORIALS_ENDPOINT = '/api/admin/tutorials'
|
||||||
|
export const TUTORIAL_UPLOAD_ENDPOINT = '/api/admin/tutorial'
|
||||||
|
export const TUTORIAL_PRESIGN_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/presign`
|
||||||
|
export const TUTORIAL_CONFIRM_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/confirm`
|
||||||
|
export const TUTORIAL_DELETE_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/delete`
|
||||||
|
|
||||||
|
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
|
||||||
|
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
|
||||||
|
|
||||||
|
export async function fetchTutorialPackages(): Promise<TutorialPackageList> {
|
||||||
|
const { data } = await http.get<unknown>(TUTORIALS_ENDPOINT)
|
||||||
|
return parseTutorialPackageList(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorialUploadTarget {
|
||||||
|
objectKey: string
|
||||||
|
uploadUrl: string
|
||||||
|
fileUrl: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第一步:向后端申请直传 PUT 预签名地址(登录会话签发,3 分钟有效)。 */
|
||||||
|
export async function requestTutorialPresign(fileName: string, version = ''): Promise<TutorialUploadTarget> {
|
||||||
|
const { data } = await http.post<unknown>(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName, version } })
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
|
||||||
|
const objectKey = typeof core.object_key === 'string' ? core.object_key : ''
|
||||||
|
if (!uploadUrl || !objectKey) {
|
||||||
|
throw new Error('后端未返回直传地址,请重试')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
objectKey,
|
||||||
|
uploadUrl,
|
||||||
|
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
|
||||||
|
version: typeof core.version === 'string' ? core.version : version.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第三步:直传完成后通知后端校验对象并写入记录(data.item 为新记录行)。 */
|
||||||
|
export async function confirmTutorialPackage(objectKey: string, fileName: string, version = ''): Promise<TutorialPackageItem | null> {
|
||||||
|
const { data } = await http.post<unknown>(TUTORIAL_CONFIRM_ENDPOINT, null, {
|
||||||
|
params: { object_key: objectKey, file_name: fileName, version },
|
||||||
|
})
|
||||||
|
return parseTutorialPackageUpload(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 id 批量删除教程包(后端会顺带清理无引用对象的 MinIO 包体);返回实际删除行数。 */
|
||||||
|
export async function deleteTutorialPackages(ids: number[]): Promise<number> {
|
||||||
|
if (!ids.length) return 0
|
||||||
|
const { data } = await http.post<unknown>(TUTORIAL_DELETE_ENDPOINT, ids)
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
return typeof core.deleted === 'number' ? core.deleted : ids.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传教程包(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
|
||||||
|
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
|
||||||
|
*/
|
||||||
|
export async function uploadTutorialPackage(
|
||||||
|
file: Blob,
|
||||||
|
fileName: string,
|
||||||
|
version = '',
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<TutorialPackageItem | null> {
|
||||||
|
const target = await requestTutorialPresign(fileName, version)
|
||||||
|
await directPut.put(target.uploadUrl, file, {
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (!onProgress) return
|
||||||
|
const total = event.total || 0
|
||||||
|
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return confirmTutorialPackage(target.objectKey, fileName, target.version)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** 教程包 DTO(教程管理页):工具台下载入口对应的教程压缩包行类型;纯逻辑。 */
|
||||||
|
|
||||||
|
/** 教程包行(GET /api/admin/tutorials data.items,Java TutorialPackageService snake)。 */
|
||||||
|
export interface TutorialPackageItem {
|
||||||
|
id: number
|
||||||
|
fileName: string
|
||||||
|
/** 版本号(上传时填写,V126 之前的历史行为空串) */
|
||||||
|
version: string
|
||||||
|
objectKey: string
|
||||||
|
fileSize: number
|
||||||
|
fileUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorialPackageList {
|
||||||
|
items: TutorialPackageItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyTutorialPackageList(): TutorialPackageList {
|
||||||
|
return { items: [] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/** 教程包列表加载模型:解析 /api/admin/tutorials data.items(snake);纯逻辑。 */
|
||||||
|
import { unwrap } from '../../api/envelope.ts'
|
||||||
|
import { type TutorialPackageItem, type TutorialPackageList } from './tutorial-dto.ts'
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析单条教程包行;缺 id 视为无效。 */
|
||||||
|
export function toTutorialPackageItem(raw: unknown): TutorialPackageItem | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const r = raw as Record<string, unknown>
|
||||||
|
const id = numberOrNull(r.id)
|
||||||
|
if (id === null) return null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
fileName: text(r.file_name ?? r.fileName),
|
||||||
|
version: text(r.version),
|
||||||
|
objectKey: text(r.object_key ?? r.objectKey),
|
||||||
|
fileSize: numberOrNull(r.file_size ?? r.fileSize) ?? 0,
|
||||||
|
fileUrl: text(r.file_url ?? r.fileUrl),
|
||||||
|
createdAt: text(r.created_at ?? r.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化教程包列表负载为前端结果;缺省回空列表。 */
|
||||||
|
export function parseTutorialPackageList(payload: unknown): TutorialPackageList {
|
||||||
|
const data = unwrap<unknown>(payload)
|
||||||
|
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : {}
|
||||||
|
const items = Array.isArray(record.items)
|
||||||
|
? record.items.map((raw) => toTutorialPackageItem(raw)).filter((item): item is TutorialPackageItem => item !== null)
|
||||||
|
: []
|
||||||
|
return { items }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传成功后返回的新记录行(data.item);无 item 回 null。 */
|
||||||
|
export function parseTutorialPackageUpload(payload: unknown): TutorialPackageItem | null {
|
||||||
|
const data = unwrap<unknown>(payload)
|
||||||
|
if (!data || typeof data !== 'object') return null
|
||||||
|
const item = (data as Record<string, unknown>).item
|
||||||
|
return item ? toTutorialPackageItem(item) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字节数展示:0=未知展示为 -,其余按 KB/MB/GB 人性化。 */
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const units = ['KB', 'MB', 'GB']
|
||||||
|
let value = bytes / 1024
|
||||||
|
let unitIndex = 0
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
@@ -1,21 +1,83 @@
|
|||||||
/** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */
|
/** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */
|
||||||
import { http } from '@/api/http'
|
import axios from 'axios'
|
||||||
|
import { http, unwrap } from '@/api/http'
|
||||||
import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts'
|
import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts'
|
||||||
import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
|
import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
|
||||||
|
|
||||||
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
|
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
|
||||||
export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version'
|
export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version'
|
||||||
|
export const SOFTWARE_VERSION_PRESIGN_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/presign`
|
||||||
|
export const SOFTWARE_VERSION_CONFIRM_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/confirm`
|
||||||
|
export const SOFTWARE_VERSION_DELETE_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/delete`
|
||||||
|
|
||||||
|
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
|
||||||
|
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
|
||||||
|
|
||||||
export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> {
|
export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> {
|
||||||
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
|
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
|
||||||
return parseSoftwareVersionList(data)
|
return parseSoftwareVersionList(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传客户端软件版本(multipart version + file);返回新版本行(含下载链接)。 */
|
export interface SoftwareVersionUploadTarget {
|
||||||
export async function uploadSoftwareVersion(version: string, file: Blob, filename = ''): Promise<SoftwareVersionItem | null> {
|
uploadUrl: string
|
||||||
const form = new FormData()
|
fileUrl: string
|
||||||
form.append('version', version)
|
version: string
|
||||||
form.append('file', file, filename || (file instanceof File ? file.name : 'version.zip'))
|
}
|
||||||
const { data } = await http.post<unknown>(SOFTWARE_VERSION_UPLOAD_ENDPOINT, form)
|
|
||||||
|
export interface SoftwareVersionUploadResult {
|
||||||
|
version: string
|
||||||
|
fileUrl: string
|
||||||
|
item: SoftwareVersionItem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第一步:向后端申请直传 PUT 预签名地址与公开下载 URL(登录会话签发,3 分钟有效)。 */
|
||||||
|
export async function requestSoftwareVersionPresign(version: string): Promise<SoftwareVersionUploadTarget> {
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_PRESIGN_ENDPOINT, null, { params: { version } })
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
|
||||||
|
if (!uploadUrl) {
|
||||||
|
throw new Error('后端未返回直传地址,请重试')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uploadUrl,
|
||||||
|
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
|
||||||
|
version: typeof core.version === 'string' ? core.version : version.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第三步:直传完成后通知后端校验对象并写入版本记录(data.item 为新版本行)。 */
|
||||||
|
export async function confirmSoftwareVersion(version: string): Promise<SoftwareVersionItem | null> {
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_CONFIRM_ENDPOINT, null, { params: { version } })
|
||||||
return parseSoftwareVersionUpload(data)
|
return parseSoftwareVersionUpload(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按 id 批量删除版本(后端会顺带清理无引用对象的 MinIO 安装包);返回实际删除行数。 */
|
||||||
|
export async function deleteSoftwareVersions(ids: number[]): Promise<number> {
|
||||||
|
if (!ids.length) return 0
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_DELETE_ENDPOINT, ids)
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
return typeof core.deleted === 'number' ? core.deleted : ids.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传客户端软件版本(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
|
||||||
|
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
|
||||||
|
*/
|
||||||
|
export async function uploadSoftwareVersion(
|
||||||
|
version: string,
|
||||||
|
file: Blob,
|
||||||
|
_filename = '',
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<SoftwareVersionUploadResult> {
|
||||||
|
const target = await requestSoftwareVersionPresign(version)
|
||||||
|
await directPut.put(target.uploadUrl, file, {
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (!onProgress) return
|
||||||
|
const total = event.total || 0
|
||||||
|
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const item = await confirmSoftwareVersion(target.version)
|
||||||
|
return { version: target.version, fileUrl: item?.fileUrl || target.fileUrl, item }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
/** 店铺密钥管理页:分页 + 新增/编辑/删除;紫鸟令牌仅掩码展示。 */
|
/** 店铺密钥管理页 · 像素复刻旧版 admin.html panel-shop-keys(自绘:面板头+新增店铺密钥、序号列、令牌掩码、白名单状态药丸+悬停详情、旧式分页)。
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } from './shop-key-api.ts'
|
import { ElMessage } from 'element-plus'
|
||||||
import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts'
|
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey, checkShopKeyWhitelist } from './shop-key-api.ts'
|
||||||
|
import { emptyShopKeyForm, normalizeZiniaoToken, validateProxyUrl } from './shop-key-form-model.ts'
|
||||||
import type { ShopKeyItem } from './shop-dto.ts'
|
import type { ShopKeyItem } from './shop-dto.ts'
|
||||||
import { whitelistStatusMeta } from './shop-key-model.ts'
|
import { whitelistStatusMeta } from './shop-key-model.ts'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<ShopKeyItem[]>([])
|
const rows = ref<ShopKeyItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
|
const jumpPage = ref('')
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
/** 筛选:备注名/紫鸟账号(用户要求店铺密钥列表补筛选)。 */
|
||||||
|
const filterKeyword = ref('')
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const kw = (filterKeyword.value || '').trim().toLowerCase()
|
||||||
|
if (!kw) return rows.value
|
||||||
|
return rows.value.filter((r) =>
|
||||||
|
`${r.remarkName || ''} ${r.ziniaoAccountName || ''}`.toLowerCase().includes(kw),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
/** 正在检测白名单的行 id,用于按钮 loading 态。 */
|
||||||
|
const checkingId = ref<number | null>(null)
|
||||||
const form = reactive(emptyShopKeyForm())
|
const form = reactive(emptyShopKeyForm())
|
||||||
|
|
||||||
function maskToken(token: string): string {
|
function maskToken(token: string): string {
|
||||||
const value = (token || '')
|
const value = token || ''
|
||||||
if (value.length <= 8) return '••••••••'
|
if (value.length <= 8) return '••••••••'
|
||||||
return `${value.slice(0, 4)}••••••••${value.slice(-4)}`
|
return `${value.slice(0, 4)}••••••••${value.slice(-4)}`
|
||||||
}
|
}
|
||||||
@@ -33,6 +48,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
|
|||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
|
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
|
||||||
if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage)
|
if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage)
|
||||||
|
if (row.ipWhitelistFailCount > 0) parts.push(`连续失败 ${row.ipWhitelistFailCount} 次`)
|
||||||
if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`)
|
if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`)
|
||||||
return parts.join('\n')
|
return parts.join('\n')
|
||||||
}
|
}
|
||||||
@@ -63,9 +79,30 @@ function openEdit(row: ShopKeyItem) {
|
|||||||
Object.assign(form, emptyShopKeyForm())
|
Object.assign(form, emptyShopKeyForm())
|
||||||
form.remarkName = row.remarkName
|
form.remarkName = row.remarkName
|
||||||
form.ziniaoAccountName = row.ziniaoAccountName
|
form.ziniaoAccountName = row.ziniaoAccountName
|
||||||
|
form.proxyUrl = row.proxyUrl || ''
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手动检测白名单:绕过缓存真实请求一次紫鸟,并重置自动重试次数。 */
|
||||||
|
async function checkWhitelist(row: ShopKeyItem) {
|
||||||
|
checkingId.value = row.id
|
||||||
|
try {
|
||||||
|
const updated = await checkShopKeyWhitelist(row.id)
|
||||||
|
if (updated.ipWhitelistStatus === 'ALLOWED') {
|
||||||
|
ElMessage.success('白名单检测通过')
|
||||||
|
} else if (updated.ipWhitelistStatus === 'BLOCKED') {
|
||||||
|
ElMessage.error(`白名单未放行:${updated.ipWhitelistMessage || '请求被拒绝'}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(updated.ipWhitelistMessage || '检测未通过')
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '检测失败')
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const token = normalizeZiniaoToken(form.ziniaoToken)
|
const token = normalizeZiniaoToken(form.ziniaoToken)
|
||||||
const creating = editingId.value == null
|
const creating = editingId.value == null
|
||||||
@@ -77,6 +114,11 @@ async function submit() {
|
|||||||
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
|
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const proxyError = validateProxyUrl(form.proxyUrl)
|
||||||
|
if (proxyError) {
|
||||||
|
ElMessage.warning(proxyError)
|
||||||
|
return
|
||||||
|
}
|
||||||
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
|
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
|
||||||
form.ziniaoToken = creating ? token : token || originalToken.value
|
form.ziniaoToken = creating ? token : token || originalToken.value
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -97,11 +139,8 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(row: ShopKeyItem) {
|
async function remove(row: ShopKeyItem) {
|
||||||
try {
|
// 对齐旧版:原生 confirm。
|
||||||
await ElMessageBox.confirm(`确定删除店铺密钥“${row.ziniaoAccountName}”吗?`, '删除确认', { type: 'warning' })
|
if (!window.confirm(`确定删除店铺密钥“${row.ziniaoAccountName}”吗?`)) return
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await deleteShopKey(row.id)
|
await deleteShopKey(row.id)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
@@ -111,61 +150,113 @@ async function remove(row: ShopKeyItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goJump() {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="keys-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="keys-head">
|
||||||
<h2>店铺密钥管理</h2>
|
<h3>店铺密钥列表</h3>
|
||||||
<p>管理各店铺的紫鸟浏览器账号与令牌。</p>
|
<button class="btn" type="button" @click="openCreate">新增店铺密钥</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="shop-key-table-scroll">
|
||||||
<div class="table-toolbar">
|
<table>
|
||||||
<el-button type="primary" @click="openCreate">新增密钥</el-button>
|
<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: 190px">代理</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="mono-mask" :title="row.proxyUrl || '直连'">{{ row.proxyUrl || '直连' }}</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-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="checkingId === row.id"
|
||||||
|
@click="checkWhitelist(row)"
|
||||||
|
>
|
||||||
|
{{ checkingId === row.id ? '检测中…' : '检测白名单' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td colspan="9" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td colspan="9" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<el-table v-loading="loading" :data="rows" stripe border>
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
<el-table-column prop="remarkName" label="备注名" min-width="140">
|
</section>
|
||||||
<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>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增密钥' : '编辑密钥'" width="560px">
|
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增密钥' : '编辑密钥'" width="560px">
|
||||||
<el-form label-width="110px">
|
<el-form label-width="110px">
|
||||||
@@ -183,6 +274,15 @@ onMounted(load)
|
|||||||
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
|
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="代理地址">
|
||||||
|
<el-input
|
||||||
|
v-model="form.proxyUrl"
|
||||||
|
placeholder="留空直连;形如 http://host:port 或 http://user:pass@host:port"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">
|
||||||
|
仅当该账号的服务器出口 IP 不在紫鸟白名单时填写:代理 IP 需已由紫鸟公司加白,留空则直连。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
@@ -193,9 +293,276 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
/* 像素复刻旧版 admin.html panel-shop-keys(蓝白末层)。 */
|
||||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
.keys-view {
|
||||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
font-family: inherit;
|
||||||
.table-footer b { color: var(--el-text-color-primary); }
|
color: #24384d;
|
||||||
.mono-mask { font-family: Consolas, monospace; font-size: 12px; background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 4px; }
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.keys-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.keys-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
color: #4f78a5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.form-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.shop-key-table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.shop-key-table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
.shop-key-table-scroll th,
|
||||||
|
.shop-key-table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.shop-key-table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.shop-key-table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.shop-key-table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.table-help {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-left: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c7d7e5;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.mono-mask {
|
||||||
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.wh-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.wh-pill.is-allowed {
|
||||||
|
color: #3d7158;
|
||||||
|
background: #e8f2eb;
|
||||||
|
border-color: #bdd7c5;
|
||||||
|
}
|
||||||
|
.wh-pill.is-blocked {
|
||||||
|
color: #91474f;
|
||||||
|
background: #f8ebeb;
|
||||||
|
border-color: #e4c2c5;
|
||||||
|
}
|
||||||
|
.wh-pill.is-unknown,
|
||||||
|
.wh-pill.is-pending,
|
||||||
|
.wh-pill.is-notchecked {
|
||||||
|
color: #5b6f83;
|
||||||
|
background: #f1f6fa;
|
||||||
|
border-color: #d6e2ec;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
/** 店铺管理页:分组/关键字筛选 + 分页 + 新增/编辑/凭证明文/删除。 */
|
/** 店铺管理页 · 像素复刻旧版 admin.html panel-shop-manage(自绘:面板头+新增店铺、筛选、密码列 ******+眼睛实时读明文、旧式表格/分页、原生 confirm 删除)。
|
||||||
|
* script 逻辑沿用现有 Vue 实现(凭证明文 dialog/编辑弹窗/分组筛选)。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
import {
|
import {
|
||||||
deleteShopManage,
|
deleteShopManage,
|
||||||
@@ -14,13 +15,18 @@ import {
|
|||||||
} from './shop-manage-api.ts'
|
} from './shop-manage-api.ts'
|
||||||
import { emptyShopManageForm } from './shop-manage-form-model.ts'
|
import { emptyShopManageForm } from './shop-manage-form-model.ts'
|
||||||
import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts'
|
import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const session = useAdminSessionStore()
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<ShopSummary[]>([])
|
const rows = ref<ShopSummary[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
|
const jumpPage = ref('')
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
const filter = reactive({ shopName: '', groupId: null as number | null })
|
const filter = reactive({ shopName: '', groupId: null as number | null })
|
||||||
@@ -38,6 +44,11 @@ const revealedPasswordCache = ref<Record<number, string>>({})
|
|||||||
const revealedRowId = ref<number | null>(null)
|
const revealedRowId = ref<number | null>(null)
|
||||||
|
|
||||||
const groupOptions = computed(() => groups.value)
|
const groupOptions = computed(() => groups.value)
|
||||||
|
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||||
|
const lockedGroupId = computed<number | null>(() => {
|
||||||
|
if (isSuperAdmin.value) return null
|
||||||
|
return groups.value.length === 1 ? groups.value[0].id : null
|
||||||
|
})
|
||||||
|
|
||||||
function shownPassword(row: ShopSummary): string {
|
function shownPassword(row: ShopSummary): string {
|
||||||
if (revealedRowId.value !== row.id) return row.passwordMasked || '******'
|
if (revealedRowId.value !== row.id) return row.passwordMasked || '******'
|
||||||
@@ -94,29 +105,39 @@ function apply() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function changeSize(size: number) {
|
function changeSize(size: number) {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
page.value = 1
|
page.value = 1
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function goJump() {
|
||||||
filter.shopName = ''
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
filter.groupId = null
|
if (Number.isNaN(n)) {
|
||||||
page.value = 1
|
ElMessage.warning('请输入页码')
|
||||||
load()
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
Object.assign(form, emptyShopManageForm())
|
Object.assign(form, emptyShopManageForm())
|
||||||
|
if (lockedGroupId.value != null) form.groupId = lockedGroupId.value
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: ShopSummary) {
|
function openEdit(row: ShopSummary) {
|
||||||
editingId.value = row.id
|
editingId.value = row.id
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
groupId: row.groupId,
|
groupId: row.groupId ?? lockedGroupId.value,
|
||||||
shopName: row.shopName,
|
shopName: row.shopName,
|
||||||
mallName: row.mallName,
|
mallName: row.mallName,
|
||||||
znUsername: row.znUsername,
|
znUsername: row.znUsername,
|
||||||
@@ -166,11 +187,8 @@ async function showCredential(row: ShopSummary) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(row: ShopSummary) {
|
async function remove(row: ShopSummary) {
|
||||||
try {
|
// 对齐旧版:原生 confirm。
|
||||||
await ElMessageBox.confirm(`确定删除店铺“${row.shopName}”吗?`, '删除确认', { type: 'warning' })
|
if (!window.confirm(`确定删除店铺“${row.shopName}”吗?`)) return
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await deleteShopManage(row.id)
|
await deleteShopManage(row.id)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
@@ -196,83 +214,88 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="shop-view">
|
||||||
<div class="page-heading">
|
<section class="panel-box">
|
||||||
<div>
|
<div class="shop-head">
|
||||||
<h2>店铺管理</h2>
|
<h3>店铺列表</h3>
|
||||||
<p>管理店铺账号、所属分组与自动化账号。</p>
|
<button class="btn" type="button" @click="openCreate">新增店铺</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" style="margin-bottom: 14px">
|
<div class="form-row shop-filter-row">
|
||||||
<div class="filter-grid">
|
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
|
||||||
<div class="f-item">
|
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable>
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item">
|
<div class="form-group" style="min-width: 220px">
|
||||||
<label>店铺名</label>
|
<label>店铺名</label>
|
||||||
<el-input v-model="filter.shopName" placeholder="模糊搜索店铺名" clearable @keyup.enter="apply" />
|
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item btn-row">
|
<button class="btn" type="button" @click="apply">查询</button>
|
||||||
<el-button type="primary" @click="apply">查询</el-button>
|
|
||||||
<el-button @click="reset">重置</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card shadow="never">
|
<div class="table-scroll shop-manage-table-scroll">
|
||||||
<div class="table-toolbar">
|
<table>
|
||||||
<el-button type="primary" @click="openCreate">新增店铺</el-button>
|
<thead>
|
||||||
</div>
|
<tr>
|
||||||
<el-table v-loading="loading" :data="rows" stripe border>
|
<th style="width: 58px">序号</th>
|
||||||
<el-table-column prop="groupName" label="分组" min-width="120" />
|
<th v-if="isSuperAdmin" style="width: 120px">分组</th>
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="160" />
|
<th style="width: 150px">店铺名</th>
|
||||||
<el-table-column prop="mallName" label="店铺商城名" min-width="120" />
|
<th style="width: 130px">店铺商城名</th>
|
||||||
<el-table-column prop="znUsername" label="自动化账号" min-width="140">
|
<th style="width: 130px">自动化账号</th>
|
||||||
<template #default="{ row }">{{ (row as ShopSummary).znUsername || '—' }}</template>
|
<th style="width: 130px">账号</th>
|
||||||
</el-table-column>
|
<th style="width: 170px">密码</th>
|
||||||
<el-table-column prop="account" label="账号" min-width="140" />
|
<th style="width: 170px">创建时间</th>
|
||||||
<el-table-column label="密码" min-width="170">
|
<th style="width: 170px">修改时间</th>
|
||||||
<template #default="{ row }">
|
<th style="width: 220px">操作</th>
|
||||||
<span class="pwd-cell">{{ shownPassword(row as ShopSummary) }}</span>
|
</tr>
|
||||||
<el-button
|
</thead>
|
||||||
link
|
<tbody>
|
||||||
type="primary"
|
<template v-if="rows.length">
|
||||||
size="small"
|
<tr v-for="(row, index) in rows" :key="row.id">
|
||||||
:aria-label="revealedRowId === (row as ShopSummary).id ? '隐藏密码' : '显示密码'"
|
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||||
@click="togglePassword(row as ShopSummary)"
|
<td v-if="isSuperAdmin" data-text :title="row.groupName || '—'">{{ row.groupName || '—' }}</td>
|
||||||
|
<td :title="row.shopName">{{ row.shopName }}</td>
|
||||||
|
<td :title="row.mallName || '—'">{{ row.mallName || '—' }}</td>
|
||||||
|
<td :title="row.znUsername || '—'">{{ row.znUsername || '—' }}</td>
|
||||||
|
<td :title="row.account">{{ row.account }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pwd-cell">{{ shownPassword(row) }}</span>
|
||||||
|
<button
|
||||||
|
class="pwd-eye btn btn-sm btn-secondary"
|
||||||
|
type="button"
|
||||||
|
:aria-label="revealedRowId === row.id ? '隐藏密码' : '显示密码'"
|
||||||
|
@click="togglePassword(row)"
|
||||||
>
|
>
|
||||||
{{ revealedRowId === (row as ShopSummary).id ? '隐藏' : '显示' }}
|
{{ revealedRowId === row.id ? '隐藏' : '显示' }}
|
||||||
</el-button>
|
</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>
|
</template>
|
||||||
</el-table-column>
|
<tr v-else-if="loading">
|
||||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
|
||||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
</tr>
|
||||||
</el-table-column>
|
<tr v-else>
|
||||||
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
|
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">暂无店铺</td>
|
||||||
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
</tr>
|
||||||
</el-table-column>
|
</tbody>
|
||||||
<el-table-column label="操作" min-width="220" fixed="right">
|
</table>
|
||||||
<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>
|
</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-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
|
||||||
<el-form label-width="120px">
|
<el-form label-width="120px">
|
||||||
<el-form-item label="所属分组" required>
|
<el-form-item label="所属分组" required>
|
||||||
<el-select v-model="form.groupId" placeholder="选择分组">
|
<el-select filterable v-model="form.groupId" placeholder="选择分组" :disabled="lockedGroupId != null">
|
||||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -301,7 +324,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="credentialVisible" title="店铺明文凭据" width="560px">
|
<el-dialog v-model="credentialVisible" title="店铺明文凭据" width="560px">
|
||||||
<el-descriptions v-if="credential" :column="1" border>
|
<el-descriptions v-if="credential" :column="1" border>
|
||||||
<el-descriptions-item label="店铺名">{{ credential.shopName }}</el-descriptions-item>
|
<el-descriptions-item label="店铺名">{{ credential.shopName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="分组">{{ credential.groupName || '—' }}</el-descriptions-item>
|
<el-descriptions-item v-if="isSuperAdmin" label="分组">{{ credential.groupName || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="店铺商城名">{{ credential.mallName || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="店铺商城名">{{ credential.mallName || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="账号">{{ credential.account }}</el-descriptions-item>
|
<el-descriptions-item label="账号">{{ credential.account }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="密码">
|
<el-descriptions-item label="密码">
|
||||||
@@ -316,14 +339,239 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
/* 像素复刻旧版 admin.html panel-shop-manage(蓝白末层)。 */
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
.shop-view {
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
font-family: inherit;
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
color: #24384d;
|
||||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
}
|
||||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
.panel-box {
|
||||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
width: 100%;
|
||||||
.table-footer b { color: var(--el-text-color-primary); }
|
min-width: 0;
|
||||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
padding: 20px 22px 24px;
|
||||||
.pwd-cell { font-family: Consolas, monospace; font-size: 12px; margin-right: 6px; }
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.shop-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.shop-manage-table-scroll > table {
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.pwd-cell {
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.pwd-eye {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,9 +22,13 @@ export interface ShopKeyItem {
|
|||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
|
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
|
||||||
ziniaoToken: SensitiveString
|
ziniaoToken: SensitiveString
|
||||||
|
/** 静态代理地址;为空表示直连。用于出口 IP 不在紫鸟白名单的 key。 */
|
||||||
|
proxyUrl: string
|
||||||
ipWhitelistStatus: IpWhitelistStatus
|
ipWhitelistStatus: IpWhitelistStatus
|
||||||
ipWhitelistCheckedAt?: string
|
ipWhitelistCheckedAt?: string
|
||||||
ipWhitelistMessage?: string
|
ipWhitelistMessage?: string
|
||||||
|
/** 连续白名单失败次数;达上限后不再自动重试,需人工检测。 */
|
||||||
|
ipWhitelistFailCount: number
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,3 +55,13 @@ export async function updateShopKey(id: number, form: ShopKeyFormValues): Promis
|
|||||||
export async function deleteShopKey(id: number): Promise<void> {
|
export async function deleteShopKey(id: number): Promise<void> {
|
||||||
await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`)
|
await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手动检测 IP 白名单:POST /api/admin/shop-keys/{id}/check-whitelist;返回更新后的记录。 */
|
||||||
|
export async function checkShopKeyWhitelist(id: number): Promise<ShopKeyItem> {
|
||||||
|
const { data } = await http.post<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}/check-whitelist`)
|
||||||
|
const item = toShopKeyItem(unwrap<unknown>(data))
|
||||||
|
if (!item) {
|
||||||
|
throw new Error('白名单检测响应异常:未返回有效记录')
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ export function buildShopKeyEditForm(item: ShopKeyItem | null | undefined): Shop
|
|||||||
remarkName: typeof item.remarkName === 'string' ? item.remarkName : '',
|
remarkName: typeof item.remarkName === 'string' ? item.remarkName : '',
|
||||||
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
|
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
|
||||||
ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '',
|
ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '',
|
||||||
|
proxyUrl: typeof item.proxyUrl === 'string' ? item.proxyUrl : '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,22 @@
|
|||||||
export const SHOP_KEY_REMARK_MAX_LEN = 128
|
export const SHOP_KEY_REMARK_MAX_LEN = 128
|
||||||
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
|
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
|
||||||
export const SHOP_KEY_TOKEN_MAX_LEN = 512
|
export const SHOP_KEY_TOKEN_MAX_LEN = 512
|
||||||
|
export const SHOP_KEY_PROXY_MAX_LEN = 255
|
||||||
|
|
||||||
export interface ShopKeyFormValues {
|
export interface ShopKeyFormValues {
|
||||||
remarkName: string
|
remarkName: string
|
||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
|
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
|
||||||
ziniaoToken: string
|
ziniaoToken: string
|
||||||
|
/** 静态代理地址(http://host:port 或 http://user:pass@host:port);留空直连。 */
|
||||||
|
proxyUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopKeyFormErrors {
|
export interface ShopKeyFormErrors {
|
||||||
remarkName?: string
|
remarkName?: string
|
||||||
ziniaoAccountName?: string
|
ziniaoAccountName?: string
|
||||||
ziniaoToken?: string
|
ziniaoToken?: string
|
||||||
|
proxyUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
|
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
|
||||||
@@ -23,10 +27,33 @@ export interface ShopKeyPayload {
|
|||||||
remarkName?: string
|
remarkName?: string
|
||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
ziniaoToken: string
|
ziniaoToken: string
|
||||||
|
proxyUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyShopKeyForm(): ShopKeyFormValues {
|
export function emptyShopKeyForm(): ShopKeyFormValues {
|
||||||
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '' }
|
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '', proxyUrl: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验代理地址(镜像后端 normalizeProxyUrl):留空合法,非空须为 http(s)://host:port。 */
|
||||||
|
export function validateProxyUrl(value: string): string | null {
|
||||||
|
const proxy = (value || '').trim()
|
||||||
|
if (!proxy) return null
|
||||||
|
if (proxy.length > SHOP_KEY_PROXY_MAX_LEN) {
|
||||||
|
return `代理地址长度不能超过${SHOP_KEY_PROXY_MAX_LEN}个字符`
|
||||||
|
}
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(proxy)
|
||||||
|
} catch {
|
||||||
|
return '代理地址格式不合法,应形如 http://host:port'
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return '代理地址仅支持 http/https 协议'
|
||||||
|
}
|
||||||
|
if (!parsed.hostname || !parsed.port) {
|
||||||
|
return '代理地址缺少主机或端口,应形如 http://host:port'
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
|
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
|
||||||
@@ -61,6 +88,10 @@ export function validateShopKeyForm(form: ShopKeyFormValues): { valid: boolean;
|
|||||||
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
|
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
|
||||||
errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符`
|
errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符`
|
||||||
}
|
}
|
||||||
|
const proxyError = validateProxyUrl(form.proxyUrl)
|
||||||
|
if (proxyError) {
|
||||||
|
errors.proxyUrl = proxyError
|
||||||
|
}
|
||||||
return { valid: Object.keys(errors).length === 0, errors }
|
return { valid: Object.keys(errors).length === 0, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,5 +103,7 @@ export function toShopKeyCreateRequest(form: ShopKeyFormValues): ShopKeyPayload
|
|||||||
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
|
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
|
||||||
}
|
}
|
||||||
if (remark) payload.remarkName = remark
|
if (remark) payload.remarkName = remark
|
||||||
|
const proxy = (form.proxyUrl || '').trim()
|
||||||
|
if (proxy) payload.proxyUrl = proxy
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ export function toShopKeyItem(raw: unknown): ShopKeyItem | null {
|
|||||||
remarkName: text(record.remarkName ?? record.remark_name),
|
remarkName: text(record.remarkName ?? record.remark_name),
|
||||||
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
|
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
|
||||||
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
|
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
|
||||||
|
proxyUrl: text(record.proxyUrl ?? record.proxy_url),
|
||||||
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
|
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
|
||||||
ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message),
|
ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message),
|
||||||
|
ipWhitelistFailCount: numberOrNull(record.ipWhitelistFailCount ?? record.ip_whitelist_fail_count) ?? 0,
|
||||||
}
|
}
|
||||||
const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at)
|
const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at)
|
||||||
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt
|
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
/** 视频任务记录页:任务卡片网格(播放/下载/复制)+ 筛选 + 批量打包下载 + 明细 + 数据范围授权。 */
|
/** 视频任务记录页 · 像素复刻旧版 admin.html panel-image-video-tasks(自绘:form-box 筛选[用户名/提交起止+查询/重置] + panel-box toolbar[全选/批量下载/权限配置/汇总] + 任务卡片网格 + 旧式分页)。
|
||||||
|
* script 逻辑沿用现有 Vue 实现(批量打包/明细抽屉/权限授权)。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { requestVideoZipDownload, fetchImageVideoTasks, fetchImageVideoTaskDetail } from './image-video-api.ts'
|
import { requestVideoZipDownload, fetchImageVideoTasks, fetchImageVideoTaskDetail } from './image-video-api.ts'
|
||||||
@@ -16,12 +17,17 @@ import {
|
|||||||
} from './image-video-selection.ts'
|
} from './image-video-selection.ts'
|
||||||
import { grantedUserIds, type TaskPermissionItem } from './task-permission.ts'
|
import { grantedUserIds, type TaskPermissionItem } from './task-permission.ts'
|
||||||
import { imageVideoDownloadFilename, videoKeysOf } from './image-video-view.ts'
|
import { imageVideoDownloadFilename, videoKeysOf } from './image-video-view.ts'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tasks = ref<ImageVideoRow[]>([])
|
const tasks = ref<ImageVideoRow[]>([])
|
||||||
const totalTasks = ref(0)
|
const totalTasks = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = ref(10)
|
||||||
|
const jumpPage = ref('')
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize.value)))
|
||||||
|
|
||||||
const filter = reactive<ImageVideoFilter & { dateRange: string[] }>({ ...createImageVideoFilter(), dateRange: [] })
|
const filter = reactive<ImageVideoFilter & { dateRange: string[] }>({ ...createImageVideoFilter(), dateRange: [] })
|
||||||
const selection = ref<Set<string>>(new Set())
|
const selection = ref<Set<string>>(new Set())
|
||||||
@@ -34,21 +40,47 @@ const permissionVisible = ref(false)
|
|||||||
const permissionItems = ref<TaskPermissionItem[]>([])
|
const permissionItems = ref<TaskPermissionItem[]>([])
|
||||||
const permissionInitial = ref<number[]>([])
|
const permissionInitial = ref<number[]>([])
|
||||||
const permissionSaving = ref(false)
|
const permissionSaving = ref(false)
|
||||||
|
/** 权限弹窗用户表客户端分页(全量用户在表格内翻页,10/20/50/100 可选)。 */
|
||||||
|
const permPage = ref(1)
|
||||||
|
const permPageSize = ref(10)
|
||||||
|
const pagedPermissionItems = computed(() =>
|
||||||
|
permissionItems.value.slice((permPage.value - 1) * permPageSize.value, permPage.value * permPageSize.value),
|
||||||
|
)
|
||||||
|
|
||||||
const downloading = ref(false)
|
const downloading = ref(false)
|
||||||
const downloadingKey = ref('')
|
const downloadingKey = ref('')
|
||||||
|
|
||||||
|
/** 旧版状态 label 映射(admin.js 前缀兜底:RUN/WAIT/PROCESS/POLL → 处理中)。 */
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' }
|
const map: Record<string, string> = {
|
||||||
return map[status] || status
|
PENDING: '待执行',
|
||||||
|
WAITING: '排队中',
|
||||||
|
RUNNING: '执行中',
|
||||||
|
POLLING: '处理中',
|
||||||
|
SUCCESS: '已完成',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
DONE: '已完成',
|
||||||
|
FAILED: '失败',
|
||||||
|
ERROR: '失败',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
CANCELED: '已取消',
|
||||||
|
DELETED: '已删除',
|
||||||
|
STOPPED: '已停止',
|
||||||
|
UNKNOWN: '未知',
|
||||||
|
}
|
||||||
|
const key = (status || '').toUpperCase()
|
||||||
|
if (map[key]) return map[key]
|
||||||
|
if (/^(RUN|WAIT|PROCESS|POLL)/.test(key)) return '处理中'
|
||||||
|
return key || '未知'
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusType(status: string): 'info' | 'warning' | 'success' | 'danger' | 'primary' {
|
function statusType(status: string): 'pending' | 'running' | 'success' | 'failed' | 'cancelled' {
|
||||||
if (status === 'SUCCESS') return 'success'
|
const key = (status || '').toUpperCase()
|
||||||
if (status === 'RUNNING') return 'warning'
|
if (key === 'SUCCESS' || key === 'COMPLETED' || key === 'DONE') return 'success'
|
||||||
if (status === 'FAILED') return 'danger'
|
if (key === 'FAILED' || key === 'ERROR') return 'failed'
|
||||||
if (status === 'CANCELLED') return 'info'
|
if (key === 'CANCELLED' || key === 'CANCELED') return 'cancelled'
|
||||||
return 'info'
|
if (key === 'PENDING' || key === 'WAITING') return 'pending'
|
||||||
|
return 'running'
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVideoKeys = computed(() => {
|
const currentVideoKeys = computed(() => {
|
||||||
@@ -78,7 +110,7 @@ function toFilter(): ImageVideoFilter {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize)
|
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize.value)
|
||||||
tasks.value = result.items
|
tasks.value = result.items
|
||||||
totalTasks.value = result.total
|
totalTasks.value = result.total
|
||||||
page.value = result.page
|
page.value = result.page
|
||||||
@@ -101,6 +133,29 @@ function reset() {
|
|||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
selection.value = new Set()
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goJump() {
|
||||||
|
const n = Number.parseInt(jumpPage.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
selection.value = allSelected.value ? clearVideoSelection() : new Set(currentVideoKeys.value)
|
selection.value = allSelected.value ? clearVideoSelection() : new Set(currentVideoKeys.value)
|
||||||
}
|
}
|
||||||
@@ -195,6 +250,7 @@ async function openPermission() {
|
|||||||
try {
|
try {
|
||||||
permissionItems.value = await fetchImageVideoPermissionUsers()
|
permissionItems.value = await fetchImageVideoPermissionUsers()
|
||||||
permissionInitial.value = grantedUserIds(permissionItems.value)
|
permissionInitial.value = grantedUserIds(permissionItems.value)
|
||||||
|
permPage.value = 1
|
||||||
permissionVisible.value = true
|
permissionVisible.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
|
||||||
@@ -221,7 +277,8 @@ async function savePermission() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function videoErrorText(task: ImageVideoRow): string {
|
function videoErrorText(task: ImageVideoRow): string {
|
||||||
if (task.status === 'FAILED' || task.status === 'CANCELLED') return '任务失败,未生成视频'
|
const key = (task.status || '').toUpperCase()
|
||||||
|
if (key === 'FAILED' || key === 'ERROR' || key === 'CANCELLED') return '任务失败,未生成视频'
|
||||||
return '视频生成中或暂无结果'
|
return '视频生成中或暂无结果'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,97 +290,89 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="iv-view">
|
||||||
<div class="page-heading">
|
<section class="form-box">
|
||||||
<div>
|
<h3>视频任务记录筛选</h3>
|
||||||
<h2>视频任务记录</h2>
|
<div class="form-row">
|
||||||
<p>查看图生视频任务卡片、在线预览与下载视频、配置数据范围授权。</p>
|
<div class="form-group" style="min-width: 160px">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="filter.username" type="text" placeholder="模糊搜索" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="form-group" style="min-width: 176px">
|
||||||
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选当前页</el-checkbox>
|
<label>提交开始</label>
|
||||||
<el-button :disabled="selectionCount === 0" :loading="downloading" @click="downloadBatch">
|
<input v-model="filter.dateRange[0]" type="datetime-local" />
|
||||||
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="openPermission">权限配置</el-button>
|
|
||||||
</div>
|
</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>
|
||||||
|
</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 v-loading="loading" class="task-card-grid">
|
||||||
<div class="filter-grid">
|
<template v-if="tasks.length">
|
||||||
<div class="f-item">
|
<section v-for="task in tasks" :key="String(task.taskId)" class="iv-card">
|
||||||
<label>用户名(模糊搜索)</label>
|
<header class="iv-card-head">
|
||||||
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
|
<input type="checkbox" :checked="cardSelected(task)" :disabled="!hasVideos(task)" @change="toggleCard(task)" />
|
||||||
</div>
|
<span class="iv-card-title">任务 {{ task.taskId }} · 视频 {{ task.videos.length }}</span>
|
||||||
<div class="f-item wide">
|
<span class="st-pill" :class="`is-${statusType(task.status)}`">{{ statusLabel(task.status) }}</span>
|
||||||
<label>提交时间</label>
|
|
||||||
<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>
|
|
||||||
</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>
|
</header>
|
||||||
|
|
||||||
<div class="task-card-info">
|
<div class="iv-card-info">
|
||||||
<div class="task-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
|
<div class="iv-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
|
||||||
<div class="task-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
|
<div v-if="session.isSuperAdmin" class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
|
||||||
<div class="task-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
|
<div class="iv-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
|
||||||
<div class="task-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
|
<div class="iv-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
|
||||||
<div class="task-info-row">
|
<div class="iv-info-row">
|
||||||
<label>调试链接</label>
|
<label>调试链接</label>
|
||||||
<el-button v-if="task.debugUrl" text type="primary" size="small" @click="copyLink(task.debugUrl)">复制链接</el-button>
|
<button v-if="task.debugUrl" class="btn btn-sm" type="button" @click="copyLink(task.debugUrl)">复制链接</button>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasVideos(task)" class="video-list">
|
<div v-if="hasVideos(task)" class="iv-video-list">
|
||||||
<div v-for="(video, index) in task.videos" :key="`${task.taskId}-${index}`" class="video-row">
|
<div v-for="(video, index) in task.videos" :key="`${task.taskId}-${index}`" class="iv-video-row">
|
||||||
<el-checkbox :model-value="selection.has(`${task.taskId}:${index}`)" @change="toggleVideoKey(`${task.taskId}:${index}`)" />
|
<input type="checkbox" :checked="selection.has(`${task.taskId}:${index}`)" @change="toggleVideoKey(`${task.taskId}:${index}`)" />
|
||||||
<video v-if="video.displayUrl" class="video-player" :src="video.displayUrl" controls preload="metadata" />
|
<video v-if="video.displayUrl" class="iv-player" :src="video.displayUrl" controls preload="metadata"></video>
|
||||||
<span v-else class="video-no-preview">视频加载失败,可尝试下载</span>
|
<span v-else class="iv-no-preview">视频加载失败,可尝试下载</span>
|
||||||
<span class="video-index">第 {{ index + 1 }} 个</span>
|
<div class="iv-video-ops">
|
||||||
<el-button
|
<button
|
||||||
text
|
class="btn btn-sm btn-secondary"
|
||||||
type="success"
|
type="button"
|
||||||
size="small"
|
|
||||||
:disabled="!video.displayUrl"
|
:disabled="!video.displayUrl"
|
||||||
:loading="downloadingKey === `${task.taskId}:${index}`"
|
:aria-busy="downloadingKey === `${task.taskId}:${index}`"
|
||||||
@click="downloadVideo(task, index, video)"
|
@click="downloadVideo(task, index, video)"
|
||||||
>
|
>
|
||||||
下载
|
下载
|
||||||
</el-button>
|
</button>
|
||||||
<el-button v-if="video.displayUrl" text type="primary" size="small" @click="copyLink(video.displayUrl)">复制链接</el-button>
|
<button v-if="video.displayUrl" class="btn btn-sm" type="button" @click="copyLink(video.displayUrl)">复制链接</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="video-empty">{{ videoErrorText(task) }}</div>
|
</div>
|
||||||
|
<div v-else class="iv-empty-note">{{ videoErrorText(task) }}</div>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="!loading" class="empty-tip">暂无符合条件的视频任务</div>
|
||||||
</div>
|
</div>
|
||||||
|
<OldPagination :total="totalTasks" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
<el-empty v-else-if="!loading" description="暂无视频任务记录" />
|
</section>
|
||||||
|
|
||||||
<el-drawer v-model="detailVisible" title="任务明细" size="720px">
|
<el-drawer v-model="detailVisible" title="任务明细" size="720px">
|
||||||
<div v-loading="detailLoading">
|
<div v-loading="detailLoading">
|
||||||
@@ -335,26 +384,16 @@ onMounted(load)
|
|||||||
<el-descriptions-item label="任务模式">{{ detail.mode || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="任务模式">{{ detail.mode || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.submittedAt) }}</el-descriptions-item>
|
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.submittedAt) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="生成时间">{{ formatDateTime(imageVideoGeneratedAt(detail)) }}</el-descriptions-item>
|
<el-descriptions-item label="生成时间">{{ formatDateTime(imageVideoGeneratedAt(detail)) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="调试链接">
|
|
||||||
<el-button v-if="detail.debugUrl" text type="primary" size="small" @click="copyLink(detail.debugUrl)">复制链接</el-button>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</el-descriptions-item>
|
|
||||||
<el-descriptions-item label="执行ID">{{ detail.cozeExecuteId || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="执行ID">{{ detail.cozeExecuteId || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="错误">{{ detail.errorMessage || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="错误">{{ detail.errorMessage || '—' }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
<h4>请求</h4>
|
|
||||||
<pre class="json-block">{{ JSON.stringify(detail.request, null, 2) }}</pre>
|
|
||||||
<h4>提交响应</h4>
|
|
||||||
<pre class="json-block">{{ JSON.stringify(detail.submitResponse, null, 2) }}</pre>
|
|
||||||
<h4>结果</h4>
|
|
||||||
<pre class="json-block">{{ JSON.stringify(detail.result, null, 2) }}</pre>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
<el-dialog v-model="permissionVisible" title="视频任务数据范围授权" width="620px">
|
<el-dialog v-model="permissionVisible" title="视频任务记录权限配置" width="620px">
|
||||||
<p class="dim">勾选允许查看/操作视频任务的用户(仅数据范围)。当前已授权 {{ permissionGrantedCount }} 人。</p>
|
<p class="dim">勾选允许查看视频任务记录的用户(仅数据范围)。当前已授权 {{ permissionGrantedCount }} 人。</p>
|
||||||
<el-table :data="permissionItems" border size="small" max-height="420">
|
<el-table :data="pagedPermissionItems" border size="small" max-height="420">
|
||||||
<el-table-column prop="id" label="ID" min-width="80" />
|
<el-table-column prop="id" label="ID" min-width="80" />
|
||||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||||
<el-table-column label="授权" min-width="90" align="center">
|
<el-table-column label="授权" min-width="90" align="center">
|
||||||
@@ -363,6 +402,7 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<OldPagination v-if="permissionItems.length > permPageSize" :total="permissionItems.length" :page="permPage" :page-size="permPageSize" @change="permPage = $event" @size-change="(s: number) => { permPageSize = s; permPage = 1 }" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="permissionVisible = false">取消</el-button>
|
<el-button @click="permissionVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
|
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
|
||||||
@@ -372,30 +412,315 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
/* 像素复刻旧版 admin.html panel-image-video-tasks(蓝白末层)。 */
|
||||||
.actions { display: flex; align-items: center; gap: 12px; }
|
.iv-view {
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
font-family: inherit;
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; }
|
color: #24384d;
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
display: flex;
|
||||||
.f-item.wide { width: 340px; }
|
flex-direction: column;
|
||||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; margin-left: auto; flex: none; }
|
gap: 18px;
|
||||||
.video-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; }
|
}
|
||||||
.task-card-grid { display: flex; flex-direction: column; gap: 14px; }
|
.form-box,
|
||||||
.task-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; }
|
.panel-box {
|
||||||
.task-card-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
width: 100%;
|
||||||
.task-card-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
min-width: 0;
|
||||||
.task-id { font-weight: 600; color: var(--el-text-color-primary); }
|
padding: 20px 22px 24px;
|
||||||
.task-card-info { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 6px 14px; padding: 12px 16px; border-bottom: 1px dashed var(--el-border-color-lighter); }
|
border: 1px solid #d8e3ee;
|
||||||
.task-info-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; min-width: 0; }
|
border-radius: 14px;
|
||||||
.task-info-row label { flex: none; color: var(--el-text-color-secondary); min-width: 60px; }
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
.task-info-row span { color: var(--el-text-color-regular); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
.video-list { padding: 6px 16px; }
|
}
|
||||||
.video-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px dashed var(--el-border-color-lighter); }
|
h3 {
|
||||||
.video-row:last-child { border-bottom: 0; }
|
margin: 0 0 16px;
|
||||||
.video-player { width: 300px; max-height: 180px; border-radius: 8px; background: #0d1117; }
|
font-size: 15px;
|
||||||
.video-no-preview { color: var(--el-text-color-secondary); font-size: 13px; }
|
font-weight: 650;
|
||||||
.video-index { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
color: #24384d;
|
||||||
.video-empty { padding: 18px 16px; color: var(--el-text-color-secondary); font-size: 13px; }
|
letter-spacing: 0.2px;
|
||||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
}
|
||||||
.json-block { background: var(--el-fill-color-lighter); border-radius: 6px; padding: 10px; max-height: 320px; overflow: auto; font-size: 12px; }
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.iv-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.iv-toolbar-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.iv-select-all {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6f83;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.iv-summary {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.task-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.iv-card {
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.iv-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e6edf4;
|
||||||
|
}
|
||||||
|
.iv-card-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.iv-card-info {
|
||||||
|
padding: 8px 16px 0;
|
||||||
|
}
|
||||||
|
.iv-info-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.iv-info-row label {
|
||||||
|
flex: none;
|
||||||
|
width: 64px;
|
||||||
|
color: #8293a5;
|
||||||
|
}
|
||||||
|
.iv-info-row span {
|
||||||
|
color: #40586e;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.iv-video-list {
|
||||||
|
padding: 8px 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.iv-video-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.iv-player {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 240px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f1722;
|
||||||
|
}
|
||||||
|
.iv-no-preview {
|
||||||
|
flex: 1;
|
||||||
|
padding: 18px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12.5px;
|
||||||
|
border: 1px dashed #d8e3ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.iv-video-ops {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.iv-empty-note {
|
||||||
|
padding: 16px;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.st-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.st-pill.is-success {
|
||||||
|
color: #3d7158;
|
||||||
|
background: #e8f2eb;
|
||||||
|
border-color: #bdd7c5;
|
||||||
|
}
|
||||||
|
.st-pill.is-failed {
|
||||||
|
color: #91474f;
|
||||||
|
background: #f8ebeb;
|
||||||
|
border-color: #e4c2c5;
|
||||||
|
}
|
||||||
|
.st-pill.is-cancelled {
|
||||||
|
color: #5b6f83;
|
||||||
|
background: #f1f6fa;
|
||||||
|
border-color: #d6e2ec;
|
||||||
|
}
|
||||||
|
.st-pill.is-pending {
|
||||||
|
color: #8a6d3b;
|
||||||
|
background: #fbf4ea;
|
||||||
|
border-color: #e7d3ae;
|
||||||
|
}
|
||||||
|
.st-pill.is-running {
|
||||||
|
color: #8a6d3b;
|
||||||
|
background: #fbf4ea;
|
||||||
|
border-color: #e7d3ae;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.pagination button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.page-jump input {
|
||||||
|
width: 56px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.task-card-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatDateTime } from '@/utils/datetime'
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
/** 店铺数据任务/记录页:每条任务一张卡片(店铺名/任务号/状态/分组/最新/国家/文件 + 下载文件/删除)
|
/** 店铺数据记录页 · 像素复刻旧版 admin.html panel-shop-data-crawl-tasks(自绘:form-box 筛选[h4 筛选条件] + panel-box toolbar[全选/批量下载/权限配置/汇总] + 每店卡片网格 + 旧式分页(店铺级+卡片级 9/页))。
|
||||||
* + 筛选 + 批量下载 + 数据范围授权(对齐 admin.js renderShopDataRecordTable)。 */
|
* script 逻辑沿用现有 Vue 实现(批量打包/删除结果文件[保留]/权限授权)。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { asinCountryLabel } from '../asin/asin-country.ts'
|
import { asinCountryLabel } from '../asin/asin-country.ts'
|
||||||
import { createShopDataFilter, type ShopDataFilter } from './shop-data-filter.ts'
|
import { createShopDataFilter, type ShopDataFilter } from './shop-data-filter.ts'
|
||||||
import type { ShopDataResultRow, ShopDataTaskGroup } from './shop-data-model.ts'
|
import type { ShopDataResultRow, ShopDataTaskGroup } from './shop-data-model.ts'
|
||||||
@@ -24,15 +24,21 @@ import {
|
|||||||
createShopDataSelection,
|
createShopDataSelection,
|
||||||
toggleShopDataSelection,
|
toggleShopDataSelection,
|
||||||
} from './shop-data-selection.ts'
|
} from './shop-data-selection.ts'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const groups = ref<ShopDataTaskGroup[]>([])
|
const groups = ref<ShopDataTaskGroup[]>([])
|
||||||
const totalShops = ref(0)
|
const totalShops = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
// 店铺级一次全载(>200 家店铺时后端按页截断,可上调或恢复底部店铺级分页)。
|
||||||
// 卡片宫格分页:一行 3 个、一页 9 张卡片(在店铺分页的结果文件内翻页)。
|
const pageSize = ref(200)
|
||||||
|
// 卡片宫格分页:一行 4 个、一页 10 张卡片(在店铺分页的结果文件内翻页)。
|
||||||
const cardPage = ref(1)
|
const cardPage = ref(1)
|
||||||
const cardPageSize = 9
|
const cardPageSize = ref(10)
|
||||||
|
const cardJump = ref('')
|
||||||
|
const cardTotalPages = computed(() => Math.max(1, Math.ceil(allResultRows.value.length / cardPageSize.value)))
|
||||||
|
|
||||||
const filter = reactive<ShopDataFilter & { dateRange: string[] }>({ ...createShopDataFilter(), dateRange: [] })
|
const filter = reactive<ShopDataFilter & { dateRange: string[] }>({ ...createShopDataFilter(), dateRange: [] })
|
||||||
const selection = ref<Set<string>>(new Set())
|
const selection = ref<Set<string>>(new Set())
|
||||||
@@ -40,20 +46,61 @@ const selection = ref<Set<string>>(new Set())
|
|||||||
const permissionVisible = ref(false)
|
const permissionVisible = ref(false)
|
||||||
const permissionItems = ref<TaskPermissionItem[]>([])
|
const permissionItems = ref<TaskPermissionItem[]>([])
|
||||||
const permissionSaving = ref(false)
|
const permissionSaving = ref(false)
|
||||||
|
/** 权限弹窗用户表客户端分页(全量用户在表格内翻页,10/20/50/100 可选)。 */
|
||||||
|
const permPage = ref(1)
|
||||||
|
const permPageSize = ref(10)
|
||||||
|
const pagedPermissionItems = computed(() =>
|
||||||
|
permissionItems.value.slice((permPage.value - 1) * permPageSize.value, permPage.value * permPageSize.value),
|
||||||
|
)
|
||||||
|
|
||||||
const busyKey = ref('')
|
const busyKey = ref('')
|
||||||
const deletingKey = ref('')
|
const deletingKey = ref('')
|
||||||
|
|
||||||
function statusLabel(status: string): string {
|
/** 下载进度弹窗:percent=0~100;indeterminate=true 表示后端未回 Content-Length,只显示流动条。 */
|
||||||
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' }
|
const downloadProgress = reactive({ visible: false, percent: 0, indeterminate: false, label: '' })
|
||||||
return map[status] || status
|
|
||||||
|
function startDownloadProgress(label: string) {
|
||||||
|
downloadProgress.label = label
|
||||||
|
downloadProgress.percent = 0
|
||||||
|
downloadProgress.indeterminate = false
|
||||||
|
downloadProgress.visible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusType(status: string): 'info' | 'warning' | 'success' | 'danger' {
|
function updateDownloadProgress(percent: number) {
|
||||||
if (status === 'SUCCESS') return 'success'
|
if (percent < 0) {
|
||||||
if (status === 'RUNNING') return 'warning'
|
downloadProgress.indeterminate = true
|
||||||
if (status === 'FAILED') return 'danger'
|
return
|
||||||
return 'info'
|
}
|
||||||
|
downloadProgress.indeterminate = false
|
||||||
|
downloadProgress.percent = percent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: '待执行',
|
||||||
|
WAITING: '排队中',
|
||||||
|
RUNNING: '执行中',
|
||||||
|
PROCESSING: '处理中',
|
||||||
|
SUCCESS: '已完成',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
FAILED: '失败',
|
||||||
|
ERROR: '失败',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
UNKNOWN: '未知',
|
||||||
|
}
|
||||||
|
const key = (status || '').toUpperCase()
|
||||||
|
if (map[key]) return map[key]
|
||||||
|
return key || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(status: string): 'pending' | 'running' | 'success' | 'failed' | 'cancelled' {
|
||||||
|
const key = (status || '').toUpperCase()
|
||||||
|
if (key === 'SUCCESS' || key === 'COMPLETED') return 'success'
|
||||||
|
if (key === 'FAILED' || key === 'ERROR') return 'failed'
|
||||||
|
if (key === 'CANCELLED') return 'cancelled'
|
||||||
|
if (key === 'PENDING' || key === 'WAITING') return 'pending'
|
||||||
|
return 'running'
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFilter(): ShopDataFilter {
|
function toFilter(): ShopDataFilter {
|
||||||
@@ -69,12 +116,12 @@ function toFilter(): ShopDataFilter {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize)
|
const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize.value)
|
||||||
groups.value = result.items
|
groups.value = result.items
|
||||||
totalShops.value = result.total
|
totalShops.value = result.total
|
||||||
page.value = result.page
|
page.value = result.page
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '店铺数据任务加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '店铺数据记录加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -94,7 +141,7 @@ function reset() {
|
|||||||
|
|
||||||
const allResultRows = computed(() => groups.value.flatMap((group) => group.results))
|
const allResultRows = computed(() => groups.value.flatMap((group) => group.results))
|
||||||
const pagedResultRows = computed(() =>
|
const pagedResultRows = computed(() =>
|
||||||
allResultRows.value.slice((cardPage.value - 1) * cardPageSize, cardPage.value * cardPageSize),
|
allResultRows.value.slice((cardPage.value - 1) * cardPageSize.value, cardPage.value * cardPageSize.value),
|
||||||
)
|
)
|
||||||
const selectionCount = computed(() => countShopDataSelection(selection.value))
|
const selectionCount = computed(() => countShopDataSelection(selection.value))
|
||||||
|
|
||||||
@@ -102,6 +149,20 @@ function changeCardPage(p: number) {
|
|||||||
cardPage.value = p
|
cardPage.value = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeCardSize(size: number) {
|
||||||
|
cardPageSize.value = size
|
||||||
|
cardPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCardJump() {
|
||||||
|
const n = Number.parseInt(cardJump.value, 10)
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
ElMessage.warning('请输入页码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changeCardPage(Math.min(Math.max(n, 1), cardTotalPages.value))
|
||||||
|
}
|
||||||
|
|
||||||
function resultFileCount(): number {
|
function resultFileCount(): number {
|
||||||
return groups.value.reduce((sum, group) => sum + group.results.length, 0)
|
return groups.value.reduce((sum, group) => sum + group.results.length, 0)
|
||||||
}
|
}
|
||||||
@@ -127,15 +188,27 @@ function rowSelected(row: ShopDataResultRow): boolean {
|
|||||||
return selection.value.has(row.resultId)
|
return selection.value.has(row.resultId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectAllChecked(): boolean {
|
||||||
|
const rows = selectableRows()
|
||||||
|
return rows.length > 0 && rows.every((row) => selection.value.has(row.resultId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllIndeterminate(): boolean {
|
||||||
|
const rows = selectableRows()
|
||||||
|
return selectionCount.value > 0 && selectionCount.value < rows.length
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadRow(row: ShopDataResultRow) {
|
async function downloadRow(row: ShopDataResultRow) {
|
||||||
if (!row.fileReady) {
|
if (!row.fileReady) {
|
||||||
ElMessage.warning('文件未就绪,无法下载')
|
ElMessage.warning('文件未就绪,无法下载')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
busyKey.value = row.resultId
|
busyKey.value = row.resultId
|
||||||
|
startDownloadProgress(`正在下载「${row.shopName || '-'}」的数据文件`)
|
||||||
try {
|
try {
|
||||||
const { blob, filename } = await fetchShopDataResultDownload(row.resultId)
|
const { blob, filename } = await fetchShopDataResultDownload(row.resultId, updateDownloadProgress)
|
||||||
saveBlob(blob, filename)
|
saveBlob(blob, filename)
|
||||||
|
downloadProgress.visible = false
|
||||||
ElMessage.success('下载已开始')
|
ElMessage.success('下载已开始')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
||||||
@@ -151,9 +224,11 @@ async function downloadBatch() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
busyKey.value = 'batch'
|
busyKey.value = 'batch'
|
||||||
|
startDownloadProgress(`正在打包下载 ${resultIds.length} 个结果文件`)
|
||||||
try {
|
try {
|
||||||
const zip = await requestShopDataZipDownload(resultIds)
|
const zip = await requestShopDataZipDownload(resultIds, updateDownloadProgress)
|
||||||
saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`)
|
saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`)
|
||||||
|
downloadProgress.visible = false
|
||||||
const note = zip.errorCount ? `,${zip.errorCount} 个失败(详见包内错误清单)` : ''
|
const note = zip.errorCount ? `,${zip.errorCount} 个失败(详见包内错误清单)` : ''
|
||||||
ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`)
|
ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -164,22 +239,15 @@ async function downloadBatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeRowByRow(row: ShopDataResultRow) {
|
async function removeRowByRow(row: ShopDataResultRow) {
|
||||||
try {
|
// 管理端真删:删除该店这条店铺数据记录(累计档 + 文件 + 明细),前台任务记录仍保留但文件不可再下载
|
||||||
await ElMessageBox.confirm(`确认删除店铺“${row.shopName}”的任务 ${row.taskNo || row.resultId} 及结果文件?`, '删除前确认', {
|
if (!window.confirm(`确认删除店铺“${row.shopName}”这条店铺数据记录及其数据文件?此操作不可恢复。`)) return
|
||||||
confirmButtonText: '确认删除',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
deletingKey.value = row.resultId
|
deletingKey.value = row.resultId
|
||||||
try {
|
try {
|
||||||
await deleteShopDataResultHistory(row.resultId)
|
await deleteShopDataResultHistory(row.resultId)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
await load()
|
await load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : `正在删除任务 ${row.taskNo || row.resultId}...`)
|
ElMessage.error(error instanceof Error ? error.message : `删除店铺“${row.shopName}”的记录失败`)
|
||||||
} finally {
|
} finally {
|
||||||
deletingKey.value = ''
|
deletingKey.value = ''
|
||||||
}
|
}
|
||||||
@@ -197,6 +265,7 @@ function saveBlob(blob: Blob, filename: string) {
|
|||||||
async function openPermission() {
|
async function openPermission() {
|
||||||
try {
|
try {
|
||||||
permissionItems.value = await fetchShopDataTaskPermissionUsers()
|
permissionItems.value = await fetchShopDataTaskPermissionUsers()
|
||||||
|
permPage.value = 1
|
||||||
permissionVisible.value = true
|
permissionVisible.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
|
||||||
@@ -227,139 +296,120 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page-stack">
|
<div class="sd-view">
|
||||||
<div class="page-heading">
|
<section class="form-box">
|
||||||
<div>
|
<h3>店铺数据记录</h3>
|
||||||
<h2>店铺数据任务/记录</h2>
|
<h4 class="sd-filter-title">筛选条件</h4>
|
||||||
<p>按店铺查看数据抓取结果文件,支持筛选、下载、删除与数据范围授权。</p>
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="min-width: 160px">
|
||||||
|
<label>店铺</label>
|
||||||
|
<input v-model="filter.shopName" type="text" placeholder="模糊搜索店铺名" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div v-if="session.isSuperAdmin" class="form-group" style="min-width: 150px">
|
||||||
<el-checkbox
|
<label>分组</label>
|
||||||
:model-value="selectableRows().length > 0 && selectableRows().every((row) => rowSelected(row))"
|
<input v-model="filter.groupName" type="text" placeholder="模糊搜索分组名" @keyup.enter="apply" />
|
||||||
: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>
|
||||||
</div>
|
<div class="form-group" style="min-width: 130px">
|
||||||
|
|
||||||
<el-card shadow="never" style="margin-bottom: 14px">
|
|
||||||
<div class="filter-grid">
|
|
||||||
<div class="f-item">
|
|
||||||
<label>店铺(模糊搜索)</label>
|
|
||||||
<el-input v-model="filter.shopName" placeholder="输入店铺关键字" clearable @keyup.enter="apply" />
|
|
||||||
</div>
|
|
||||||
<div class="f-item">
|
|
||||||
<label>分组(模糊搜索)</label>
|
|
||||||
<el-input v-model="filter.groupName" placeholder="输入分组关键字" clearable @keyup.enter="apply" />
|
|
||||||
</div>
|
|
||||||
<div class="f-item">
|
|
||||||
<label>国家</label>
|
<label>国家</label>
|
||||||
<el-select v-model="filter.country" placeholder="全部" clearable>
|
<select v-model="filter.country">
|
||||||
<el-option v-for="code in ['UK', 'DE', 'FR', 'ES', 'IT']" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
<option value="">全部</option>
|
||||||
</el-select>
|
<option v-for="code in ['UK', 'DE', 'FR', 'ES', 'IT']" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item wide">
|
<div class="form-group" style="min-width: 176px">
|
||||||
<label>创建时间</label>
|
<label>创建开始</label>
|
||||||
<el-date-picker v-model="filter.dateRange" type="datetimerange" range-separator="至" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DDTHH:mm" unlink-panels />
|
<input v-model="filter.dateRange[0]" type="datetime-local" />
|
||||||
</div>
|
</div>
|
||||||
<div class="f-item btn-row">
|
<div class="form-group" style="min-width: 176px">
|
||||||
<el-button type="primary" @click="apply">查询</el-button>
|
<label>创建结束</label>
|
||||||
<el-button @click="reset">重置</el-button>
|
<input v-model="filter.dateRange[1]" type="datetime-local" />
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn" type="button" @click="apply">查询</button>
|
||||||
|
<button class="btn btn-secondary" type="button" @click="reset">重置</button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</section>
|
||||||
|
|
||||||
<div v-loading="loading" class="shop-summary-row">
|
<section class="panel-box">
|
||||||
<span>共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
|
<div class="sd-toolbar">
|
||||||
<el-pagination
|
<div class="sd-toolbar-main">
|
||||||
background
|
<label class="sd-select-all">
|
||||||
layout="prev, pager, next, jumper"
|
<input type="checkbox" :checked="selectAllChecked()" :indeterminate="selectAllIndeterminate()" @change="toggleSelectAllReady" />
|
||||||
:total="totalShops"
|
全选可下载
|
||||||
:page-size="pageSize"
|
</label>
|
||||||
:current-page="page"
|
<button class="btn" type="button" :disabled="selectionCount === 0 || busyKey === 'batch'" @click="downloadBatch">
|
||||||
@current-change="(p: number) => { page = p; cardPage = 1; selection = new Set(); load() }"
|
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
|
||||||
/>
|
</button>
|
||||||
|
<button v-if="session.isSuperAdmin" class="btn btn-secondary" type="button" @click="openPermission">权限配置</button>
|
||||||
|
</div>
|
||||||
|
<div class="sd-toolbar-side">
|
||||||
|
<span class="sd-summary">共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="allResultRows.length" class="shop-card-grid">
|
<div v-loading="loading" class="shop-card-grid">
|
||||||
<article v-for="row in pagedResultRows" :key="row.resultId || `${row.taskNo}-${row.filename}`" class="shop-card">
|
<template v-if="allResultRows.length">
|
||||||
<header class="shop-card-head">
|
<article
|
||||||
<span class="shop-name" :title="row.shopName || '-'">{{ row.shopName || '-' }}</span>
|
v-for="row in pagedResultRows"
|
||||||
|
:key="row.resultId || `${row.taskNo}-${row.filename}`"
|
||||||
|
class="sd-card"
|
||||||
|
>
|
||||||
|
<header class="sd-card-head">
|
||||||
|
<span class="sd-shop-name" :title="row.shopName || '-'">{{ row.shopName || '-' }}</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="shop-card-select-row">
|
<div class="sd-card-select-row">
|
||||||
<el-checkbox
|
<input type="checkbox" :checked="rowSelected(row)" :disabled="!isUsableShopDataResult(row)" @change="toggleRow(row)" />
|
||||||
:model-value="rowSelected(row)"
|
<span class="shop-card-task-no" :title="`任务 ${row.taskId || row.taskNo || row.resultId}`">任务 {{ row.taskId || row.taskNo || row.resultId }}</span>
|
||||||
:disabled="!isUsableShopDataResult(row)"
|
<span class="st-pill" :class="`is-${statusType(row.status)}`">{{ statusLabel(row.status) }}</span>
|
||||||
@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>
|
||||||
<div class="shop-card-row">
|
<div v-if="session.isSuperAdmin && row.groupName" class="sd-card-row">
|
||||||
<span class="shop-card-label">分组</span>
|
<span class="sd-card-label">分组</span>
|
||||||
<span class="shop-card-value">{{ row.groupName || '-' }}</span>
|
<span class="sd-card-value">{{ row.groupName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-card-row">
|
<div class="sd-card-row">
|
||||||
<span class="shop-card-label">最新</span>
|
<span class="sd-card-label">最新</span>
|
||||||
<span class="shop-card-value">{{ formatDateTime(row.updatedAt || row.createdAt) || '-' }}</span>
|
<span class="sd-card-value">{{ formatDateTime(row.updatedAt || row.createdAt) || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-card-row">
|
<div class="sd-card-row">
|
||||||
<span class="shop-card-label">国家</span>
|
<span class="sd-card-label">国家</span>
|
||||||
<span class="shop-card-value">
|
<span class="sd-card-value">
|
||||||
<span v-for="code in row.countryCodes" :key="code" class="country-chip">{{ asinCountryLabel(code) }}</span>
|
<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 v-if="!row.countryCodes.length" class="dim">-</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-card-row">
|
<div class="sd-card-row">
|
||||||
<span class="shop-card-label">文件</span>
|
<span class="sd-card-label">文件</span>
|
||||||
<span class="shop-card-value shop-card-file" :title="row.filename">{{ row.filename || '-' }}</span>
|
<span class="sd-card-value sd-card-file" :title="row.filename">{{ row.filename || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-card-actions">
|
<div class="sd-card-actions">
|
||||||
<el-button
|
<button
|
||||||
text
|
class="btn btn-sm"
|
||||||
type="success"
|
type="button"
|
||||||
size="small"
|
:disabled="!isUsableShopDataResult(row) || busyKey === row.resultId"
|
||||||
:loading="busyKey === row.resultId"
|
|
||||||
:disabled="!isUsableShopDataResult(row)"
|
|
||||||
@click="downloadRow(row)"
|
@click="downloadRow(row)"
|
||||||
>
|
>
|
||||||
下载文件
|
{{ busyKey === row.resultId ? '下载中...' : '下载文件' }}
|
||||||
</el-button>
|
</button>
|
||||||
<el-button
|
<button
|
||||||
v-if="isTerminalStatus(row.status) && !!row.resultId"
|
v-if="isTerminalStatus(row.status) && !!row.resultId"
|
||||||
text
|
class="btn btn-sm btn-danger"
|
||||||
type="danger"
|
type="button"
|
||||||
size="small"
|
:disabled="deletingKey === row.resultId"
|
||||||
:loading="deletingKey === row.resultId"
|
|
||||||
@click="removeRowByRow(row)"
|
@click="removeRowByRow(row)"
|
||||||
>
|
>
|
||||||
删除
|
{{ deletingKey === row.resultId ? '删除中...' : '删除' }}
|
||||||
</el-button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</template>
|
||||||
<div v-if="allResultRows.length > cardPageSize" class="table-footer">
|
<div v-else-if="!loading" class="empty-tip">暂无符合条件的店铺数据记录</div>
|
||||||
<el-pagination
|
|
||||||
background
|
|
||||||
layout="prev, pager, next, jumper"
|
|
||||||
:total="allResultRows.length"
|
|
||||||
:page-size="cardPageSize"
|
|
||||||
:current-page="cardPage"
|
|
||||||
@current-change="changeCardPage"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-empty v-else-if="!loading" description="暂无符合条件的店铺数据任务" />
|
<OldPagination v-if="allResultRows.length > 0" :total="allResultRows.length" :page="cardPage" :page-size="cardPageSize" @change="changeCardPage" @size-change="changeCardSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-dialog v-model="permissionVisible" title="店铺数据任务数据范围授权" width="620px">
|
<el-dialog v-model="permissionVisible" title="店铺数据记录数据范围授权" width="620px">
|
||||||
<p class="dim">勾选允许查看/操作店铺数据任务的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p>
|
<p class="dim">勾选允许查看/操作店铺数据记录的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p>
|
||||||
<el-table :data="permissionItems" border size="small" max-height="420">
|
<el-table :data="pagedPermissionItems" border size="small" max-height="420">
|
||||||
<el-table-column prop="id" label="ID" min-width="80" />
|
<el-table-column prop="id" label="ID" min-width="80" />
|
||||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||||
<el-table-column label="授权" min-width="90" align="center">
|
<el-table-column label="授权" min-width="90" align="center">
|
||||||
@@ -368,34 +418,376 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<OldPagination v-if="permissionItems.length > permPageSize" :total="permissionItems.length" :page="permPage" :page-size="permPageSize" @change="permPage = $event" @size-change="(s: number) => { permPageSize = s; permPage = 1 }" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="permissionVisible = false">取消</el-button>
|
<el-button @click="permissionVisible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
|
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="downloadProgress.visible" :title="downloadProgress.label" width="440px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||||
|
<el-progress
|
||||||
|
:percentage="downloadProgress.indeterminate ? 50 : downloadProgress.percent"
|
||||||
|
:indeterminate="downloadProgress.indeterminate"
|
||||||
|
:duration="downloadProgress.indeterminate ? 3 : undefined"
|
||||||
|
:stroke-width="14"
|
||||||
|
/>
|
||||||
|
<p class="sd-progress-hint">{{ downloadProgress.indeterminate ? '正在接收数据,大小未知,请稍候…' : `已完成 ${downloadProgress.percent}%` }}</p>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
/* 像素复刻旧版 admin.html panel-shop-data-crawl-tasks(蓝白末层)。 */
|
||||||
.actions { display: flex; align-items: center; gap: 12px; }
|
.sd-view {
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
font-family: inherit;
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; }
|
color: #24384d;
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
display: flex;
|
||||||
.f-item.wide { width: 340px; }
|
flex-direction: column;
|
||||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
gap: 18px;
|
||||||
.shop-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; }
|
}
|
||||||
.shop-card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
.form-box,
|
||||||
.shop-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; }
|
.panel-box {
|
||||||
.shop-card-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
width: 100%;
|
||||||
.shop-name { font-weight: 600; color: var(--el-text-color-primary); }
|
min-width: 0;
|
||||||
.shop-card-select-row { display: flex; align-items: center; gap: 10px; padding: 10px 16px 0; }
|
padding: 20px 22px 24px;
|
||||||
.shop-card-task-no { font-weight: 600; font-size: 13px; color: var(--el-text-color-primary); }
|
border: 1px solid #d8e3ee;
|
||||||
.shop-card-row { display: flex; align-items: flex-start; gap: 10px; padding: 6px 16px; font-size: 13px; }
|
border-radius: 14px;
|
||||||
.shop-card-label { flex: none; width: 52px; color: var(--el-text-color-secondary); }
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
.shop-card-value { color: var(--el-text-color-regular); min-width: 0; }
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
.shop-card-file { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
}
|
||||||
.shop-card-actions { display: flex; gap: 6px; padding: 4px 16px 12px; }
|
h3 {
|
||||||
.country-chip { padding: 1px 8px; margin-right: 4px; border-radius: 999px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 12px; }
|
margin: 0 0 14px;
|
||||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.sd-filter-title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #40586e;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
color: #2f5d8b;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.sd-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.sd-toolbar-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sd-toolbar-side {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.sd-select-all {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6f83;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sd-summary {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sd-shop-pager {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6f83;
|
||||||
|
}
|
||||||
|
.sd-shop-pager button,
|
||||||
|
.pagination button,
|
||||||
|
.jump-btn {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sd-shop-pager button:hover:not(:disabled),
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.sd-shop-pager button:disabled,
|
||||||
|
.pagination button:disabled {
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #9baaba;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.sd-shop-pager input,
|
||||||
|
.page-jump input {
|
||||||
|
width: 52px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.shop-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.sd-card {
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sd-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e6edf4;
|
||||||
|
}
|
||||||
|
.sd-shop-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.sd-card-select-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px 0;
|
||||||
|
}
|
||||||
|
.shop-card-task-no {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #24384d;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sd-card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sd-card-label {
|
||||||
|
flex: none;
|
||||||
|
width: 52px;
|
||||||
|
color: #8293a5;
|
||||||
|
}
|
||||||
|
.sd-card-value {
|
||||||
|
color: #40586e;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sd-card-file {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.sd-card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 16px 14px;
|
||||||
|
}
|
||||||
|
.st-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.st-pill.is-success {
|
||||||
|
color: #3d7158;
|
||||||
|
background: #e8f2eb;
|
||||||
|
border-color: #bdd7c5;
|
||||||
|
}
|
||||||
|
.st-pill.is-failed {
|
||||||
|
color: #91474f;
|
||||||
|
background: #f8ebeb;
|
||||||
|
border-color: #e4c2c5;
|
||||||
|
}
|
||||||
|
.st-pill.is-cancelled {
|
||||||
|
color: #5b6f83;
|
||||||
|
background: #f1f6fa;
|
||||||
|
border-color: #d6e2ec;
|
||||||
|
}
|
||||||
|
.st-pill.is-pending,
|
||||||
|
.st-pill.is-running {
|
||||||
|
color: #8a6d3b;
|
||||||
|
background: #fbf4ea;
|
||||||
|
border-color: #e7d3ae;
|
||||||
|
}
|
||||||
|
.country-chip {
|
||||||
|
padding: 1px 8px;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef4fa;
|
||||||
|
color: #40586e;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sd-progress-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.page-total {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.page-jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.shop-card-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.shop-card-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shop-card-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export async function fetchDuplicateLedger(
|
|||||||
return parseLedgerPage(data)
|
return parseLedgerPage(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出 CSV(撞款 view=monitor / 台账 view=all):GET /duplicate-check-export → Blob。 */
|
/** 导出 Excel(撞款 view=monitor / 台账 view=all):GET /duplicate-check-export → xlsx Blob。 */
|
||||||
export async function requestDuplicateCsvBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
|
export async function requestDuplicateExportBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
|
||||||
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
|
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
|
||||||
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
|
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** 重复检查明细筛选与分页(任务 117):与 /duplicate-check-items 的 snake 参数契约对齐;纯逻辑。 */
|
/** 重复检查明细筛选与分页(任务 117):与 /duplicate-check-items 的 snake 参数契约对齐;纯逻辑。 */
|
||||||
|
|
||||||
export const DUPLICATE_DEFAULT_PAGE_SIZE = 20
|
export const DUPLICATE_DEFAULT_PAGE_SIZE = 10
|
||||||
export const DUPLICATE_MIN_PAGE_SIZE = 10
|
export const DUPLICATE_MIN_PAGE_SIZE = 10
|
||||||
export const DUPLICATE_MAX_PAGE_SIZE = 100
|
export const DUPLICATE_MAX_PAGE_SIZE = 100
|
||||||
export const DUPLICATE_PAGE_MIN = 1
|
export const DUPLICATE_PAGE_MIN = 1
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** 视频任务筛选与分页(任务 102):图生视频任务列表筛选/分页归一,与 Java AdminImageVideoTaskController
|
/** 视频任务筛选与分页(任务 102):图生视频任务列表筛选/分页归一,与 Java AdminImageVideoTaskController
|
||||||
* snake_case 查询契约对齐;纯逻辑。 */
|
* snake_case 查询契约对齐;纯逻辑。 */
|
||||||
|
|
||||||
export const IMAGE_VIDEO_DEFAULT_PAGE_SIZE = 20
|
export const IMAGE_VIDEO_DEFAULT_PAGE_SIZE = 10
|
||||||
export const IMAGE_VIDEO_MIN_PAGE_SIZE = 10
|
export const IMAGE_VIDEO_MIN_PAGE_SIZE = 10
|
||||||
export const IMAGE_VIDEO_MAX_PAGE_SIZE = 100
|
export const IMAGE_VIDEO_MAX_PAGE_SIZE = 100
|
||||||
export const IMAGE_VIDEO_PAGE_MIN = 1
|
export const IMAGE_VIDEO_PAGE_MIN = 1
|
||||||
|
|||||||
@@ -27,10 +27,15 @@ export interface ShopDataZipDownloadResult {
|
|||||||
errorCount?: number
|
errorCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量下载选中结果文件为 zip:POST /download-zip(blob);从响应头读成功/失败计数。 */
|
/** 批量下载选中结果文件为 zip:POST /download-zip(blob);从响应头读成功/失败计数。
|
||||||
export async function requestShopDataZipDownload(resultIds: readonly (number | string)[]): Promise<ShopDataZipDownloadResult> {
|
* onProgress: 可选下载进度回调(0-100,后端未回 Content-Length 时退化为不定进度)。 */
|
||||||
|
export async function requestShopDataZipDownload(
|
||||||
|
resultIds: readonly (number | string)[],
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<ShopDataZipDownloadResult> {
|
||||||
const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), {
|
const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
|
||||||
})
|
})
|
||||||
const counts = downloadZipHeaderCounts(headers as Record<string, string>)
|
const counts = downloadZipHeaderCounts(headers as Record<string, string>)
|
||||||
return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount }
|
return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount }
|
||||||
@@ -41,18 +46,34 @@ export interface ShopDataResultDownload {
|
|||||||
filename: string
|
filename: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载单个结果文件(服务端原文件名):GET /api/admin/shop-data-crawl/results/{resultId}/download。 */
|
/** 下载单店最新采集文件(按每日累计档 id):GET /api/admin/shop-data-crawl-tasks/daily-files/{id}/download。
|
||||||
export async function fetchShopDataResultDownload(resultId: number | string): Promise<ShopDataResultDownload> {
|
* onProgress: 可选下载进度回调(0-100)。 */
|
||||||
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_SINGLE_ENDPOINT}/results/${resultId}/download`, {
|
export async function fetchShopDataResultDownload(
|
||||||
|
resultId: number | string,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<ShopDataResultDownload> {
|
||||||
|
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}/download`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
|
||||||
})
|
})
|
||||||
const disposition = (headers as Record<string, string> | undefined)?.['content-disposition']
|
const disposition = (headers as Record<string, string> | undefined)?.['content-disposition']
|
||||||
return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) }
|
return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除单条结果历史(仅终态):DELETE /api/admin/shop-data-crawl/history/{resultId}。 */
|
/** 将 axios 进度事件转为 0-100 百分比;响应未回 Content-Length 时报 -1,前端显示不定进度。 */
|
||||||
|
function toAxiosProgress(onProgress: (percent: number) => void) {
|
||||||
|
return (event: { loaded: number; total?: number }) => {
|
||||||
|
if (event.total) {
|
||||||
|
onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
|
||||||
|
} else {
|
||||||
|
onProgress(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除该店这条店铺数据记录(按每日累计档 id,管理端显式操作):DELETE .../daily-files/{id}。 */
|
||||||
export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> {
|
export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> {
|
||||||
await http.delete<unknown>(`${SHOP_DATA_SINGLE_ENDPOINT}/history/${resultId}`)
|
await http.delete<unknown>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 加载店铺数据任务数据范围授权用户:GET /api/admin/shop-data-crawl-task-permissions。 */
|
/** 加载店铺数据任务数据范围授权用户:GET /api/admin/shop-data-crawl-task-permissions。 */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** 店铺数据任务筛选与分页(任务 109):与 Java AdminShopDataCrawlTasksController snake 契约对齐;纯逻辑。 */
|
/** 店铺数据任务筛选与分页(任务 109):与 Java AdminShopDataCrawlTasksController snake 契约对齐;纯逻辑。 */
|
||||||
|
|
||||||
export const SHOP_DATA_DEFAULT_PAGE_SIZE = 20
|
export const SHOP_DATA_DEFAULT_PAGE_SIZE = 10
|
||||||
export const SHOP_DATA_MIN_PAGE_SIZE = 10
|
export const SHOP_DATA_MIN_PAGE_SIZE = 10
|
||||||
export const SHOP_DATA_MAX_PAGE_SIZE = 100
|
export const SHOP_DATA_MAX_PAGE_SIZE = 100
|
||||||
export const SHOP_DATA_PAGE_MIN = 1
|
export const SHOP_DATA_PAGE_MIN = 1
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ export const adminPages: AdminPageDef[] = [
|
|||||||
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
|
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
|
||||||
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
||||||
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
||||||
|
{ path: 'account/user-secrets', menuKey: 'admin_user_secrets', title: '密钥管理', load: () => import('@/pages/account/UserSecretsPage.vue') },
|
||||||
|
{ path: 'account/user-secret-usage', menuKey: 'admin_user_secret_usage', title: '密钥用量统计', load: () => import('@/pages/account/UserSecretUsagePage.vue') },
|
||||||
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
|
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
|
||||||
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
|
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
|
||||||
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
|
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
|
||||||
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
|
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
|
||||||
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
|
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
|
||||||
|
{ path: 'records/tutorial', menuKey: 'admin_tutorial', title: '教程管理', load: () => import('@/pages/records/RecordsTutorialPage.vue') },
|
||||||
|
{ path: 'records/device-logs', menuKey: 'admin_device_logs', title: '日志管理', load: () => import('@/pages/records/DeviceLogsPage.vue') },
|
||||||
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
|
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
|
||||||
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
|
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
|
||||||
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据任务/记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
||||||
{ path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') },
|
{ path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') },
|
||||||
{ path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') },
|
{ path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') },
|
||||||
{ path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') },
|
{ path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') },
|
||||||
|
|||||||
@@ -183,6 +183,30 @@ button, input, textarea, select { font: inherit; }
|
|||||||
.el-tag { border-radius: 6px; }
|
.el-tag { border-radius: 6px; }
|
||||||
.el-button { border-radius: 9px; }
|
.el-button { border-radius: 9px; }
|
||||||
.el-message-box { border-radius: 14px; }
|
.el-message-box { border-radius: 14px; }
|
||||||
|
/* 消息提示层级:Element 弹层 z-index 自 2000 递增,低于页面自定义 modal-mask(3000/3100);
|
||||||
|
遮罩弹窗内触发的 ElMessage 警告(如去重台账导入/编辑未选分组)会被浮层挡住看不见,
|
||||||
|
统一把消息提示抬到 4000(高于全部自定义浮层),!important 覆盖实例内联层级。
|
||||||
|
消息盒确认弹窗同类隐患一并抬到 4100(z-index 落在 .el-overlay.is-message-box 内联样式)。 */
|
||||||
|
.el-message { z-index: 4000 !important; }
|
||||||
|
.el-overlay.is-message-box { z-index: 4100 !important; }
|
||||||
|
|
||||||
|
/* 分组筛选下拉(可模糊搜索):对齐旧版控件观感(42px/9px/浅底/focus ring)。 */
|
||||||
|
.el-select.admin-filter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.el-select.admin-filter .el-select__wrapper {
|
||||||
|
min-height: 42px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
box-shadow: 0 0 0 1px #cbd9e6 inset;
|
||||||
|
}
|
||||||
|
.el-select.admin-filter .el-select__wrapper:hover {
|
||||||
|
box-shadow: 0 0 0 1px #9fb7cd inset;
|
||||||
|
}
|
||||||
|
.el-select.admin-filter.is-focused .el-select__wrapper {
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.admin-sidebar { flex-basis: 64px; }
|
.admin-sidebar { flex-basis: 64px; }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AdminUser {
|
|||||||
createdById?: number | null
|
createdById?: number | null
|
||||||
creatorUsername?: string
|
creatorUsername?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
pinyinAbbr?: string
|
pinyinAbbr?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
|
/** 对齐旧版 .btn-sm:account 域行内操作统一实心小按钮(不再 link/text)。 */
|
||||||
|
|
||||||
|
function assertRowButtonSolid(source: string, action: string) {
|
||||||
|
assert.match(source, new RegExp(`<el-button[^>]*size="small"[^>]*@click="${action}`), `${action} 为 size=small 实心按钮`)
|
||||||
|
assert.doesNotMatch(source, new RegExp(`<el-button[^>]*link[^>]*@click="${action}`), `${action} 不再 link`)
|
||||||
|
assert.doesNotMatch(source, new RegExp(`<el-button[^>]*text[^>]*@click="${action}`), `${action} 不再 text`)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('align_users_row_buttons_old_btn', () => {
|
||||||
|
const s = readSource('src/pages/account/UsersPage.vue')
|
||||||
|
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
|
||||||
|
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="removeUser\(row\)"/, '删除用旧版 btn-sm btn-danger')
|
||||||
|
assert.doesNotMatch(s, /el-button/, '用户页已自绘,无 EP 按钮')
|
||||||
|
assert.match(s, /window\.confirm\(deleteConfirmMessage\(row\)\)/, '删除确认走原生 confirm 对齐旧版')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_groups_row_buttons_old_btn', () => {
|
||||||
|
const s = readSource('src/pages/account/GroupsPage.vue')
|
||||||
|
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(group\)"/, '编辑用旧版 btn-sm 实心小按钮')
|
||||||
|
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="removeGroup\(group\)"/, '删除用旧版 btn-sm btn-danger')
|
||||||
|
assert.doesNotMatch(s, /el-button[^>]*@click="openEdit/, '行内不再用 EP 按钮')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_menus_row_buttons_old_btn', () => {
|
||||||
|
const s = readSource('src/pages/account/MenusPage.vue')
|
||||||
|
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
|
||||||
|
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="removeMenu\(row\)"/, '删除用旧版 btn-sm btn-danger')
|
||||||
|
assert.doesNotMatch(s, /el-button[^>]*@click="openEdit/, '行内不再用 EP 按钮')
|
||||||
|
assert.match(s, /window\.confirm\(`确定删除菜单/, '删除确认走原生 confirm 对齐旧版')
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
|
/** 对齐旧版 .btn-sm:asin 域 5 页已像素复刻,行内操作(编辑/删除/配置)用原生 btn btn-sm 实心小按钮(非 EP 按钮)。 */
|
||||||
|
|
||||||
|
// 断言单个 el-button 标签同时含 btn-sm 与目标 @click,且不含 link/text 修饰(action 含正则元字符需转义)。
|
||||||
|
const escapeRe = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const OLD_BTN = (action: string) => new RegExp(`class="btn btn-sm[^"]*"[^>]*@click="${escapeRe(action)}`)
|
||||||
|
const NO_LINK = (action: string) => new RegExp(`<el-button[^>]*link[^>]*@click="${escapeRe(action)}`)
|
||||||
|
const NO_TEXT = (action: string) => new RegExp(`<el-button[^>]*text[^>]*@click="${escapeRe(action)}`)
|
||||||
|
const NO_EL = (action: string) => new RegExp(`<el-button[^>]*@click="${escapeRe(action)}`)
|
||||||
|
|
||||||
|
function assertOldRowButton(source: string, action: string, page: string) {
|
||||||
|
assert.match(source, OLD_BTN(action), `${page} ${action} 为 btn-sm 实心按钮`)
|
||||||
|
assert.doesNotMatch(source, NO_LINK(action), `${page} ${action} 不再 link`)
|
||||||
|
assert.doesNotMatch(source, NO_TEXT(action), `${page} ${action} 不再 text`)
|
||||||
|
assert.doesNotMatch(source, NO_EL(action), `${page} ${action} 不再用 EP 按钮`)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('align_invalid_asin_row_buttons_old_btn', () => {
|
||||||
|
const s = readSource('src/pages/asin/AsinInvalidPage.vue')
|
||||||
|
assertOldRowButton(s, 'openEdit(row)', '不符合ASIN')
|
||||||
|
assertOldRowButton(s, 'remove(row)', '不符合ASIN')
|
||||||
|
assert.match(s, /window\.confirm\(invalidAsinDeleteText\(row\)\)/, '删除确认原生 confirm 对齐旧版')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_query_asin_config_old_btn', () => {
|
||||||
|
assertOldRowButton(readSource('src/pages/asin/QueryAsinPage.vue'), 'openConfig(row.item as QueryAsinItem)', '查询ASIN')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_skip_price_config_old_btn', () => {
|
||||||
|
assertOldRowButton(readSource('src/pages/asin/SkipPricePage.vue'), 'openConfig(row.item as SkipPriceItem)', '最低价ASIN')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_product_category_row_buttons_old_btn', () => {
|
||||||
|
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
|
||||||
|
assertOldRowButton(s, 'openEdit(row.node)', '商品类目树视图')
|
||||||
|
assertOldRowButton(s, 'openEdit(row)', '商品类目搜索视图')
|
||||||
|
assert.match(s, /btn-sm btn-danger"[^>]*@click="removeCategory/, '删除为 btn-sm btn-danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_product_category_no_indigo_left', () => {
|
||||||
|
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
|
||||||
|
assert.doesNotMatch(s, /#6366f1|#eef0fe|6366f1|eef0fe/, '靛蓝色点已清')
|
||||||
|
assert.match(s, /--admin-primary-soft/, '折叠钮改用蓝白主色 soft token')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_product_category_load_more_old_class', () => {
|
||||||
|
// 类目「加载更多」行按钮沿用 btn-sm btn-secondary(旧版小组按钮样式)。
|
||||||
|
const s = readSource('src/pages/asin/ProductCategoryPage.vue')
|
||||||
|
assert.match(s, /class="btn btn-sm btn-secondary"[^>]*loadMoreChildren/, '加载更多为旧版 btn-sm btn-secondary')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_dedupe_registry_row_buttons_old_btn', () => {
|
||||||
|
const s = readSource('src/pages/asin/DedupeRegistryPage.vue')
|
||||||
|
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
|
||||||
|
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="removeRow\(row\)"/, '删除用旧版 btn-sm btn-danger')
|
||||||
|
assert.doesNotMatch(s, /el-button/, '去重汇总已自绘,无 EP 按钮')
|
||||||
|
assert.match(s, /window\.confirm\(`确定删除总数据/, '删除确认文案对齐旧版(原生 confirm)')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_asin_actions_cell_spacing', () => {
|
||||||
|
// 反馈:查询ASIN操作按钮要有间距——asin-col-actions 统一 flex + gap 8px(对齐全站 ops-cell)。
|
||||||
|
for (const page of ['src/pages/asin/QueryAsinPage.vue', 'src/pages/asin/SkipPricePage.vue']) {
|
||||||
|
const s = readSource(page)
|
||||||
|
assert.match(s, /\.asin-col-actions\s*\{[^}]*gap:\s*8px/, `${page} 操作列按钮间距 8px`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -16,9 +16,9 @@ test('align_category_tree_table_normal_primary_path', () => {
|
|||||||
test('align_category_tree_table_normal_variant_input', () => {
|
test('align_category_tree_table_normal_variant_input', () => {
|
||||||
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
|
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
|
||||||
assert.match(page, /expandedIds/, '客户端展开状态集合')
|
assert.match(page, /expandedIds/, '客户端展开状态集合')
|
||||||
assert.match(page, /flatRows|treeRows/, '按展开状态扁平化行集')
|
assert.match(page, /treeRows/, '按展开状态扁平化行集')
|
||||||
for (const col of ['层级路径', '排序', '来源', '说明']) {
|
for (const col of ['层级路径', '排序', '来源', '备注']) {
|
||||||
assert.match(page, new RegExp(col), `树形表格含「${col}」列`)
|
assert.match(page, new RegExp(col), `树形表格含「${col}」列(旧版列名为备注)`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ test('align_category_tree_table_repeated_operation_is_idempotent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('align_category_tree_table_dependency_failure_returns_actionable_message', () => {
|
test('align_category_tree_table_dependency_failure_returns_actionable_message', () => {
|
||||||
// 视觉复刻旧版:20px 圆角方块标记、缩进宽度变量、名称加粗。
|
// 视觉复刻旧版:圆角方块标记、层级缩进(24px/级)、名称加粗。
|
||||||
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
|
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
|
||||||
assert.match(page, /tree-node-mark/, '展开标记样式存在')
|
assert.match(page, /tree-node-mark/, '展开标记样式存在')
|
||||||
assert.match(page, /var\(--indent|--indent/, '缩进变量')
|
assert.match(page, /row\.depth \* 24/, '缩进 24px/级')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,19 +7,6 @@ import { readSource } from './helpers.ts'
|
|||||||
// 例外:DuplicateCheckPage 不动;dialog/表单的 width 不属于表格列。
|
// 例外:DuplicateCheckPage 不动;dialog/表单的 width 不属于表格列。
|
||||||
|
|
||||||
const PAGES = [
|
const PAGES = [
|
||||||
'src/pages/account/GroupsPage.vue',
|
|
||||||
'src/pages/account/MenusPage.vue',
|
|
||||||
'src/pages/account/UsersPage.vue',
|
|
||||||
'src/pages/asin/AsinInvalidPage.vue',
|
|
||||||
'src/pages/asin/DedupeRegistryPage.vue',
|
|
||||||
'src/pages/asin/ProductCategoryPage.vue',
|
|
||||||
'src/pages/asin/QueryAsinPage.vue',
|
|
||||||
'src/pages/asin/SkipPricePage.vue',
|
|
||||||
'src/pages/records/RecordsDigitalHumanVersionPage.vue',
|
|
||||||
'src/pages/records/RecordsHistoryPage.vue',
|
|
||||||
'src/pages/records/RecordsSoftwareVersionPage.vue',
|
|
||||||
'src/pages/shop/ShopKeysPage.vue',
|
|
||||||
'src/pages/shop/ShopManagePage.vue',
|
|
||||||
'src/pages/tasks/ImageVideoTasksPage.vue',
|
'src/pages/tasks/ImageVideoTasksPage.vue',
|
||||||
'src/pages/tasks/ShopDataTasksPage.vue',
|
'src/pages/tasks/ShopDataTasksPage.vue',
|
||||||
]
|
]
|
||||||
@@ -47,9 +34,40 @@ test('align_column_width_normal_variant_input', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('align_column_width_boundary_empty_input', () => {
|
test('align_column_width_boundary_empty_input', () => {
|
||||||
// 例外页 DuplicateCheckPage 保持自身实现不动。
|
// 例外页 DuplicateCheckPage 已整页复刻 reference 撞款控制台(原生 ledger 表格),不参与 el-table 列宽约定。
|
||||||
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||||
assert.match(dup, /el-table-column/, 'DuplicateCheckPage 表格仍在且未被修改')
|
assert.equal(/el-table-column/.test(dup), false, '撞款台账已用 reference 原生表格,不再有 el-table-column')
|
||||||
|
assert.match(dup, /class="ledger"/, '撞款台账仍为表格实现')
|
||||||
|
const hist = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||||
|
assert.equal(/el-table-column/.test(hist), false, '生成记录已像素复刻 admin panel-history 原生表格,不再有 el-table-column')
|
||||||
|
assert.match(hist, /class="table-scroll history-table-scroll"/, '生成记录用原生 .table-scroll 表格')
|
||||||
|
assert.match(hist, /<th[^>]*>结果预览<\/th>/, '原生表含结果预览列')
|
||||||
|
const dedupe = readSource('src/pages/asin/DedupeRegistryPage.vue')
|
||||||
|
assert.equal(/el-table-column/.test(dedupe), false, '去重汇总已像素复刻 admin panel-dedupe-total-data 原生表格,不再有 el-table-column')
|
||||||
|
assert.match(dedupe, /class="table-scroll dedupe-table-scroll"/, '去重汇总用原生 .table-scroll 表格')
|
||||||
|
const users = readSource('src/pages/account/UsersPage.vue')
|
||||||
|
assert.equal(/el-table-column/.test(users), false, '用户管理已像素复刻 admin panel-users 原生表格,不再有 el-table-column')
|
||||||
|
assert.match(users, /class="table-scroll user-table-scroll"/, '用户管理用原生 .table-scroll 表格')
|
||||||
|
const menus = readSource('src/pages/account/MenusPage.vue')
|
||||||
|
assert.equal(/el-table-column/.test(menus), false, '菜单配置已像素复刻 admin panel-columns 原生表格,不再有 el-table-column')
|
||||||
|
assert.match(menus, /class="table-scroll columns-table-scroll"/, '菜单配置用原生 .table-scroll 表格')
|
||||||
|
const groups = readSource('src/pages/account/GroupsPage.vue')
|
||||||
|
assert.equal(/el-table-column/.test(groups), false, '分组管理已像素复刻 admin panel-group-manage 原生表格,不再有 el-table-column')
|
||||||
|
assert.match(groups, /class="table-scroll"/, '分组管理用原生 .table-scroll 表格')
|
||||||
|
for (const [page, cls] of [
|
||||||
|
['src/pages/asin/AsinInvalidPage.vue', 'invalid-asin-table-scroll'],
|
||||||
|
['src/pages/asin/QueryAsinPage.vue', 'query-asin-table-scroll'],
|
||||||
|
['src/pages/asin/SkipPricePage.vue', 'skip-price-asin-table-scroll'],
|
||||||
|
['src/pages/asin/ProductCategoryPage.vue', 'category-table-scroll'],
|
||||||
|
['src/pages/shop/ShopKeysPage.vue', 'shop-key-table-scroll'],
|
||||||
|
['src/pages/shop/ShopManagePage.vue', 'shop-manage-table-scroll'],
|
||||||
|
['src/pages/records/RecordsSoftwareVersionPage.vue', 'version-table-scroll'],
|
||||||
|
['src/pages/records/RecordsDigitalHumanVersionPage.vue', 'digital-human-version-table-scroll'],
|
||||||
|
]) {
|
||||||
|
const src = readSource(page)
|
||||||
|
assert.equal(/el-table-column/.test(src), false, `${page} 已像素复刻原生表格,不再有 el-table-column`)
|
||||||
|
assert.match(src, new RegExp(cls), `${page} 用原生 .table-scroll 表格`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('align_column_width_dependency_failure_returns_actionable_message', () => {
|
test('align_column_width_dependency_failure_returns_actionable_message', () => {
|
||||||
|
|||||||
@@ -53,11 +53,20 @@ test('align_dedupe_group_option_parses_leader', () => {
|
|||||||
assert.deepEqual(options, [{ id: 3, name: 'C组', leaderUserId: 12 }])
|
assert.deepEqual(options, [{ id: 3, name: 'C组', leaderUserId: 12 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('align_dedupe_page_summary_and_export_wait_wiring', () => {
|
test('align_dedupe_group_option_parses_java_groupName_field', () => {
|
||||||
|
// Java /api/admin/shop-manages/groups 返回 groupName(驼峰);解析器需同时认驼峰与 snake。
|
||||||
|
const options = parseDedupeGroupOptionList({
|
||||||
|
success: true,
|
||||||
|
data: { items: [{ id: 2, groupName: '周丽娥', leaderUserId: 23 }] },
|
||||||
|
})
|
||||||
|
assert.deepEqual(options, [{ id: 2, name: '周丽娥', leaderUserId: 23 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_dedupe_page_no_summary_block', () => {
|
||||||
|
// 用户反馈(样办2):数据权限分组摘要块多余,从页面移除;模型纯函数保留。
|
||||||
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
|
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
|
||||||
assert.match(page, /数据权限分组/, '页顶数据权限分组汇总卡')
|
assert.doesNotMatch(page, /dedupe-group-access|dedupeGroupSummaryOf|groupSummary/, '页面不再有分组摘要块与接线')
|
||||||
assert.match(page, /dedupeGroupSummaryOf/, '汇总走纯模型')
|
|
||||||
assert.match(page, /正在生成导出文件/, '导出等待遮罩文案对齐')
|
assert.match(page, /正在生成导出文件/, '导出等待遮罩文案对齐')
|
||||||
assert.match(page, /已等待/, '导出等待含等待秒数')
|
assert.match(page, /已等待/, '导出等待含等待秒数')
|
||||||
assert.match(page, /未分组/, '分组空值显示未分组')
|
assert.match(page, /未分组/, '分组空值行内显示未分组')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,14 +58,16 @@ test('align_digital_human_api_download_url', () => {
|
|||||||
|
|
||||||
test('align_digital_human_page_wiring', () => {
|
test('align_digital_human_page_wiring', () => {
|
||||||
const page = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
|
const page = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
|
||||||
assert.match(page, /pageSize\s*=\s*20|:page-size="20"/, '分页大小 20')
|
assert.match(page, /pageSize\s*=\s*ref\(10\)/, '分页大小默认 10(全站统一)')
|
||||||
assert.match(page, /el-pagination/, '分页组件')
|
assert.match(page, /page-nums/, '数字页码分页(像素复刻旧版)')
|
||||||
assert.match(page, /设为最新/, '按钮名对齐“设为最新”')
|
assert.match(page, /设为最新/, '按钮名对齐“设为最新”')
|
||||||
assert.match(page, /上传(草稿状态)/, '上传按钮名对齐')
|
assert.match(page, /上传新版本/, '页头上传按钮名对齐')
|
||||||
assert.match(page, /上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布/, '上传弹窗描述对齐')
|
assert.match(page, /上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布/, '上传弹窗描述对齐')
|
||||||
assert.match(page, /正在上传/, '上传进度文案')
|
assert.match(page, /正在上传/, '上传进度文案')
|
||||||
assert.match(page, /digitalHumanDeleteConfirmText/, '删除确认文案走对齐模型')
|
assert.match(page, /digitalHumanDeleteConfirmText/, '删除确认文案走对齐模型')
|
||||||
assert.match(page, /downloadUrl|fetchDigitalHumanDownloadUrl/, 'RELEASED 下载按钮点击取链接')
|
assert.match(page, /fetchDigitalHumanDownloadUrl/, 'RELEASED 下载按钮点击取链接')
|
||||||
assert.match(page, /已废弃/, '状态三态接入')
|
assert.match(page, /digitalHumanStatusDisplay/, '状态走展示层模型')
|
||||||
|
const display = readSource('src/pages/records/digitalhuman-status-display.ts')
|
||||||
|
assert.match(display, /已废弃/, '状态三态接入(草稿/已发布/已废弃)')
|
||||||
assert.match(page, /digitalHumanChangelogShort/, '更新说明 50 字符截断')
|
assert.match(page, /digitalHumanChangelogShort/, '更新说明 50 字符截断')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,7 +42,23 @@ test('align_dup_console_drawer_template', () => {
|
|||||||
assert.match(page, /drawerAggRows/, '抽屉渲染聚合行')
|
assert.match(page, /drawerAggRows/, '抽屉渲染聚合行')
|
||||||
assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章')
|
assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章')
|
||||||
assert.match(page, /次上架/, '汇总含 N 次上架')
|
assert.match(page, /次上架/, '汇总含 N 次上架')
|
||||||
assert.match(page, /label="次数"/, '聚合表含次数列')
|
assert.match(page, /<th[^>]*>次数<\/th>/, '聚合表含次数列')
|
||||||
|
assert.match(page, /v-for="row in drawerRows"/, '抽屉聚合表绑定分页切片')
|
||||||
|
assert.match(page, /:total="drawerAggRows\.length"/, '抽屉聚合表有分页器')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_dup_console_pagination_all_lists', () => {
|
||||||
|
// 全站统一收官:撞款页四处列表(分布/矩阵/撞款详情卡片/抽屉明细)均挂共享 OldPagination,
|
||||||
|
// 且分布/矩阵/详情常显(不足一页也显示分页器,反馈:数据少时看不到分页)。
|
||||||
|
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||||
|
assert.match(page, /:total="distCount"/, '店铺上架分布有分页器(常显)')
|
||||||
|
assert.match(page, /:total="dupRows\.length"/, '撞款覆盖矩阵有分页器(常显)')
|
||||||
|
assert.match(page, /v-for="item in dupCardRows"/, '撞款详情卡片绑定分页切片')
|
||||||
|
assert.match(page, /:total="dupRows\.length"[\s\S]*dupCardPageSize/, '撞款详情卡片有分页器(常显)')
|
||||||
|
assert.match(page, /<OldPagination[^>]*:total="distCount"/, '分布分页器无 v-if 条件')
|
||||||
|
assert.match(page, /<OldPagination[^>]*:total="dupRows\.length"/, '矩阵/详情分页器无 v-if 条件')
|
||||||
|
assert.match(page, /v-for="group in pagedGroupStats"/, '分组说明抽屉表绑定分页切片')
|
||||||
|
assert.match(page, /:total="groupStats\.length"/, '分组说明抽屉表有分页器')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('align_dup_console_ledger_total_preload', () => {
|
test('align_dup_console_ledger_total_preload', () => {
|
||||||
@@ -56,3 +72,30 @@ test('align_dup_console_matrix_sticky', () => {
|
|||||||
assert.match(page, /table\.matrix th\s*\{[^}]*position:\s*sticky/, '矩阵表头吸顶')
|
assert.match(page, /table\.matrix th\s*\{[^}]*position:\s*sticky/, '矩阵表头吸顶')
|
||||||
assert.match(page, /\.matrix-wrap\s*\{[^}]*max-height/, '矩阵容器限高滚动')
|
assert.match(page, /\.matrix-wrap\s*\{[^}]*max-height/, '矩阵容器限高滚动')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('align_dup_console_role_scope_group_filter_only_super', () => {
|
||||||
|
// 角色范围:分组筛选下拉仅超管需要;管理员数据由后端裁到本组。
|
||||||
|
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||||
|
assert.match(page, /useAdminSessionStore/, '页面读取登录会话取角色')
|
||||||
|
assert.match(page, /const isSuper = computed\(\(\) => session\.isSuperAdmin\)/, 'isSuper 由会话角色推导')
|
||||||
|
assert.match(page, /<div v-if="isSuper" class="f-group">\s*<label>分组<\/label>/s, '分组筛选仅超管可见')
|
||||||
|
assert.match(page, /group: filter\.group\.trim\(\)/, '分组筛选仍参与查询参数')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_dup_console_role_scope_group_controls_only_super', () => {
|
||||||
|
// 分组撞款/分组说明为超管专属交互;后端超管全量、普通管理员所管分组店铺。
|
||||||
|
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||||
|
assert.match(page, /<div v-if="isSuper" class="scope-bar">/, '分组撞款分段仅超管可见')
|
||||||
|
assert.match(page, /v-if="isSuper" class="btn btn-ghost btn-sm" type="button" @click="groupInfoVisible = true"/, '分组说明按钮仅超管可见')
|
||||||
|
const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java')
|
||||||
|
assert.match(service, /if \(superAdmin\) \{\s*return null;/, '超管可见范围=全量(null)')
|
||||||
|
assert.match(service, /selectManagedShopNames\(operatorId\)/, '普通管理员可见范围=所管分组店铺')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_dup_console_country_labels_chinese', () => {
|
||||||
|
// 反馈:国家显示一定要用中文——站点/国家一律经 asinCountryLabel 映射,禁止裸国家码直出。
|
||||||
|
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||||
|
assert.match(page, /asinCountryLabel\(/, '站点显示走中文映射函数')
|
||||||
|
assert.doesNotMatch(page, /\{\{\s*(row|item)\.country\s*\}\}/, '表格/抽屉无裸国家码直出')
|
||||||
|
assert.match(page, /asinCountryLabel\(code\)/, '筛选下拉站点显示中文')
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
|
// 回归(生产:用户菜单权限“自己没掉”):编辑用户弹窗打开时先把 columnIds 清空,再异步回填;
|
||||||
|
// 若授权尚未加载完成(或加载失败)就点保存,会把空数组提交成“清空授权”,整树权限被抹掉。
|
||||||
|
// 修复:授权未就绪时禁用保存并明确提示。
|
||||||
|
|
||||||
|
test('edit_user_dialog_blocks_save_until_auth_loaded', () => {
|
||||||
|
const dialog = readSource('src/pages/account/EditUserDialog.vue')
|
||||||
|
|
||||||
|
assert.match(dialog, /authReady/, '存在授权就绪标记')
|
||||||
|
assert.match(dialog, /authReady\.value = false/, '打开/失败时标记为未就绪')
|
||||||
|
|
||||||
|
// 加载失败分支必须保持未就绪:不能回退成“可以保存空授权”
|
||||||
|
const refreshBlock = dialog.slice(
|
||||||
|
dialog.indexOf('async function refreshAuth'),
|
||||||
|
dialog.indexOf('function close'),
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
refreshBlock,
|
||||||
|
/catch \(error\) \{[\s\S]*?authReady\.value = false/,
|
||||||
|
'授权加载失败保持未就绪,不用空数组覆盖服务端授权',
|
||||||
|
)
|
||||||
|
assert.match(refreshBlock, /authReady\.value = true/, '仅在加载成功后置为就绪')
|
||||||
|
|
||||||
|
// save() 自身兜底拦截,不依赖按钮禁用
|
||||||
|
const saveBlock = dialog.slice(dialog.indexOf('async function save'), dialog.indexOf('async function save') + 400)
|
||||||
|
assert.match(saveBlock, /if \(!authReady\.value\)/, '未就绪时直接拦截保存')
|
||||||
|
|
||||||
|
// 保存按钮在未就绪时禁用
|
||||||
|
assert.match(dialog, /:disabled="!authReady"/, '未就绪时保存按钮禁用')
|
||||||
|
assert.match(dialog, /加载中/, '加载中有提示')
|
||||||
|
assert.match(dialog, /保存已禁用/, '加载失败有明确提示')
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组维度仅超管可见:非超管(管理员/普通用户)数据已由后端裁剪到本人分组,
|
||||||
|
* 后台各页的分组筛选与分组列一律不展示,只有超管需要按分组筛选/区分。
|
||||||
|
* 参考实现见撞款检测页(DuplicateCheckPage.vue 分组筛选用 isSuper 门控)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 页面 → 分组筛选门控片段(正则不做宽松匹配,确保门控真实存在)。 */
|
||||||
|
const FILTER_GATES: Array<[string, RegExp]> = [
|
||||||
|
['src/pages/asin/AsinInvalidPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/asin/QueryAsinPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/asin/SkipPricePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/asin/DedupeRegistryPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/shop/ShopManagePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/tasks/ShopDataTasksPage.vue', /v-if="session\.isSuperAdmin"[\s\S]{0,160}模糊搜索分组名/],
|
||||||
|
['src/pages/account/UserSecretsPage.vue', /v-if="isSuperAdmin[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 页面 → 分组列表头门控片段。 */
|
||||||
|
const COLUMN_GATES: Array<[string, RegExp]> = [
|
||||||
|
['src/pages/asin/AsinInvalidPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/asin/QueryAsinPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/asin/SkipPricePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/asin/DedupeRegistryPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/shop/ShopManagePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/tasks/DuplicateCheckPage.vue', /<th v-if="isSuper"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/account/UserSecretsPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
]
|
||||||
|
|
||||||
|
test('align_group_visibility_filter_gated_by_super_admin', () => {
|
||||||
|
for (const [file, pattern] of FILTER_GATES) {
|
||||||
|
assert.match(readSource(file), pattern, `${file} 分组筛选未按超管门控`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_group_visibility_column_gated_by_super_admin', () => {
|
||||||
|
for (const [file, pattern] of COLUMN_GATES) {
|
||||||
|
assert.match(readSource(file), pattern, `${file} 分组列未按超管门控`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_group_visibility_no_ungated_group_column_header', () => {
|
||||||
|
// 不变量:以上页面的“分组”数据列表头必须带 v-if 门控,防止后续改动误放开。
|
||||||
|
// DuplicateCheckPage 的分组说明抽屉内另有一张分组统计表,该抽屉由已门控的按钮打开,
|
||||||
|
// 非超管无法触达,故不纳入本不变量(单独在 detail_cards 用例中校验按钮门控)。
|
||||||
|
const ungated = /<th(?![^>]*v-if)[^>]*>分组<\/th>/
|
||||||
|
for (const [file] of COLUMN_GATES) {
|
||||||
|
if (file.endsWith('DuplicateCheckPage.vue')) continue
|
||||||
|
assert.doesNotMatch(readSource(file), ungated, `${file} 存在未门控的分组列表头`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_group_visibility_detail_cards_gated', () => {
|
||||||
|
// 明细卡片/信息行里的分组展示同样只给超管。
|
||||||
|
assert.match(
|
||||||
|
readSource('src/pages/tasks/ImageVideoTasksPage.vue'),
|
||||||
|
/v-if="session\.isSuperAdmin"[\s\S]{0,120}所属分组/,
|
||||||
|
'图片视频任务页「所属分组」未门控',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
readSource('src/pages/tasks/ShopDataTasksPage.vue'),
|
||||||
|
/v-if="session\.isSuperAdmin && row\.groupName"/,
|
||||||
|
'店铺数据记录页卡片分组行未门控',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
readSource('src/pages/tasks/DuplicateCheckPage.vue'),
|
||||||
|
/v-if="isSuper && block\.groupName"/,
|
||||||
|
'撞款检测页卡片分组标签未门控',
|
||||||
|
)
|
||||||
|
// 分组说明抽屉整体为分组维度内容:入口按钮与分组范围切换条均已按超管门控。
|
||||||
|
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||||
|
assert.match(dup, /<button v-if="isSuper"[^>]*@click="groupInfoVisible = true"/, '分组说明入口未门控')
|
||||||
|
assert.match(dup, /<div v-if="isSuper" class="scope-bar"/, '分组撞款范围条未门控')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_group_visibility_dialogs_keep_locked_selector', () => {
|
||||||
|
// 弹窗分组选择保留展示但非超管锁定(单分组时),不隐藏。
|
||||||
|
const lockedPages = [
|
||||||
|
'src/pages/asin/QueryAsinPage.vue',
|
||||||
|
'src/pages/asin/SkipPricePage.vue',
|
||||||
|
'src/pages/asin/DedupeRegistryPage.vue',
|
||||||
|
'src/pages/shop/ShopManagePage.vue',
|
||||||
|
]
|
||||||
|
for (const file of lockedPages) {
|
||||||
|
const src = readSource(file)
|
||||||
|
assert.match(src, /lockedGroupId/, `${file} 缺少弹窗分组锁定计算`)
|
||||||
|
assert.match(src, /:disabled="lockedGroupId != null"/, `${file} 弹窗分组未锁定`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -2,29 +2,23 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readSource } from './helpers.ts'
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
// 验收反馈:生成记录的数据要可清理。
|
// 用户反馈(round2 像素复刻样办):生成记录页还原旧版交互——「清理数据」为旧版没有的新增入口,从页面移除。
|
||||||
// 契约:页面有「清理数据」入口(二次确认)、前端 DELETE /api/admin/history、后端清空端点与日志。
|
// 契约:页面无清理入口、无二次确认文案;前端适配层不再暴露 clearHistory;后端 DELETE /history 端点保留(非破坏性,供后续需要时再接线)。
|
||||||
|
|
||||||
test('align_history_clear_normal_primary_path', () => {
|
test('align_history_clear_removed_from_page', () => {
|
||||||
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||||
assert.match(page, /清理数据/, '页头有清理数据按钮')
|
assert.doesNotMatch(page, /清理数据/, '页面不再有「清理数据」按钮')
|
||||||
assert.match(page, /确定清空全部生成记录吗/, '清理前需二次确认')
|
assert.doesNotMatch(page, /clearAll|clearHistory/, '页面不再调用清理逻辑')
|
||||||
assert.match(page, /clearHistory\(\)/, '页面调用清理 API')
|
assert.doesNotMatch(page, /确定清空全部生成记录吗/, '无清理二次确认文案')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('align_history_clear_normal_variant_input', () => {
|
test('align_history_clear_api_no_longer_exposed', () => {
|
||||||
const api = readSource('src/pages/records/history-api.ts')
|
const api = readSource('src/pages/records/history-api.ts')
|
||||||
assert.match(api, /http\.delete<unknown>\(HISTORY_ENDPOINT\)/, '清理走 DELETE /api/admin/history')
|
assert.doesNotMatch(api, /clearHistory/, '前端适配层不再暴露 clearHistory')
|
||||||
assert.match(api, /clearHistory/, '暴露 clearHistory 适配函数')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('align_history_clear_boundary_empty_input', () => {
|
test('align_history_clear_backend_endpoint_kept', () => {
|
||||||
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
// 后端契约保留:DELETE /history 端点 + service 清空 + 中文排查日志(从前端摘除入口,不动后端)。
|
||||||
assert.match(page, /已清空 \$\{removed\} 条生成记录/, '成功提示含清理条数')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('align_history_clear_dependency_failure_returns_actionable_message', () => {
|
|
||||||
// 后端契约:DELETE /history 端点 + service 清空 + 中文排查日志。
|
|
||||||
const ctrl = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java')
|
const ctrl = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java')
|
||||||
assert.match(ctrl, /@DeleteMapping\("\/history"\)/, '后端有 DELETE /api/admin/history 端点')
|
assert.match(ctrl, /@DeleteMapping\("\/history"\)/, '后端有 DELETE /api/admin/history 端点')
|
||||||
assert.match(ctrl, /admin_history/, '清理端点沿用生成记录模块权限')
|
assert.match(ctrl, /admin_history/, '清理端点沿用生成记录模块权限')
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { historyEmptyText } from '../src/pages/records/history-feedback.ts'
|
|||||||
|
|
||||||
test('align_history_column_labels', () => {
|
test('align_history_column_labels', () => {
|
||||||
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||||
assert.match(page, /label="类型"/, '列名对齐参考「类型」')
|
assert.match(page, /<th[^>]*>类型<\/th>/, '列名对齐参考「类型」(自绘 th)')
|
||||||
assert.match(page, /label="创建时间"/, '列名对齐参考「创建时间」')
|
assert.match(page, /<th[^>]*>创建时间<\/th>/, '列名对齐参考「创建时间」(自绘 th)')
|
||||||
assert.doesNotMatch(page, /记录类型/, '去掉“记录类型”列名')
|
assert.doesNotMatch(page, /记录类型/, '去掉“记录类型”列名')
|
||||||
assert.doesNotMatch(page, /label="生成时间"/, '去掉“生成时间”列名')
|
assert.doesNotMatch(page, /label="生成时间"/, '去掉“生成时间”列名')
|
||||||
})
|
})
|
||||||
@@ -18,3 +18,11 @@ test('align_history_empty_text', () => {
|
|||||||
assert.equal(historyEmptyText({ hasFilter: false, total: 0 }), '暂无记录')
|
assert.equal(historyEmptyText({ hasFilter: false, total: 0 }), '暂无记录')
|
||||||
assert.equal(historyEmptyText({ hasFilter: false, total: 2 }), '')
|
assert.equal(historyEmptyText({ hasFilter: false, total: 2 }), '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('align_history_form_group_no_global_margin_leak', () => {
|
||||||
|
// 修复按钮与筛选输入框不对齐:全局 .form-group{margin-bottom:20px} 由登录页非 scoped 样式泄漏;登录页样式收窄为 scoped,页面内显式重置。
|
||||||
|
const login = readSource('src/pages/login/LoginPage.vue')
|
||||||
|
assert.match(login, /<style scoped>/, '登录页样式 scoped,不再全局泄漏 .form-group')
|
||||||
|
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||||
|
assert.match(page, /\.form-group \{[^}]*margin-bottom: 0/, '页面内 form-group 显式重置 margin')
|
||||||
|
})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user