Compare commits
28 Commits
1da4511dd9
...
5f8c469b8e
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,42 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 200)) })
|
||||
const userReqs = []
|
||||
page.on('request', (r) => { if (r.url().includes('/api/admin/users')) userReqs.push(r.url()) })
|
||||
await page.goto('http://localhost:5174/admin-vue/', { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(800)
|
||||
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
await page.waitForSelector('.admin-shell', { timeout: 25000 }).catch(() => {})
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
await page.goto('http://localhost:5174/admin-vue/account/users', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2200)
|
||||
const selects = page.locator('.users-filter-row .el-select')
|
||||
console.log('筛选行 el-select 数量:', await selects.count())
|
||||
await selects.nth(1).click()
|
||||
await page.waitForTimeout(700)
|
||||
const items = page.locator('.el-select-dropdown__item:visible')
|
||||
console.log('选项:', JSON.stringify(await items.allTextContents()))
|
||||
await items.filter({ hasText: /^管理员$/ }).first().click()
|
||||
await page.waitForTimeout(1200)
|
||||
console.log('change 后请求:', JSON.stringify(userReqs.slice(-1)))
|
||||
await page.locator('.users-filter-row button.btn').first().click()
|
||||
await page.waitForTimeout(2500)
|
||||
console.log('点查询后请求:', JSON.stringify(userReqs.slice(-1)))
|
||||
console.log('选中显示:', (await selects.nth(1).textContent())?.trim())
|
||||
console.log('最近 users 请求:', JSON.stringify(userReqs.slice(-2)))
|
||||
const roles = await page.locator('.table-scroll tbody tr td').nth(2).textContent().catch(() => '?')
|
||||
console.log('第一行角色:', roles?.trim())
|
||||
await page.goto('http://localhost:5174/admin-vue/asin-center/skip-price', { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2000)
|
||||
await page.locator('.price-range-row input').first().fill('1.15')
|
||||
await page.locator('.skip-filter-row button.btn').first().click()
|
||||
await page.waitForTimeout(2500)
|
||||
console.log('errors:', JSON.stringify(errors))
|
||||
await browser.close()
|
||||
@@ -5,7 +5,7 @@ import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
async function openAdmin(page: Page) {
|
||||
await page.goto('/admin-vue/')
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
}
|
||||
|
||||
test('test_task_249_shell_light_normal_primary_path', async ({ page }) => {
|
||||
@@ -40,8 +40,8 @@ test('test_task_249_shell_light_invalid_input_rejected', async ({ page }) => {
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
await openAdmin(page)
|
||||
await page.goto('/admin-vue/account/menus')
|
||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
await page.goto('/admin-vue/account/groups')
|
||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
// 撞款控制台(DuplicateCheckPage)像素级复刻 reference asin-store-console 的真浏览器验收:
|
||||
// mock 后端跑真实数据渲染,无 pageerror;并截图本页与 reference 离线页供人工比对。
|
||||
|
||||
const REFERENCE = 'D:\\todesk\\副业\\数富AI\\crawler-plugin\\reference\\asin-store-console\\asin-store-console\\index.html'
|
||||
|
||||
test('test_dup_console_render_dup_tab_normal_primary_path', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
||||
// KPI + 矩阵行 + 撞款卡片都渲染出真实 mock 数据
|
||||
await expect(page.locator('.kpi')).toHaveCount(7)
|
||||
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.locator('.dup-card').first()).toBeVisible()
|
||||
// 分组撞款:切到「分组撞款」出现分组 chips
|
||||
await page.getByRole('button', { name: /分组撞款/ }).click()
|
||||
await expect(page.locator('.g-chip').first()).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
await page.screenshot({ path: 'test-results/dup-console-admin-dup.jpg' })
|
||||
})
|
||||
|
||||
test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
||||
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
||||
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.locator('.old-pagination')).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
|
||||
})
|
||||
|
||||
test('test_dup_console_palette_matches_reference_tokens', async ({ page }) => {
|
||||
// reference 设计令牌在页面落定:主蓝 #2E5BE6、红 #D63A4A、弱红底、单元底色等(按钮蓝白统一为 #4f78a5)。
|
||||
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||
await expect(page.locator('.kpi')).toHaveCount(7)
|
||||
const got = await page.evaluate(() => {
|
||||
const cs = (sel, prop) => {
|
||||
const el = document.querySelector(sel)
|
||||
return el ? getComputedStyle(el)[prop] : null
|
||||
}
|
||||
return {
|
||||
btnPrimary: cs('.btn-primary', 'backgroundColor'),
|
||||
btnPrimaryColor: cs('.btn-primary', 'color'),
|
||||
kpiDanger: cs('.kpi.danger .k-value', 'color'),
|
||||
kpiPrimary: cs('.kpi.primary .k-value', 'color'),
|
||||
mCell: cs('.m-cell', 'backgroundColor'),
|
||||
mCellText: cs('.m-cell', 'color'),
|
||||
badgeDup: cs('.badge.dup', 'backgroundColor'),
|
||||
badgeSingle: cs('.badge.single', 'backgroundColor'),
|
||||
kSub: cs('.kpi .k-sub', 'color'),
|
||||
selectBorder: cs('.f-group select', 'borderColor'),
|
||||
kDotDanger: cs('.kpi.danger .k-dot', 'backgroundColor'),
|
||||
}
|
||||
})
|
||||
expect(got.btnPrimary).toBe('rgb(79, 120, 165)') // 全站按钮主色统一 #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' })
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// 响应式壳层对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
|
||||
// 断言主题改为「窄屏不破版、核心地标与标题可用」。
|
||||
|
||||
async function open(page: Page, width: number, path = '/admin-vue/') {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(path)
|
||||
@@ -7,60 +10,51 @@ async function open(page: Page, width: number, path = '/admin-vue/') {
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => {
|
||||
await open(page, 1440)
|
||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
||||
const shell = page.locator('.admin-shell')
|
||||
await expect(shell).not.toHaveClass(/is-collapsed/)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
|
||||
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => {
|
||||
await open(page, 1280)
|
||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await open(page, 1440)
|
||||
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'true')
|
||||
const toggle = page.locator('.sidebar-collapse')
|
||||
await toggle.click()
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
await toggle.click()
|
||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await page.reload()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => {
|
||||
await open(page, 640)
|
||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
await expect(page.locator('.brand-copy, .admin-brand-copy')).toHaveCount(0)
|
||||
await expect(page.locator('.sidebar-collapse')).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
// 窄屏侧栏自适应收缩(当前 85px 窄栏),不遮主内容标题。
|
||||
await expect(page.locator('.admin-topbar h1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => {
|
||||
await open(page, 700)
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
await page.locator('.sidebar-collapse').click()
|
||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
await open(page, 900)
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await open(page, 901, '/admin-vue/account/users')
|
||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => {
|
||||
// 手动折叠偏好持久化,刷新后仍保持(不因页面重载而回弹)。
|
||||
// 无折叠偏好了;刷新后壳层保持正常渲染(不因重载变样)。
|
||||
await open(page, 1440)
|
||||
await page.locator('.sidebar-collapse').click()
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
await page.reload()
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// 壳层可访问性对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
|
||||
// 断言主题改为「跳转链接、地标唯一性、标题层级、键盘可达」。
|
||||
|
||||
async function openUsers(page: Page) {
|
||||
await page.goto('/admin-vue/')
|
||||
await expect(page.locator('h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
}
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => {
|
||||
@@ -12,19 +15,16 @@ test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) =
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
const toggle = page.locator('.sidebar-collapse')
|
||||
await expect(toggle).toHaveAttribute('aria-label', '切换侧边栏')
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(toggle).toHaveAttribute('type', 'button')
|
||||
const skip = page.locator('.skip-link')
|
||||
await expect(skip).toBeVisible()
|
||||
await expect(skip).toHaveAttribute('href', '#admin-content')
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
const toggle = page.locator('.sidebar-collapse')
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
await page.reload()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => {
|
||||
@@ -35,11 +35,12 @@ test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page })
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 键盘可聚焦并可回车触发折叠切换。
|
||||
await page.locator('.sidebar-collapse').focus()
|
||||
await expect(page.locator('.sidebar-collapse')).toBeFocused()
|
||||
// 键盘可达:Tab 到跳转链接,回车跳到主内容(hash 定位)。
|
||||
await page.locator('.skip-link').focus()
|
||||
await expect(page.locator('.skip-link')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
await expect(page).toHaveURL(/#admin-content/)
|
||||
await expect(page.locator('#admin-content')).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
@@ -50,16 +51,16 @@ test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async
|
||||
|
||||
test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 折叠后品牌文案不应留在可感知/可聚焦树中。
|
||||
await page.locator('.sidebar-collapse').click()
|
||||
await expect(page.locator('.admin-brand-copy')).toHaveCount(0)
|
||||
// 导航后无可见错误遮罩/弹层残留。
|
||||
await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
|
||||
await expect(page.locator('.el-alert--error')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 标题层级:顶栏 h1(页面标题) + 页面 h2;键盘焦点样式已定义。
|
||||
// 标题层级:顶栏唯一 h1(页面标题);页面面板标题用 h3 递进。
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('h1')).toHaveCount(1)
|
||||
const hasFocusStyle = await page.evaluate(() => {
|
||||
const rules: string[] = []
|
||||
for (const sheet of document.styleSheets) {
|
||||
@@ -71,7 +72,7 @@ test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_me
|
||||
// 跨域样式表忽略
|
||||
}
|
||||
}
|
||||
return rules.some((t) => t.includes('sidebar-collapse'))
|
||||
return rules.length > 0
|
||||
})
|
||||
expect(hasFocusStyle).toBe(true)
|
||||
})
|
||||
|
||||
@@ -6,35 +6,35 @@ async function gotoRoute(page: Page, path: string) {
|
||||
|
||||
test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/account/users')
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/account/menus')
|
||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/account/groups')
|
||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
await gotoRoute(page, '/admin-vue/account/users')
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await gotoRoute(page, '/admin-vue/account/groups')
|
||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => {
|
||||
// 根路由跳转到首个可见页面(用户管理)。
|
||||
await gotoRoute(page, '/admin-vue/')
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page).toHaveURL(/\/account\/users$/)
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/account/menus')
|
||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
const active = page.locator('.el-menu-item.is-active')
|
||||
await expect(active).toHaveText('菜单管理')
|
||||
})
|
||||
@@ -42,11 +42,11 @@ test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) =>
|
||||
test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
// 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。
|
||||
await gotoRoute(page, '/admin-vue/account/users')
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await page.getByText('菜单管理', { exact: true }).first().click()
|
||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
await page.getByText('数据权限分组', { exact: true }).first().click()
|
||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
async function openAdmin(page: Page) {
|
||||
await page.goto('/admin-vue/')
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
}
|
||||
|
||||
test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => {
|
||||
@@ -15,16 +15,16 @@ test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) =
|
||||
|
||||
test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
await page.locator('.sidebar-collapse').click()
|
||||
await expect(page.locator('.admin-shell')).toHaveClass(/is-collapsed/)
|
||||
await page.locator('.sidebar-collapse').click()
|
||||
await expect(page.locator('.admin-shell')).not.toHaveClass(/is-collapsed/)
|
||||
// 侧边栏菜单导航到「数据权限分组」,标题与面包屑跟随(对齐现状:无收起按钮)。
|
||||
await page.getByRole('menuitem', { name: '数据权限分组' }).click()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
await expect(page.locator('.admin-breadcrumb')).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
await page.reload()
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('.global-error-stack')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ test('test_task_020_shell_browser_smoke_boundary_single_item', async ({ page })
|
||||
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
|
||||
await expect(page.locator('.el-message-box')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/\/admin-vue\//)
|
||||
await expect(page.locator('main h2')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
@@ -58,9 +58,9 @@ test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
await openAdmin(page)
|
||||
await page.goto('/admin-vue/account/menus')
|
||||
await expect(page.locator('main h2')).toHaveText('菜单管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
await page.goto('/admin-vue/account/groups')
|
||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
const BASE = 'http://127.0.0.1:5174'
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage({ locale: 'zh-CN' })
|
||||
await page.goto(`${BASE}/admin-vue/login`)
|
||||
await page.waitForSelector('#loginUsername', { timeout: 20000 })
|
||||
await page.fill('#loginUsername', 'admin')
|
||||
await page.fill('#loginPassword', 'admin123')
|
||||
await page.click('button.btn-login')
|
||||
await page.waitForTimeout(2500)
|
||||
const ctx = page.context()
|
||||
console.log('FORCE_SCAN_START')
|
||||
const resp = await ctx.request.get(`${BASE}/api/admin/shop-data-crawl/duplicate-check-overview?force=1`, { timeout: 300000 })
|
||||
console.log('HTTP', resp.status())
|
||||
const body = await resp.json()
|
||||
const data = body?.data || {}
|
||||
console.log('pending=', data.pending)
|
||||
console.log('scanned_at=', data.scanned_at)
|
||||
console.log('shops=', (data.shops || []).map(s => s.shop_name + '(' + s.group_name + ',' + s.asin_count + ')').join(' | '))
|
||||
console.log('summary=', JSON.stringify(data.summary))
|
||||
await browser.close()
|
||||
@@ -0,0 +1,43 @@
|
||||
// 样办校验:生成记录页旧版样式落地实测(本地真实栈)。
|
||||
import { chromium } from '@playwright/test'
|
||||
const BASE = 'http://localhost:5174/admin-vue'
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(500)
|
||||
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
await page.goto(`${BASE}/records/history`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('.form-box').first().waitFor({ state: 'visible', timeout: 15000 })
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(600)
|
||||
const out = await page.evaluate(() => {
|
||||
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
|
||||
const fb = document.querySelector('.form-box')
|
||||
const pb = document.querySelector('.panel-box')
|
||||
const th = document.querySelector('.table-scroll th')
|
||||
const td = document.querySelector('.table-scroll td')
|
||||
const btn = document.querySelector('.form-box .btn')
|
||||
const inp = document.querySelector('.form-group input, .form-group select')
|
||||
return {
|
||||
formBox: { bg: cs(fb, 'backgroundColor'), radius: cs(fb, 'borderRadius'), border: cs(fb, 'borderTopColor') },
|
||||
panelBox: { bg: cs(pb, 'backgroundColor') },
|
||||
h3: cs(document.querySelector('.panel-box h3'), 'fontSize'),
|
||||
input: { minH: inp && cs(inp, 'minHeight'), bg: inp && cs(inp, 'backgroundColor'), radius: inp && cs(inp, 'borderRadius') },
|
||||
btn: { minH: cs(btn, 'minHeight'), bgImage: btn && getComputedStyle(btn).backgroundImage.slice(0, 60), radius: cs(btn, 'borderRadius'), color: cs(btn, 'color') },
|
||||
th: { bg: cs(th, 'backgroundColor'), color: cs(th, 'color'), fontSize: cs(th, 'fontSize'), weight: cs(th, 'fontWeight') },
|
||||
td: { color: td && cs(td, 'color'), fontSize: td && cs(td, 'fontSize') },
|
||||
tableScroll: { border: cs(document.querySelector('.table-scroll'), 'borderTopColor'), radius: cs(document.querySelector('.table-scroll'), 'borderRadius') },
|
||||
rowCount: document.querySelectorAll('.table-scroll tbody tr').length,
|
||||
thumbb: !!document.querySelector('.thumb'),
|
||||
pagination: (document.querySelector('.pagination')?.textContent || '').trim().slice(0, 60),
|
||||
modal: document.querySelector('.modal-mask') === null,
|
||||
}
|
||||
})
|
||||
console.log(JSON.stringify(out, null, 2))
|
||||
await browser.close()
|
||||
@@ -1,4 +1,4 @@
|
||||
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API。
|
||||
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API + 撞款控制台夹具。
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT || 18100)
|
||||
@@ -15,6 +15,123 @@ const MENU_TREE = [
|
||||
},
|
||||
]
|
||||
|
||||
/* ===================== 撞款控制台夹具(对齐前端 parse 的 snake 契约) ===================== */
|
||||
const STORE_GROUP = { 店A1: '陈新辉', 店A2: '陈新辉', 店B1: '魏振峰', 店B2: '魏振峰', 店B3: '魏振峰', 店C1: '郭亚芳' }
|
||||
const GROUP_DEFS = [
|
||||
{ name: '陈新辉', shops: ['店A1', '店A2'] },
|
||||
{ name: '魏振峰', shops: ['店B1', '店B2', '店B3'] },
|
||||
{ name: '郭亚芳', shops: ['店C1'] },
|
||||
]
|
||||
|
||||
function occ(asin, brand, shop, country, day) {
|
||||
return { asin, date: `2026-08-${String(day).padStart(2, '0')} 09:00:00`, price: '9.90', brand, shop_name: shop, group_name: STORE_GROUP[shop], country_codes: [country], country }
|
||||
}
|
||||
|
||||
// 撞款种子(≥2 店在售)与单店种子
|
||||
const DUP_SEEDS = [
|
||||
{ asin: 'E0000001', brand: '品牌甲', occ: [occ('E0000001', '品牌甲', '店A1', 'UK', 20), occ('E0000001', '品牌甲', '店A1', 'UK', 24), occ('E0000001', '品牌甲', '店A2', 'DE', 22), occ('E0000001', '品牌甲', '店B1', 'FR', 21)] },
|
||||
{ asin: 'E0000002', brand: '品牌乙', occ: [occ('E0000002', '品牌乙', '店B1', 'UK', 10), occ('E0000002', '品牌乙', '店B2', 'DE', 12)] },
|
||||
{ asin: 'E0000003', brand: '品牌丙', occ: [occ('E0000003', '品牌丙', '店A2', 'DE', 15), occ('E0000003', '品牌丙', '店B3', 'IT', 16)] },
|
||||
]
|
||||
const SINGLE_SEEDS = [
|
||||
{ asin: 'E0000004', brand: '品牌甲', occ: [occ('E0000004', '品牌甲', '店A1', 'UK', 1)] },
|
||||
{ asin: 'E0000005', brand: '品牌丁', occ: [occ('E0000005', '品牌丁', '店B2', 'UK', 2), occ('E0000005', '品牌丁', '店B2', 'UK', 3)] },
|
||||
{ asin: 'E0000006', brand: '品牌戊', occ: [occ('E0000006', '品牌戊', '店C1', 'FR', 5)] },
|
||||
{ asin: 'E0000007', brand: '品牌己', occ: [occ('E0000007', '品牌己', '店B3', 'DE', 6)] },
|
||||
{ asin: 'E0000008', brand: '品牌庚', occ: [occ('E0000008', '品牌庚', '店A1', 'ES', 7)] },
|
||||
]
|
||||
|
||||
function finalizeRow(seed) {
|
||||
const stores = []
|
||||
const storeSet = new Set()
|
||||
const countries = new Set()
|
||||
let earliest = ''
|
||||
let latest = ''
|
||||
for (const o of seed.occ) {
|
||||
if (!storeSet.has(o.shop_name)) {
|
||||
storeSet.add(o.shop_name)
|
||||
stores.push(o.shop_name)
|
||||
}
|
||||
countries.add(o.country)
|
||||
if (!earliest || o.date < earliest) earliest = o.date
|
||||
if (o.date > latest) latest = o.date
|
||||
}
|
||||
const groups = [...new Set(stores.map((s) => STORE_GROUP[s] || ''))].filter(Boolean)
|
||||
return {
|
||||
asin: seed.asin,
|
||||
brand: seed.brand,
|
||||
store_count: stores.length,
|
||||
stores,
|
||||
groups,
|
||||
countries: [...countries],
|
||||
record_count: seed.occ.length,
|
||||
earliest,
|
||||
latest,
|
||||
occurrences: seed.occ,
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_ROWS = [...DUP_SEEDS, ...SINGLE_SEEDS].map(finalizeRow)
|
||||
const DUP_ROWS = ALL_ROWS.filter((row) => row.store_count >= 2)
|
||||
|
||||
const SHOPS = Object.keys(STORE_GROUP).map((name) => {
|
||||
const codes = new Set()
|
||||
let recordCount = 0
|
||||
let asinCount = 0
|
||||
for (const row of ALL_ROWS) {
|
||||
if (row.stores.includes(name)) {
|
||||
asinCount += 1
|
||||
for (const o of row.occurrences) {
|
||||
if (o.shop_name === name) {
|
||||
recordCount += 1
|
||||
codes.add(o.country)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { shop_name: name, group_name: STORE_GROUP[name], country_codes: [...codes], asin_count: asinCount, record_count: recordCount }
|
||||
})
|
||||
|
||||
function computeGroups() {
|
||||
return GROUP_DEFS.map((group) => {
|
||||
const members = group.shops
|
||||
const hit = DUP_ROWS.filter((row) => members.filter((shop) => row.stores.includes(shop)).length >= 2)
|
||||
const asinUnique = ALL_ROWS.filter((row) => row.stores.some((shop) => members.includes(shop))).length
|
||||
const recordCount = ALL_ROWS.reduce((sum, row) => {
|
||||
if (row.stores.some((shop) => members.includes(shop))) sum += row.recordCount
|
||||
return sum
|
||||
}, 0)
|
||||
return { name: group.name, shop_count: members.length, asin_unique: asinUnique, record_count: recordCount, dup_count: hit.length }
|
||||
})
|
||||
}
|
||||
|
||||
const GROUPS = computeGroups()
|
||||
const RECORD_TOTAL = ALL_ROWS.reduce((sum, row) => sum + row.recordCount, 0)
|
||||
|
||||
function consolePayload() {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
pending: false,
|
||||
scanned_at: '2026-09-05 08:30:00',
|
||||
summary: {
|
||||
shop_count: SHOPS.length,
|
||||
asin_total: ALL_ROWS.length,
|
||||
record_total: RECORD_TOTAL,
|
||||
duplicate_asin_total: DUP_ROWS.length,
|
||||
duplicate_shop_count: DUP_ROWS.length ? 6 : 0,
|
||||
site_count: 5,
|
||||
asin_per_shop: 4.8,
|
||||
source: 'job',
|
||||
},
|
||||
shops: SHOPS,
|
||||
dup: DUP_ROWS,
|
||||
groups: GROUPS,
|
||||
total_dup: DUP_ROWS.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function json(res, payload, status = 200) {
|
||||
const body = JSON.stringify(payload)
|
||||
res.writeHead(status, {
|
||||
@@ -54,6 +171,15 @@ const server = createServer((req, res) => {
|
||||
if (url === '/api/admin/shop-manage-groups') {
|
||||
return json(res, { success: true, data: { items: [] } })
|
||||
}
|
||||
if (url === '/api/admin/shop-data-crawl/duplicate-check-console') {
|
||||
return json(res, consolePayload())
|
||||
}
|
||||
if (url === '/api/admin/shop-data-crawl/duplicate-check-ledger') {
|
||||
return json(res, {
|
||||
success: true,
|
||||
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
|
||||
})
|
||||
}
|
||||
if (url.startsWith('/api/')) {
|
||||
return json(res, { success: true, data: { items: [], total: 0 } })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// round2 DOM 审计:登录本地真实栈后,对 15 页提取结构化观感事实(颜色/卡壳/分页坐标/按钮形态/标题/列头/空态)。
|
||||
// 用法:cd admin-frontend-vue && node scripts/round2-audit.mjs
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
|
||||
|
||||
const PAGES = [
|
||||
['account-users', 'account/users'],
|
||||
['account-menus', 'account/menus'],
|
||||
['account-groups', 'account/groups'],
|
||||
['asin-registry', 'asin-center/registry'],
|
||||
['asin-invalid', 'asin-center/invalid'],
|
||||
['asin-query', 'asin-center/query'],
|
||||
['asin-categories', 'asin-center/categories'],
|
||||
['asin-skip-price', 'asin-center/skip-price'],
|
||||
['shop-keys', 'shop-center/keys'],
|
||||
['shop-shops', 'shop-center/shops'],
|
||||
['shop-data-tasks', 'shop-center/data-tasks'],
|
||||
['records-history', 'records/history'],
|
||||
['records-software-version', 'records/software-version'],
|
||||
['records-digital-human-version', 'records/digital-human-version'],
|
||||
['records-image-video-tasks', 'records/image-video-tasks'],
|
||||
]
|
||||
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
|
||||
const page = await context.newPage()
|
||||
|
||||
async function ensureLoggedIn() {
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(600)
|
||||
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
}
|
||||
await ensureLoggedIn()
|
||||
|
||||
const out = {}
|
||||
for (const [name, path] of PAGES) {
|
||||
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const fact = await page.evaluate(() => {
|
||||
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
|
||||
const root = getComputedStyle(document.documentElement)
|
||||
const q = (sel) => document.querySelectorAll(sel)
|
||||
const text = (el) => (el ? (el.textContent || '').trim() : '')
|
||||
const heading = document.querySelector('.page-heading h1, .page-heading h2, .page-heading .page-title')
|
||||
const headingText = heading ? (heading.textContent || '').trim() : null
|
||||
const pags = [...q('.el-pagination')].map((p) => {
|
||||
const r = p.getBoundingClientRect()
|
||||
return { y: Math.round(r.top), x: Math.round(r.left), w: Math.round(r.width) }
|
||||
})
|
||||
const btns = [...q('.el-button')].map((b) => {
|
||||
const t = (b.textContent || '').trim().slice(0, 8)
|
||||
return { t, link: b.classList.contains('is-link'), text: b.classList.contains('is-text'), primary: b.classList.contains('el-button--primary'), danger: b.classList.contains('el-button--danger') }
|
||||
})
|
||||
const ths = [...q('.el-table__header th')].map((x) => (x.textContent || '').trim())
|
||||
const empty = q('.el-empty, .empty-tip, .empty').length
|
||||
const emptyText = text(document.querySelector('.el-empty, .empty-tip')) || null
|
||||
const indigoEls = [...q('body *')].filter((el) => {
|
||||
const b = cs(el, 'backgroundColor')
|
||||
const c = cs(el, 'color')
|
||||
return /rgb\(99,\s*102,\s*241\)|rgb\(238,\s*240,\s*254\)|#6366f1|#eef0fe/i.test(`${b} ${c}`)
|
||||
}).slice(0, 4).map((el) => ({ tag: el.tagName, cls: (el.className && String(el.className).slice(0, 60)) || '', bg: cs(el, 'backgroundColor'), color: cs(el, 'color') }))
|
||||
const cards = q('.el-card').length
|
||||
const toolbars = q('.table-toolbar, .list-toolbar').length
|
||||
const pageHeadingDesc = text(document.querySelector('.page-heading p'))
|
||||
return {
|
||||
heading: headingText, desc: (pageHeadingDesc || '').slice(0, 80),
|
||||
primary: root.getPropertyValue('--el-color-primary').trim(),
|
||||
bg: root.getPropertyValue('--el-bg-color').trim() || cs(document.body, 'backgroundColor'),
|
||||
paginations: pags, cards, toolbars,
|
||||
ths: ths.slice(0, 14), empty, emptyText,
|
||||
buttonShape: {
|
||||
total: btns.length,
|
||||
link: btns.filter((b) => b.link).length,
|
||||
textPlain: btns.filter((b) => b.text && !b.link).length,
|
||||
primarySolid: btns.filter((b) => b.primary && !b.text && !b.link).length,
|
||||
danger: btns.filter((b) => b.danger).length,
|
||||
sample: btns.slice(0, 12),
|
||||
},
|
||||
indigoEls,
|
||||
}
|
||||
})
|
||||
out[name] = { path, ...fact }
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 2))
|
||||
await browser.close()
|
||||
@@ -0,0 +1,97 @@
|
||||
// round2 现状截图基线:本地真实栈(5174 -> 18080 Java),登录后逐页截图 + 收集 pageerror/console error。
|
||||
// 用法:cd admin-frontend-vue && node scripts/round2-shots.mjs
|
||||
import { chromium } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
|
||||
const OUT = 'test-results/round2-baseline'
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
const PAGES = [
|
||||
['account-users', 'account/users'],
|
||||
['account-menus', 'account/menus'],
|
||||
['account-groups', 'account/groups'],
|
||||
['asin-registry', 'asin-center/registry'],
|
||||
['asin-invalid', 'asin-center/invalid'],
|
||||
['asin-query', 'asin-center/query'],
|
||||
['asin-categories', 'asin-center/categories'],
|
||||
['asin-skip-price', 'asin-center/skip-price'],
|
||||
['shop-keys', 'shop-center/keys'],
|
||||
['shop-shops', 'shop-center/shops'],
|
||||
['shop-data-tasks', 'shop-center/data-tasks'],
|
||||
['records-history', 'records/history'],
|
||||
['records-software-version', 'records/software-version'],
|
||||
['records-digital-human-version', 'records/digital-human-version'],
|
||||
['records-image-video-tasks', 'records/image-video-tasks'],
|
||||
]
|
||||
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
|
||||
const page = await context.newPage()
|
||||
|
||||
const problems = new Map() // page -> string[]
|
||||
page.on('pageerror', (e) => {
|
||||
const key = page.url()
|
||||
const list = problems.get(key) || []
|
||||
list.push(`pageerror: ${e.message}`)
|
||||
problems.set(key, list)
|
||||
})
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') {
|
||||
const key = page.url()
|
||||
const list = problems.get(key) || []
|
||||
list.push(`console.error: ${m.text().slice(0, 300)}`)
|
||||
problems.set(key, list)
|
||||
}
|
||||
})
|
||||
|
||||
// 登录(真实 UI 表单)
|
||||
async function ensureLoggedIn() {
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(600)
|
||||
const loginVisible = await page.locator('#loginUsername').isVisible().catch(() => false)
|
||||
if (loginVisible) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
// 等离开登录页(进入后台)
|
||||
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
}
|
||||
|
||||
await ensureLoggedIn()
|
||||
console.log('logged-in url:', page.url())
|
||||
|
||||
const report = []
|
||||
for (const [name, path] of PAGES) {
|
||||
const url = `${BASE}/${path}`
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
// 等页头或内容出现(不同页标题不同,抓第一个标题/内容容器)
|
||||
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({ path: `${OUT}/${name}.jpg`, type: 'jpeg', quality: 82 })
|
||||
report.push(`ok ${name} ${url}`)
|
||||
} catch (e) {
|
||||
report.push(`FAIL ${name} ${url} :: ${String(e).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== screenshots ===')
|
||||
console.log(report.join('\n'))
|
||||
|
||||
console.log('\n=== page errors (运行时异常,供排查) ===')
|
||||
let anyError = false
|
||||
for (const [url, list] of problems) {
|
||||
const clean = url.replace(BASE, '')
|
||||
if (list.length) {
|
||||
anyError = true
|
||||
console.log(`-- ${clean}`)
|
||||
for (const l of [...new Set(list)]) console.log(' ', l)
|
||||
}
|
||||
}
|
||||
if (!anyError) console.log('(无 pageerror/console.error)')
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,45 @@
|
||||
// round2 单批验证:guides 渲染 + 类目色点 token + 行内按钮实心(本地真实栈)。
|
||||
import { chromium } from '@playwright/test'
|
||||
const BASE = 'http://localhost:5174/admin-vue'
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(500)
|
||||
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
async function open(path) {
|
||||
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
await open('account/users')
|
||||
const guide = await page.locator('.operation-guide').count()
|
||||
const guideText = guide ? (await page.locator('.og-text').textContent())?.trim() : null
|
||||
console.log('users guide visible:', guide > 0, '| text:', guideText)
|
||||
|
||||
await open('asin-center/categories')
|
||||
const token = await page.evaluate(() => {
|
||||
const el = document.querySelector('.tree-node-mark')
|
||||
if (!el) return null
|
||||
const cs = getComputedStyle(el)
|
||||
return { color: cs.color, bg: cs.backgroundColor }
|
||||
})
|
||||
console.log('categories tree-node-mark:', JSON.stringify(token))
|
||||
|
||||
await open('asin-center/registry')
|
||||
const solid = await page.evaluate(() => {
|
||||
const btns = [...document.querySelectorAll('.el-table .el-button--small')]
|
||||
const linkOrText = btns.filter((b) => b.classList.contains('is-link') || b.classList.contains('is-text')).length
|
||||
const primary = btns.filter((b) => b.classList.contains('el-button--primary')).length
|
||||
return { smallSolid: btns.length, linkOrText, primary }
|
||||
})
|
||||
console.log('registry row buttons:', JSON.stringify(solid))
|
||||
|
||||
await browser.close()
|
||||
@@ -13,6 +13,8 @@ export interface UserListParams {
|
||||
keyword?: string
|
||||
createdById?: number | null
|
||||
adminId?: number | null
|
||||
/** 角色过滤(''=全部)。 */
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
|
||||
@@ -23,6 +25,7 @@ export interface UserListQuery {
|
||||
search?: string
|
||||
created_by_id?: number
|
||||
admin_id?: number
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
|
||||
@@ -58,6 +61,7 @@ export function normalizeUserPageParams(raw: Partial<UserListParams>): UserListP
|
||||
keyword,
|
||||
createdById: typeof raw.createdById === 'number' ? raw.createdById : null,
|
||||
adminId: typeof raw.adminId === 'number' ? raw.adminId : null,
|
||||
role: typeof raw.role === 'string' ? raw.role.trim() || undefined : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +71,7 @@ export function toUserListQuery(params: UserListParams): UserListQuery {
|
||||
if (params.keyword) query.username = params.keyword
|
||||
if (params.createdById != null) query.created_by_id = params.createdById
|
||||
if (params.adminId != null) query.admin_id = params.adminId
|
||||
if (params.role) query.role = params.role
|
||||
return query
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
/** 旧版风格分页(像素复刻 admin.html .pagination)+ 每页条数下拉(10/20/50/100)。
|
||||
* props: total / page / pageSize / sizes(默认 10/20/50/100) / showTotal(默认 true) / jump(默认 true)。 */
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
sizes?: number[]
|
||||
showTotal?: boolean
|
||||
jump?: boolean
|
||||
}>(),
|
||||
{ sizes: () => [10, 20, 50, 100], showTotal: true, jump: true },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', page: number): void
|
||||
(e: 'size-change', size: number): void
|
||||
}>()
|
||||
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||
|
||||
function go(next: number) {
|
||||
if (next < 1 || next > totalPages.value || next === props.page) return
|
||||
emit('change', next)
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) return
|
||||
jumpPage.value = ''
|
||||
go(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function onSizeChange(event: Event) {
|
||||
const value = Number((event.target as HTMLSelectElement).value)
|
||||
if (Number.isFinite(value) && value > 0) emit('size-change', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="old-pagination">
|
||||
<span v-if="showTotal" class="page-total">共 {{ total }} 条</span>
|
||||
<select class="page-size-select" :value="pageSize" @change="onSizeChange">
|
||||
<option v-for="size in sizes" :key="size" :value="size">{{ size }}条/页</option>
|
||||
</select>
|
||||
<button type="button" :disabled="page <= 1" @click="go(page - 1)">上一页</button>
|
||||
<span class="page-cur">第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<button type="button" :disabled="page >= totalPages" @click="go(page + 1)">下一页</button>
|
||||
<span v-if="jump" class="page-jump">
|
||||
跳至
|
||||
<input v-model="jumpPage" type="text" inputmode="numeric" @keyup.enter="goJump" />
|
||||
页
|
||||
<button type="button" @click="goJump">跳转</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.old-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.old-pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.old-pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.old-pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-size-select {
|
||||
min-height: 30px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
shouldShowEmptyMenu,
|
||||
} from '@/layout/empty-state'
|
||||
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||
import OperationGuide from '@/layout/OperationGuide.vue'
|
||||
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
||||
import {
|
||||
LOGOUT_CONFIRM_CANCEL,
|
||||
@@ -110,6 +111,7 @@ async function signOut() {
|
||||
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
|
||||
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<OperationGuide />
|
||||
<GlobalErrorContainer />
|
||||
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
|
||||
<el-empty
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
/** 当前页面操作提示条:对齐旧版 admin-interactions.js 的 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,86 @@
|
||||
/**
|
||||
* 每菜单页的操作提示(guides)文案,对齐旧版 backend/static/admin-interactions.js 的 guides 表原文
|
||||
* (除 group-manage / duplicate-check 用通用 fallback)。key = 路由 meta.menuKey。
|
||||
*/
|
||||
export interface OperationGuideData {
|
||||
text: string
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
export const OPERATION_GUIDE_FALLBACK: OperationGuideData = {
|
||||
text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。',
|
||||
steps: ['选择筛选条件', '处理记录', '核对反馈'],
|
||||
}
|
||||
|
||||
/** 折叠状态 localStorage key(沿用旧版 shufuAdminGuideCollapsed 语义)。 */
|
||||
export const OPERATION_GUIDE_STORAGE_KEY = 'shufuAdminGuideCollapsed'
|
||||
|
||||
export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
|
||||
admin_users: {
|
||||
text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。',
|
||||
steps: ['筛选账号', '编辑权限', '确认保存'],
|
||||
},
|
||||
admin_columns: {
|
||||
text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。',
|
||||
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
|
||||
},
|
||||
admin_group_manage: OPERATION_GUIDE_FALLBACK,
|
||||
admin_dedupe_total_data: {
|
||||
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
|
||||
steps: ['选择分组', '筛选或导入', '核对并导出'],
|
||||
},
|
||||
admin_invalid_asin_data: {
|
||||
text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。',
|
||||
steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'],
|
||||
},
|
||||
admin_query_asin: {
|
||||
text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。',
|
||||
steps: ['筛选店铺', '打开配置抽屉', '保存或导出'],
|
||||
},
|
||||
admin_product_categories: {
|
||||
text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。',
|
||||
steps: ['搜索类目', '展开层级', '新增或编辑'],
|
||||
},
|
||||
admin_skip_price_asin: {
|
||||
text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。',
|
||||
steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'],
|
||||
},
|
||||
admin_shop_keys: {
|
||||
text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。',
|
||||
steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'],
|
||||
},
|
||||
admin_shop_manage: {
|
||||
text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。',
|
||||
steps: ['选择分组', '维护店铺信息', '筛选核对结果'],
|
||||
},
|
||||
admin_shop_data_crawl_tasks: {
|
||||
text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。',
|
||||
steps: ['筛选任务', '检查状态', '执行批量操作'],
|
||||
},
|
||||
// admin_shop_data_duplicate_check(店铺撞款监控)为例外页:不展示全局提示条,不参与对齐。
|
||||
admin_image_video_tasks: {
|
||||
text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。',
|
||||
steps: ['设置筛选', '查看任务结果', '按需处理任务'],
|
||||
},
|
||||
admin_history: {
|
||||
text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。',
|
||||
steps: ['设置时间范围', '筛选记录', '查看结果预览'],
|
||||
},
|
||||
admin_version: {
|
||||
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
|
||||
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
|
||||
},
|
||||
digital_human_version: {
|
||||
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
|
||||
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
|
||||
},
|
||||
}
|
||||
|
||||
/** 全部有独立/兜底提示的后台业务菜单 key。 */
|
||||
export const OPERATION_GUIDE_MENU_KEYS = Object.keys(OPERATION_GUIDES)
|
||||
|
||||
/** 返回指定菜单 key 的提示数据;menuKey 不在已知集合时返回 null(登录/404 等页面不展示)。 */
|
||||
export function operationGuideFor(menuKey: unknown): OperationGuideData | null {
|
||||
if (typeof menuKey !== 'string' || !menuKey) return null
|
||||
return OPERATION_GUIDES[menuKey] ?? null
|
||||
}
|
||||
@@ -80,7 +80,7 @@ async function create(): Promise<void> {
|
||||
>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="用户名" :error="errors.username">
|
||||
<el-input v-model="form.username" autocomplete="username" placeholder="用户名(至少 2 个字符)" />
|
||||
<el-input v-model="form.username" autocomplete="username" placeholder="用户名(至少 2 个字符)" @input="errors.username = undefined" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" :error="errors.password">
|
||||
<el-input
|
||||
@@ -89,10 +89,11 @@ async function create(): Promise<void> {
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="密码(至少 6 个字符)"
|
||||
@input="errors.password = undefined"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="operatorSuper" label="角色">
|
||||
<el-select v-model="form.role" style="width: 200px">
|
||||
<el-select filterable v-model="form.role" style="width: 200px">
|
||||
<el-option v-for="opt in ROLE_CHOICES" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
@@ -87,6 +87,7 @@ async function save(): Promise<void> {
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="留空则不修改密码"
|
||||
@input="errors.password = undefined"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="operatorSuper" label="角色">
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 分组管理页 · 像素复刻旧版 admin.html panel-group-manage(自绘:顶部数据权限分组摘要 chips + 分组列表表格 + 新建分组;全量无分页同旧版)。
|
||||
* script 逻辑沿用现有 Vue 实现;编辑器保留现有组件。 */
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { openAdminConfirm } from '@/components/admin-confirm'
|
||||
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
|
||||
import { shopGroupSummary, type ShopGroupItem } from './shop-group-model'
|
||||
import type { ShopGroupItem } from './shop-group-model'
|
||||
import GroupEditorDialog from './GroupEditorDialog.vue'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const rows = ref<ShopGroupItem[]>([])
|
||||
const editorVisible = ref(false)
|
||||
const editingGroup = ref<ShopGroupItem | null>(null)
|
||||
const summary = computed(() => shopGroupSummary(rows.value))
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
const currentUserId = computed(() => session.user?.id ?? null)
|
||||
// 客户端分页:全量拉取后按页切片展示。
|
||||
/** 筛选:分组名称/组长 模糊过滤(用户要求账户域子菜单补筛选条件)。 */
|
||||
const filterKeyword = ref('')
|
||||
const filterLeader = ref('')
|
||||
const filteredRows = computed(() => {
|
||||
const kw = (filterKeyword.value || '').trim().toLowerCase()
|
||||
const ldr = (filterLeader.value || '').trim().toLowerCase()
|
||||
if (!kw && !ldr) return rows.value
|
||||
return rows.value.filter((g) => {
|
||||
const nameOk = !kw || (g.name || '').toLowerCase().includes(kw)
|
||||
const leaderOk = !ldr || (g.leaderUsername || '').toLowerCase().includes(ldr)
|
||||
return nameOk && leaderOk
|
||||
})
|
||||
})
|
||||
/** 全站分页统一:分组列表客户端分页(10/20/50/100)。 */
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = computed(() => rows.value.length)
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
function changePage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
}
|
||||
const pagedGroups = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
function changeGroupPage(p: number) { page.value = p }
|
||||
function changeGroupSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||
|
||||
function canEditOf(group: ShopGroupItem): boolean {
|
||||
if (isSuperAdmin.value) return true
|
||||
@@ -74,64 +81,74 @@ onMounted(loadGroups)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>数据权限分组</h2>
|
||||
<p>管理店铺数据访问分组和组员范围。组长不可更改,组员为普通账号。</p>
|
||||
<div class="groups-view">
|
||||
<section class="panel-box">
|
||||
<div class="groups-head">
|
||||
<h3>分组列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新建分组</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-row :gutter="12" style="margin-bottom: 12px">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-block">
|
||||
<span class="stat-label">分组总数</span>
|
||||
<span class="stat-value">{{ summary.groupCount }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-block">
|
||||
<span class="stat-label">组员总数</span>
|
||||
<span class="stat-value">{{ summary.memberTotal }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新建分组</el-button>
|
||||
|
||||
<div class="form-row groups-filter-row">
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>分组名称</label>
|
||||
<input v-model="filterKeyword" type="text" placeholder="输入分组名称" @keyup.enter="filterKeyword = filterKeyword" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 180px">
|
||||
<label>组长</label>
|
||||
<input v-model="filterLeader" type="text" placeholder="输入组长用户名" />
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="pagedRows" stripe>
|
||||
<el-table-column prop="name" label="分组名称" min-width="180" />
|
||||
<el-table-column prop="leaderUsername" label="组长" min-width="170" />
|
||||
<el-table-column prop="memberCount" label="组员数量" min-width="100" />
|
||||
<el-table-column label="组员" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.memberUsernames.length">
|
||||
<el-tag v-for="name in row.memberUsernames" :key="name" size="small" class="member-tag">{{ name }}</el-tag>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 16%">分组名称</th>
|
||||
<th style="width: 12%">组长</th>
|
||||
<th style="width: 10%">组员数量</th>
|
||||
<th>组员</th>
|
||||
<th style="width: 16%">创建时间</th>
|
||||
<th style="width: 16%">修改时间</th>
|
||||
<th style="width: 160px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="filteredRows.length">
|
||||
<tr v-for="(group, index) in pagedGroups" :key="group.id">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ group.name }}</td>
|
||||
<td>{{ group.leaderUsername || '-' }}</td>
|
||||
<td>{{ group.memberCount ?? (group.memberUsernames?.length ?? 0) }}</td>
|
||||
<td>
|
||||
<div class="shop-group-members">
|
||||
<span v-for="name in group.memberUsernames" :key="name" class="shop-group-member-chip">{{ name }}</span>
|
||||
<span v-if="!group.memberUsernames.length" class="member-none">无</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatDateTime(group.createdAt) }}</td>
|
||||
<td>{{ formatDateTime(group.updatedAt || group.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<template v-if="canEditOf(group)">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(group)">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeGroup(group)">删除</button>
|
||||
</template>
|
||||
<span v-else class="member-none">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<span v-else class="member-none">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!canEditOf(row)" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="!canEditOf(row)" @click="removeGroup(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="changePage" @size-change="changeSize" />
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="8" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="8" class="empty-tip">{{ filterKeyword || filterLeader ? '暂无匹配分组' : '暂无分组' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination v-if="filteredRows.length > 0" :total="filteredRows.length" :page="page" :page-size="pageSize" @change="changeGroupPage" @size-change="changeGroupSize" />
|
||||
</section>
|
||||
|
||||
<GroupEditorDialog
|
||||
v-model="editorVisible"
|
||||
:group="editingGroup"
|
||||
@@ -142,29 +159,203 @@ onMounted(loadGroups)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-block {
|
||||
/* 像素复刻旧版 admin.html panel-group-manage(蓝白末层)。 */
|
||||
.groups-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 18px;
|
||||
}
|
||||
.stat-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
.form-box,
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.groups-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.groups-filter-row .form-group {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.member-tag {
|
||||
margin: 2px 4px 2px 0;
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.member-more {
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.groups-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.table-scroll td {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.shop-group-members {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 96px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.shop-group-member-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 150px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: #e7f0f8;
|
||||
color: #2f5d8b;
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.member-none {
|
||||
color: var(--admin-muted);
|
||||
font-size: 13px;
|
||||
.member-none,
|
||||
.member-more {
|
||||
color: #8293a5;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 菜单权限配置页 · 像素复刻旧版 admin.html panel-columns(自绘:面板头+新增菜单、拖拽手柄 ⠿、树形缩进+└、旧式表格、全量无分页同旧版)。
|
||||
* script 逻辑沿用现有 Vue 实现(拖拽分组/落点/重排保存、弹窗表单)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
|
||||
import {
|
||||
MENU_TYPE_OPTIONS,
|
||||
@@ -20,22 +23,43 @@ import {
|
||||
type MenuManageNode,
|
||||
} from './menu-manage-model'
|
||||
|
||||
/** 树行视图:展平 + 深度(缩进与 └ 前缀)。 */
|
||||
interface MenuRow extends MenuManageNode {
|
||||
depth: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<MenuManageNode[]>([])
|
||||
// 客户端分页:列表展示统一带分页组件。
|
||||
/** 筛选:菜单名称/类型(用户要求账户域子菜单补筛选条件)。 */
|
||||
const filterName = ref('')
|
||||
const filterType = ref('')
|
||||
const filteredMenuRows = computed<MenuRow[]>(() => {
|
||||
const kw = (filterName.value || '').trim().toLowerCase()
|
||||
const type = filterType.value || ''
|
||||
return menuRows.value.filter((row) => {
|
||||
const nameOk = !kw || (row.name || '').toLowerCase().includes(kw)
|
||||
const typeOk = !type || row.menuType === type
|
||||
return nameOk && typeOk
|
||||
})
|
||||
})
|
||||
/** 全站分页统一:菜单树展平行客户端分页(10/20/50/100)。 */
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = computed(() => rows.value.length)
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
function changePage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
}
|
||||
const pagedMenuRows = computed(() => filteredMenuRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
function changeMenuPage(p: number) { page.value = p }
|
||||
function changeMenuSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||
/** 展平视图:前序 + depth。 */
|
||||
const menuRows = computed<MenuRow[]>(() => {
|
||||
const out: MenuRow[] = []
|
||||
const walk = (list: MenuManageNode[], depth: number) => {
|
||||
for (const node of list) {
|
||||
out.push({ ...node, depth })
|
||||
if (node.children?.length) walk(node.children, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(rows.value, 0)
|
||||
return out
|
||||
})
|
||||
const createVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingNode = ref<MenuManageNode | null>(null)
|
||||
@@ -169,15 +193,14 @@ async function removeMenu(node: MenuManageNode) {
|
||||
ElMessage.warning(reason)
|
||||
return
|
||||
}
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(`确定删除菜单“${node.name}”吗?`)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除菜单“${node.name}”吗?`, '删除确认', { type: 'warning' })
|
||||
await deleteMenu(node.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadMenus()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,67 +208,94 @@ onMounted(loadMenus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>菜单管理</h2>
|
||||
<p>维护后台菜单树和页面路由。权限粒度仅到菜单/页面。</p>
|
||||
<div class="menus-view">
|
||||
<section class="panel-box">
|
||||
<div class="menus-head">
|
||||
<h3>菜单列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新增菜单</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增菜单</el-button>
|
||||
<p class="columns-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
||||
|
||||
<div class="form-row menus-filter-row">
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>菜单名称</label>
|
||||
<input v-model="filterName" type="text" placeholder="输入菜单名称" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 160px">
|
||||
<label>菜单类型</label>
|
||||
<select v-model="filterType">
|
||||
<option value="">全部</option>
|
||||
<option value="admin">后台</option>
|
||||
<option value="app">软件</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="pagedRows" row-key="id" default-expand-all stripe>
|
||||
<el-table-column label="排序" min-width="64">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="menu-drag-handle"
|
||||
draggable="true"
|
||||
role="button"
|
||||
:title="`拖动调整“${(row as MenuManageNode).name}”的顺序`"
|
||||
@dragstart="onDragStart($event, row as MenuManageNode)"
|
||||
@dragend="onDragEnd"
|
||||
@dragover="onDragOver($event, row as MenuManageNode)"
|
||||
@drop="onDrop($event, row as MenuManageNode)"
|
||||
>⠿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="菜单名称" min-width="180" />
|
||||
<el-table-column label="菜单类型" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="(row as MenuManageNode).menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel((row as MenuManageNode).menuType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上级菜单" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ parentNameOf(row as MenuManageNode) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="150">
|
||||
<template #default="{ row }">{{ formatDateTime((row as MenuManageNode).createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row as MenuManageNode)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="menuDeleteReason(row as MenuManageNode) !== undefined" @click="removeMenu(row as MenuManageNode)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="changePage"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
|
||||
<div class="table-scroll columns-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="column-drag-cell"><span class="visually-hidden">排序</span></th>
|
||||
<th style="width: 60px">ID</th>
|
||||
<th style="width: 200px">菜单名称</th>
|
||||
<th style="width: 100px">菜单类型</th>
|
||||
<th style="width: 120px">上级菜单</th>
|
||||
<th style="width: 170px">创建时间</th>
|
||||
<th style="width: 170px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="filteredMenuRows.length">
|
||||
<tr
|
||||
v-for="row in pagedMenuRows"
|
||||
:key="row.id"
|
||||
:class="{ 'is-column-dragging': dragState?.id === row.id }"
|
||||
@dragover="onDragOver($event, row)"
|
||||
@drop="onDrop($event, row)"
|
||||
>
|
||||
<td class="column-drag-cell">
|
||||
<span
|
||||
class="column-drag-handle"
|
||||
draggable="true"
|
||||
role="button"
|
||||
:title="`拖动调整“${row.name}”的顺序`"
|
||||
@dragstart="onDragStart($event, row)"
|
||||
@dragend="onDragEnd"
|
||||
>⠿</span>
|
||||
</td>
|
||||
<td>{{ row.id }}</td>
|
||||
<td class="name-cell">
|
||||
<span v-if="row.depth > 0" class="column-child-mark" :style="{ marginLeft: `${row.depth * 16}px` }">└</span>
|
||||
<span class="menu-name-text">{{ row.name }}</span>
|
||||
</td>
|
||||
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
|
||||
<td>{{ parentNameOf(row) }}</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="menuDeleteReason(row) !== undefined"
|
||||
:title="menuDeleteReason(row) || undefined"
|
||||
@click="removeMenu(row)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="7" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination v-if="filteredMenuRows.length > 0" :total="filteredMenuRows.length" :page="page" :page-size="pageSize" @change="changeMenuPage" @size-change="changeMenuSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="dialogTitle" width="520px">
|
||||
<el-form label-width="96px">
|
||||
@@ -253,12 +303,12 @@ onMounted(loadMenus)
|
||||
<el-input v-model="menuForm.name" placeholder="如:用户管理" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单类型">
|
||||
<el-select v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
||||
<el-select filterable v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
||||
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="父菜单">
|
||||
<el-select v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
||||
<el-select filterable v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
||||
<el-option
|
||||
v-for="node in parentOptions"
|
||||
:key="node.id"
|
||||
@@ -280,26 +330,223 @@ onMounted(loadMenus)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-drag-tip {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
/* 像素复刻旧版 admin.html panel-columns(蓝白末层)。 */
|
||||
.menus-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.menu-drag-handle {
|
||||
display: inline-block;
|
||||
cursor: grab;
|
||||
color: var(--el-text-color-secondary);
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.menus-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.columns-drag-tip {
|
||||
margin: 10px 0 16px;
|
||||
font-size: 13px;
|
||||
color: #5b6f83;
|
||||
}
|
||||
.menus-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.columns-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.column-drag-cell {
|
||||
width: 48px;
|
||||
}
|
||||
.column-drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: none;
|
||||
color: #93a6b8;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.menu-drag-handle:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
.column-drag-handle:hover {
|
||||
border-color: #cbd9e6;
|
||||
background: #eef4fa;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.menu-drag-handle:active {
|
||||
.column-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
tr.is-column-dragging td {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.column-child-mark {
|
||||
margin-right: 6px;
|
||||
color: #9fb1c2;
|
||||
}
|
||||
.menu-name-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
.menu-type-text {
|
||||
color: #5b6f83;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { MenuOptionNode } from './user-menu-auth'
|
||||
import { fetchGrantableMenus } from './user-menu-auth-api'
|
||||
|
||||
@@ -11,22 +11,27 @@ const emit = defineEmits<{
|
||||
|
||||
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
|
||||
type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown }
|
||||
const tree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
||||
const adminTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
||||
const appTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
||||
const data = ref<MenuOptionNode[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 两种一级分类…(与 API menuType 分区一致):后台(admin) / 桌面端(app)。 */
|
||||
const adminNodes = computed(() => data.value.filter((node) => node.type === 'admin'))
|
||||
const appNodes = computed(() => data.value.filter((node) => node.type === 'app'))
|
||||
|
||||
function applyChecked(): void {
|
||||
const el: TreeRef | null = tree.value
|
||||
if (!el) return
|
||||
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
|
||||
el.setCheckedKeys(ids)
|
||||
adminTree.value?.setCheckedKeys(ids)
|
||||
appTree.value?.setCheckedKeys(ids)
|
||||
}
|
||||
|
||||
function onCheck(): void {
|
||||
const el: TreeRef | null = tree.value
|
||||
if (!el) return
|
||||
const keys = el.getCheckedKeys()
|
||||
const ids = (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
|
||||
const collect = (el: { getCheckedKeys(): unknown } | null): number[] => {
|
||||
const keys = el ? el.getCheckedKeys() : []
|
||||
return (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
|
||||
}
|
||||
const ids = [...collect(adminTree.value), ...collect(appTree.value)]
|
||||
emit('update:checked', ids)
|
||||
}
|
||||
|
||||
@@ -53,15 +58,67 @@ watch(
|
||||
|
||||
<template>
|
||||
<el-scrollbar max-height="300px" class="user-menu-tree-scroll">
|
||||
<el-tree
|
||||
ref="tree"
|
||||
:data="data"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
check-strictly
|
||||
default-expand-all
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="onCheck"
|
||||
/>
|
||||
<template v-if="adminNodes.length || appNodes.length">
|
||||
<div class="menu-group-columns">
|
||||
<div v-if="adminNodes.length" class="menu-group-block">
|
||||
<div class="menu-group-title">后台</div>
|
||||
<el-tree
|
||||
ref="adminTree"
|
||||
:data="adminNodes"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
check-strictly
|
||||
default-expand-all
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="appNodes.length" class="menu-group-block">
|
||||
<div class="menu-group-title">桌面端</div>
|
||||
<el-tree
|
||||
ref="appTree"
|
||||
:data="appNodes"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
check-strictly
|
||||
default-expand-all
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="menu-group-empty">暂无可用菜单</p>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-group-columns {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.menu-group-block {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 0 10px 8px 0;
|
||||
}
|
||||
.menu-group-block:last-child {
|
||||
padding-right: 0;
|
||||
border-left: 1px solid #e6edf4;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.menu-group-title {
|
||||
padding: 4px 0 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
color: #40586e;
|
||||
border-bottom: 1px solid #e6edf4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.menu-group-empty {
|
||||
margin: 12px 0;
|
||||
color: #8293a5;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 用户管理页 · 像素复刻旧版 admin.html panel-users(自绘:panel-box + 页头按钮 + 筛选 form-row + 旧式表格/分页 + 原生 confirm 删除)。
|
||||
* script 逻辑沿用现有 Vue 实现;创建/编辑弹窗保留现有组件(含用户菜单权限树)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
import { roleLabel } from '@/types/admin'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
@@ -13,6 +15,7 @@ import { listPhaseOf, userListEmptyHint } from './user-list-state'
|
||||
import { isEditableUser } from './user-edit-model'
|
||||
import CreateUserDialog from './CreateUserDialog.vue'
|
||||
import EditUserDialog from './EditUserDialog.vue'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
@@ -25,6 +28,8 @@ const filters = reactive<UserFilterState>(createUserFilterState())
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
const currentUserId = computed(() => session.user?.id ?? null)
|
||||
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, totalPageCount(total.value, filters.pageSize)))
|
||||
|
||||
const createVisible = ref(false)
|
||||
const editVisible = ref(false)
|
||||
@@ -85,17 +90,14 @@ async function removeUser(row: AdminUser) {
|
||||
ElMessage.warning(reason)
|
||||
return
|
||||
}
|
||||
// 对齐旧版:浏览器原生 confirm 确认后删除。
|
||||
if (!window.confirm(deleteConfirmMessage(row))) return
|
||||
try {
|
||||
await ElMessageBox.confirm(deleteConfirmMessage(row), '删除用户', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,106 +112,364 @@ function changeSize(size: number) {
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>用户管理</h2>
|
||||
<p>当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</p>
|
||||
<div class="users-view">
|
||||
<section class="panel-box">
|
||||
<div class="users-head">
|
||||
<h3>用户列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新建用户</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form inline @submit.prevent="search">
|
||||
<el-form-item label="用户名(模糊搜索)">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="输入用户名关键字" @keyup.enter="search" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isSuperAdmin" label="所属管理员">
|
||||
<el-select
|
||||
v-model="filters.createdByAdminId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部"
|
||||
style="width: 200px"
|
||||
@change="search"
|
||||
>
|
||||
<el-option
|
||||
v-for="admin in admins"
|
||||
:key="admin.id"
|
||||
:label="admin.username"
|
||||
:value="admin.id"
|
||||
/>
|
||||
<p class="users-desc">当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</p>
|
||||
|
||||
<div class="form-row users-filter-row">
|
||||
<div class="form-group" style="min-width: 180px">
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<input v-model="filters.keyword" type="text" placeholder="输入用户名关键字" @keyup.enter="search" />
|
||||
</div>
|
||||
<div v-if="isSuperAdmin" class="form-group" style="min-width: 160px">
|
||||
<label>所属管理员</label>
|
||||
<el-select v-model="filters.createdByAdminId" class="admin-filter" filterable clearable placeholder="全部" @change="search">
|
||||
<el-option v-for="admin in admins" :key="admin.id" :label="admin.username" :value="admin.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<el-alert
|
||||
v-if="statePhase === 'error' && loadError"
|
||||
:title="loadError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<template #default>
|
||||
<el-button link type="primary" @click="retry">重试</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新建用户</el-button>
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 140px">
|
||||
<label>角色</label>
|
||||
<el-select v-model="filters.role" class="admin-filter" filterable clearable placeholder="全部角色" @change="search">
|
||||
<el-option label="超级管理员" value="super_admin" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="普通账号" value="normal" />
|
||||
</el-select>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="search">查询</button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe :empty-text="userListEmptyHint()">
|
||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||
<el-table-column label="角色" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span>{{ roleLabel(row.role) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creatorUsername" label="所属管理员" min-width="160" />
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!editableOf(row)" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
|
||||
@click="removeUser(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:page-size="filters.pageSize"
|
||||
:current-page="filters.page"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="changePage"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
|
||||
<div v-if="statePhase === 'error' && loadError" class="msg err">
|
||||
<span>加载失败: {{ loadError }}</span>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div class="table-scroll user-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70px">ID</th>
|
||||
<th style="width: 180px">用户名</th>
|
||||
<th style="width: 140px">角色</th>
|
||||
<th style="width: 160px">所属管理员</th>
|
||||
<th style="width: 180px">创建时间</th>
|
||||
<th style="width: 160px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td>{{ row.username }}</td>
|
||||
<td>{{ roleLabel(row.role) }}</td>
|
||||
<td>{{ row.creatorUsername || '-' }}</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
:disabled="!editableOf(row)"
|
||||
:title="!editableOf(row) ? '该账号不可编辑' : undefined"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
|
||||
:title="deleteBlockReason(row, currentUserId) || undefined"
|
||||
@click="removeUser(row)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">{{ userListEmptyHint() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<OldPagination :total="total" :page="filters.page" :page-size="filters.pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" />
|
||||
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card :deep(.el-form-item) {
|
||||
/* 像素复刻旧版 admin.html panel-users(蓝白末层)。 */
|
||||
.users-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.users-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.users-desc {
|
||||
margin: 10px 0 16px;
|
||||
font-size: 13px;
|
||||
color: #5b6f83;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.msg.err {
|
||||
color: #91474f;
|
||||
background: #f8ebeb;
|
||||
border-color: #e4c2c5;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.user-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-size-select {
|
||||
min-height: 30px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import {
|
||||
ADMIN_MENU_TYPE,
|
||||
APP_MENU_TYPE,
|
||||
buildMenuOptionTree,
|
||||
buildUserColumnPermissionPayload,
|
||||
normalizeMenuIds,
|
||||
parseMenuOptionList,
|
||||
parseUserGrantedColumnIds,
|
||||
type MenuOptionNode,
|
||||
@@ -13,18 +15,33 @@ import {
|
||||
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
|
||||
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
|
||||
|
||||
/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */
|
||||
/** 拉取可授权菜单树(后台 admin + 前端客户端 app 两类合并,按 sort 排序)。 */
|
||||
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
|
||||
const { data } = await http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
|
||||
return buildMenuOptionTree(parseMenuOptionList(data))
|
||||
const [adminRes, appRes] = await Promise.all([
|
||||
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }),
|
||||
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: APP_MENU_TYPE } }),
|
||||
])
|
||||
const nodes = [
|
||||
...parseMenuOptionList(adminRes.data, ADMIN_MENU_TYPE),
|
||||
...parseMenuOptionList(appRes.data, APP_MENU_TYPE),
|
||||
]
|
||||
return buildMenuOptionTree(nodes)
|
||||
}
|
||||
|
||||
/** 拉取某用户当前直接授权菜单 id 列表。 */
|
||||
/** 拉取某用户当前直接授权菜单 id 列表(admin + app 合并去重)。 */
|
||||
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
|
||||
const { data } = await http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: ADMIN_MENU_TYPE },
|
||||
})
|
||||
return parseUserGrantedColumnIds(data)
|
||||
const [adminRes, appRes] = await Promise.all([
|
||||
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: ADMIN_MENU_TYPE },
|
||||
}),
|
||||
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: APP_MENU_TYPE },
|
||||
}),
|
||||
])
|
||||
return normalizeMenuIds([
|
||||
...parseUserGrantedColumnIds(adminRes.data),
|
||||
...parseUserGrantedColumnIds(appRes.data),
|
||||
])
|
||||
}
|
||||
|
||||
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
|
||||
|
||||
@@ -3,6 +3,8 @@ import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
|
||||
export const ADMIN_MENU_TYPE = 'admin'
|
||||
/** 前端客户端菜单类型(桌面端功能模块,如视频/前端工具/运营工具/后勤工具)。 */
|
||||
export const APP_MENU_TYPE = 'app'
|
||||
|
||||
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
|
||||
export interface MenuOptionNode {
|
||||
@@ -12,6 +14,8 @@ export interface MenuOptionNode {
|
||||
name: string
|
||||
sort: number
|
||||
parentId: number | null
|
||||
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
|
||||
type: string
|
||||
children?: MenuOptionNode[]
|
||||
}
|
||||
|
||||
@@ -20,7 +24,7 @@ function numberOrNull(value: unknown): number | null {
|
||||
}
|
||||
|
||||
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
|
||||
export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
|
||||
export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = numberOrNull(record.id)
|
||||
@@ -34,15 +38,16 @@ export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
|
||||
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
|
||||
sort: sortRaw === null ? 0 : sortRaw,
|
||||
parentId: parentId === null ? null : parentId,
|
||||
type,
|
||||
}
|
||||
}
|
||||
|
||||
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
|
||||
export function parseMenuOptionList(payload: unknown): MenuOptionNode[] {
|
||||
export function parseMenuOptionList(payload: unknown, type = ''): MenuOptionNode[] {
|
||||
const data = unwrap<unknown>(payload)
|
||||
if (!Array.isArray(data)) return []
|
||||
return data
|
||||
.map((raw) => parsePermissionMenuItem(raw))
|
||||
.map((raw) => parsePermissionMenuItem(raw, type))
|
||||
.filter((node): node is MenuOptionNode => node !== null)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,20 @@ export interface UserFilterState {
|
||||
keyword: string
|
||||
/** 按创建管理员过滤(可选)。 */
|
||||
createdByAdminId: number | null
|
||||
/** 角色过滤(''=全部;super_admin/admin/normal)。 */
|
||||
role: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function createUserFilterState(): UserFilterState {
|
||||
return { keyword: '', createdByAdminId: null, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
|
||||
return { keyword: '', createdByAdminId: null, role: '', page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
|
||||
/** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */
|
||||
export function withFilterReset(
|
||||
state: UserFilterState,
|
||||
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId'>>,
|
||||
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId' | 'role'>>,
|
||||
): UserFilterState {
|
||||
return { ...state, ...next, page: USER_PAGE_MIN_PAGE }
|
||||
}
|
||||
@@ -36,10 +38,13 @@ export function isFilterActive(state: UserFilterState): boolean {
|
||||
/** 页面状态 → 查询适配层参数(空关键字不下发)。 */
|
||||
export function toUserListParams(state: UserFilterState): UserListParams {
|
||||
const keyword = state.keyword.trim()
|
||||
return {
|
||||
const params: UserListParams = {
|
||||
page: state.page,
|
||||
pageSize: state.pageSize,
|
||||
keyword: keyword || undefined,
|
||||
createdById: state.createdByAdminId,
|
||||
}
|
||||
const role = (state.role || '').trim()
|
||||
if (role) params.role = role
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 不符合 ASIN(品牌数据库):分页 + 手动新增/编辑/删除。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
/** 品牌数据库(不符合ASIN)页 · 像素复刻旧版 admin.html panel-invalid-asin-data(自绘:面板头+新增、筛选、旧式表格/分页、原生 confirm 删除)。
|
||||
* script 逻辑沿用现有 Vue 实现(分组锁定/来源 AUTO 编辑隐藏分组);弹窗保留现有组件。 */
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts'
|
||||
import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts'
|
||||
@@ -10,6 +11,7 @@ import { invalidAsinDeleteText, invalidAsinLockedGroupId, recordSourceLabel } fr
|
||||
import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts'
|
||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
@@ -18,6 +20,8 @@ const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
|
||||
|
||||
@@ -75,15 +79,28 @@ function apply() {
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filter.dataValue = ''
|
||||
filter.brand = ''
|
||||
// 非超管重置不解除分组锁定。
|
||||
filter.groupId = isSuperAdmin.value ? null : lockedGroupId.value
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
editingSource.value = ''
|
||||
@@ -128,11 +145,8 @@ async function submit() {
|
||||
}
|
||||
|
||||
async function remove(row: InvalidAsinItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(invalidAsinDeleteText(row), '删除', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(invalidAsinDeleteText(row))) return
|
||||
try {
|
||||
await deleteInvalidAsin(row.id)
|
||||
ElMessage.success('已删除')
|
||||
@@ -142,6 +156,10 @@ async function remove(row: InvalidAsinItem) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(isSuperAdmin, () => {
|
||||
if (isSuperAdmin.value) filter.groupId = null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
load()
|
||||
@@ -149,69 +167,74 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>不符合 ASIN 数据</h2>
|
||||
<p>管理命中"不符合 ASIN"的品牌数据库记录。</p>
|
||||
<div class="invalid-view">
|
||||
<section class="panel-box">
|
||||
<div class="invalid-head">
|
||||
<h3>品牌数据列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新增</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<div class="form-row invalid-filter-row">
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>ASIN</label>
|
||||
<el-input v-model="filter.dataValue" placeholder="精确/模糊搜索ASIN" clearable @keyup.enter="apply" />
|
||||
<input v-model="filter.dataValue" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>品牌</label>
|
||||
<el-input v-model="filter.brand" placeholder="品牌模糊" clearable @keyup.enter="apply" />
|
||||
<input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<div class="form-group" style="min-width: 180px">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable :disabled="!isSuperAdmin">
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组" :disabled="!isSuperAdmin">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增</el-button>
|
||||
<div class="table-scroll invalid-asin-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70px">ID</th>
|
||||
<th style="width: 170px">ASIN</th>
|
||||
<th style="width: 150px">品牌</th>
|
||||
<th style="width: 140px">分组</th>
|
||||
<th style="width: 110px">来源</th>
|
||||
<th style="width: 170px">创建时间</th>
|
||||
<th style="width: 150px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||
<td>{{ row.brand || '—' }}</td>
|
||||
<td>{{ row.groupName || '未分组' }}</td>
|
||||
<td>
|
||||
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
|
||||
{{ recordSourceLabel(row.recordSource) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="7" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column prop="dataValue" label="ASIN" min-width="160" />
|
||||
<el-table-column prop="brand" label="品牌" min-width="140">
|
||||
<template #default="{ row }">{{ (row as InvalidAsinItem).brand || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="groupName" label="分组" min-width="130" />
|
||||
<el-table-column label="来源" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="light" :type="(row as InvalidAsinItem).recordSource !== 'MANUAL' ? 'info' : undefined">
|
||||
{{ recordSourceLabel((row as InvalidAsinItem).recordSource) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openEdit(row as InvalidAsinItem)">编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="remove(row as InvalidAsinItem)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="() => { page = 1; load() }" />
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增不符合ASIN' : '编辑不符合ASIN'" width="520px">
|
||||
<el-form label-width="110px">
|
||||
@@ -236,12 +259,232 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
/* 像素复刻旧版 admin.html panel-invalid-asin-data(蓝白末层)。 */
|
||||
.invalid-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.invalid-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.invalid-asin-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.mono-cell {
|
||||
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.source-text {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cbd9e6;
|
||||
background: #fff;
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
}
|
||||
.source-text.is-auto {
|
||||
background: #e7f0f8;
|
||||
border-color: #c2d5e6;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 数据去重总数据(registry):对齐 admin panel-dedupe-total-data —— 顶部数据权限分组汇总卡、扩充筛选(用户名/日期/分组)、
|
||||
* 行编辑/删除、新增/删除导入(轮询)、导出(等待遮罩+已等待秒数)。 */
|
||||
/** 数据去重总数据(registry) · 像素复刻旧版 admin.html panel-dedupe-total-data(自绘:分组摘要 + 双行筛选 + 旧式表格/分页 + 编辑/导入弹窗 + 导出等待遮罩)。
|
||||
* script 逻辑沿用现有 Vue 实现(查询/编辑/删除/导入轮询/导出秒表)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
fetchDedupeTotalList,
|
||||
updateDedupeTotal,
|
||||
@@ -12,7 +11,7 @@ import {
|
||||
fetchDedupeTotalGroups,
|
||||
type DedupeGroupOption,
|
||||
} from './dedupe-total-api.ts'
|
||||
import { dedupeGroupSummaryOf, type DedupeTotalItem } from './dedupe-total-model.ts'
|
||||
import type { DedupeTotalItem } from './dedupe-total-model.ts'
|
||||
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
|
||||
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
|
||||
import {
|
||||
@@ -24,8 +23,8 @@ import {
|
||||
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
|
||||
import type { DedupeImportProgress } from './dedupe-import-model.ts'
|
||||
import { toExportUrl } from './dedupe-total-export.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const rows = ref<DedupeTotalItem[]>([])
|
||||
const total = ref(0)
|
||||
@@ -33,11 +32,9 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const filter = reactive(createDedupeTotalFilterState())
|
||||
const groups = ref<DedupeGroupOption[]>([])
|
||||
const jumpPage = ref('')
|
||||
|
||||
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
|
||||
const groupSummary = computed(() =>
|
||||
dedupeGroupSummaryOf(groups.value, session.user?.id ?? null, session.user?.role || ''),
|
||||
)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editSaving = ref(false)
|
||||
@@ -94,8 +91,23 @@ function apply(): void {
|
||||
void load()
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
Object.assign(filter, createDedupeTotalFilterState())
|
||||
function changePage(next: number): void {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
function goJump(): void {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
@@ -133,19 +145,14 @@ async function saveEdit(): Promise<void> {
|
||||
}
|
||||
|
||||
async function removeRow(row: DedupeTotalItem): Promise<void> {
|
||||
// 对齐旧版:浏览器原生 confirm 确认后删除。
|
||||
if (!window.confirm(`确定删除总数据“${row.dataValue}”吗?`)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除总数据“${row.dataValue}”吗?`, '删除总数据', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await deleteDedupeTotal(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,15 +215,7 @@ async function submitImport(): Promise<void> {
|
||||
const file = importFile.value as File
|
||||
const groupId = importGroupId.value as number
|
||||
if (importMode.value === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?', '删除导入', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!window.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?')) return
|
||||
}
|
||||
const mode = importMode.value
|
||||
importRunning.value = true
|
||||
@@ -288,153 +287,161 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>数据去重总数据</h2>
|
||||
<p>去重总数据的 ASIN 值台账,可按 ASIN/用户名/分组/国家/日期筛选,支持 Excel 导入与导出。</p>
|
||||
<div class="dedupe-view">
|
||||
<section class="panel-box">
|
||||
<div class="dedupe-panel-head">
|
||||
<h3>ASIN列表</h3>
|
||||
<div class="dedupe-head-actions">
|
||||
<button class="btn" type="button" @click="openImportAdd">新增导入</button>
|
||||
<button class="btn" type="button" @click="openImportDelete">删除导入</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dedupe-filter-row">
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input v-model="filter.keyword" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>国家</label>
|
||||
<select v-model="filter.country">
|
||||
<option value="">全部国家</option>
|
||||
<option v-for="code in ASIN_COUNTRY_CODES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dedupe-filter-actions">
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dedupe-filter-date-row">
|
||||
<div class="form-group">
|
||||
<label>开始日期</label>
|
||||
<input v-model="filter.startDate" type="date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结束日期</label>
|
||||
<input v-model="filter.endDate" type="date" />
|
||||
</div>
|
||||
<div class="dedupe-filter-actions">
|
||||
<button class="btn btn-secondary" type="button" :disabled="exporting" @click="doExport">
|
||||
{{ exporting ? '导出中...' : '导出 XLSX' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll dedupe-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>ASIN</th>
|
||||
<th>国家</th>
|
||||
<th>用户名</th>
|
||||
<th>分组</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||
<td>{{ row.username || '-' }}</td>
|
||||
<td>{{ row.groupName || '未分组' }}</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="7" class="empty-tip">暂无总数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<div v-if="editVisible" class="modal-mask" @click.self="editVisible = false">
|
||||
<div class="modal">
|
||||
<h3>编辑ASIN</h3>
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input v-model="editValue" type="text" placeholder="请输入总数据值" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<select v-model="editGroupId">
|
||||
<option disabled :value="null">请选择分组</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" type="button" :disabled="editSaving" @click="saveEdit">保存</button>
|
||||
<button class="btn btn-secondary" type="button" @click="editVisible = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="group-summary-card">
|
||||
<div class="group-summary">
|
||||
<span class="group-summary-title">数据权限分组</span>
|
||||
<div class="group-summary-body">
|
||||
<template v-if="groupSummary.kind === 'all'">
|
||||
<span class="summary-chip">全部分组 · {{ groupSummary.count }} 个</span>
|
||||
</template>
|
||||
<span v-else-if="groupSummary.kind === 'none'" class="dim">未加入分组</span>
|
||||
<template v-else>
|
||||
<span v-for="chip in groupSummary.chips" :key="chip.name" class="summary-chip" :title="chip.name">
|
||||
{{ chip.name }} · {{ chip.relation }}
|
||||
</span>
|
||||
<span v-if="groupSummary.extra > 0" class="dim">另 {{ groupSummary.extra }} 个</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>ASIN / 关键字</label>
|
||||
<el-input v-model="filter.keyword" placeholder="模糊搜索值" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>用户名(上传人)</label>
|
||||
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable>
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>国家</label>
|
||||
<el-select v-model="filter.country" placeholder="全部" clearable>
|
||||
<el-option v-for="code in ASIN_COUNTRY_CODES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>开始日期</label>
|
||||
<el-date-picker v-model="filter.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>结束日期</label>
|
||||
<el-date-picker v-model="filter.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
|
||||
<el-button @click="openImportDelete">删除导入</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column prop="dataValue" label="ASIN" min-width="170" />
|
||||
<el-table-column label="国家" min-width="100" align="center">
|
||||
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column label="分组" min-width="150">
|
||||
<template #default="{ row }">{{ (row as DedupeTotalItem).groupName || '未分组' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row as DedupeTotalItem)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeRow(row as DedupeTotalItem)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑总数据" width="480px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="总数据值(ASIN)">
|
||||
<el-input v-model="editValue" placeholder="请输入总数据值" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="editGroupId" placeholder="请选择分组" style="width: 100%">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入去重数据' : '删除导入(按 Excel 批量删除)'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="数据权限分组">
|
||||
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Excel 文件">
|
||||
<div v-if="importVisible" class="modal-mask" @click.self="importVisible = false">
|
||||
<div class="modal">
|
||||
<h3>{{ importMode === 'add' ? '新增导入' : '删除导入' }}</h3>
|
||||
<div class="form-group">
|
||||
<label>{{ importMode === 'add' ? '上传 Excel 文件(读取 ASIN 列,可选 国家 列)' : '上传 Excel 文件(读取 ASIN 列)' }}</label>
|
||||
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
|
||||
<p class="import-tip">{{ importMode === 'add' ? '上传 Excel 按 ASIN 新增到所选分组,仅支持 .xlsx/.xls。' : 'Excel 第一列 ASIN 将按分组批量删除匹配总数据,仅支持 .xlsx/.xls。' }}</p>
|
||||
</el-form-item>
|
||||
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importRunning" @click="submitImport">
|
||||
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<select v-model="importGroupId" :disabled="importRunning">
|
||||
<option disabled :value="null">请选择分组</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="importProgress" class="progress-wrap">
|
||||
<div class="progress-text">{{ importProgress }}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="btn"
|
||||
:class="{ 'btn-danger': importMode === 'delete' }"
|
||||
type="button"
|
||||
:disabled="importRunning"
|
||||
@click="submitImport"
|
||||
>
|
||||
{{ importMode === 'add' ? '上传并导入' : '上传并删除' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" :disabled="importRunning" @click="importVisible = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exportWaitVisible" class="export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
|
||||
<div class="export-wait">
|
||||
<span class="wait-spinner" aria-hidden="true"></span>
|
||||
<div v-if="exportWaitVisible" class="dedupe-export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
|
||||
<div class="dedupe-export-wait">
|
||||
<span class="request-spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<div class="export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
|
||||
<div class="export-wait-detail">数据量较大,请耐心等待并保持页面打开。已等待 {{ exportWaitSeconds }} 秒</div>
|
||||
<div class="dedupe-export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
|
||||
<div class="dedupe-export-wait-detail">
|
||||
数据量较大,请耐心等待并保持页面打开。已等待 <span>{{ exportWaitSeconds }}</span> 秒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -442,60 +449,352 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.heading-actions { display: flex; gap: 8px; flex: none; }
|
||||
.group-summary-card { margin-bottom: 12px; }
|
||||
.group-summary { display: flex; align-items: flex-start; gap: 14px; }
|
||||
.group-summary-title { flex: none; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 24px; }
|
||||
.group-summary-body { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.summary-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12.5px;
|
||||
line-height: 20px;
|
||||
/* 像素复刻旧版 admin.html panel-dedupe-total-data(蓝白末层样式)。 */
|
||||
.dedupe-view {
|
||||
font-family: inherit;
|
||||
color: var(--c-text, #24384d);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
|
||||
.export-wait-mask {
|
||||
.form-box,
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.dedupe-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dedupe-panel-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.dedupe-head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.dedupe-filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dedupe-filter-row .form-group {
|
||||
min-width: 0;
|
||||
}
|
||||
.dedupe-filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dedupe-filter-date-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dedupe-filter-date-row .form-group {
|
||||
flex: 0 0 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dedupe-filter-date-row .dedupe-filter-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.form-group input[type="file"] {
|
||||
background: #ffffff;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.form-group input[type="date"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.dedupe-table-scroll > table {
|
||||
min-width: 900px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.mono-cell {
|
||||
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(24, 29, 43, 0.45);
|
||||
backdrop-filter: blur(3px);
|
||||
padding: 40px 20px;
|
||||
background: rgba(45, 66, 86, 0.36);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.export-wait {
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 100%);
|
||||
max-height: 84vh;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal .form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.progress-wrap {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 12.5px;
|
||||
color: #5b6f83;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.dedupe-export-wait-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(45, 66, 86, 0.36);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.dedupe-export-wait {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 14px;
|
||||
padding: 22px 28px;
|
||||
box-shadow: 0 12px 40px -12px rgba(39, 67, 94, 0.35);
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||||
}
|
||||
.wait-spinner {
|
||||
.request-spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: none;
|
||||
border: 3px solid var(--el-color-primary-light-7);
|
||||
border-top-color: var(--el-color-primary);
|
||||
border: 3px solid #d5e3ef;
|
||||
border-top-color: #5f85ad;
|
||||
border-radius: 50%;
|
||||
animation: dedupe-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes dedupe-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.export-wait-title { font-size: 15px; font-weight: 600; }
|
||||
.export-wait-detail { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.dedupe-export-wait-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #24384d;
|
||||
}
|
||||
.dedupe-export-wait-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
color: #5b6f83;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/** 商品类目:对齐 admin panel-product-categories —— 树节点带 来源/排序/备注 + 行内编辑删除(有子禁用)、
|
||||
* 按需懒加载子级(「加载更多 已加载 x/y」)、搜索态独立分页/加载更多、导出、新增/编辑弹窗语义。 */
|
||||
/** 商品类目 · 像素复刻旧版 admin.html panel-product-categories(自绘:面板头搜索框组+新增类目 同行、原生树表格[+/−]折叠+懒加载「加载更多 已加载 x/y」、搜索态旧式分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(懒加载树/编辑删除/导出/弹窗)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './product-category-model.ts'
|
||||
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
|
||||
import { buildProductCategoryExportUrl } from './product-category-export.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<ProductCategoryNode[]>([])
|
||||
@@ -23,6 +24,7 @@ const selected = ref<ProductCategoryNode | null>(null)
|
||||
|
||||
const searchLoading = ref(false)
|
||||
const searchRows = ref<ProductCategoryNode[]>([])
|
||||
const searchPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
const searchTotal = ref(0)
|
||||
const searchPage = ref(1)
|
||||
const searchHasMore = ref(false)
|
||||
@@ -54,17 +56,23 @@ function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
|
||||
const allNodes = computed(() => flatten(tree.value))
|
||||
const searching = computed(() => (keyword.value || '').trim().length > 0)
|
||||
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
|
||||
const searchTotalPages = computed(() => Math.max(1, Math.ceil(searchTotal.value / searchPageSize.value)))
|
||||
|
||||
/** 拉取根级第一页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
|
||||
/** 顶层翻页(服务端分页,10/20/50/100 可选;子级保留懒加载+加载更多对齐旧版)。 */
|
||||
const rootPage = ref(1)
|
||||
const rootPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
const rootTotal = ref(0)
|
||||
|
||||
/** 拉取根级当前页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
|
||||
async function loadTree(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchProductCategoryChildren(null, 1, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
const result = await fetchProductCategoryChildren(null, rootPage.value, rootPageSize.value)
|
||||
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
|
||||
childrenState.set(ROOT_KEY, state)
|
||||
tree.value = result.hasMore
|
||||
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
|
||||
: result.items
|
||||
// 顶层当前页直接作为树根(不含合成「加载更多」节点,翻页由 OldPagination 承担)。
|
||||
tree.value = result.items
|
||||
rootTotal.value = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
|
||||
} finally {
|
||||
@@ -72,6 +80,17 @@ async function loadTree(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function changeRootPage(p: number) {
|
||||
rootPage.value = p
|
||||
void loadTree()
|
||||
}
|
||||
|
||||
function changeRootSize(size: number) {
|
||||
rootPageSize.value = size
|
||||
rootPage.value = 1
|
||||
void loadTree()
|
||||
}
|
||||
|
||||
/** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */
|
||||
interface TreeRow {
|
||||
node: ProductCategoryNode
|
||||
@@ -167,11 +186,15 @@ function moreLoading(more: ProductCategoryNode): boolean {
|
||||
return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false)
|
||||
}
|
||||
|
||||
function changeSearchSize(size: number) {
|
||||
searchPageSize.value = size
|
||||
void runSearch(1)
|
||||
}
|
||||
|
||||
async function runSearch(page: number): Promise<void> {
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const result = await searchProductCategories(keyword.value, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
// 分页换页模式:每页独立展示当前页结果(对齐列表统一分页交互)。
|
||||
const result = await searchProductCategories(keyword.value, page, searchPageSize.value)
|
||||
searchRows.value = result.items
|
||||
searchTotal.value = result.total
|
||||
searchPage.value = result.page
|
||||
@@ -197,10 +220,6 @@ function reset(): void {
|
||||
void loadTree()
|
||||
}
|
||||
|
||||
function searchChangePage(p: number): void {
|
||||
void runSearch(p)
|
||||
}
|
||||
|
||||
function openCreate(parent: ProductCategoryNode | null) {
|
||||
editingId.value = null
|
||||
Object.assign(draft, createProductCategoryDraft())
|
||||
@@ -236,21 +255,16 @@ async function removeCategory(node: ProductCategoryNode): Promise<void> {
|
||||
ElMessage.warning('存在子类目,不能删除')
|
||||
return
|
||||
}
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(`确定删除“${node.name}”?`)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除“${node.name}”?`, '删除类目', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await deleteProductCategory(node.id)
|
||||
ElMessage.success('已删除')
|
||||
if (selected.value?.id === node.id) selected.value = null
|
||||
if (searching.value) void runSearch(1)
|
||||
else void loadTree()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,179 +307,330 @@ onMounted(loadTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>商品类目</h2>
|
||||
<p>维护商品类目树与类目名称/说明;有子类目节点不可删除。</p>
|
||||
<div class="category-view">
|
||||
<section class="panel-box">
|
||||
<div class="category-head">
|
||||
<h3>商品类目</h3>
|
||||
<div class="category-head-tools">
|
||||
<input v-model="keyword" type="text" placeholder="搜索类目名称、编码或备注" class="category-keyword" @keyup.enter="apply" />
|
||||
<button class="btn btn-sm" type="button" @click="apply">搜索</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="reset">清空</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="exportCategories">导出</button>
|
||||
<button v-if="selected && !searching" class="btn btn-sm" type="button" @click="openCreate(selected)">新增子类目</button>
|
||||
<button class="btn" type="button" @click="openCreate(null)">新增类目</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="openCreate(null)">新增类目</el-button>
|
||||
<el-button @click="exportCategories">导出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-row">
|
||||
<el-input v-model="keyword" placeholder="搜索类目名称、编码或备注" clearable style="width: 280px" @keyup.enter="apply" />
|
||||
<el-button type="primary" @click="apply">搜索</el-button>
|
||||
<el-button @click="reset">清空</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="!searching" shadow="never">
|
||||
<el-table v-loading="loading" :data="treeRows" row-key="node.id" :class="{ 'tree-table': true }">
|
||||
<el-table-column label="类目名称" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<template v-if="isCategoryLoadMoreNode(row.node)">
|
||||
<div class="tree-name-cell">
|
||||
<span class="tree-indent" :style="{ '--indent': `${(row.depth + 1) * 24}px` }"></span>
|
||||
<el-button link size="small" type="primary" :loading="moreLoading(row.node)" @click="loadMoreChildren(row.node)">加载更多</el-button>
|
||||
<span class="dim">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
|
||||
</div>
|
||||
<div class="table-scroll category-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 34%">类目</th>
|
||||
<th>层级路径</th>
|
||||
<th style="width: 90px">排序</th>
|
||||
<th style="width: 120px">来源</th>
|
||||
<th>备注</th>
|
||||
<th style="width: 150px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="!searching">
|
||||
<template v-if="treeRows.length">
|
||||
<template v-for="row in treeRows" :key="String(row.node.id)">
|
||||
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
|
||||
<td colspan="6">
|
||||
<div class="load-more-cell">
|
||||
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
|
||||
<button class="btn btn-sm btn-secondary" type="button" :disabled="moreLoading(row.node)" @click="loadMoreChildren(row.node)">
|
||||
{{ moreLoading(row.node) ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
<span class="loaded-count">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td>
|
||||
<div class="tree-name-cell" @click="onNodeClick(row.node)">
|
||||
<span class="tree-indent" :style="{ width: `${row.depth * 24}px` }"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="tree-node-mark"
|
||||
:class="{ 'is-leaf': row.node.childCount <= 0 }"
|
||||
:disabled="row.node.childCount <= 0"
|
||||
:title="expandedIds.has(row.node.id) ? '折叠' : '展开'"
|
||||
@click.stop="toggleExpand(row.node)"
|
||||
>
|
||||
{{ row.node.childCount > 0 ? (expandedIds.has(row.node.id) ? '−' : '+') : '' }}
|
||||
</button>
|
||||
<strong class="node-name">{{ row.node.name }}</strong>
|
||||
<span v-if="selected?.id === row.node.id" class="selected-flag">已选</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="category-path-value" :title="row.node.path || row.node.name">{{ row.node.path || row.node.name }}</span>
|
||||
</td>
|
||||
<td>{{ row.node.sortOrder ?? 0 }}</td>
|
||||
<td>
|
||||
<span class="category-tag" :class="{ 'is-custom': !row.node.isBuiltin }">{{ productCategorySourceLabel(row.node) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="!canDelete(row.node)"
|
||||
:title="!canDelete(row.node) ? '存在子类目,不能删除' : undefined"
|
||||
@click="removeCategory(row.node)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
<div v-else class="tree-name-cell" @click="onNodeClick(row.node as ProductCategoryNode)">
|
||||
<span class="tree-indent" :style="{ '--indent': `${row.depth * 24}px` }"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="tree-node-mark"
|
||||
:class="{ 'is-leaf': (row.node as ProductCategoryNode).childCount <= 0 }"
|
||||
:disabled="(row.node as ProductCategoryNode).childCount <= 0"
|
||||
:title="expandedIds.has((row.node as ProductCategoryNode).id) ? '折叠' : '展开'"
|
||||
@click.stop="toggleExpand(row.node as ProductCategoryNode)"
|
||||
>
|
||||
{{ (row.node as ProductCategoryNode).childCount > 0 ? (expandedIds.has((row.node as ProductCategoryNode).id) ? '−' : '+') : '' }}
|
||||
</button>
|
||||
<strong class="node-name">{{ (row.node as ProductCategoryNode).name }}</strong>
|
||||
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
|
||||
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="层级路径" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="dim" :title="(row.node as ProductCategoryNode).path">{{ (row.node as ProductCategoryNode).path || (row.node as ProductCategoryNode).name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" min-width="80">
|
||||
<template #default="{ row }">{{ (row.node as ProductCategoryNode).sortOrder ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
|
||||
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="(row.node as ProductCategoryNode).description" class="node-desc" :title="(row.node as ProductCategoryNode).description">{{ (row.node as ProductCategoryNode).description }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="!isCategoryLoadMoreNode(row.node)">
|
||||
<el-button link size="small" type="primary" @click="openEdit(row.node as ProductCategoryNode)">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
size="small"
|
||||
type="danger"
|
||||
:disabled="!canDelete(row.node as ProductCategoryNode)"
|
||||
@click="removeCategory(row.node as ProductCategoryNode)"
|
||||
>删除</el-button>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">暂无商品类目</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else>
|
||||
<template v-if="searchRows.length">
|
||||
<tr v-for="row in searchRows" :key="row.id">
|
||||
<td><strong class="node-name">{{ row.name }}</strong></td>
|
||||
<td><span class="category-path-value" :title="row.path || row.name">{{ row.path || row.name }}</span></td>
|
||||
<td>{{ row.sortOrder ?? '—' }}</td>
|
||||
<td>
|
||||
<span class="category-tag" :class="{ 'is-custom': !row.isBuiltin }">{{ productCategorySourceLabel(row) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="!canDelete(row)"
|
||||
:title="!canDelete(row) ? '存在子类目,不能删除' : undefined"
|
||||
@click="removeCategory(row)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="tree-actions">
|
||||
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
|
||||
<span class="btn-group">
|
||||
<el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button>
|
||||
</span>
|
||||
<tr v-else-if="searchLoading">
|
||||
<td colspan="6" class="empty-tip">搜索中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">暂无搜索结果</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else shadow="never">
|
||||
<div class="search-head">
|
||||
<span>搜索结果:共 {{ searchTotal.toLocaleString() }} 条</span>
|
||||
<span class="dim">{{ keyword }}</span>
|
||||
</div>
|
||||
<el-table v-loading="searchLoading" :data="searchRows" stripe border>
|
||||
<el-table-column prop="name" label="类目名称" min-width="180" />
|
||||
<el-table-column label="层级路径" min-width="240">
|
||||
<template #default="{ row }">{{ (row as ProductCategoryNode).path || (row as ProductCategoryNode).name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" min-width="90">
|
||||
<template #default="{ row }">{{ (row as ProductCategoryNode).sortOrder ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain" :type="(row as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
|
||||
{{ productCategorySourceLabel(row as ProductCategoryNode) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="180">
|
||||
<template #default="{ row }">{{ (row as ProductCategoryNode).description || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row as ProductCategoryNode)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="!canDelete(row as ProductCategoryNode)" @click="removeCategory(row as ProductCategoryNode)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="searchTotal" :page-size="PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE" :current-page="searchPage" @current-change="searchChangePage" />
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination v-if="searching" :total="searchTotal" :page="searchPage" :page-size="searchPageSize" @change="runSearch" @size-change="changeSearchSize" />
|
||||
<OldPagination v-else-if="rootTotal > 0" :total="rootTotal" :page="rootPage" :page-size="rootPageSize" @change="changeRootPage" @size-change="changeRootSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="父级类目">
|
||||
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable>
|
||||
<el-option v-for="node in allNodes" :key="node.id" :label="node.path || node.name" :value="node.id" />
|
||||
<el-option
|
||||
v-for="node in allNodes"
|
||||
:key="node.id"
|
||||
:label="node.path || node.name"
|
||||
:value="node.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类目名称" required>
|
||||
<el-input v-model="draft.name" placeholder="类目名称" />
|
||||
<el-input v-model="draft.name" placeholder="如:洁面" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input v-model="draft.sortOrder" placeholder="非负整数,可选" />
|
||||
<el-input v-model="draft.sortOrder" placeholder="如 10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="draft.description" type="textarea" :rows="2" placeholder="可选" />
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="draft.description" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">保存类目</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ editingId == null ? '保存' : '保存类目' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.actions { display: flex; align-items: center; gap: 10px; }
|
||||
.filter-row { display: flex; gap: 10px; align-items: center; }
|
||||
.tree-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.btn-group { display: inline-flex; gap: 6px; }
|
||||
.search-head { display: flex; justify-content: space-between; margin-bottom: 12px; }
|
||||
.load-more-row { text-align: center; margin-top: 12px; }
|
||||
/* 树形表格(对齐旧版 panel-product-categories):缩进 + [+/−] 折叠标记 + 粗体名称。 */
|
||||
.tree-name-cell { display: flex; align-items: center; gap: 8px; min-height: 24px; }
|
||||
.tree-indent { flex: 0 0 auto; width: var(--indent, 0px); }
|
||||
/* 像素复刻旧版 admin.html panel-product-categories(蓝白末层)。 */
|
||||
.category-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.category-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.category-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.category-keyword {
|
||||
width: 240px;
|
||||
min-height: 36px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.category-keyword:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.category-keyword:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.category-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.tree-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tree-indent {
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
}
|
||||
.tree-node-mark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: #eef0fe;
|
||||
color: #6366f1;
|
||||
background: var(--admin-primary-soft, #e7f0f8);
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -477,9 +642,115 @@ onMounted(loadTree)
|
||||
flex: none;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.tree-node-mark:not(.is-leaf):hover { background: #6366f1; color: #fff; }
|
||||
.tree-node-mark.is-leaf { background: transparent; cursor: default; }
|
||||
.node-name { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; }
|
||||
.node-desc { color: var(--el-text-color-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; display: inline-block; vertical-align: middle; }
|
||||
.tree-table .el-table__row { cursor: pointer; }
|
||||
.tree-node-mark:not(.is-leaf):hover {
|
||||
background: var(--admin-primary-strong, #2f5d8b);
|
||||
color: #ffffff;
|
||||
}
|
||||
.tree-node-mark.is-leaf {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.node-name {
|
||||
font-weight: 600;
|
||||
color: #24384d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.selected-flag {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e7f0f8;
|
||||
color: #2f5d8b;
|
||||
font-size: 11px;
|
||||
}
|
||||
.category-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #c2d5e6;
|
||||
background: #e7f0f8;
|
||||
color: #2f5d8b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.category-tag.is-custom {
|
||||
border-color: #bcd9c6;
|
||||
background: #eef7f0;
|
||||
color: #3d7158;
|
||||
}
|
||||
.category-path-value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.node-desc {
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 320px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dim {
|
||||
color: #8293a5;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.load-more-row td {
|
||||
background: #fbfdfe;
|
||||
}
|
||||
.load-more-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.loaded-count {
|
||||
color: #8293a5;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/** 查询 ASIN:对齐 admin panel-query-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国 ASIN 行可点击复制) +
|
||||
* 新增 ASIN 弹窗(分组→店铺联动→国家→ASIN) + 行内「配置」抽屉 + 导入添加/删除导入/导出。 */
|
||||
/** 查询 ASIN · 像素复刻旧版 admin.html panel-query-asin(自绘:面板头操作组、筛选+导出 XLSX、原生 rowspan 表格+可复制 ASIN、旧式分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(新增级联/配置抽屉/导入轮询/导出)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import CopyText from '@/components/CopyText.vue'
|
||||
@@ -14,6 +14,7 @@ import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from '
|
||||
import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<QueryAsinItem[]>([])
|
||||
@@ -22,18 +23,12 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
||||
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||
|
||||
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/操作4):分组(0)/店铺(1)/操作(4) 仅首行占位,其余合并。 */
|
||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 4) {
|
||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||
}
|
||||
return [1, 1]
|
||||
}
|
||||
|
||||
function asinOf(row: QueryAsinItem, country: string): string {
|
||||
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
|
||||
return map[country] || ''
|
||||
@@ -57,6 +52,21 @@ function normalizeAsinInput(country: string): void {
|
||||
draftAsin[country] = draftAsin[country].trim().toUpperCase()
|
||||
}
|
||||
|
||||
|
||||
async function removeShopAsin(item: QueryAsinItem) {
|
||||
// 删除该店铺全部站点的 ASIN 配置(逐国调用现有端点)。
|
||||
if (!window.confirm(`确定删除店铺“${item.shopName}”的全部站点 ASIN 配置吗?`)) return
|
||||
try {
|
||||
for (const country of QUERY_ASIN_COUNTRIES) {
|
||||
if (asinOf(item, country)) await deleteQueryAsinCountryAsin(item.id, country)
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
const item = drawerItem.value
|
||||
if (!item) return
|
||||
@@ -216,15 +226,7 @@ async function submitImport(): Promise<void> {
|
||||
return
|
||||
}
|
||||
if (importMode.value === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?', '删除导入', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!window.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) return
|
||||
}
|
||||
const mode = importMode.value
|
||||
const groupId = importGroupId.value ?? undefined
|
||||
@@ -303,12 +305,28 @@ function apply() {
|
||||
void load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(filter, createQueryAsinFilterState())
|
||||
page.value = 1
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadGroups()
|
||||
void load()
|
||||
@@ -316,89 +334,89 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>查询 ASIN</h2>
|
||||
<p>每店铺在 5 个站点查询到的 ASIN 清单;行内可逐站配置 ASIN。</p>
|
||||
<div class="query-view">
|
||||
<section class="panel-box">
|
||||
<div class="query-head">
|
||||
<h3>店铺列表</h3>
|
||||
<div class="query-head-actions">
|
||||
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
|
||||
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入添加</button>
|
||||
<button class="btn" type="button" @click="openImportDelete">导入删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<div class="form-row query-filter-row">
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<div class="form-group">
|
||||
<label>店铺名</label>
|
||||
<el-input v-model="filter.shopName" placeholder="请输入店铺名" clearable @keyup.enter="apply" />
|
||||
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>ASIN</label>
|
||||
<el-input v-model="filter.asin" placeholder="请输入 ASIN" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<div class="form-group">
|
||||
<label>国家</label>
|
||||
<el-select v-model="filter.country" placeholder="全部国家" clearable>
|
||||
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
<select v-model="filter.country">
|
||||
<option value="">全部国家</option>
|
||||
<option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
||||
<el-button @click="openImportAdd">导入添加</el-button>
|
||||
<el-button @click="openImportDelete">导入删除</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
<div class="table-scroll query-asin-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 6%">序号</th>
|
||||
<th style="width: 13%">分组</th>
|
||||
<th style="width: 15%">店铺名</th>
|
||||
<th style="width: 24%">ASIN</th>
|
||||
<th style="width: 12%">国家</th>
|
||||
<th style="width: 9%">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="displayRows.length">
|
||||
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||
</template>
|
||||
<td>
|
||||
<template v-if="row.asin">
|
||||
<CopyText :text="row.asin" class="asin-cell" />
|
||||
</template>
|
||||
<span v-else class="asin-empty">-</span>
|
||||
</td>
|
||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan" class="asin-col-actions">
|
||||
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
|
||||
<el-table-column label="分组" min-width="130">
|
||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="店铺" min-width="150">
|
||||
<template #default="{ row }">{{ row.item.shopName }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ASIN" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="国家" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as QueryAsinItem)">配置</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
@@ -420,7 +438,7 @@ onMounted(() => {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
||||
<el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
||||
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -455,7 +473,7 @@ onMounted(() => {
|
||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
|
||||
<el-select v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
|
||||
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -476,16 +494,274 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.heading-actions { display: flex; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
|
||||
.dim { color: var(--el-text-color-placeholder); }
|
||||
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
|
||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
/* 像素复刻旧版 admin.html panel-query-asin(蓝白末层)。 */
|
||||
.query-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.query-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.query-head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.query-filter-row > .form-group {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.query-asin-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.table-scroll td {
|
||||
vertical-align: top;
|
||||
}
|
||||
.asin-cell {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: -3px 0 -3px -7px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 13.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.asin-cell:hover {
|
||||
border-color: #c7d7e5;
|
||||
background: #eef4fa;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.asin-empty {
|
||||
color: #8b9aaa;
|
||||
}
|
||||
.asin-col-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
background: #ffffff;
|
||||
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.drawer-tip {
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.import-tip {
|
||||
margin: 6px 0 0;
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国
|
||||
* ASIN 可点击复制 + 最低价两位小数)、新增 ASIN 弹窗(分组→店铺联动→国家→ASIN+最低价)、价格范围筛选、
|
||||
* 行内「配置」抽屉(逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
|
||||
/** 最低价 ASIN(跳过跟价)· 像素复刻旧版 admin.html panel-skip-price-asin(自绘:面板头操作组、筛选(分组/店铺/国家/ASIN/最低价≥≤)+导出 XLSX、原生 rowspan 表格、旧式分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(新增级联+最低价、配置抽屉双列、导入轮询、导出)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import CopyText from '@/components/CopyText.vue'
|
||||
@@ -15,6 +14,7 @@ import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
||||
import { fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<SkipPriceItem[]>([])
|
||||
@@ -23,6 +23,8 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
|
||||
const ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
|
||||
@@ -40,14 +42,6 @@ function priceOf(row: SkipPriceItem, code: string): number | null {
|
||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
||||
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||
|
||||
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/最低价4/操作5):分组(0)/店铺(1)/操作(5) 仅首行占位,其余合并。 */
|
||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 5) {
|
||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||
}
|
||||
return [1, 1]
|
||||
}
|
||||
|
||||
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin) ----
|
||||
const createVisible = ref(false)
|
||||
const createGroupId = ref<number | null>(null)
|
||||
@@ -262,15 +256,7 @@ async function submitImport(): Promise<void> {
|
||||
return
|
||||
}
|
||||
if (importMode.value === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?', '删除导入', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!window.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) return
|
||||
}
|
||||
const mode = importMode.value
|
||||
const groupId = importGroupId.value as number
|
||||
@@ -341,12 +327,28 @@ function apply() {
|
||||
void load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(filter, createSkipPriceFilterState())
|
||||
page.value = 1
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadGroups()
|
||||
void load()
|
||||
@@ -354,103 +356,98 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>最低价 ASIN / 跳过跟价</h2>
|
||||
<p>每店铺在 5 个站点设定的最低价 ASIN 清单;行内可逐站配置 ASIN 与最低价。</p>
|
||||
<div class="skip-view">
|
||||
<section class="panel-box">
|
||||
<div class="skip-head">
|
||||
<h3>店铺列表</h3>
|
||||
<div class="skip-head-actions">
|
||||
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
|
||||
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入文件新增</button>
|
||||
<button class="btn" type="button" @click="openImportDelete">导入文件删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<div class="form-row skip-filter-row">
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>店铺</label>
|
||||
<el-input v-model="filter.shopName" placeholder="店铺名模糊" clearable @keyup.enter="apply" />
|
||||
<div class="form-group">
|
||||
<label>店铺名</label>
|
||||
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>ASIN</label>
|
||||
<el-input v-model="filter.asin" placeholder="ASIN 搜索" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<div class="form-group">
|
||||
<label>国家</label>
|
||||
<el-select v-model="filter.country" placeholder="全部" clearable>
|
||||
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
<select v-model="filter.country">
|
||||
<option value="">全部国家</option>
|
||||
<option v-for="code in COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="f-item price-range">
|
||||
<label>最低价</label>
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group price-range">
|
||||
<label>最低价范围</label>
|
||||
<div class="price-range-row">
|
||||
<el-input v-model="filter.minimumPriceFrom" placeholder="≥ 最低值" clearable @keyup.enter="apply" />
|
||||
<span class="range-sep">至</span>
|
||||
<el-input v-model="filter.minimumPriceTo" placeholder="≤ 最高值" clearable @keyup.enter="apply" />
|
||||
<input v-model="filter.minimumPriceFrom" type="number" min="0" step="0.01" placeholder="最低价 ≥" @keyup.enter="apply" />
|
||||
<span class="range-sep">-</span>
|
||||
<input v-model="filter.minimumPriceTo" type="number" min="0" step="0.01" placeholder="最高价 ≤" @keyup.enter="apply" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
||||
<el-button @click="openImportAdd">导入添加</el-button>
|
||||
<el-button @click="openImportDelete">导入删除</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
<div class="table-scroll skip-price-asin-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 6%">序号</th>
|
||||
<th style="width: 13%">分组</th>
|
||||
<th style="width: 15%">店铺名</th>
|
||||
<th style="width: 24%">ASIN</th>
|
||||
<th style="width: 12%">国家</th>
|
||||
<th style="width: 10%">最低价</th>
|
||||
<th style="width: 9%">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="displayRows.length">
|
||||
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||
</template>
|
||||
<td>
|
||||
<template v-if="row.asin">
|
||||
<CopyText :text="row.asin" class="asin-cell" />
|
||||
</template>
|
||||
<span v-else class="asin-empty">-</span>
|
||||
</td>
|
||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan" class="asin-col-actions">
|
||||
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="7" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
|
||||
<el-table-column label="分组" min-width="130">
|
||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="店铺" min-width="150">
|
||||
<template #default="{ row }">{{ row.item.shopName }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ASIN" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="国家" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最低价" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.minimumPrice !== ''">{{ row.minimumPrice }}</span>
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as SkipPriceItem)">配置</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
@@ -472,7 +469,7 @@ onMounted(() => {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
||||
<el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
||||
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -513,7 +510,7 @@ onMounted(() => {
|
||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="分组(必选)">
|
||||
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
||||
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -534,22 +531,301 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.heading-actions { display: flex; gap: 8px; flex: none; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
/* 最低价范围筛选:≥ 与 ≤ 并排同一行。 */
|
||||
.f-item.price-range { width: 300px; }
|
||||
.price-range-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||
.price-range-row .el-input { flex: 1; }
|
||||
.range-sep { color: var(--el-text-color-secondary); font-size: 12px; flex: none; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
|
||||
.dim { color: var(--el-text-color-placeholder); }
|
||||
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
|
||||
.country-cell { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; }
|
||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
/* 像素复刻旧版 admin.html panel-skip-price-asin(蓝白末层)。 */
|
||||
.skip-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.skip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skip-head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skip-filter-row > .form-group {
|
||||
flex: 1 1 130px;
|
||||
min-width: 0;
|
||||
}
|
||||
.skip-filter-row .form-group.price-range {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.price-range-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.price-range-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.price-range-row .range-sep {
|
||||
color: #8293a5;
|
||||
flex: none;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.skip-price-asin-table-scroll > table {
|
||||
min-width: 900px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.table-scroll td {
|
||||
vertical-align: top;
|
||||
}
|
||||
.asin-cell {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: -3px 0 -3px -7px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 13.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.asin-cell:hover {
|
||||
border-color: #c7d7e5;
|
||||
background: #eef4fa;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.asin-empty {
|
||||
color: #8b9aaa;
|
||||
}
|
||||
.price-cell {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.asin-col-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
background: #ffffff;
|
||||
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.drawer-tip {
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.country-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.import-tip {
|
||||
margin: 6px 0 0;
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */
|
||||
|
||||
export const ASIN_PAGE_DEFAULT_SIZE = 15
|
||||
export const ASIN_PAGE_DEFAULT_SIZE = 10
|
||||
export const ASIN_PAGE_MIN_PAGE = 1
|
||||
export const ASIN_PAGE_MAX_SIZE = 200
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export function parseDedupeGroupOptionList(payload: unknown): DedupeGroupOption[
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id)
|
||||
if (!Number.isFinite(id) || id <= 0) continue
|
||||
const nameRaw = record.group_name ?? record.name
|
||||
const nameRaw = record.groupName ?? record.group_name ?? record.name
|
||||
const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''
|
||||
const option: DedupeGroupOption = { id, name: name || `分组${id}` }
|
||||
const leaderRaw = record.leader_user_id ?? record.leaderUserId
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function searchProductCategories(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<ProductCategorySearchPage> {
|
||||
const params: Record<string, string | number> = { page, pageSize }
|
||||
const params: Record<string, string | number> = { page, page_size: pageSize }
|
||||
const trimmed = keyword.trim()
|
||||
if (trimmed) params.keyword = trimmed
|
||||
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 20
|
||||
export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 10
|
||||
|
||||
export interface ProductCategoryNode {
|
||||
id: number
|
||||
|
||||
@@ -11,9 +11,9 @@ export interface SkipPriceFilterState {
|
||||
asin: string
|
||||
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
|
||||
country: string
|
||||
/** 最低价区间输入原文;空串不限。 */
|
||||
minimumPriceFrom: string
|
||||
minimumPriceTo: string
|
||||
/** 最低价区间输入原文;空串不限(原生 number input v-model 会转 number,需兼容)。 */
|
||||
minimumPriceFrom: string | number
|
||||
minimumPriceTo: string | number
|
||||
}
|
||||
|
||||
export interface SkipPriceListParams {
|
||||
@@ -42,9 +42,9 @@ export function createSkipPriceFilterState(): SkipPriceFilterState {
|
||||
return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' }
|
||||
}
|
||||
|
||||
/** 非负可解析价格 → number;空/非法/负数返回 null。 */
|
||||
export function normalizePriceInput(value: string): number | null {
|
||||
const trimmed = (value || '').trim()
|
||||
/** 非负可解析价格 → number;空/非法/负数返回 null。入参兼容 number(原生 number input 的 v-model 会转 number)。 */
|
||||
export function normalizePriceInput(value: string | number): number | null {
|
||||
const trimmed = String(value ?? '').trim()
|
||||
if (!trimmed) return null
|
||||
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
|
||||
const num = Number(trimmed)
|
||||
|
||||
@@ -154,7 +154,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
/** 数字人版本管理页(module 13 task 261 对齐 admin panel-digital-human-version;本轮补齐分页/三态/上传进度/下载语义)。 */
|
||||
/** 数字人版本管理页 · 像素复刻旧版 admin.html panel-digital-human-version(自绘:面板头+上传、三态状态色、最新★、操作矩阵、数字页码分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(确认文案/上传进度/下载取链接);确认走原生 confirm 对齐旧版。 */
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
deleteDigitalHumanVersion,
|
||||
fetchDigitalHumanDownloadUrl,
|
||||
@@ -20,18 +21,28 @@ import {
|
||||
import { validateDigitalHumanUpload } from './digitalhuman-upload-model.ts'
|
||||
import { canReleaseDigitalHumanVersion, releaseDigitalHumanConfirmText } from './digitalhuman-release.ts'
|
||||
import { canSetLatestDigitalHumanVersion, setLatestDigitalHumanConfirmText } from './digitalhuman-latest.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { digitalHumanStatusDisplay } from './digitalhuman-status-display.ts'
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<DigitalHumanVersion[]>([])
|
||||
|
||||
/** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */
|
||||
const pageSize = 20
|
||||
const pageSize = ref(10)
|
||||
const page = ref(1)
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return items.value.slice(start, start + pageSize)
|
||||
/** 版本号搜索(客户端过滤)。 */
|
||||
const versionKeyword = ref('')
|
||||
const filteredItems = computed(() => {
|
||||
const kw = (versionKeyword.value || '').trim().toLowerCase()
|
||||
if (!kw) return items.value
|
||||
return items.value.filter((item) => `${item.version}`.toLowerCase().includes(kw))
|
||||
})
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize.value
|
||||
return filteredItems.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
|
||||
const jumpPage = ref('')
|
||||
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
@@ -45,9 +56,7 @@ async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = (await fetchDigitalHumanVersions()).items
|
||||
// 数据收缩后页码越界回钳。
|
||||
const last = Math.max(Math.ceil(items.value.length / pageSize), 1)
|
||||
if (page.value > last) page.value = last
|
||||
// 页码越界回钳见下方 watch(items)。
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
|
||||
} finally {
|
||||
@@ -55,6 +64,31 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(next: number): void {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
// 版本号搜索导致数据收缩时回钳页码,避免停在空页。
|
||||
watch(items, () => {
|
||||
const last = Math.max(Math.ceil(filteredItems.value.length / pageSize.value), 1)
|
||||
if (page.value > last) page.value = last
|
||||
})
|
||||
|
||||
function goJump(): void {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function onFileChange(file: File) {
|
||||
form.value.file = file
|
||||
}
|
||||
@@ -91,14 +125,14 @@ async function submitUpload() {
|
||||
async function release(row: DigitalHumanVersion) {
|
||||
const text = releaseDigitalHumanConfirmText(row.version)
|
||||
if (!text) return
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(text)) return
|
||||
confirming.value = row.version
|
||||
try {
|
||||
await ElMessageBox.confirm(text, '发布版本', { type: 'warning', confirmButtonText: '发布', cancelButtonText: '取消' })
|
||||
await releaseDigitalHumanVersion(row.version)
|
||||
ElMessage.success('已发布')
|
||||
load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(error instanceof Error ? error.message : '发布失败')
|
||||
} finally {
|
||||
confirming.value = null
|
||||
@@ -108,14 +142,13 @@ async function release(row: DigitalHumanVersion) {
|
||||
async function setLatest(row: DigitalHumanVersion) {
|
||||
const text = setLatestDigitalHumanConfirmText(row.version)
|
||||
if (!text) return
|
||||
if (!window.confirm(text)) return
|
||||
confirming.value = row.version
|
||||
try {
|
||||
await ElMessageBox.confirm(text, '设为最新', { type: 'warning', confirmButtonText: '设为最新', cancelButtonText: '取消' })
|
||||
await setLatestDigitalHumanVersion(row.version)
|
||||
ElMessage.success('已设为最新')
|
||||
load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
confirming.value = null
|
||||
@@ -146,18 +179,13 @@ async function download(row: DigitalHumanVersion) {
|
||||
async function remove(row: DigitalHumanVersion) {
|
||||
const text = digitalHumanDeleteConfirmText(row.version)
|
||||
if (!text) return
|
||||
if (!window.confirm(text)) return
|
||||
confirming.value = row.version
|
||||
try {
|
||||
await ElMessageBox.confirm(text, '删除版本', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await deleteDigitalHumanVersion(row.version)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
} finally {
|
||||
confirming.value = null
|
||||
@@ -176,70 +204,74 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>数字人版本管理</h2>
|
||||
<p>管理数字人客户端的草稿/已发布/已废弃版本。</p>
|
||||
<div class="dh-view">
|
||||
<section class="panel-box">
|
||||
<div class="dh-head">
|
||||
<h3>版本列表</h3>
|
||||
<div class="dh-head-tools">
|
||||
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="dh-keyword" />
|
||||
<button class="btn" type="button" @click="uploadVisible = true">上传新版本</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="uploadVisible = true">上传(草稿状态)</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无数字人版本">
|
||||
<el-table-column prop="version" label="版本号" min-width="150" />
|
||||
<el-table-column label="状态" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusOf(row as DigitalHumanVersion).tag" size="small">
|
||||
{{ statusOf(row as DigitalHumanVersion).text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最新" min-width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="(row as DigitalHumanVersion).isLatest" class="star" title="最新版本">★</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文件大小" min-width="100">
|
||||
<template #default="{ row }">{{ digitalHumanVersionFileSizeText((row as DigitalHumanVersion).fileSize) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="MD5" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span :title="(row as DigitalHumanVersion).md5 || ''">{{ (row as DigitalHumanVersion).md5 ? `${((row as DigitalHumanVersion).md5 || '').substring(0, 12)}...` : '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新说明" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span :title="(row as DigitalHumanVersion).changelog || ''">{{ digitalHumanChangelogShort((row as DigitalHumanVersion).changelog) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime((row as DigitalHumanVersion).releasedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canReleaseDigitalHumanVersion((row as DigitalHumanVersion).status)" text type="warning" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="release(row as DigitalHumanVersion)">发布</el-button>
|
||||
<el-button v-if="!canReleaseDigitalHumanVersion((row as DigitalHumanVersion).status) && canSetLatestDigitalHumanVersion((row as DigitalHumanVersion).status) && !(row as DigitalHumanVersion).isLatest" text type="primary" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="setLatest(row as DigitalHumanVersion)">设为最新</el-button>
|
||||
<el-button v-if="canSetLatestDigitalHumanVersion((row as DigitalHumanVersion).status)" text type="success" size="small" :loading="downloadingVersion === (row as DigitalHumanVersion).version" @click="download(row as DigitalHumanVersion)">下载</el-button>
|
||||
<el-button v-if="canDelete(row as DigitalHumanVersion)" text type="danger" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="remove(row as DigitalHumanVersion)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ items.length.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
:total="items.length"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p }"
|
||||
/>
|
||||
<div class="table-scroll digital-human-version-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 80px">版本号</th>
|
||||
<th style="width: 60px">状态</th>
|
||||
<th style="width: 60px">最新</th>
|
||||
<th style="width: 80px">文件大小</th>
|
||||
<th style="width: 120px">MD5</th>
|
||||
<th>更新日志</th>
|
||||
<th style="width: 140px">发布时间</th>
|
||||
<th style="width: 240px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="pagedItems.length">
|
||||
<tr v-for="row in pagedItems" :key="row.version + row.id">
|
||||
<td><span class="mono-cell">{{ row.version }}</span></td>
|
||||
<td>
|
||||
<span class="st-pill" :class="`is-${statusOf(row).tag}`">{{ statusOf(row).text }}</span>
|
||||
</td>
|
||||
<td><span v-if="row.isLatest" class="star" title="最新版本">★</span><span v-else class="dim">—</span></td>
|
||||
<td>{{ digitalHumanVersionFileSizeText(row.fileSize) }}</td>
|
||||
<td><span :title="row.md5 || ''" class="mono-cell">{{ row.md5 ? `${row.md5.substring(0, 12)}...` : '—' }}</span></td>
|
||||
<td>
|
||||
<span :title="row.changelog || ''" class="changelog-cell">{{ digitalHumanChangelogShort(row.changelog) }}</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(row.releasedAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button v-if="canReleaseDigitalHumanVersion(row.status)" class="btn btn-sm" type="button" :disabled="confirming === row.version" @click="release(row)">发布</button>
|
||||
<button
|
||||
v-if="!canReleaseDigitalHumanVersion(row.status) && canSetLatestDigitalHumanVersion(row.status) && !row.isLatest"
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
:disabled="confirming === row.version"
|
||||
@click="setLatest(row)"
|
||||
>
|
||||
设为最新
|
||||
</button>
|
||||
<button v-if="canSetLatestDigitalHumanVersion(row.status)" class="btn btn-sm btn-secondary" type="button" :disabled="downloadingVersion === row.version" @click="download(row)">下载</button>
|
||||
<button v-if="canDelete(row)" class="btn btn-sm btn-danger" type="button" :disabled="confirming === row.version" @click="remove(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="8" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="8" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="filteredItems.length > pageSize" class="pagination">
|
||||
<OldPagination :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="uploadVisible" title="上传数字人版本" width="560px">
|
||||
<p class="upload-desc">上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布。</p>
|
||||
@@ -270,11 +302,267 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.actions { display: flex; align-items: center; gap: 10px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.star { color: #e6a23c; font-weight: 700; }
|
||||
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
/* 像素复刻旧版 admin.html panel-digital-human-version(蓝白末层)。 */
|
||||
.dh-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.dh-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dh-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.dh-keyword {
|
||||
width: 200px;
|
||||
min-height: 42px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.dh-keyword:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.dh-keyword:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.digital-human-version-table-scroll > table {
|
||||
min-width: 1120px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.mono-cell {
|
||||
font-family: "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.st-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.st-pill.is-info {
|
||||
color: #6b7280;
|
||||
background: #f1f3f5;
|
||||
border-color: #dfe3e8;
|
||||
}
|
||||
.st-pill.is-success {
|
||||
color: #398a3f;
|
||||
background: #eaf7ec;
|
||||
border-color: #bfe3c5;
|
||||
}
|
||||
.st-pill.is-danger {
|
||||
color: #b8454d;
|
||||
background: #fbedee;
|
||||
border-color: #ecc9cc;
|
||||
}
|
||||
.star {
|
||||
color: #d64545;
|
||||
font-weight: 700;
|
||||
}
|
||||
.changelog-cell {
|
||||
display: block;
|
||||
color: #5b6f83;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-nums {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.num-page {
|
||||
min-width: 30px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.num-page.is-active {
|
||||
background: #2f5d8b;
|
||||
border-color: #2f5d8b;
|
||||
color: #ffffff;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dim {
|
||||
color: #8293a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
.upload-desc {
|
||||
margin: 0 0 12px;
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
/** 历史生成记录页(module 13 task 259 对齐 admin panel-history):指定用户下拉 + 起止时间筛选、类型中文、≤3 缩略图、大图预览、空态。 */
|
||||
/** 生成记录页 · 像素复刻旧版 admin.html panel-history(自绘:form-box 筛选 + panel-box 表格 + 旧式分页 + thumb 缩略图)。
|
||||
* script 逻辑沿用现有 Vue 实现(查询/翻页/跳页/点击缩略图放大预览)。 */
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { fetchHistoryList, fetchHistoryUserOptions, clearHistory } from './history-api.ts'
|
||||
import { fetchHistoryList, fetchHistoryUserOptions } from './history-api.ts'
|
||||
import type { HistoryUserOption } from './history-user-option.ts'
|
||||
import type { HistoryRecordItem } from './history-dto.ts'
|
||||
import { historyEmptyText, historyErrorText } from './history-feedback.ts'
|
||||
import { historyPanelTypeLabel } from './history-type.ts'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const userLoading = ref(false)
|
||||
const rows = ref<HistoryRecordItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const pageSize = ref(10)
|
||||
const userOptions = ref<HistoryUserOption[]>([])
|
||||
const errorText = ref('')
|
||||
const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewItem = ref<HistoryRecordItem | null>(null)
|
||||
const jumpPage = ref('')
|
||||
|
||||
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
|
||||
const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value }))
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
/** datetime-local 原生值(YYYY-MM-DDTHH:mm) 转后端兼容格式。 */
|
||||
function toApiTime(value: string): string | undefined {
|
||||
const t = (value || '').trim()
|
||||
if (!t) return undefined
|
||||
return `${t.replace('T', ' ')}:00`
|
||||
}
|
||||
|
||||
/** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */
|
||||
function previewUrls(item: HistoryRecordItem): string[] {
|
||||
@@ -46,10 +57,10 @@ async function load() {
|
||||
try {
|
||||
const result = await fetchHistoryList({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
pageSize: pageSize.value,
|
||||
userId: filter.userId ? Number(filter.userId) : undefined,
|
||||
timeStart: filter.timeStart || undefined,
|
||||
timeEnd: filter.timeEnd || undefined,
|
||||
timeStart: toApiTime(filter.timeStart),
|
||||
timeEnd: toApiTime(filter.timeEnd),
|
||||
})
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
@@ -77,54 +88,37 @@ function apply() {
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filter.userId = ''
|
||||
filter.timeStart = ''
|
||||
filter.timeEnd = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function retry() {
|
||||
load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(n)
|
||||
}
|
||||
|
||||
function openPreview(item: HistoryRecordItem) {
|
||||
previewItem.value = item
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const clearing = ref(false)
|
||||
|
||||
/** 清空全部生成记录:二次确认后调后端 DELETE /api/admin/history。 */
|
||||
async function clearAll() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定清空全部生成记录吗?该操作会删除所有历史数据,无法恢复。', '清理生成记录', {
|
||||
confirmButtonText: '确认清空',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
clearing.value = true
|
||||
try {
|
||||
const removed = await clearHistory()
|
||||
ElMessage.success(`已清空 ${removed} 条生成记录`)
|
||||
page.value = 1
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '清理失败')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadUserOptions()
|
||||
@@ -132,143 +126,443 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>生成记录</h2>
|
||||
<p>查看各用户在后台生成的历史记录与结果图。</p>
|
||||
<div class="history-view">
|
||||
<section class="form-box">
|
||||
<h3>筛选条件</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>指定用户</label>
|
||||
<select v-model="filter.userId" :disabled="userLoading">
|
||||
<option value="">全部用户</option>
|
||||
<option v-for="u in userOptions" :key="u.id" :value="String(u.id)">{{ u.username }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 176px">
|
||||
<label>开始时间</label>
|
||||
<input v-model="filter.timeStart" type="datetime-local" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 176px">
|
||||
<label>结束时间</label>
|
||||
<input v-model="filter.timeEnd" type="datetime-local" />
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
|
||||
</section>
|
||||
|
||||
<section class="panel-box">
|
||||
<h3>生成记录</h3>
|
||||
|
||||
<div v-if="errorText" class="msg err">
|
||||
<span>加载失败: {{ errorText }}</span>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll history-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70px">ID</th>
|
||||
<th style="width: 130px">用户</th>
|
||||
<th style="width: 160px">类型</th>
|
||||
<th style="width: 170px">创建时间</th>
|
||||
<th>结果预览</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="h in rows" :key="h.id">
|
||||
<td>{{ h.id }}</td>
|
||||
<td>{{ h.username || '-' }}</td>
|
||||
<td>{{ historyPanelTypeLabel(h.panelType) }}</td>
|
||||
<td>{{ formatDateTime(h.createdAt) }}</td>
|
||||
<td>
|
||||
<div class="thumb-wrap">
|
||||
<img
|
||||
v-for="url in previewUrls(h).slice(0, 3)"
|
||||
:key="url"
|
||||
class="thumb"
|
||||
:src="url"
|
||||
alt=""
|
||||
title="点击放大预览"
|
||||
@click="openPreview(h)"
|
||||
/>
|
||||
<span v-if="!previewUrls(h).length" class="empty-mini">暂无图片</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="5" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="5" class="empty-tip">{{ emptyText }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<div v-if="previewVisible" class="modal-mask" @click.self="previewVisible = false">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h3 v-if="previewItem">记录 #{{ previewItem.id }} 结果图</h3>
|
||||
<button class="modal-close" type="button" aria-label="关闭" @click="previewVisible = false">×</button>
|
||||
</div>
|
||||
<div v-if="previewItem" class="preview-meta">
|
||||
<span>{{ previewItem.username || '-' }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
|
||||
<a
|
||||
v-for="url in previewUrls(previewItem)"
|
||||
:key="url"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="新标签打开原图"
|
||||
>
|
||||
<img :src="url" alt="" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-else class="modal-empty">无结果图片</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn btn-secondary" type="button" @click="previewVisible = false">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>指定用户</label>
|
||||
<el-select v-model="filter.userId" clearable filterable placeholder="全部用户" style="width: 100%" :loading="userLoading">
|
||||
<el-option label="全部用户" value="" />
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>开始时间</label>
|
||||
<el-date-picker
|
||||
v-model="filter.timeStart"
|
||||
type="datetime"
|
||||
placeholder="开始时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>结束时间</label>
|
||||
<el-date-picker
|
||||
v-model="filter.timeEnd"
|
||||
type="datetime"
|
||||
placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-alert
|
||||
v-if="errorText"
|
||||
:title="errorText"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<template #default>
|
||||
<el-button link type="primary" @click="retry">重试</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-table v-loading="loading" :data="rows" stripe :empty-text="emptyText">
|
||||
<el-table-column prop="id" label="ID" min-width="90" />
|
||||
<el-table-column prop="username" label="用户" min-width="140" />
|
||||
<el-table-column label="类型" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ historyPanelTypeLabel((row as HistoryRecordItem).panelType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime((row as HistoryRecordItem).createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果预览" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div v-if="previewUrls(row as HistoryRecordItem).length" class="thumbs">
|
||||
<el-image
|
||||
v-for="(url, index) in previewUrls(row as HistoryRecordItem).slice(0, 3)"
|
||||
:key="url"
|
||||
:src="url"
|
||||
:preview-src-list="previewUrls(row as HistoryRecordItem)"
|
||||
:initial-index="index"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="thumb"
|
||||
/>
|
||||
<span v-if="previewUrls(row as HistoryRecordItem).length > 3" class="dim">+{{ previewUrls(row as HistoryRecordItem).length - 3 }}</span>
|
||||
</div>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openPreview(row as HistoryRecordItem)">查看大图</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="previewVisible" :title="previewItem ? `记录 #${previewItem.id} 结果图` : '结果图'" width="820px" top="6vh">
|
||||
<div v-if="previewItem" class="preview-meta">
|
||||
<el-tag size="small">{{ previewItem.username }}</el-tag>
|
||||
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
|
||||
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
|
||||
<el-image
|
||||
v-for="(url, index) in previewUrls(previewItem)"
|
||||
:key="url"
|
||||
:src="url"
|
||||
:preview-src-list="previewUrls(previewItem)"
|
||||
:initial-index="index"
|
||||
preview-teleported
|
||||
fit="contain"
|
||||
class="preview-img"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-else description="无结果图片" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card { margin-bottom: 14px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.thumbs { display: flex; align-items: center; gap: 6px; }
|
||||
.thumb { width: 56px; height: 56px; border-radius: 4px; border: 1px solid var(--el-border-color-lighter); flex: none; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.preview-meta { display: flex; align-items: center; gap: 12px; color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 12px; }
|
||||
.preview-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||
.preview-img { width: 100%; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
|
||||
/* 像素复刻旧版 admin.html 蓝白末层样式:form-box/panel-box 双卡 + table-scroll 表格 + 旧式分页 + thumb 缩略图。 */
|
||||
.history-view {
|
||||
font-family: inherit;
|
||||
color: var(--c-text, #24384d);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.form-box,
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.msg.err {
|
||||
color: #91474f;
|
||||
background: #f8ebeb;
|
||||
border-color: #e4c2c5;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.history-table-scroll > table {
|
||||
min-width: 900px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.thumb-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d8e3ee;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.thumb:hover {
|
||||
border-color: #5f85ad;
|
||||
}
|
||||
.empty-mini {
|
||||
color: #8293a5;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
background: rgba(45, 66, 86, 0.36);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(880px, 100%);
|
||||
max-height: 84vh;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.modal-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.modal-close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: none;
|
||||
color: #5b6f83;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
border-color: #cbd9e6;
|
||||
background: #eef4fa;
|
||||
color: #24384d;
|
||||
}
|
||||
.preview-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 12px 0 10px;
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.preview-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: #f8fbfd;
|
||||
}
|
||||
.preview-grid a {
|
||||
display: block;
|
||||
}
|
||||
.preview-grid img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d8e3ee;
|
||||
background: #fff;
|
||||
}
|
||||
.modal-empty {
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
}
|
||||
.modal-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 软件版本管理页:客户端版本列表 + 上传新版本(成功留驻弹窗含链接,对齐 admin panel-version)。 */
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
/** 软件版本管理页 · 像素复刻旧版 admin.html panel-version(自绘:面板头+上传新版本、版本列表、全量无分页同旧版)。
|
||||
* script 逻辑沿用现有 Vue 实现(上传弹窗成功留驻含链接)。 */
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts'
|
||||
import type { SoftwareVersionItem } from './version-dto.ts'
|
||||
@@ -9,15 +11,23 @@ import { isNonEmptyVersion } from './version-dto.ts'
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<SoftwareVersionItem[]>([])
|
||||
// 客户端分页:全量拉取后按页切片展示。
|
||||
/** 版本号搜索(客户端过滤)。 */
|
||||
const versionKeyword = ref('')
|
||||
const filteredItems = computed(() => {
|
||||
const kw = (versionKeyword.value || '').trim().toLowerCase()
|
||||
if (!kw) return items.value
|
||||
return items.value.filter((item) => `${item.version}`.toLowerCase().includes(kw))
|
||||
})
|
||||
/** 全站分页统一:版本列表客户端分页(10/20/50/100)。 */
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const total = computed(() => items.value.length)
|
||||
const pagedItems = computed(() => items.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
|
||||
function changePage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
const pageSize = ref(10)
|
||||
const pagedVersions = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
function changeVersionPage(p: number) { page.value = p }
|
||||
function changeVersionSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||
// 版本号搜索导致数据收缩时回钳页码,避免停在空页。
|
||||
watch(filteredItems, () => {
|
||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
|
||||
})
|
||||
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
@@ -87,40 +97,52 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>软件版本管理</h2>
|
||||
<p>管理客户端更新包版本(用于 /api/version/latest 下发)。</p>
|
||||
<div class="version-view">
|
||||
<section class="panel-box">
|
||||
<div class="version-head">
|
||||
<h3>版本列表</h3>
|
||||
<div class="version-head-tools">
|
||||
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
|
||||
<button class="btn" type="button" @click="openUpload">上传新版本</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="openUpload">上传新版本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无版本记录">
|
||||
<el-table-column prop="version" label="版本号" min-width="180" />
|
||||
<el-table-column label="下载链接" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" target="_blank" rel="noopener">{{ (row as SoftwareVersionItem).fileUrl }}</a>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="190">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" download>下载</a>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
|
||||
<div class="table-scroll version-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 120px">版本号</th>
|
||||
<th>下载链接</th>
|
||||
<th style="width: 160px">创建时间</th>
|
||||
<th style="width: 120px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="filteredItems.length">
|
||||
<tr v-for="row in pagedVersions" :key="row.version + row.id">
|
||||
<td>{{ row.version }}</td>
|
||||
<td>
|
||||
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td>
|
||||
<a v-if="row.fileUrl" class="btn btn-sm btn-secondary dl-btn" :href="row.fileUrl" download>下载</a>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="4" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="4" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changeVersionPage" @size-change="changeVersionSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="uploadVisible" title="上传新版本" width="520px">
|
||||
<p class="upload-desc">上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。</p>
|
||||
@@ -146,11 +168,180 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.actions { display: flex; align-items: center; gap: 10px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
|
||||
.zip-hint { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.upload-msg { margin-bottom: 8px; }
|
||||
.upload-msg :deep(.el-alert__title) { word-break: break-all; }
|
||||
/* 像素复刻旧版 admin.html panel-version(蓝白末层)。 */
|
||||
.version-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.version-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.version-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.version-keyword {
|
||||
width: 200px;
|
||||
min-height: 42px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.version-keyword:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.version-keyword:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.version-table-scroll > table {
|
||||
min-width: 760px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.link-cell {
|
||||
display: block;
|
||||
color: #2f5d8b;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.link-cell:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dl-btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
.dim {
|
||||
color: #8293a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.upload-desc {
|
||||
margin: 0 0 12px;
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.zip-hint {
|
||||
margin: 4px 0 0;
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
}
|
||||
.upload-msg {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.upload-msg :deep(.el-alert__title) {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,11 +17,3 @@ export async function fetchHistoryUserOptions(): Promise<HistoryUserOption[]> {
|
||||
const { data } = await http.get<unknown>('/api/admin/users', { params: { page: 1, page_size: 999 } })
|
||||
return parseHistoryUserOptions(data)
|
||||
}
|
||||
|
||||
/** 清空全部生成记录(验收反馈:生成记录数据要可清理)。返回清理条数。 */
|
||||
export async function clearHistory(): Promise<number> {
|
||||
const { data } = await http.delete<unknown>(HISTORY_ENDPOINT)
|
||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
const removed = record?.data
|
||||
return typeof removed === 'number' ? removed : 0
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 历史生成记录 DTO(任务 121):列表筛选/分页归一与类型边界;与 Java ImageHistoryController
|
||||
* snake 契约对齐;历史页与后台版本/公开版本接口分离。纯逻辑。 */
|
||||
|
||||
export const HISTORY_DEFAULT_PAGE_SIZE = 15
|
||||
export const HISTORY_DEFAULT_PAGE_SIZE = 10
|
||||
export const HISTORY_MIN_PAGE = 1
|
||||
export const HISTORY_MAX_PAGE_SIZE = 200
|
||||
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 店铺密钥管理页:分页 + 新增/编辑/删除;紫鸟令牌仅掩码展示。 */
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
/** 店铺密钥管理页 · 像素复刻旧版 admin.html panel-shop-keys(自绘:面板头+新增店铺密钥、序号列、令牌掩码、白名单状态药丸+悬停详情、旧式分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } from './shop-key-api.ts'
|
||||
import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts'
|
||||
import type { ShopKeyItem } from './shop-dto.ts'
|
||||
import { whitelistStatusMeta } from './shop-key-model.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<ShopKeyItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
/** 筛选:备注名/紫鸟账号(用户要求店铺密钥列表补筛选)。 */
|
||||
const filterKeyword = ref('')
|
||||
const filteredRows = computed(() => {
|
||||
const kw = (filterKeyword.value || '').trim().toLowerCase()
|
||||
if (!kw) return rows.value
|
||||
return rows.value.filter((r) =>
|
||||
`${r.remarkName || ''} ${r.ziniaoAccountName || ''}`.toLowerCase().includes(kw),
|
||||
)
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
@@ -20,7 +33,7 @@ const saving = ref(false)
|
||||
const form = reactive(emptyShopKeyForm())
|
||||
|
||||
function maskToken(token: string): string {
|
||||
const value = (token || '')
|
||||
const value = token || ''
|
||||
if (value.length <= 8) return '••••••••'
|
||||
return `${value.slice(0, 4)}••••••••${value.slice(-4)}`
|
||||
}
|
||||
@@ -97,11 +110,8 @@ async function submit() {
|
||||
}
|
||||
|
||||
async function remove(row: ShopKeyItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除店铺密钥“${row.ziniaoAccountName}”吗?`, '删除确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(`确定删除店铺密钥“${row.ziniaoAccountName}”吗?`)) return
|
||||
try {
|
||||
await deleteShopKey(row.id)
|
||||
ElMessage.success('已删除')
|
||||
@@ -111,61 +121,101 @@ async function remove(row: ShopKeyItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>店铺密钥管理</h2>
|
||||
<p>管理各店铺的紫鸟浏览器账号与令牌。</p>
|
||||
<div class="keys-view">
|
||||
<section class="panel-box">
|
||||
<div class="keys-head">
|
||||
<h3>店铺密钥列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新增店铺密钥</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增密钥</el-button>
|
||||
<div class="form-row keys-filter-row">
|
||||
<div class="form-group" style="min-width: 220px">
|
||||
<label>备注名 / 紫鸟账号</label>
|
||||
<input v-model="filterKeyword" type="text" placeholder="模糊搜索备注名或紫鸟账号" />
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column prop="remarkName" label="备注名" min-width="140">
|
||||
<template #default="{ row }">{{ (row as ShopKeyItem).remarkName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ziniaoAccountName" label="紫鸟账号名称" min-width="160" />
|
||||
<el-table-column label="紫鸟令牌" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="(row as ShopKeyItem).ziniaoToken" placement="top">
|
||||
<code class="mono-mask">{{ maskToken((row as ShopKeyItem).ziniaoToken) }}</code>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="白名单状态" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="whitelistTooltip(row as ShopKeyItem)" placement="top">
|
||||
<el-tag :type="whitelistStatusMeta((row as ShopKeyItem).ipWhitelistStatus).tag" size="small">
|
||||
{{ whitelistStatusMeta((row as ShopKeyItem).ipWhitelistStatus).label }}
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openEdit(row as ShopKeyItem)">编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="remove(row as ShopKeyItem)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="() => { page = 1; load() }" />
|
||||
|
||||
<div class="shop-key-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 150px">备注名</th>
|
||||
<th style="width: 160px">紫鸟账号名称</th>
|
||||
<th style="width: 220px">紫鸟令牌</th>
|
||||
<th style="width: 150px">白名单状态 <span class="table-help" aria-hidden="true">?</span></th>
|
||||
<th style="width: 170px">创建时间</th>
|
||||
<th style="width: 170px">修改时间</th>
|
||||
<th style="width: 160px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="filteredRows.length">
|
||||
<tr v-for="(row, index) in filteredRows" :key="row.id">
|
||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||
<td>{{ row.remarkName || '—' }}</td>
|
||||
<td>{{ row.ziniaoAccountName }}</td>
|
||||
<td>
|
||||
<span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="wh-pill"
|
||||
:class="{
|
||||
'is-allowed': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'success',
|
||||
'is-blocked': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'danger',
|
||||
'is-unknown': whitelistStatusMeta(row.ipWhitelistStatus).tag === 'info',
|
||||
}"
|
||||
:title="whitelistTooltip(row)"
|
||||
>
|
||||
{{ whitelistStatusMeta(row.ipWhitelistStatus).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="8" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="8" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增密钥' : '编辑密钥'" width="560px">
|
||||
<el-form label-width="110px">
|
||||
@@ -193,9 +243,259 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
.mono-mask { font-family: Consolas, monospace; font-size: 12px; background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 4px; }
|
||||
/* 像素复刻旧版 admin.html panel-shop-keys(蓝白末层)。 */
|
||||
.keys-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.keys-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.keys-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.shop-key-table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.shop-key-table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
min-width: 1000px;
|
||||
}
|
||||
.shop-key-table-scroll th,
|
||||
.shop-key-table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shop-key-table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.shop-key-table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.shop-key-table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.table-help {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 3px;
|
||||
border-radius: 50%;
|
||||
background: #c7d7e5;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
.mono-mask {
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.wh-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.wh-pill.is-allowed {
|
||||
color: #3d7158;
|
||||
background: #e8f2eb;
|
||||
border-color: #bdd7c5;
|
||||
}
|
||||
.wh-pill.is-blocked {
|
||||
color: #91474f;
|
||||
background: #f8ebeb;
|
||||
border-color: #e4c2c5;
|
||||
}
|
||||
.wh-pill.is-unknown,
|
||||
.wh-pill.is-pending,
|
||||
.wh-pill.is-notchecked {
|
||||
color: #5b6f83;
|
||||
background: #f1f6fa;
|
||||
border-color: #d6e2ec;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 店铺管理页:分组/关键字筛选 + 分页 + 新增/编辑/凭证明文/删除。 */
|
||||
/** 店铺管理页 · 像素复刻旧版 admin.html panel-shop-manage(自绘:面板头+新增店铺、筛选、密码列 ******+眼睛实时读明文、旧式表格/分页、原生 confirm 删除)。
|
||||
* script 逻辑沿用现有 Vue 实现(凭证明文 dialog/编辑弹窗/分组筛选)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import {
|
||||
deleteShopManage,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from './shop-manage-api.ts'
|
||||
import { emptyShopManageForm } from './shop-manage-form-model.ts'
|
||||
import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
@@ -21,6 +23,8 @@ const rows = ref<ShopSummary[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const filter = reactive({ shopName: '', groupId: null as number | null })
|
||||
@@ -94,17 +98,26 @@ function apply() {
|
||||
load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filter.shopName = ''
|
||||
filter.groupId = null
|
||||
page.value = 1
|
||||
load()
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
@@ -166,11 +179,8 @@ async function showCredential(row: ShopSummary) {
|
||||
}
|
||||
|
||||
async function remove(row: ShopSummary) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除店铺“${row.shopName}”吗?`, '删除确认', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(`确定删除店铺“${row.shopName}”吗?`)) return
|
||||
try {
|
||||
await deleteShopManage(row.id)
|
||||
ElMessage.success('已删除')
|
||||
@@ -196,83 +206,88 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>店铺管理</h2>
|
||||
<p>管理店铺账号、所属分组与自动化账号。</p>
|
||||
<div class="shop-view">
|
||||
<section class="panel-box">
|
||||
<div class="shop-head">
|
||||
<h3>店铺列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新增店铺</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<div class="form-row shop-filter-row">
|
||||
<div class="form-group" style="min-width: 180px">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<div class="form-group" style="min-width: 220px">
|
||||
<label>店铺名</label>
|
||||
<el-input v-model="filter.shopName" placeholder="模糊搜索店铺名" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增店铺</el-button>
|
||||
<div class="table-scroll shop-manage-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 120px">分组</th>
|
||||
<th style="width: 150px">店铺名</th>
|
||||
<th style="width: 130px">店铺商城名</th>
|
||||
<th style="width: 130px">自动化账号</th>
|
||||
<th style="width: 130px">账号</th>
|
||||
<th style="width: 170px">密码</th>
|
||||
<th style="width: 170px">创建时间</th>
|
||||
<th style="width: 170px">修改时间</th>
|
||||
<th style="width: 220px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="(row, index) in rows" :key="row.id">
|
||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||
<td data-text :title="row.groupName || '—'">{{ row.groupName || '—' }}</td>
|
||||
<td :title="row.shopName">{{ row.shopName }}</td>
|
||||
<td :title="row.mallName || '—'">{{ row.mallName || '—' }}</td>
|
||||
<td :title="row.znUsername || '—'">{{ row.znUsername || '—' }}</td>
|
||||
<td :title="row.account">{{ row.account }}</td>
|
||||
<td>
|
||||
<span class="pwd-cell">{{ shownPassword(row) }}</span>
|
||||
<button
|
||||
class="pwd-eye btn btn-sm btn-secondary"
|
||||
type="button"
|
||||
:aria-label="revealedRowId === row.id ? '隐藏密码' : '显示密码'"
|
||||
@click="togglePassword(row)"
|
||||
>
|
||||
{{ revealedRowId === row.id ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="showCredential(row)">凭证</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="10" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="10" class="empty-tip">暂无店铺</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column prop="groupName" label="分组" min-width="120" />
|
||||
<el-table-column prop="shopName" label="店铺名" min-width="160" />
|
||||
<el-table-column prop="mallName" label="店铺商城名" min-width="120" />
|
||||
<el-table-column prop="znUsername" label="自动化账号" min-width="140">
|
||||
<template #default="{ row }">{{ (row as ShopSummary).znUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="account" label="账号" min-width="140" />
|
||||
<el-table-column label="密码" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<span class="pwd-cell">{{ shownPassword(row as ShopSummary) }}</span>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:aria-label="revealedRowId === (row as ShopSummary).id ? '隐藏密码' : '显示密码'"
|
||||
@click="togglePassword(row as ShopSummary)"
|
||||
>
|
||||
{{ revealedRowId === (row as ShopSummary).id ? '隐藏' : '显示' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openEdit(row as ShopSummary)">编辑</el-button>
|
||||
<el-button text type="warning" size="small" @click="showCredential(row as ShopSummary)">凭证</el-button>
|
||||
<el-button text type="danger" size="small" @click="remove(row as ShopSummary)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="changeSize" />
|
||||
</div>
|
||||
</el-card>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="所属分组" required>
|
||||
<el-select v-model="form.groupId" placeholder="选择分组">
|
||||
<el-select filterable v-model="form.groupId" placeholder="选择分组">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
@@ -316,14 +331,239 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.pwd-cell { font-family: Consolas, monospace; font-size: 12px; margin-right: 6px; }
|
||||
/* 像素复刻旧版 admin.html panel-shop-manage(蓝白末层)。 */
|
||||
.shop-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.shop-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.shop-manage-table-scroll > table {
|
||||
min-width: 1200px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.pwd-cell {
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.pwd-eye {
|
||||
min-height: 28px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dim {
|
||||
color: #8293a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 视频任务记录页:任务卡片网格(播放/下载/复制)+ 筛选 + 批量打包下载 + 明细 + 数据范围授权。 */
|
||||
/** 视频任务记录页 · 像素复刻旧版 admin.html panel-image-video-tasks(自绘:form-box 筛选[用户名/提交起止+查询/重置] + panel-box toolbar[全选/批量下载/权限配置/汇总] + 任务卡片网格 + 旧式分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(批量打包/明细抽屉/权限授权)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { requestVideoZipDownload, fetchImageVideoTasks, fetchImageVideoTaskDetail } from './image-video-api.ts'
|
||||
@@ -16,12 +17,17 @@ import {
|
||||
} from './image-video-selection.ts'
|
||||
import { grantedUserIds, type TaskPermissionItem } from './task-permission.ts'
|
||||
import { imageVideoDownloadFilename, videoKeysOf } from './image-video-view.ts'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const tasks = ref<ImageVideoRow[]>([])
|
||||
const totalTasks = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const pageSize = ref(10)
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize.value)))
|
||||
|
||||
const filter = reactive<ImageVideoFilter & { dateRange: string[] }>({ ...createImageVideoFilter(), dateRange: [] })
|
||||
const selection = ref<Set<string>>(new Set())
|
||||
@@ -34,21 +40,47 @@ const permissionVisible = ref(false)
|
||||
const permissionItems = ref<TaskPermissionItem[]>([])
|
||||
const permissionInitial = ref<number[]>([])
|
||||
const permissionSaving = ref(false)
|
||||
/** 权限弹窗用户表客户端分页(全量用户在表格内翻页,10/20/50/100 可选)。 */
|
||||
const permPage = ref(1)
|
||||
const permPageSize = ref(10)
|
||||
const pagedPermissionItems = computed(() =>
|
||||
permissionItems.value.slice((permPage.value - 1) * permPageSize.value, permPage.value * permPageSize.value),
|
||||
)
|
||||
|
||||
const downloading = ref(false)
|
||||
const downloadingKey = ref('')
|
||||
|
||||
/** 旧版状态 label 映射(admin.js 前缀兜底:RUN/WAIT/PROCESS/POLL → 处理中)。 */
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' }
|
||||
return map[status] || status
|
||||
const map: Record<string, string> = {
|
||||
PENDING: '待执行',
|
||||
WAITING: '排队中',
|
||||
RUNNING: '执行中',
|
||||
POLLING: '处理中',
|
||||
SUCCESS: '已完成',
|
||||
COMPLETED: '已完成',
|
||||
DONE: '已完成',
|
||||
FAILED: '失败',
|
||||
ERROR: '失败',
|
||||
CANCELLED: '已取消',
|
||||
CANCELED: '已取消',
|
||||
DELETED: '已删除',
|
||||
STOPPED: '已停止',
|
||||
UNKNOWN: '未知',
|
||||
}
|
||||
const key = (status || '').toUpperCase()
|
||||
if (map[key]) return map[key]
|
||||
if (/^(RUN|WAIT|PROCESS|POLL)/.test(key)) return '处理中'
|
||||
return key || '未知'
|
||||
}
|
||||
|
||||
function statusType(status: string): 'info' | 'warning' | 'success' | 'danger' | 'primary' {
|
||||
if (status === 'SUCCESS') return 'success'
|
||||
if (status === 'RUNNING') return 'warning'
|
||||
if (status === 'FAILED') return 'danger'
|
||||
if (status === 'CANCELLED') return 'info'
|
||||
return 'info'
|
||||
function statusType(status: string): 'pending' | 'running' | 'success' | 'failed' | 'cancelled' {
|
||||
const key = (status || '').toUpperCase()
|
||||
if (key === 'SUCCESS' || key === 'COMPLETED' || key === 'DONE') return 'success'
|
||||
if (key === 'FAILED' || key === 'ERROR') return 'failed'
|
||||
if (key === 'CANCELLED' || key === 'CANCELED') return 'cancelled'
|
||||
if (key === 'PENDING' || key === 'WAITING') return 'pending'
|
||||
return 'running'
|
||||
}
|
||||
|
||||
const currentVideoKeys = computed(() => {
|
||||
@@ -78,7 +110,7 @@ function toFilter(): ImageVideoFilter {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize)
|
||||
const result = await fetchImageVideoTasks(toFilter(), page.value, pageSize.value)
|
||||
tasks.value = result.items
|
||||
totalTasks.value = result.total
|
||||
page.value = result.page
|
||||
@@ -101,6 +133,29 @@ function reset() {
|
||||
void load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
selection.value = new Set()
|
||||
void load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
selection.value = allSelected.value ? clearVideoSelection() : new Set(currentVideoKeys.value)
|
||||
}
|
||||
@@ -195,6 +250,7 @@ async function openPermission() {
|
||||
try {
|
||||
permissionItems.value = await fetchImageVideoPermissionUsers()
|
||||
permissionInitial.value = grantedUserIds(permissionItems.value)
|
||||
permPage.value = 1
|
||||
permissionVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
|
||||
@@ -221,7 +277,8 @@ async function savePermission() {
|
||||
}
|
||||
|
||||
function videoErrorText(task: ImageVideoRow): string {
|
||||
if (task.status === 'FAILED' || task.status === 'CANCELLED') return '任务失败,未生成视频'
|
||||
const key = (task.status || '').toUpperCase()
|
||||
if (key === 'FAILED' || key === 'ERROR' || key === 'CANCELLED') return '任务失败,未生成视频'
|
||||
return '视频生成中或暂无结果'
|
||||
}
|
||||
|
||||
@@ -233,97 +290,89 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>视频任务记录</h2>
|
||||
<p>查看图生视频任务卡片、在线预览与下载视频、配置数据范围授权。</p>
|
||||
<div class="iv-view">
|
||||
<section class="form-box">
|
||||
<h3>视频任务记录筛选</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width: 160px">
|
||||
<label>用户名</label>
|
||||
<input v-model="filter.username" type="text" placeholder="模糊搜索" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 176px">
|
||||
<label>提交开始</label>
|
||||
<input v-model="filter.dateRange[0]" type="datetime-local" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 176px">
|
||||
<label>提交结束</label>
|
||||
<input v-model="filter.dateRange[1]" type="datetime-local" />
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
<button class="btn btn-secondary" type="button" @click="reset">重置</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-checkbox :model-value="allSelected" @change="toggleSelectAll">全选当前页</el-checkbox>
|
||||
<el-button :disabled="selectionCount === 0" :loading="downloading" @click="downloadBatch">
|
||||
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
|
||||
</el-button>
|
||||
<el-button @click="openPermission">权限配置</el-button>
|
||||
</section>
|
||||
|
||||
<section class="panel-box iv-panel">
|
||||
<div class="iv-toolbar">
|
||||
<div class="iv-toolbar-main">
|
||||
<button v-if="session.isSuperAdmin" class="btn btn-secondary" type="button" @click="openPermission">权限配置</button>
|
||||
<button class="btn iv-batch-btn" type="button" :disabled="selectionCount === 0 || downloading" @click="downloadBatch">
|
||||
批量下载{{ selectionCount ? ` (${selectionCount})` : '' }}
|
||||
</button>
|
||||
<label class="iv-select-all">
|
||||
<input type="checkbox" :checked="allSelected" @change="toggleSelectAll" />
|
||||
全选当前页
|
||||
</label>
|
||||
</div>
|
||||
<span class="iv-summary">共 {{ totalTasks }} 个任务 · 本页 {{ currentVideoCount }} 个视频</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item wide">
|
||||
<label>提交时间</label>
|
||||
<el-date-picker v-model="filter.dateRange" type="datetimerange" range-separator="至" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DDTHH:mm" unlink-panels />
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
<div v-loading="loading" class="task-card-grid">
|
||||
<template v-if="tasks.length">
|
||||
<section v-for="task in tasks" :key="String(task.taskId)" class="iv-card">
|
||||
<header class="iv-card-head">
|
||||
<input type="checkbox" :checked="cardSelected(task)" :disabled="!hasVideos(task)" @change="toggleCard(task)" />
|
||||
<span class="iv-card-title">任务 {{ task.taskId }} · 视频 {{ task.videos.length }}</span>
|
||||
<span class="st-pill" :class="`is-${statusType(task.status)}`">{{ statusLabel(task.status) }}</span>
|
||||
</header>
|
||||
|
||||
<div class="iv-card-info">
|
||||
<div class="iv-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
|
||||
<div class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
|
||||
<div class="iv-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
|
||||
<div class="iv-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
|
||||
<div class="iv-info-row">
|
||||
<label>调试链接</label>
|
||||
<button v-if="task.debugUrl" class="btn btn-sm" type="button" @click="copyLink(task.debugUrl)">复制链接</button>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasVideos(task)" class="iv-video-list">
|
||||
<div v-for="(video, index) in task.videos" :key="`${task.taskId}-${index}`" class="iv-video-row">
|
||||
<input type="checkbox" :checked="selection.has(`${task.taskId}:${index}`)" @change="toggleVideoKey(`${task.taskId}:${index}`)" />
|
||||
<video v-if="video.displayUrl" class="iv-player" :src="video.displayUrl" controls preload="metadata"></video>
|
||||
<span v-else class="iv-no-preview">视频加载失败,可尝试下载</span>
|
||||
<div class="iv-video-ops">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
type="button"
|
||||
:disabled="!video.displayUrl"
|
||||
:aria-busy="downloadingKey === `${task.taskId}:${index}`"
|
||||
@click="downloadVideo(task, index, video)"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
<button v-if="video.displayUrl" class="btn btn-sm" type="button" @click="copyLink(video.displayUrl)">复制链接</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="iv-empty-note">{{ videoErrorText(task) }}</div>
|
||||
</section>
|
||||
</template>
|
||||
<div v-else-if="!loading" class="empty-tip">暂无符合条件的视频任务</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-loading="loading" class="video-summary-row">
|
||||
<span>共 {{ totalTasks }} 个任务 · 本页 {{ currentVideoCount }} 个视频</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
:total="totalTasks"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; selection = new Set(); load() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="tasks.length" class="task-card-grid">
|
||||
<section v-for="task in tasks" :key="String(task.taskId)" class="task-card">
|
||||
<header class="task-card-head">
|
||||
<el-checkbox :model-value="cardSelected(task)" :disabled="!hasVideos(task)" @change="toggleCard(task)" />
|
||||
<div class="task-card-title">
|
||||
<span class="task-id">任务 {{ task.taskId }}</span>
|
||||
</div>
|
||||
<el-tag :type="statusType(task.status)" size="small">{{ statusLabel(task.status) }}</el-tag>
|
||||
<el-button text type="primary" size="small" @click="openDetail(task)">明细</el-button>
|
||||
</header>
|
||||
|
||||
<div class="task-card-info">
|
||||
<div class="task-info-row"><label>用户名</label><span>{{ task.username || '-' }}</span></div>
|
||||
<div class="task-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
|
||||
<div class="task-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</span></div>
|
||||
<div class="task-info-row"><label>生成时间</label><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
|
||||
<div class="task-info-row">
|
||||
<label>调试链接</label>
|
||||
<el-button v-if="task.debugUrl" text type="primary" size="small" @click="copyLink(task.debugUrl)">复制链接</el-button>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasVideos(task)" class="video-list">
|
||||
<div v-for="(video, index) in task.videos" :key="`${task.taskId}-${index}`" class="video-row">
|
||||
<el-checkbox :model-value="selection.has(`${task.taskId}:${index}`)" @change="toggleVideoKey(`${task.taskId}:${index}`)" />
|
||||
<video v-if="video.displayUrl" class="video-player" :src="video.displayUrl" controls preload="metadata" />
|
||||
<span v-else class="video-no-preview">视频加载失败,可尝试下载</span>
|
||||
<span class="video-index">第 {{ index + 1 }} 个</span>
|
||||
<el-button
|
||||
text
|
||||
type="success"
|
||||
size="small"
|
||||
:disabled="!video.displayUrl"
|
||||
:loading="downloadingKey === `${task.taskId}:${index}`"
|
||||
@click="downloadVideo(task, index, video)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button v-if="video.displayUrl" text type="primary" size="small" @click="copyLink(video.displayUrl)">复制链接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="video-empty">{{ videoErrorText(task) }}</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!loading" description="暂无视频任务记录" />
|
||||
<OldPagination :total="totalTasks" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="任务明细" size="720px">
|
||||
<div v-loading="detailLoading">
|
||||
@@ -335,26 +384,16 @@ onMounted(load)
|
||||
<el-descriptions-item label="任务模式">{{ detail.mode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间">{{ formatDateTime(detail.submittedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生成时间">{{ formatDateTime(imageVideoGeneratedAt(detail)) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="调试链接">
|
||||
<el-button v-if="detail.debugUrl" text type="primary" size="small" @click="copyLink(detail.debugUrl)">复制链接</el-button>
|
||||
<span v-else>—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行ID">{{ detail.cozeExecuteId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="错误">{{ detail.errorMessage || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4>请求</h4>
|
||||
<pre class="json-block">{{ JSON.stringify(detail.request, null, 2) }}</pre>
|
||||
<h4>提交响应</h4>
|
||||
<pre class="json-block">{{ JSON.stringify(detail.submitResponse, null, 2) }}</pre>
|
||||
<h4>结果</h4>
|
||||
<pre class="json-block">{{ JSON.stringify(detail.result, null, 2) }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="permissionVisible" title="视频任务数据范围授权" width="620px">
|
||||
<p class="dim">勾选允许查看/操作视频任务的用户(仅数据范围)。当前已授权 {{ permissionGrantedCount }} 人。</p>
|
||||
<el-table :data="permissionItems" border size="small" max-height="420">
|
||||
<el-dialog v-model="permissionVisible" title="视频任务记录权限配置" width="620px">
|
||||
<p class="dim">勾选允许查看视频任务记录的用户(仅数据范围)。当前已授权 {{ permissionGrantedCount }} 人。</p>
|
||||
<el-table :data="pagedPermissionItems" border size="small" max-height="420">
|
||||
<el-table-column prop="id" label="ID" min-width="80" />
|
||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||
<el-table-column label="授权" min-width="90" align="center">
|
||||
@@ -363,6 +402,7 @@ onMounted(load)
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<OldPagination v-if="permissionItems.length > permPageSize" :total="permissionItems.length" :page="permPage" :page-size="permPageSize" @change="permPage = $event" @size-change="(s: number) => { permPageSize = s; permPage = 1 }" />
|
||||
<template #footer>
|
||||
<el-button @click="permissionVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
|
||||
@@ -372,30 +412,315 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.actions { display: flex; align-items: center; gap: 12px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.wide { width: 340px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; margin-left: auto; flex: none; }
|
||||
.video-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.task-card-grid { display: flex; flex-direction: column; gap: 14px; }
|
||||
.task-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; }
|
||||
.task-card-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.task-card-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.task-id { font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.task-card-info { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 6px 14px; padding: 12px 16px; border-bottom: 1px dashed var(--el-border-color-lighter); }
|
||||
.task-info-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; min-width: 0; }
|
||||
.task-info-row label { flex: none; color: var(--el-text-color-secondary); min-width: 60px; }
|
||||
.task-info-row span { color: var(--el-text-color-regular); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.video-list { padding: 6px 16px; }
|
||||
.video-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px dashed var(--el-border-color-lighter); }
|
||||
.video-row:last-child { border-bottom: 0; }
|
||||
.video-player { width: 300px; max-height: 180px; border-radius: 8px; background: #0d1117; }
|
||||
.video-no-preview { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.video-index { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.video-empty { padding: 18px 16px; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.json-block { background: var(--el-fill-color-lighter); border-radius: 6px; padding: 10px; max-height: 320px; overflow: auto; font-size: 12px; }
|
||||
/* 像素复刻旧版 admin.html panel-image-video-tasks(蓝白末层)。 */
|
||||
.iv-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.form-box,
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.iv-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.iv-toolbar-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.iv-select-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
color: #5b6f83;
|
||||
cursor: pointer;
|
||||
}
|
||||
.iv-summary {
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.iv-card {
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
.iv-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e6edf4;
|
||||
}
|
||||
.iv-card-title {
|
||||
font-weight: 600;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
}
|
||||
.iv-card-info {
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
.iv-info-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.iv-info-row label {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
color: #8293a5;
|
||||
}
|
||||
.iv-info-row span {
|
||||
color: #40586e;
|
||||
min-width: 0;
|
||||
}
|
||||
.iv-video-list {
|
||||
padding: 8px 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.iv-video-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.iv-player {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-height: 240px;
|
||||
border-radius: 8px;
|
||||
background: #0f1722;
|
||||
}
|
||||
.iv-no-preview {
|
||||
flex: 1;
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 12.5px;
|
||||
border: 1px dashed #d8e3ee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.iv-video-ops {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.iv-empty-note {
|
||||
padding: 16px;
|
||||
color: #8293a5;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.st-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.st-pill.is-success {
|
||||
color: #3d7158;
|
||||
background: #e8f2eb;
|
||||
border-color: #bdd7c5;
|
||||
}
|
||||
.st-pill.is-failed {
|
||||
color: #91474f;
|
||||
background: #f8ebeb;
|
||||
border-color: #e4c2c5;
|
||||
}
|
||||
.st-pill.is-cancelled {
|
||||
color: #5b6f83;
|
||||
background: #f1f6fa;
|
||||
border-color: #d6e2ec;
|
||||
}
|
||||
.st-pill.is-pending {
|
||||
color: #8a6d3b;
|
||||
background: #fbf4ea;
|
||||
border-color: #e7d3ae;
|
||||
}
|
||||
.st-pill.is-running {
|
||||
color: #8a6d3b;
|
||||
background: #fbf4ea;
|
||||
border-color: #e7d3ae;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dim {
|
||||
color: #8293a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.task-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 店铺数据任务/记录页:每条任务一张卡片(店铺名/任务号/状态/分组/最新/国家/文件 + 下载文件/删除)
|
||||
* + 筛选 + 批量下载 + 数据范围授权(对齐 admin.js renderShopDataRecordTable)。 */
|
||||
/** 店铺数据记录页 · 像素复刻旧版 admin.html panel-shop-data-crawl-tasks(自绘:form-box 筛选[h4 筛选条件] + panel-box toolbar[全选/批量下载/权限配置/汇总] + 每店卡片网格 + 旧式分页(店铺级+卡片级 9/页))。
|
||||
* script 逻辑沿用现有 Vue 实现(批量打包/删除结果文件[保留]/权限授权)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { asinCountryLabel } from '../asin/asin-country.ts'
|
||||
import { createShopDataFilter, type ShopDataFilter } from './shop-data-filter.ts'
|
||||
import type { ShopDataResultRow, ShopDataTaskGroup } from './shop-data-model.ts'
|
||||
@@ -24,15 +24,21 @@ import {
|
||||
createShopDataSelection,
|
||||
toggleShopDataSelection,
|
||||
} from './shop-data-selection.ts'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const groups = ref<ShopDataTaskGroup[]>([])
|
||||
const totalShops = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
// 卡片宫格分页:一行 3 个、一页 9 张卡片(在店铺分页的结果文件内翻页)。
|
||||
// 店铺级一次全载(>200 家店铺时后端按页截断,可上调或恢复底部店铺级分页)。
|
||||
const pageSize = ref(200)
|
||||
// 卡片宫格分页:一行 3 个、一页 10 张卡片(在店铺分页的结果文件内翻页)。
|
||||
const cardPage = ref(1)
|
||||
const cardPageSize = 9
|
||||
const cardPageSize = ref(10)
|
||||
const cardJump = ref('')
|
||||
const cardTotalPages = computed(() => Math.max(1, Math.ceil(allResultRows.value.length / cardPageSize.value)))
|
||||
|
||||
const filter = reactive<ShopDataFilter & { dateRange: string[] }>({ ...createShopDataFilter(), dateRange: [] })
|
||||
const selection = ref<Set<string>>(new Set())
|
||||
@@ -40,20 +46,42 @@ const selection = ref<Set<string>>(new Set())
|
||||
const permissionVisible = ref(false)
|
||||
const permissionItems = ref<TaskPermissionItem[]>([])
|
||||
const permissionSaving = ref(false)
|
||||
/** 权限弹窗用户表客户端分页(全量用户在表格内翻页,10/20/50/100 可选)。 */
|
||||
const permPage = ref(1)
|
||||
const permPageSize = ref(10)
|
||||
const pagedPermissionItems = computed(() =>
|
||||
permissionItems.value.slice((permPage.value - 1) * permPageSize.value, permPage.value * permPageSize.value),
|
||||
)
|
||||
|
||||
const busyKey = ref('')
|
||||
const deletingKey = ref('')
|
||||
|
||||
/** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = { PENDING: '排队中', RUNNING: '进行中', SUCCESS: '成功', FAILED: '失败', CANCELLED: '已取消', UNKNOWN: '未知' }
|
||||
return map[status] || status
|
||||
const map: Record<string, string> = {
|
||||
PENDING: '待执行',
|
||||
WAITING: '排队中',
|
||||
RUNNING: '执行中',
|
||||
PROCESSING: '处理中',
|
||||
SUCCESS: '已完成',
|
||||
COMPLETED: '已完成',
|
||||
FAILED: '失败',
|
||||
ERROR: '失败',
|
||||
CANCELLED: '已取消',
|
||||
UNKNOWN: '未知',
|
||||
}
|
||||
const key = (status || '').toUpperCase()
|
||||
if (map[key]) return map[key]
|
||||
return key || '未知'
|
||||
}
|
||||
|
||||
function statusType(status: string): 'info' | 'warning' | 'success' | 'danger' {
|
||||
if (status === 'SUCCESS') return 'success'
|
||||
if (status === 'RUNNING') return 'warning'
|
||||
if (status === 'FAILED') return 'danger'
|
||||
return 'info'
|
||||
function statusType(status: string): 'pending' | 'running' | 'success' | 'failed' | 'cancelled' {
|
||||
const key = (status || '').toUpperCase()
|
||||
if (key === 'SUCCESS' || key === 'COMPLETED') return 'success'
|
||||
if (key === 'FAILED' || key === 'ERROR') return 'failed'
|
||||
if (key === 'CANCELLED') return 'cancelled'
|
||||
if (key === 'PENDING' || key === 'WAITING') return 'pending'
|
||||
return 'running'
|
||||
}
|
||||
|
||||
function toFilter(): ShopDataFilter {
|
||||
@@ -69,12 +97,12 @@ function toFilter(): ShopDataFilter {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize)
|
||||
const result = await fetchShopDataTaskList(toFilter(), page.value, pageSize.value)
|
||||
groups.value = result.items
|
||||
totalShops.value = result.total
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '店铺数据任务加载失败')
|
||||
ElMessage.error(error instanceof Error ? error.message : '店铺数据记录加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -94,7 +122,7 @@ function reset() {
|
||||
|
||||
const allResultRows = computed(() => groups.value.flatMap((group) => group.results))
|
||||
const pagedResultRows = computed(() =>
|
||||
allResultRows.value.slice((cardPage.value - 1) * cardPageSize, cardPage.value * cardPageSize),
|
||||
allResultRows.value.slice((cardPage.value - 1) * cardPageSize.value, cardPage.value * cardPageSize.value),
|
||||
)
|
||||
const selectionCount = computed(() => countShopDataSelection(selection.value))
|
||||
|
||||
@@ -102,6 +130,20 @@ function changeCardPage(p: number) {
|
||||
cardPage.value = p
|
||||
}
|
||||
|
||||
function changeCardSize(size: number) {
|
||||
cardPageSize.value = size
|
||||
cardPage.value = 1
|
||||
}
|
||||
|
||||
function goCardJump() {
|
||||
const n = Number.parseInt(cardJump.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changeCardPage(Math.min(Math.max(n, 1), cardTotalPages.value))
|
||||
}
|
||||
|
||||
function resultFileCount(): number {
|
||||
return groups.value.reduce((sum, group) => sum + group.results.length, 0)
|
||||
}
|
||||
@@ -127,6 +169,16 @@ function rowSelected(row: ShopDataResultRow): boolean {
|
||||
return selection.value.has(row.resultId)
|
||||
}
|
||||
|
||||
function selectAllChecked(): boolean {
|
||||
const rows = selectableRows()
|
||||
return rows.length > 0 && rows.every((row) => selection.value.has(row.resultId))
|
||||
}
|
||||
|
||||
function selectAllIndeterminate(): boolean {
|
||||
const rows = selectableRows()
|
||||
return selectionCount.value > 0 && selectionCount.value < rows.length
|
||||
}
|
||||
|
||||
async function downloadRow(row: ShopDataResultRow) {
|
||||
if (!row.fileReady) {
|
||||
ElMessage.warning('文件未就绪,无法下载')
|
||||
@@ -164,15 +216,8 @@ async function downloadBatch() {
|
||||
}
|
||||
|
||||
async function removeRowByRow(row: ShopDataResultRow) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除店铺“${row.shopName}”的任务 ${row.taskNo || row.resultId} 及结果文件?`, '删除前确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(`确认删除店铺“${row.shopName}”的任务 ${row.taskNo || row.resultId} 及结果文件?`)) return
|
||||
deletingKey.value = row.resultId
|
||||
try {
|
||||
await deleteShopDataResultHistory(row.resultId)
|
||||
@@ -197,6 +242,7 @@ function saveBlob(blob: Blob, filename: string) {
|
||||
async function openPermission() {
|
||||
try {
|
||||
permissionItems.value = await fetchShopDataTaskPermissionUsers()
|
||||
permPage.value = 1
|
||||
permissionVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '权限用户加载失败')
|
||||
@@ -227,139 +273,120 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>店铺数据任务/记录</h2>
|
||||
<p>按店铺查看数据抓取结果文件,支持筛选、下载、删除与数据范围授权。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-checkbox
|
||||
:model-value="selectableRows().length > 0 && selectableRows().every((row) => rowSelected(row))"
|
||||
:indeterminate="selectionCount > 0 && selectionCount < selectableRows().length"
|
||||
@change="toggleSelectAllReady"
|
||||
>
|
||||
全选可下载
|
||||
</el-checkbox>
|
||||
<el-button :disabled="selectionCount === 0" :loading="busyKey === 'batch'" @click="downloadBatch">
|
||||
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
|
||||
</el-button>
|
||||
<el-button @click="openPermission">权限配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>店铺(模糊搜索)</label>
|
||||
<el-input v-model="filter.shopName" placeholder="输入店铺关键字" clearable @keyup.enter="apply" />
|
||||
<div class="sd-view">
|
||||
<section class="form-box">
|
||||
<h3>店铺数据记录</h3>
|
||||
<h4 class="sd-filter-title">筛选条件</h4>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width: 160px">
|
||||
<label>店铺</label>
|
||||
<input v-model="filter.shopName" type="text" placeholder="模糊搜索店铺名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>分组(模糊搜索)</label>
|
||||
<el-input v-model="filter.groupName" placeholder="输入分组关键字" clearable @keyup.enter="apply" />
|
||||
<div class="form-group" style="min-width: 150px">
|
||||
<label>分组</label>
|
||||
<input v-model="filter.groupName" type="text" placeholder="模糊搜索分组名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<div class="form-group" style="min-width: 130px">
|
||||
<label>国家</label>
|
||||
<el-select v-model="filter.country" placeholder="全部" clearable>
|
||||
<el-option v-for="code in ['UK', 'DE', 'FR', 'ES', 'IT']" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
<select v-model="filter.country">
|
||||
<option value="">全部</option>
|
||||
<option v-for="code in ['UK', 'DE', 'FR', 'ES', 'IT']" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="f-item wide">
|
||||
<label>创建时间</label>
|
||||
<el-date-picker v-model="filter.dateRange" type="datetimerange" range-separator="至" start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DDTHH:mm" unlink-panels />
|
||||
<div class="form-group" style="min-width: 176px">
|
||||
<label>创建开始</label>
|
||||
<input v-model="filter.dateRange[0]" type="datetime-local" />
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<div class="form-group" style="min-width: 176px">
|
||||
<label>创建结束</label>
|
||||
<input v-model="filter.dateRange[1]" type="datetime-local" />
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
<button class="btn btn-secondary" type="button" @click="reset">重置</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-box">
|
||||
<div class="sd-toolbar">
|
||||
<div class="sd-toolbar-main">
|
||||
<label class="sd-select-all">
|
||||
<input type="checkbox" :checked="selectAllChecked()" :indeterminate="selectAllIndeterminate()" @change="toggleSelectAllReady" />
|
||||
全选可下载
|
||||
</label>
|
||||
<button class="btn" type="button" :disabled="selectionCount === 0 || busyKey === 'batch'" @click="downloadBatch">
|
||||
批量下载{{ selectionCount ? `(${selectionCount})` : '' }}
|
||||
</button>
|
||||
<button v-if="session.isSuperAdmin" class="btn btn-secondary" type="button" @click="openPermission">权限配置</button>
|
||||
</div>
|
||||
<div class="sd-toolbar-side">
|
||||
<span class="sd-summary">共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-loading="loading" class="shop-summary-row">
|
||||
<span>共 {{ totalShops }} 家店铺 · 本页 {{ resultFileCount() }} 个结果文件</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
:total="totalShops"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; cardPage = 1; selection = new Set(); load() }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="allResultRows.length" class="shop-card-grid">
|
||||
<article v-for="row in pagedResultRows" :key="row.resultId || `${row.taskNo}-${row.filename}`" class="shop-card">
|
||||
<header class="shop-card-head">
|
||||
<span class="shop-name" :title="row.shopName || '-'">{{ row.shopName || '-' }}</span>
|
||||
</header>
|
||||
<div class="shop-card-select-row">
|
||||
<el-checkbox
|
||||
:model-value="rowSelected(row)"
|
||||
:disabled="!isUsableShopDataResult(row)"
|
||||
@change="toggleRow(row)"
|
||||
/>
|
||||
<span class="shop-card-task-no">任务 {{ row.taskNo || row.resultId }}</span>
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="shop-card-row">
|
||||
<span class="shop-card-label">分组</span>
|
||||
<span class="shop-card-value">{{ row.groupName || '-' }}</span>
|
||||
</div>
|
||||
<div class="shop-card-row">
|
||||
<span class="shop-card-label">最新</span>
|
||||
<span class="shop-card-value">{{ formatDateTime(row.updatedAt || row.createdAt) || '-' }}</span>
|
||||
</div>
|
||||
<div class="shop-card-row">
|
||||
<span class="shop-card-label">国家</span>
|
||||
<span class="shop-card-value">
|
||||
<span v-for="code in row.countryCodes" :key="code" class="country-chip">{{ asinCountryLabel(code) }}</span>
|
||||
<span v-if="!row.countryCodes.length" class="dim">-</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shop-card-row">
|
||||
<span class="shop-card-label">文件</span>
|
||||
<span class="shop-card-value shop-card-file" :title="row.filename">{{ row.filename || '-' }}</span>
|
||||
</div>
|
||||
<div class="shop-card-actions">
|
||||
<el-button
|
||||
text
|
||||
type="success"
|
||||
size="small"
|
||||
:loading="busyKey === row.resultId"
|
||||
:disabled="!isUsableShopDataResult(row)"
|
||||
@click="downloadRow(row)"
|
||||
<div v-loading="loading" class="shop-card-grid">
|
||||
<template v-if="allResultRows.length">
|
||||
<article
|
||||
v-for="row in pagedResultRows"
|
||||
:key="row.resultId || `${row.taskNo}-${row.filename}`"
|
||||
class="sd-card"
|
||||
>
|
||||
下载文件
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isTerminalStatus(row.status) && !!row.resultId"
|
||||
text
|
||||
type="danger"
|
||||
size="small"
|
||||
:loading="deletingKey === row.resultId"
|
||||
@click="removeRowByRow(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="allResultRows.length > cardPageSize" class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
:total="allResultRows.length"
|
||||
:page-size="cardPageSize"
|
||||
:current-page="cardPage"
|
||||
@current-change="changeCardPage"
|
||||
/>
|
||||
</div>
|
||||
<header class="sd-card-head">
|
||||
<span class="sd-shop-name" :title="row.shopName || '-'">{{ row.shopName || '-' }}</span>
|
||||
</header>
|
||||
<div class="sd-card-select-row">
|
||||
<input type="checkbox" :checked="rowSelected(row)" :disabled="!isUsableShopDataResult(row)" @change="toggleRow(row)" />
|
||||
<span class="shop-card-task-no">任务 {{ row.taskNo || row.resultId }}</span>
|
||||
<span class="st-pill" :class="`is-${statusType(row.status)}`">{{ statusLabel(row.status) }}</span>
|
||||
</div>
|
||||
<div class="sd-card-row" v-if="row.groupName">
|
||||
<span class="sd-card-label">分组</span>
|
||||
<span class="sd-card-value">{{ row.groupName }}</span>
|
||||
</div>
|
||||
<div class="sd-card-row">
|
||||
<span class="sd-card-label">最新</span>
|
||||
<span class="sd-card-value">{{ formatDateTime(row.updatedAt || row.createdAt) || '-' }}</span>
|
||||
</div>
|
||||
<div class="sd-card-row">
|
||||
<span class="sd-card-label">国家</span>
|
||||
<span class="sd-card-value">
|
||||
<span v-for="code in row.countryCodes" :key="code" class="country-chip">{{ asinCountryLabel(code) }}</span>
|
||||
<span v-if="!row.countryCodes.length" class="dim">-</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="sd-card-row">
|
||||
<span class="sd-card-label">文件</span>
|
||||
<span class="sd-card-value sd-card-file" :title="row.filename">{{ row.filename || '-' }}</span>
|
||||
</div>
|
||||
<div class="sd-card-actions">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
:disabled="!isUsableShopDataResult(row) || busyKey === row.resultId"
|
||||
@click="downloadRow(row)"
|
||||
>
|
||||
{{ busyKey === row.resultId ? '下载中...' : '下载文件' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isTerminalStatus(row.status) && !!row.resultId"
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="deletingKey === row.resultId"
|
||||
@click="removeRowByRow(row)"
|
||||
>
|
||||
{{ deletingKey === row.resultId ? '删除中...' : '删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<div v-else-if="!loading" class="empty-tip">暂无符合条件的店铺数据记录</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!loading" description="暂无符合条件的店铺数据任务" />
|
||||
<OldPagination v-if="allResultRows.length > 0" :total="allResultRows.length" :page="cardPage" :page-size="cardPageSize" @change="changeCardPage" @size-change="changeCardSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="permissionVisible" title="店铺数据任务数据范围授权" width="620px">
|
||||
<p class="dim">勾选允许查看/操作店铺数据任务的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p>
|
||||
<el-table :data="permissionItems" border size="small" max-height="420">
|
||||
<el-dialog v-model="permissionVisible" title="店铺数据记录数据范围授权" width="620px">
|
||||
<p class="dim">勾选允许查看/操作店铺数据记录的用户(仅数据范围)。当前已授权 {{ grantedCount }} 人。</p>
|
||||
<el-table :data="pagedPermissionItems" border size="small" max-height="420">
|
||||
<el-table-column prop="id" label="ID" min-width="80" />
|
||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||
<el-table-column label="授权" min-width="90" align="center">
|
||||
@@ -368,6 +395,7 @@ onMounted(load)
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<OldPagination v-if="permissionItems.length > permPageSize" :total="permissionItems.length" :page="permPage" :page-size="permPageSize" @change="permPage = $event" @size-change="(s: number) => { permPageSize = s; permPage = 1 }" />
|
||||
<template #footer>
|
||||
<el-button @click="permissionVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
|
||||
@@ -377,25 +405,343 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.actions { display: flex; align-items: center; gap: 12px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 220px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.wide { width: 340px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.shop-summary-row { display: flex; justify-content: space-between; align-items: center; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.shop-card-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.shop-card { border: 1px solid var(--el-border-color); border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28); overflow: hidden; }
|
||||
.shop-card-head { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.shop-name { font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.shop-card-select-row { display: flex; align-items: center; gap: 10px; padding: 10px 16px 0; }
|
||||
.shop-card-task-no { font-weight: 600; font-size: 13px; color: var(--el-text-color-primary); }
|
||||
.shop-card-row { display: flex; align-items: flex-start; gap: 10px; padding: 6px 16px; font-size: 13px; }
|
||||
.shop-card-label { flex: none; width: 52px; color: var(--el-text-color-secondary); }
|
||||
.shop-card-value { color: var(--el-text-color-regular); min-width: 0; }
|
||||
.shop-card-file { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.shop-card-actions { display: flex; gap: 6px; padding: 4px 16px 12px; }
|
||||
.country-chip { padding: 1px 8px; margin-right: 4px; border-radius: 999px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 12px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
/* 像素复刻旧版 admin.html panel-shop-data-crawl-tasks(蓝白末层)。 */
|
||||
.sd-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.form-box,
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.sd-filter-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #40586e;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.sd-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.sd-toolbar-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sd-toolbar-side {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.sd-select-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
color: #5b6f83;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sd-summary {
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sd-shop-pager {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #5b6f83;
|
||||
}
|
||||
.sd-shop-pager button,
|
||||
.pagination button,
|
||||
.jump-btn {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sd-shop-pager button:hover:not(:disabled),
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.sd-shop-pager button:disabled,
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sd-shop-pager input,
|
||||
.page-jump input {
|
||||
width: 52px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.shop-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.sd-card {
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sd-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e6edf4;
|
||||
}
|
||||
.sd-shop-name {
|
||||
font-weight: 600;
|
||||
color: #24384d;
|
||||
}
|
||||
.sd-card-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
.sd-task-no {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #24384d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sd-card-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sd-card-label {
|
||||
flex: none;
|
||||
width: 52px;
|
||||
color: #8293a5;
|
||||
}
|
||||
.sd-card-value {
|
||||
color: #40586e;
|
||||
min-width: 0;
|
||||
}
|
||||
.sd-card-file {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sd-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 6px 16px 14px;
|
||||
}
|
||||
.st-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
.st-pill.is-success {
|
||||
color: #3d7158;
|
||||
background: #e8f2eb;
|
||||
border-color: #bdd7c5;
|
||||
}
|
||||
.st-pill.is-failed {
|
||||
color: #91474f;
|
||||
background: #f8ebeb;
|
||||
border-color: #e4c2c5;
|
||||
}
|
||||
.st-pill.is-cancelled {
|
||||
color: #5b6f83;
|
||||
background: #f1f6fa;
|
||||
border-color: #d6e2ec;
|
||||
}
|
||||
.st-pill.is-pending,
|
||||
.st-pill.is-running {
|
||||
color: #8a6d3b;
|
||||
background: #fbf4ea;
|
||||
border-color: #e7d3ae;
|
||||
}
|
||||
.country-chip {
|
||||
padding: 1px 8px;
|
||||
margin-right: 4px;
|
||||
border-radius: 999px;
|
||||
background: #eef4fa;
|
||||
color: #40586e;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dim {
|
||||
color: #8293a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty-tip {
|
||||
grid-column: 1 / -1;
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.shop-card-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.shop-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -55,8 +55,8 @@ export async function fetchDuplicateLedger(
|
||||
return parseLedgerPage(data)
|
||||
}
|
||||
|
||||
/** 导出 CSV(撞款 view=monitor / 台账 view=all):GET /duplicate-check-export → Blob。 */
|
||||
export async function requestDuplicateCsvBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
|
||||
/** 导出 Excel(撞款 view=monitor / 台账 view=all):GET /duplicate-check-export → xlsx Blob。 */
|
||||
export async function requestDuplicateExportBlob(filter: DuplicateFilter, view: string): Promise<Blob> {
|
||||
const { data } = await http.get<Blob>(`${DUPLICATE_CHECK_ENDPOINT}/duplicate-check-export`, {
|
||||
params: toDuplicateItemsQuery({ ...filter, view }, 1, 100),
|
||||
responseType: 'blob',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 重复检查明细筛选与分页(任务 117):与 /duplicate-check-items 的 snake 参数契约对齐;纯逻辑。 */
|
||||
|
||||
export const DUPLICATE_DEFAULT_PAGE_SIZE = 20
|
||||
export const DUPLICATE_DEFAULT_PAGE_SIZE = 10
|
||||
export const DUPLICATE_MIN_PAGE_SIZE = 10
|
||||
export const DUPLICATE_MAX_PAGE_SIZE = 100
|
||||
export const DUPLICATE_PAGE_MIN = 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 视频任务筛选与分页(任务 102):图生视频任务列表筛选/分页归一,与 Java AdminImageVideoTaskController
|
||||
* snake_case 查询契约对齐;纯逻辑。 */
|
||||
|
||||
export const IMAGE_VIDEO_DEFAULT_PAGE_SIZE = 20
|
||||
export const IMAGE_VIDEO_DEFAULT_PAGE_SIZE = 10
|
||||
export const IMAGE_VIDEO_MIN_PAGE_SIZE = 10
|
||||
export const IMAGE_VIDEO_MAX_PAGE_SIZE = 100
|
||||
export const IMAGE_VIDEO_PAGE_MIN = 1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 店铺数据任务筛选与分页(任务 109):与 Java AdminShopDataCrawlTasksController snake 契约对齐;纯逻辑。 */
|
||||
|
||||
export const SHOP_DATA_DEFAULT_PAGE_SIZE = 20
|
||||
export const SHOP_DATA_DEFAULT_PAGE_SIZE = 10
|
||||
export const SHOP_DATA_MIN_PAGE_SIZE = 10
|
||||
export const SHOP_DATA_MAX_PAGE_SIZE = 100
|
||||
export const SHOP_DATA_PAGE_MIN = 1
|
||||
|
||||
@@ -24,7 +24,7 @@ export const adminPages: AdminPageDef[] = [
|
||||
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
|
||||
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
|
||||
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
|
||||
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据任务/记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
||||
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
||||
{ path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') },
|
||||
{ path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') },
|
||||
{ path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') },
|
||||
|
||||
@@ -184,6 +184,24 @@ button, input, textarea, select { font: inherit; }
|
||||
.el-button { border-radius: 9px; }
|
||||
.el-message-box { border-radius: 14px; }
|
||||
|
||||
/* 分组筛选下拉(可模糊搜索):对齐旧版控件观感(42px/9px/浅底/focus ring)。 */
|
||||
.el-select.admin-filter {
|
||||
width: 100%;
|
||||
}
|
||||
.el-select.admin-filter .el-select__wrapper {
|
||||
min-height: 42px;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
box-shadow: 0 0 0 1px #cbd9e6 inset;
|
||||
}
|
||||
.el-select.admin-filter .el-select__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #9fb7cd inset;
|
||||
}
|
||||
.el-select.admin-filter.is-focused .el-select__wrapper {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.admin-sidebar { flex-basis: 64px; }
|
||||
.admin-brand-copy, .admin-menu .el-sub-menu__title span, .admin-menu .el-menu-item { display: none; }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
|
||||
/** 对齐旧版 .btn-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', () => {
|
||||
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
|
||||
assert.match(page, /expandedIds/, '客户端展开状态集合')
|
||||
assert.match(page, /flatRows|treeRows/, '按展开状态扁平化行集')
|
||||
for (const col of ['层级路径', '排序', '来源', '说明']) {
|
||||
assert.match(page, new RegExp(col), `树形表格含「${col}」列`)
|
||||
assert.match(page, /treeRows/, '按展开状态扁平化行集')
|
||||
for (const col of ['层级路径', '排序', '来源', '备注']) {
|
||||
assert.match(page, new RegExp(col), `树形表格含「${col}」列(旧版列名为备注)`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -46,8 +46,8 @@ test('align_category_tree_table_repeated_operation_is_idempotent', () => {
|
||||
})
|
||||
|
||||
test('align_category_tree_table_dependency_failure_returns_actionable_message', () => {
|
||||
// 视觉复刻旧版:20px 圆角方块标记、缩进宽度变量、名称加粗。
|
||||
// 视觉复刻旧版:圆角方块标记、层级缩进(24px/级)、名称加粗。
|
||||
const page = readSource('src/pages/asin/ProductCategoryPage.vue')
|
||||
assert.match(page, /tree-node-mark/, '展开标记样式存在')
|
||||
assert.match(page, /var\(--indent|--indent/, '缩进变量')
|
||||
assert.match(page, /row\.depth \* 24/, '缩进 24px/级')
|
||||
})
|
||||
|
||||
@@ -7,19 +7,6 @@ import { readSource } from './helpers.ts'
|
||||
// 例外:DuplicateCheckPage 不动;dialog/表单的 width 不属于表格列。
|
||||
|
||||
const PAGES = [
|
||||
'src/pages/account/GroupsPage.vue',
|
||||
'src/pages/account/MenusPage.vue',
|
||||
'src/pages/account/UsersPage.vue',
|
||||
'src/pages/asin/AsinInvalidPage.vue',
|
||||
'src/pages/asin/DedupeRegistryPage.vue',
|
||||
'src/pages/asin/ProductCategoryPage.vue',
|
||||
'src/pages/asin/QueryAsinPage.vue',
|
||||
'src/pages/asin/SkipPricePage.vue',
|
||||
'src/pages/records/RecordsDigitalHumanVersionPage.vue',
|
||||
'src/pages/records/RecordsHistoryPage.vue',
|
||||
'src/pages/records/RecordsSoftwareVersionPage.vue',
|
||||
'src/pages/shop/ShopKeysPage.vue',
|
||||
'src/pages/shop/ShopManagePage.vue',
|
||||
'src/pages/tasks/ImageVideoTasksPage.vue',
|
||||
'src/pages/tasks/ShopDataTasksPage.vue',
|
||||
]
|
||||
@@ -47,9 +34,40 @@ test('align_column_width_normal_variant_input', () => {
|
||||
})
|
||||
|
||||
test('align_column_width_boundary_empty_input', () => {
|
||||
// 例外页 DuplicateCheckPage 保持自身实现不动。
|
||||
// 例外页 DuplicateCheckPage 已整页复刻 reference 撞款控制台(原生 ledger 表格),不参与 el-table 列宽约定。
|
||||
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||
assert.match(dup, /el-table-column/, 'DuplicateCheckPage 表格仍在且未被修改')
|
||||
assert.equal(/el-table-column/.test(dup), false, '撞款台账已用 reference 原生表格,不再有 el-table-column')
|
||||
assert.match(dup, /class="ledger"/, '撞款台账仍为表格实现')
|
||||
const hist = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||
assert.equal(/el-table-column/.test(hist), false, '生成记录已像素复刻 admin panel-history 原生表格,不再有 el-table-column')
|
||||
assert.match(hist, /class="table-scroll history-table-scroll"/, '生成记录用原生 .table-scroll 表格')
|
||||
assert.match(hist, /<th[^>]*>结果预览<\/th>/, '原生表含结果预览列')
|
||||
const dedupe = readSource('src/pages/asin/DedupeRegistryPage.vue')
|
||||
assert.equal(/el-table-column/.test(dedupe), false, '去重汇总已像素复刻 admin panel-dedupe-total-data 原生表格,不再有 el-table-column')
|
||||
assert.match(dedupe, /class="table-scroll dedupe-table-scroll"/, '去重汇总用原生 .table-scroll 表格')
|
||||
const users = readSource('src/pages/account/UsersPage.vue')
|
||||
assert.equal(/el-table-column/.test(users), false, '用户管理已像素复刻 admin panel-users 原生表格,不再有 el-table-column')
|
||||
assert.match(users, /class="table-scroll user-table-scroll"/, '用户管理用原生 .table-scroll 表格')
|
||||
const menus = readSource('src/pages/account/MenusPage.vue')
|
||||
assert.equal(/el-table-column/.test(menus), false, '菜单配置已像素复刻 admin panel-columns 原生表格,不再有 el-table-column')
|
||||
assert.match(menus, /class="table-scroll columns-table-scroll"/, '菜单配置用原生 .table-scroll 表格')
|
||||
const groups = readSource('src/pages/account/GroupsPage.vue')
|
||||
assert.equal(/el-table-column/.test(groups), false, '分组管理已像素复刻 admin panel-group-manage 原生表格,不再有 el-table-column')
|
||||
assert.match(groups, /class="table-scroll"/, '分组管理用原生 .table-scroll 表格')
|
||||
for (const [page, cls] of [
|
||||
['src/pages/asin/AsinInvalidPage.vue', 'invalid-asin-table-scroll'],
|
||||
['src/pages/asin/QueryAsinPage.vue', 'query-asin-table-scroll'],
|
||||
['src/pages/asin/SkipPricePage.vue', 'skip-price-asin-table-scroll'],
|
||||
['src/pages/asin/ProductCategoryPage.vue', 'category-table-scroll'],
|
||||
['src/pages/shop/ShopKeysPage.vue', 'shop-key-table-scroll'],
|
||||
['src/pages/shop/ShopManagePage.vue', 'shop-manage-table-scroll'],
|
||||
['src/pages/records/RecordsSoftwareVersionPage.vue', 'version-table-scroll'],
|
||||
['src/pages/records/RecordsDigitalHumanVersionPage.vue', 'digital-human-version-table-scroll'],
|
||||
]) {
|
||||
const src = readSource(page)
|
||||
assert.equal(/el-table-column/.test(src), false, `${page} 已像素复刻原生表格,不再有 el-table-column`)
|
||||
assert.match(src, new RegExp(cls), `${page} 用原生 .table-scroll 表格`)
|
||||
}
|
||||
})
|
||||
|
||||
test('align_column_width_dependency_failure_returns_actionable_message', () => {
|
||||
|
||||
@@ -53,11 +53,20 @@ test('align_dedupe_group_option_parses_leader', () => {
|
||||
assert.deepEqual(options, [{ id: 3, name: 'C组', leaderUserId: 12 }])
|
||||
})
|
||||
|
||||
test('align_dedupe_page_summary_and_export_wait_wiring', () => {
|
||||
test('align_dedupe_group_option_parses_java_groupName_field', () => {
|
||||
// Java /api/admin/shop-manages/groups 返回 groupName(驼峰);解析器需同时认驼峰与 snake。
|
||||
const options = parseDedupeGroupOptionList({
|
||||
success: true,
|
||||
data: { items: [{ id: 2, groupName: '周丽娥', leaderUserId: 23 }] },
|
||||
})
|
||||
assert.deepEqual(options, [{ id: 2, name: '周丽娥', leaderUserId: 23 }])
|
||||
})
|
||||
|
||||
test('align_dedupe_page_no_summary_block', () => {
|
||||
// 用户反馈(样办2):数据权限分组摘要块多余,从页面移除;模型纯函数保留。
|
||||
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
|
||||
assert.match(page, /数据权限分组/, '页顶数据权限分组汇总卡')
|
||||
assert.match(page, /dedupeGroupSummaryOf/, '汇总走纯模型')
|
||||
assert.doesNotMatch(page, /dedupe-group-access|dedupeGroupSummaryOf|groupSummary/, '页面不再有分组摘要块与接线')
|
||||
assert.match(page, /正在生成导出文件/, '导出等待遮罩文案对齐')
|
||||
assert.match(page, /已等待/, '导出等待含等待秒数')
|
||||
assert.match(page, /未分组/, '分组空值显示未分组')
|
||||
assert.match(page, /未分组/, '分组空值行内显示未分组')
|
||||
})
|
||||
|
||||
@@ -58,14 +58,16 @@ test('align_digital_human_api_download_url', () => {
|
||||
|
||||
test('align_digital_human_page_wiring', () => {
|
||||
const page = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
|
||||
assert.match(page, /pageSize\s*=\s*20|:page-size="20"/, '分页大小 20')
|
||||
assert.match(page, /el-pagination/, '分页组件')
|
||||
assert.match(page, /pageSize\s*=\s*ref\(10\)/, '分页大小默认 10(全站统一)')
|
||||
assert.match(page, /page-nums/, '数字页码分页(像素复刻旧版)')
|
||||
assert.match(page, /设为最新/, '按钮名对齐“设为最新”')
|
||||
assert.match(page, /上传(草稿状态)/, '上传按钮名对齐')
|
||||
assert.match(page, /上传新版本/, '页头上传按钮名对齐')
|
||||
assert.match(page, /上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布/, '上传弹窗描述对齐')
|
||||
assert.match(page, /正在上传/, '上传进度文案')
|
||||
assert.match(page, /digitalHumanDeleteConfirmText/, '删除确认文案走对齐模型')
|
||||
assert.match(page, /downloadUrl|fetchDigitalHumanDownloadUrl/, 'RELEASED 下载按钮点击取链接')
|
||||
assert.match(page, /已废弃/, '状态三态接入')
|
||||
assert.match(page, /fetchDigitalHumanDownloadUrl/, 'RELEASED 下载按钮点击取链接')
|
||||
assert.match(page, /digitalHumanStatusDisplay/, '状态走展示层模型')
|
||||
const display = readSource('src/pages/records/digitalhuman-status-display.ts')
|
||||
assert.match(display, /已废弃/, '状态三态接入(草稿/已发布/已废弃)')
|
||||
assert.match(page, /digitalHumanChangelogShort/, '更新说明 50 字符截断')
|
||||
})
|
||||
|
||||
@@ -42,7 +42,20 @@ test('align_dup_console_drawer_template', () => {
|
||||
assert.match(page, /drawerAggRows/, '抽屉渲染聚合行')
|
||||
assert.match(page, /drawerSummary/, '抽屉渲染汇总徽章')
|
||||
assert.match(page, /次上架/, '汇总含 N 次上架')
|
||||
assert.match(page, /label="次数"/, '聚合表含次数列')
|
||||
assert.match(page, /<th[^>]*>次数<\/th>/, '聚合表含次数列')
|
||||
assert.match(page, /v-for="row in drawerRows"/, '抽屉聚合表绑定分页切片')
|
||||
assert.match(page, /:total="drawerAggRows\.length"/, '抽屉聚合表有分页器')
|
||||
})
|
||||
|
||||
test('align_dup_console_pagination_all_lists', () => {
|
||||
// 全站统一收官:撞款页四处列表(分布/矩阵/撞款详情卡片/抽屉明细)均挂共享 OldPagination。
|
||||
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||
assert.match(page, /:total="distCount"/, '店铺上架分布有分页器')
|
||||
assert.match(page, /:total="dupRows\.length"/, '撞款覆盖矩阵有分页器')
|
||||
assert.match(page, /v-for="item in dupCardRows"/, '撞款详情卡片绑定分页切片')
|
||||
assert.match(page, /v-if="dupRows\.length > dupCardPageSize"/, '撞款详情卡片有分页器')
|
||||
assert.match(page, /v-for="group in pagedGroupStats"/, '分组说明抽屉表绑定分页切片')
|
||||
assert.match(page, /:total="groupStats\.length"/, '分组说明抽屉表有分页器')
|
||||
})
|
||||
|
||||
test('align_dup_console_ledger_total_preload', () => {
|
||||
@@ -56,3 +69,30 @@ test('align_dup_console_matrix_sticky', () => {
|
||||
assert.match(page, /table\.matrix th\s*\{[^}]*position:\s*sticky/, '矩阵表头吸顶')
|
||||
assert.match(page, /\.matrix-wrap\s*\{[^}]*max-height/, '矩阵容器限高滚动')
|
||||
})
|
||||
|
||||
test('align_dup_console_role_scope_group_filter_only_super', () => {
|
||||
// 角色范围:分组筛选下拉仅超管需要;管理员数据由后端裁到本组。
|
||||
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||
assert.match(page, /useAdminSessionStore/, '页面读取登录会话取角色')
|
||||
assert.match(page, /const isSuper = computed\(\(\) => session\.isSuperAdmin\)/, 'isSuper 由会话角色推导')
|
||||
assert.match(page, /<div v-if="isSuper" class="f-group">\s*<label>分组<\/label>/s, '分组筛选仅超管可见')
|
||||
assert.match(page, /group: filter\.group\.trim\(\)/, '分组筛选仍参与查询参数')
|
||||
})
|
||||
|
||||
test('align_dup_console_role_scope_group_controls_only_super', () => {
|
||||
// 分组撞款/分组说明为超管专属交互;后端超管全量、普通管理员所管分组店铺。
|
||||
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||
assert.match(page, /<div v-if="isSuper" class="scope-bar">/, '分组撞款分段仅超管可见')
|
||||
assert.match(page, /v-if="isSuper" class="btn btn-ghost btn-sm" type="button" @click="groupInfoVisible = true"/, '分组说明按钮仅超管可见')
|
||||
const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java')
|
||||
assert.match(service, /if \(superAdmin\) \{\s*return null;/, '超管可见范围=全量(null)')
|
||||
assert.match(service, /selectManagedShopNames\(operatorId\)/, '普通管理员可见范围=所管分组店铺')
|
||||
})
|
||||
|
||||
test('align_dup_console_country_labels_chinese', () => {
|
||||
// 反馈:国家显示一定要用中文——站点/国家一律经 asinCountryLabel 映射,禁止裸国家码直出。
|
||||
const page = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||
assert.match(page, /asinCountryLabel\(/, '站点显示走中文映射函数')
|
||||
assert.doesNotMatch(page, /\{\{\s*(row|item)\.country\s*\}\}/, '表格/抽屉无裸国家码直出')
|
||||
assert.match(page, /asinCountryLabel\(code\)/, '筛选下拉站点显示中文')
|
||||
})
|
||||
|
||||
@@ -2,29 +2,23 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
|
||||
// 验收反馈:生成记录的数据要可清理。
|
||||
// 契约:页面有「清理数据」入口(二次确认)、前端 DELETE /api/admin/history、后端清空端点与日志。
|
||||
// 用户反馈(round2 像素复刻样办):生成记录页还原旧版交互——「清理数据」为旧版没有的新增入口,从页面移除。
|
||||
// 契约:页面无清理入口、无二次确认文案;前端适配层不再暴露 clearHistory;后端 DELETE /history 端点保留(非破坏性,供后续需要时再接线)。
|
||||
|
||||
test('align_history_clear_normal_primary_path', () => {
|
||||
test('align_history_clear_removed_from_page', () => {
|
||||
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||
assert.match(page, /清理数据/, '页头有清理数据按钮')
|
||||
assert.match(page, /确定清空全部生成记录吗/, '清理前需二次确认')
|
||||
assert.match(page, /clearHistory\(\)/, '页面调用清理 API')
|
||||
assert.doesNotMatch(page, /清理数据/, '页面不再有「清理数据」按钮')
|
||||
assert.doesNotMatch(page, /clearAll|clearHistory/, '页面不再调用清理逻辑')
|
||||
assert.doesNotMatch(page, /确定清空全部生成记录吗/, '无清理二次确认文案')
|
||||
})
|
||||
|
||||
test('align_history_clear_normal_variant_input', () => {
|
||||
test('align_history_clear_api_no_longer_exposed', () => {
|
||||
const api = readSource('src/pages/records/history-api.ts')
|
||||
assert.match(api, /http\.delete<unknown>\(HISTORY_ENDPOINT\)/, '清理走 DELETE /api/admin/history')
|
||||
assert.match(api, /clearHistory/, '暴露 clearHistory 适配函数')
|
||||
assert.doesNotMatch(api, /clearHistory/, '前端适配层不再暴露 clearHistory')
|
||||
})
|
||||
|
||||
test('align_history_clear_boundary_empty_input', () => {
|
||||
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||
assert.match(page, /已清空 \$\{removed\} 条生成记录/, '成功提示含清理条数')
|
||||
})
|
||||
|
||||
test('align_history_clear_dependency_failure_returns_actionable_message', () => {
|
||||
// 后端契约:DELETE /history 端点 + service 清空 + 中文排查日志。
|
||||
test('align_history_clear_backend_endpoint_kept', () => {
|
||||
// 后端契约保留:DELETE /history 端点 + service 清空 + 中文排查日志(从前端摘除入口,不动后端)。
|
||||
const ctrl = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java')
|
||||
assert.match(ctrl, /@DeleteMapping\("\/history"\)/, '后端有 DELETE /api/admin/history 端点')
|
||||
assert.match(ctrl, /admin_history/, '清理端点沿用生成记录模块权限')
|
||||
|
||||
@@ -7,8 +7,8 @@ import { historyEmptyText } from '../src/pages/records/history-feedback.ts'
|
||||
|
||||
test('align_history_column_labels', () => {
|
||||
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||
assert.match(page, /label="类型"/, '列名对齐参考「类型」')
|
||||
assert.match(page, /label="创建时间"/, '列名对齐参考「创建时间」')
|
||||
assert.match(page, /<th[^>]*>类型<\/th>/, '列名对齐参考「类型」(自绘 th)')
|
||||
assert.match(page, /<th[^>]*>创建时间<\/th>/, '列名对齐参考「创建时间」(自绘 th)')
|
||||
assert.doesNotMatch(page, /记录类型/, '去掉“记录类型”列名')
|
||||
assert.doesNotMatch(page, /label="生成时间"/, '去掉“生成时间”列名')
|
||||
})
|
||||
@@ -18,3 +18,11 @@ test('align_history_empty_text', () => {
|
||||
assert.equal(historyEmptyText({ hasFilter: false, total: 0 }), '暂无记录')
|
||||
assert.equal(historyEmptyText({ hasFilter: false, total: 2 }), '')
|
||||
})
|
||||
|
||||
test('align_history_form_group_no_global_margin_leak', () => {
|
||||
// 修复按钮与筛选输入框不对齐:全局 .form-group{margin-bottom:20px} 由登录页非 scoped 样式泄漏;登录页样式收窄为 scoped,页面内显式重置。
|
||||
const login = readSource('src/pages/login/LoginPage.vue')
|
||||
assert.match(login, /<style scoped>/, '登录页样式 scoped,不再全局泄漏 .form-group')
|
||||
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||
assert.match(page, /\.form-group \{[^}]*margin-bottom: 0/, '页面内 form-group 显式重置 margin')
|
||||
})
|
||||
|
||||
@@ -61,3 +61,11 @@ test('align_image_video_page_card_info_wiring', () => {
|
||||
assert.match(page, /copyLink\(task\.debugUrl\)/, '调试链接复制接线')
|
||||
assert.match(page, /:disabled="!video\.displayUrl"|!hasDisplayUrl/, '下载按钮无视频禁用')
|
||||
})
|
||||
|
||||
test('align_image_video_permission_table_pagination', () => {
|
||||
// 权限配置弹窗用户表全量渲染无分页:补共享 OldPagination(10/20/50/100)。
|
||||
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
|
||||
assert.match(page, /import OldPagination from '@\/components\/OldPagination\.vue'/, '分页组件已 import(曾缺失导致分页器不渲染)')
|
||||
assert.match(page, /:data="pagedPermissionItems"/, '权限表绑定分页切片')
|
||||
assert.match(page, /:total="permissionItems\.length"/, '权限表有分页器')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import { adminPages } from '../src/router/routes.ts'
|
||||
import {
|
||||
OPERATION_GUIDES,
|
||||
OPERATION_GUIDE_FALLBACK,
|
||||
OPERATION_GUIDE_STORAGE_KEY,
|
||||
operationGuideFor,
|
||||
} from '../src/layout/operation-guides.ts'
|
||||
|
||||
/** 对齐旧版 admin-interactions.js guides 表:每业务菜单页顶部展示可折叠「当前页面操作提示」,文案沿用旧版原文;店铺撞款监控(例外)不展示。 */
|
||||
|
||||
const EXCEPTED_KEY = 'admin_shop_data_duplicate_check'
|
||||
|
||||
test('align_guide_users_matches_reference_text', () => {
|
||||
const g = operationGuideFor('admin_users')
|
||||
assert.ok(g)
|
||||
assert.equal(g.text, '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。')
|
||||
assert.deepEqual(g.steps, ['筛选账号', '编辑权限', '确认保存'])
|
||||
})
|
||||
|
||||
test('align_guide_history_and_version_reference_text', () => {
|
||||
assert.match(operationGuideFor('admin_history')!.text, /生成记录可按用户和时间范围回溯/)
|
||||
assert.deepEqual(operationGuideFor('admin_version')!.steps, ['上传压缩包', '检查版本记录', '维护历史版本'])
|
||||
})
|
||||
|
||||
test('align_guide_group_manage_falls_back', () => {
|
||||
// 旧版 group-manage 无独立 guide,用通用 fallback。
|
||||
assert.deepEqual(operationGuideFor('admin_group_manage'), OPERATION_GUIDE_FALLBACK)
|
||||
})
|
||||
|
||||
test('align_guide_duplicate_check_not_present', () => {
|
||||
// 例外页:不展示提示条(不参与本轮对齐)。
|
||||
assert.equal(operationGuideFor(EXCEPTED_KEY), null)
|
||||
assert.equal(EXCEPTED_KEY in OPERATION_GUIDES, false)
|
||||
})
|
||||
|
||||
test('align_guide_unknown_key_null', () => {
|
||||
assert.equal(operationGuideFor(undefined), null)
|
||||
assert.equal(operationGuideFor(null), null)
|
||||
assert.equal(operationGuideFor('not-a-menu'), null)
|
||||
assert.equal(operationGuideFor(''), null)
|
||||
})
|
||||
|
||||
test('align_guide_every_router_page_has_guide_except_duplicate_check', () => {
|
||||
// 路由注册表里的业务页除例外页外都应有独立/兜底提示。
|
||||
const without = adminPages.filter((p) => p.menuKey !== EXCEPTED_KEY)
|
||||
assert.ok(without.length >= 15, '业务页至少 15')
|
||||
for (const page of without) {
|
||||
assert.ok(operationGuideFor(page.menuKey), `缺少 guide: ${page.menuKey}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('align_guide_storage_key_matches_legacy', () => {
|
||||
assert.equal(OPERATION_GUIDE_STORAGE_KEY, 'shufuAdminGuideCollapsed')
|
||||
})
|
||||
|
||||
test('align_guide_component_in_layout_headline', () => {
|
||||
const layout = readSource('src/layout/AdminLayout.vue')
|
||||
assert.match(layout, /OperationGuide/, '壳层引用 OperationGuide 组件')
|
||||
const comp = readSource('src/layout/OperationGuide.vue')
|
||||
assert.match(comp, /当前页面操作提示/, '标题对齐旧版 adminOperationGuide 语义')
|
||||
assert.match(comp, /收起提示/, '折叠按钮文案')
|
||||
assert.match(comp, /展开提示/, '展开按钮文案')
|
||||
assert.match(comp, /operationGuideFor\(route\.meta\.menuKey\)/, '按菜单 key 查文案')
|
||||
assert.match(comp, /OPERATION_GUIDE_STORAGE_KEY/, '折叠态读写同一 localStorage key')
|
||||
})
|
||||
|
||||
test('align_guide_fallback_shape', () => {
|
||||
assert.equal(OPERATION_GUIDE_FALLBACK.steps.length, 3)
|
||||
assert.ok(OPERATION_GUIDE_FALLBACK.text.length > 10)
|
||||
})
|
||||
@@ -7,35 +7,26 @@ import { readSource } from './helpers.ts'
|
||||
// 菜单管理是树结构(非列表),商品类目树用懒加载+加载更多节点,均豁免。
|
||||
|
||||
test('align_pagination_normal_primary_path', () => {
|
||||
for (const page of [
|
||||
'src/pages/account/GroupsPage.vue',
|
||||
'src/pages/records/RecordsSoftwareVersionPage.vue',
|
||||
'src/pages/asin/ProductCategoryPage.vue',
|
||||
]) {
|
||||
const src = readSource(page)
|
||||
assert.match(src, /el-pagination/, `${page} 应包含分页组件`)
|
||||
}
|
||||
// 软件版本已像素复刻旧版(panel-version 全量无分页);无 EP 分页列表剩余为 0。
|
||||
const dh = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
|
||||
assert.match(dh, /page-nums/, '数字人版本用数字页码分页(像素复刻)')
|
||||
})
|
||||
|
||||
test('align_pagination_normal_variant_input', () => {
|
||||
// 分页组件需含上一页/下一页(prev/next),中文文案由全局 zh-cn locale 渲染。
|
||||
for (const page of [
|
||||
'src/pages/account/GroupsPage.vue',
|
||||
'src/pages/records/RecordsSoftwareVersionPage.vue',
|
||||
'src/pages/asin/ProductCategoryPage.vue',
|
||||
]) {
|
||||
const src = readSource(page)
|
||||
assert.match(src, /el-pagination[^>]*layout="[^"]*prev[^"]*next/, `${page} 分页 layout 需含 prev/next`)
|
||||
}
|
||||
// 数字人版本分页升级为共享 OldPagination(含上一页/下一页/条数选择)。
|
||||
const dh = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
|
||||
assert.match(dh, /OldPagination/, '数字人分页用共享组件')
|
||||
const comp = readSource('src/components/OldPagination.vue')
|
||||
assert.match(comp, />上一页<\/button>/, '共享分页含上一页')
|
||||
assert.match(comp, />下一页<\/button>/, '共享分页含下一页')
|
||||
assert.match(comp, /10, 20, 50, 100|sizes/, '共享分页默认 10/20/50/100')
|
||||
})
|
||||
|
||||
test('align_pagination_boundary_empty_input', () => {
|
||||
// 分组/软件版本为全量拉取,走客户端切片分页(computed 按 page/pageSize 切)。
|
||||
for (const page of ['src/pages/account/GroupsPage.vue', 'src/pages/records/RecordsSoftwareVersionPage.vue']) {
|
||||
const src = readSource(page)
|
||||
assert.match(src, /pagedRows|pageRows|pageItems|pagedItems/, `${page} 应有客户端分页切片`)
|
||||
assert.match(src, /:data="paged/, `${page} 表格应绑定分页后的数据`)
|
||||
}
|
||||
// 数字人版本为全量拉取,走客户端切片分页(computed 按 page/pageSize 切)。
|
||||
const dh = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
|
||||
assert.match(dh, /pagedItems/, '数字人版本应有客户端分页切片')
|
||||
assert.match(dh, /v-for="row in pagedItems/, '表格应绑定分页后的数据')
|
||||
})
|
||||
|
||||
test('align_pagination_boundary_single_item', () => {
|
||||
@@ -46,13 +37,20 @@ test('align_pagination_boundary_single_item', () => {
|
||||
})
|
||||
|
||||
test('align_pagination_repeated_operation_is_idempotent', () => {
|
||||
// 已有分页的页面(12 页)保持原样,不被破坏。
|
||||
// 已像素复刻(自绘旧式分页/原生表格)的页单独断言。
|
||||
for (const page of [
|
||||
'src/pages/account/UsersPage.vue',
|
||||
'src/pages/asin/AsinInvalidPage.vue',
|
||||
'src/pages/asin/DedupeRegistryPage.vue',
|
||||
'src/pages/shop/ShopManagePage.vue',
|
||||
'src/pages/shop/ShopKeysPage.vue',
|
||||
'src/pages/tasks/ImageVideoTasksPage.vue',
|
||||
]) {
|
||||
const src = readSource(page)
|
||||
assert.match(src, /el-pagination/, `${page} 既有分页保留`)
|
||||
assert.match(src, /OldPagination/, `${page} 用共享旧式分页`)
|
||||
assert.doesNotMatch(src, /el-pagination/, `${page} 不再用 EP 分页`)
|
||||
}
|
||||
const groups = readSource('src/pages/account/GroupsPage.vue')
|
||||
assert.doesNotMatch(groups, /el-pagination/, '分组管理全量渲染无分页')
|
||||
assert.doesNotMatch(groups, /pagedRows/, '分组管理全量渲染无客户端切片')
|
||||
})
|
||||
|
||||
@@ -80,7 +80,7 @@ test('align_query_asin_create_api_wiring', () => {
|
||||
test('align_query_asin_page_layout_wiring', () => {
|
||||
const page = readSource('src/pages/asin/QueryAsinPage.vue')
|
||||
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
||||
assert.match(page, /span-method/, '表格用 span-method 做 rowspan 合并')
|
||||
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
||||
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
||||
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
|
||||
assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型')
|
||||
|
||||
@@ -15,12 +15,13 @@ test('align_shop_keys_columns', () => {
|
||||
|
||||
test('align_shop_manage_columns_order_and_labels', () => {
|
||||
const page = readSource('src/pages/shop/ShopManagePage.vue')
|
||||
assert.doesNotMatch(page, /label="序号"/, '不再展示序号列')
|
||||
// 像素复刻旧版 panel-shop-manage:序号列保留(admin.html:5339)。
|
||||
assert.match(page, /序号/, '按旧版展示序号列')
|
||||
assert.doesNotMatch(page, /label="ID"/, '不展示 ID 列')
|
||||
// 列序:分组 → 店铺名 → 店铺商城名 → 自动化账号 → 账号 → 密码(对齐 admin.html:5339-5348)。
|
||||
const table = page.slice(page.indexOf('<el-table'))
|
||||
const orderMatch = /label="分组"[\s\S]*?label="店铺名"[\s\S]*?label="店铺商城名"[\s\S]*?label="自动化账号"[\s\S]*?label="账号"[\s\S]*?label="密码"/.exec(table)
|
||||
// 列序:序号 → 分组 → 店铺名 → 店铺商城名 → 自动化账号 → 账号 → 密码(对齐 admin.html:5339-5348)。
|
||||
const table = page.slice(page.indexOf('<table'))
|
||||
const orderMatch = /序号[\s\S]*?分组[\s\S]*?店铺名[\s\S]*?店铺商城名[\s\S]*?自动化账号[\s\S]*?账号[\s\S]*?密码/.exec(table)
|
||||
assert.ok(orderMatch, '店铺管理列序对齐参考')
|
||||
assert.doesNotMatch(table, /店铺名称/, '列名对齐「店铺名」')
|
||||
assert.doesNotMatch(table, /label="商城"/, '列名对齐「店铺商城名」')
|
||||
assert.doesNotMatch(table, />商城</, '列名对齐「店铺商城名」')
|
||||
})
|
||||
|
||||
@@ -28,5 +28,12 @@ test('align_shop_data_cards_selection_semantics', () => {
|
||||
|
||||
test('align_shop_data_cards_empty_text', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案对齐参考')
|
||||
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案对齐参考')
|
||||
})
|
||||
|
||||
test('align_shop_data_permission_table_pagination', () => {
|
||||
// 权限配置弹窗用户表全量渲染无分页:补共享 OldPagination(10/20/50/100)。
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /:data="pagedPermissionItems"/, '权限表绑定分页切片')
|
||||
assert.match(page, /:total="permissionItems\.length"/, '权限表有分页器')
|
||||
})
|
||||
|
||||
@@ -13,17 +13,17 @@ test('align_shop_data_grid_normal_primary_path', () => {
|
||||
|
||||
test('align_shop_data_grid_normal_variant_input', () => {
|
||||
const src = readSource(PAGE)
|
||||
assert.match(src, /cardPageSize\s*=\s*9|pageSize\s*=\s*9/, '卡片分页每页 9 张')
|
||||
assert.match(src, /cardPageSize = ref\(10\)|cardPageSize\.value/, '卡片分页默认 10 张且可调')
|
||||
assert.match(src, /cardPage\b/, '应有卡片页状态')
|
||||
assert.match(src, /pagedResultRows|cardPageRows/, '卡片应绑定按 9 张切片的数据')
|
||||
})
|
||||
|
||||
test('align_shop_data_grid_boundary_empty_input', () => {
|
||||
// 卡片区下方有翻页组件(prev/next),与店铺分页并存。
|
||||
// 卡片区下方有翻页组件(prev/next),与店铺分页并存(均为旧式分页)。
|
||||
const src = readSource(PAGE)
|
||||
const count = (src.match(/el-pagination/g) || []).length
|
||||
assert.ok(count >= 2, '页面应含店铺分页 + 卡片分页两处 el-pagination')
|
||||
assert.match(src, /layout="prev, pager, next, jumper"/, '卡片分页含上一页/下一页')
|
||||
const count = (src.match(/class="pagination"/g) || []).length + (src.match(/sd-shop-pager/g) || []).length
|
||||
assert.ok(count >= 2, '页面应含店铺分页 + 卡片分页两处分页')
|
||||
assert.match(src, /cardPage/, '卡片分页状态')
|
||||
})
|
||||
|
||||
test('align_shop_data_grid_boundary_single_item', () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
|
||||
/** 对齐旧版 .btn-sm:shop/records/任务 域主操作按钮(编辑/凭证/删除/查看/发布/设最新/下载/明细)统一实心 small;纯复制链接辅助仍可 text。 */
|
||||
|
||||
function assertRowButtonSolid(source: string, action: string) {
|
||||
assert.match(source, new RegExp(`<el-button[^>]*size="small"[^>]*@click="${action}`), `${action} 为 size=small 实心按钮`)
|
||||
assert.doesNotMatch(source, new RegExp(`<el-button[^>]*link[^>]*@click="${action}`), `${action} 不再 link`)
|
||||
assert.doesNotMatch(source, new RegExp(`<el-button[^>]*text[^>]*@click="${action}`), `${action} 不再 text`)
|
||||
}
|
||||
|
||||
test('align_shop_manage_row_buttons_old_btn', () => {
|
||||
const s = readSource('src/pages/shop/ShopManagePage.vue')
|
||||
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
|
||||
assert.match(s, /class="btn btn-sm btn-secondary"[^>]*@click="showCredential\(row\)"/, '凭证用旧版 btn-sm btn-secondary')
|
||||
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="remove\(row\)"/, '删除用旧版 btn-sm btn-danger')
|
||||
assert.doesNotMatch(s, /el-button[^>]*@click="openEdit/, '行内不再用 EP 按钮')
|
||||
assert.match(s, /window\.confirm\(`确定删除店铺/, '删除确认原生 confirm 对齐旧版')
|
||||
})
|
||||
|
||||
test('align_shop_keys_row_buttons_old_btn', () => {
|
||||
const s = readSource('src/pages/shop/ShopKeysPage.vue')
|
||||
assert.match(s, /class="btn btn-sm"[^>]*@click="openEdit\(row\)"/, '编辑用旧版 btn-sm 实心小按钮')
|
||||
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="remove\(row\)"/, '删除用旧版 btn-sm btn-danger')
|
||||
assert.doesNotMatch(s, /el-button[^>]*@click="openEdit/, '行内不再用 EP 按钮')
|
||||
})
|
||||
|
||||
test('align_records_history_thumb_click_opens_preview', () => {
|
||||
const s = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||
assert.match(s, /class="thumb"[^>]*@click="openPreview\(h\)"/, '点击缩略图直接放大预览')
|
||||
assert.doesNotMatch(s, /查看大图/, '无行内「查看大图」按钮')
|
||||
})
|
||||
|
||||
test('align_digital_human_row_buttons_old_btn', () => {
|
||||
const s = readSource('src/pages/records/RecordsDigitalHumanVersionPage.vue')
|
||||
assert.match(s, /class="btn btn-sm"[^>]*@click="release\(row\)"/, '发布用旧版 btn-sm')
|
||||
assert.match(s, /class="btn btn-sm"[^>]*@click="setLatest\(row\)"/, '设为最新用旧版 btn-sm')
|
||||
assert.match(s, /class="btn btn-sm btn-secondary"[^>]*@click="download\(row\)"/, '下载用旧版 btn-sm btn-secondary')
|
||||
assert.match(s, /class="btn btn-sm btn-danger"[^>]*@click="remove\(row\)"/, '删除用旧版 btn-sm btn-danger')
|
||||
assert.doesNotMatch(s, /el-button[^>]*@click="release/, '行内不再用 EP 按钮')
|
||||
assert.match(s, /window\.confirm\(text\)/, '确认走原生 confirm 对齐旧版')
|
||||
})
|
||||
|
||||
test('align_image_video_detail_and_download_old_btn', () => {
|
||||
const s = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
|
||||
assert.match(s, /class="btn btn-sm btn-secondary"[^>]*@click="downloadVideo\(task, index, video\)"/, '视频下载为旧版 btn-sm btn-secondary')
|
||||
assert.match(s, /class="btn btn-sm"[^>]*@click="copyLink\(video\.displayUrl\)"/, '复制链接为旧版 btn-sm')
|
||||
})
|
||||
|
||||
test('align_copy_link_aux_old_btn', () => {
|
||||
// 辅助"复制链接"为旧版 btn-sm 小按钮。
|
||||
const s = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
|
||||
assert.match(s, /class="btn btn-sm"[^>]*@click="copyLink/, '复制链接用 btn-sm')
|
||||
})
|
||||
@@ -80,7 +80,7 @@ test('align_skip_price_create_api_wiring', () => {
|
||||
test('align_skip_price_page_layout_wiring', () => {
|
||||
const page = readSource('src/pages/asin/SkipPricePage.vue')
|
||||
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
||||
assert.match(page, /span-method/, '表格用 span-method 做 rowspan 合并')
|
||||
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
||||
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
||||
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
|
||||
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
|
||||
@@ -93,7 +93,7 @@ test('align_skip_price_filter_price_range_same_row', () => {
|
||||
// 验收反馈:筛选条件「最低价」的 ≥/≤ 输入并排同一排(price-range-row),不换行拆开。
|
||||
const page = readSource('src/pages/asin/SkipPricePage.vue')
|
||||
assert.match(page, /price-range-row/, '最低价范围筛选并排容器存在')
|
||||
assert.match(page, /placeholder="≥ 最低值"/, '下限占位文案')
|
||||
assert.match(page, /placeholder="≤ 最高值"/, '上限占位文案')
|
||||
assert.match(page, /placeholder="最低价 ≥"/, '下限占位文案(旧版)')
|
||||
assert.match(page, /placeholder="最高价 ≤"/, '上限占位文案(旧版)')
|
||||
assert.ok(!page.includes('label="最低价 ≥"'), '不再有独立的最低价 ≥ 筛选项')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import {
|
||||
ADMIN_MENU_TYPE,
|
||||
APP_MENU_TYPE,
|
||||
parseMenuOptionList,
|
||||
parsePermissionMenuItem,
|
||||
} from '../src/pages/account/user-menu-auth.ts'
|
||||
|
||||
// 验收反馈:用户创建/编辑的菜单权限配置要同时包含 后台(admin) 与 前端客户端(app) 两类菜单,
|
||||
// 并按类型分区落库(internal 不纳入)。
|
||||
|
||||
test('align_user_menu_auth_both_types_tree_nodes', () => {
|
||||
// 授权节点携带 type:admin/app 两类菜单都能进同一棵树。
|
||||
assert.equal(APP_MENU_TYPE, 'app')
|
||||
const admin = parsePermissionMenuItem({ id: 1, name: '账号权限', parent_id: null, sort_order: 1 }, ADMIN_MENU_TYPE)
|
||||
assert.equal(admin?.type, ADMIN_MENU_TYPE)
|
||||
const app = parseMenuOptionList(
|
||||
[{ id: 2, name: '视频', parent_id: null, sort_order: 1, column_key: 'wb' }],
|
||||
APP_MENU_TYPE,
|
||||
)
|
||||
assert.equal(app[0].type, APP_MENU_TYPE)
|
||||
})
|
||||
|
||||
test('align_user_menu_auth_both_types_fetch_and_submit_source', () => {
|
||||
const api = readSource('src/pages/account/user-menu-auth-api.ts')
|
||||
assert.match(api, /APP_MENU_TYPE/, '拉取/回显含客户端类型')
|
||||
assert.match(api, /Promise\.all/, '两类菜单并行拉取')
|
||||
assert.match(api, /normalizeMenuIds/, '两类已授权 id 合并去重')
|
||||
// 提交仍由后端把勾选 id 按类型分区写(user PUT),前端不再限定单类型。
|
||||
const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java')
|
||||
assert.match(service, /resolveMenuTypes/, '后端按菜单 id 解析类型')
|
||||
assert.match(service, /MENU_TYPE_ADMIN/, 'admin 分区写')
|
||||
assert.match(service, /MENU_TYPE_APP/, 'app 分区写')
|
||||
const auth = readSource('src/pages/account/user-menu-auth.ts')
|
||||
assert.match(auth, /type: string/, '授权节点携带 type')
|
||||
})
|
||||
@@ -64,7 +64,7 @@ test('test_datetime_sweep_boundary_single_item', () => {
|
||||
// 含时间展示的页:时间标签仍存在(列还在),只是改成走格式化;无时间列页面豁免。
|
||||
for (const page of PAGES) {
|
||||
const src = readSource(page)
|
||||
if (TIME_CONTENT.test(src)) assert.match(src, /时间/, `${page} 仍应含“时间”列/文案`)
|
||||
if (TIME_CONTENT.test(src)) assert.match(src, /时间|最新/, `${page} 仍应含“时间/最新”列/文案`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ test('test_task_103_video_list_load_boundary_empty_input', () => {
|
||||
const page = parseImageVideoPage({})
|
||||
assert.deepEqual(page.items, [])
|
||||
assert.equal(page.total, 0)
|
||||
assert.equal(page.pageSize, 20)
|
||||
assert.equal(page.pageSize, 10)
|
||||
})
|
||||
|
||||
test('test_task_103_video_list_load_boundary_single_item', () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ test('test_task_110_shop_data_list_load_boundary_empty_input', () => {
|
||||
const page = parseShopDataTaskPage({})
|
||||
assert.deepEqual(page.items, [])
|
||||
assert.equal(page.total, 0)
|
||||
assert.equal(page.pageSize, 20)
|
||||
assert.equal(page.pageSize, 10)
|
||||
})
|
||||
|
||||
test('test_task_110_shop_data_list_load_boundary_single_item', () => {
|
||||
|
||||
@@ -39,8 +39,10 @@ test('test_task_250_users_list_boundary_empty_input', () => {
|
||||
test('test_task_250_users_list_boundary_single_item', () => {
|
||||
// 边界:分页/合计形态为“共 N 条”,查询后回第一页。
|
||||
const page = readSource('src/pages/account/UsersPage.vue')
|
||||
assert.match(page, /共 .*total.*条/, '分页页脚应有“共 N 条”合计')
|
||||
assert.match(page, /page\s*=\s*1|filters\.page\s*=\s*USER_PAGE_MIN_PAGE/, '查询需重置回第一页')
|
||||
assert.match(page, /OldPagination/, '分页走共享组件')
|
||||
const comp = readSource('src/components/OldPagination.vue')
|
||||
assert.match(comp, /共 \{\{ total \}\} 条/, '共享分页含“共 N 条”合计')
|
||||
assert.match(page, /filters\.page = 1/, '查询需重置回第一页')
|
||||
})
|
||||
|
||||
test('test_task_250_users_list_boundary_limit_or_missing_field', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ test('test_task_255_invalidasin_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/asin/AsinInvalidPage.vue')
|
||||
assert.match(page, /分组/, '筛选需含分组下拉')
|
||||
assert.match(page, /recordSourceLabel/, '来源列需用 recordSourceLabel 文案')
|
||||
assert.match(page, /el-tag/, '来源宜用 tag 形态')
|
||||
assert.match(page, /source-text/, '来源用旧版源样式(自绘)')
|
||||
})
|
||||
|
||||
test('test_task_255_invalidasin_normal_variant_input', () => {
|
||||
|
||||
@@ -17,9 +17,9 @@ test('test_task_258_skipprice_normal_variant_input', () => {
|
||||
const page = readSource('src/pages/asin/SkipPricePage.vue')
|
||||
assert.match(page, /填写最低价时必须填写 ASIN/, '低价必带 ASIN 校验文案')
|
||||
assert.match(page, /最低价格式不正确/, '低价格式校验文案')
|
||||
assert.match(page, /导入添加/, '页头导入添加')
|
||||
assert.match(page, /导入删除/, '页头删除导入')
|
||||
assert.match(page, /导出/, '页头导出')
|
||||
assert.match(page, /导入文件新增/, '页头导入文件新增(旧版文案)')
|
||||
assert.match(page, /导入文件删除/, '页头导入文件删除(旧版文案)')
|
||||
assert.match(page, /导出 XLSX/, '页头导出 XLSX(旧版文案)')
|
||||
})
|
||||
|
||||
test('test_task_258_skipprice_normal_repeated_operation_is_idempotent', () => {
|
||||
|
||||
@@ -58,7 +58,7 @@ test('test_task_259_history_boundary_single_item', () => {
|
||||
test('test_task_259_history_boundary_limit_or_missing_field', () => {
|
||||
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||
assert.match(page, /指定用户/, '筛选需有“指定用户”标签(admin 语义)')
|
||||
assert.match(page, /el-date-picker/, '需用日期时间选择器')
|
||||
assert.match(page, /type="datetime-local"/, '自绘复刻用原生日历控件(对齐 admin datetime-local)')
|
||||
assert.match(page, /historyPanelTypeLabel|panelTypeLabel/, '类型列需走中文映射')
|
||||
assert.match(page, /formatDateTime/, '时间统一格式化')
|
||||
})
|
||||
|
||||
@@ -36,7 +36,7 @@ test('test_task_264_view_boundary_empty_input', () => {
|
||||
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
|
||||
assert.match(page, /视频生成中或暂无结果/)
|
||||
assert.match(page, /任务失败,未生成视频/)
|
||||
assert.match(page, /暂无视频任务记录/)
|
||||
assert.match(page, /暂无符合条件的视频任务/)
|
||||
})
|
||||
|
||||
test('test_task_264_view_boundary_single_item', () => {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { normalizeImageVideoFilter } from '../src/pages/tasks/image-video-filter
|
||||
|
||||
test('test_task_265_filter_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/tasks/ImageVideoTasksPage.vue')
|
||||
assert.match(page, /用户名(模糊搜索)/, '需用户名模糊筛选')
|
||||
assert.match(page, /提交时间/, '需提交时间范围')
|
||||
assert.match(page, /用户名/, '需用户名模糊筛选')
|
||||
assert.match(page, /提交开始/, '需提交开始时间')
|
||||
assert.match(page, /提交结束/, '需提交结束时间')
|
||||
assert.match(page, /查询/, '需查询按钮')
|
||||
assert.match(page, /reset/, '需重置')
|
||||
})
|
||||
@@ -43,7 +44,7 @@ test('test_task_265_permission_normal_primary_path', () => {
|
||||
assert.match(page, /权限配置/, '权限入口')
|
||||
assert.match(page, /fetchImageVideoPermissionUsers/, '权限用户加载')
|
||||
assert.match(page, /saveImageVideoPermissions/, '权限保存')
|
||||
assert.match(page, /数据范围授权/, '权限弹窗语义')
|
||||
assert.match(page, /数据范围/, '权限弹窗语义')
|
||||
})
|
||||
|
||||
test('test_task_265_permission_boundary_limit_or_missing_field', () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ test('test_task_266_view_normal_primary_path', () => {
|
||||
assert.match(page, /shop-card-grid/, '需任务卡片网格')
|
||||
assert.match(page, /v-for="row in pagedResultRows"/, '逐任务渲染卡片')
|
||||
assert.match(page, /shop-card-task-no/, '卡片含任务号')
|
||||
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案')
|
||||
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案')
|
||||
})
|
||||
|
||||
test('test_task_266_view_normal_variant_input', () => {
|
||||
@@ -56,5 +56,5 @@ test('test_task_266_delete_boundary_limit_or_missing_field', () => {
|
||||
test('test_task_266_dependency_failure_returns_actionable_message', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.equal(/<el-table[^>]*:data="(rows|allResultRows)"/.test(page), false, '结果文件不再用整表渲染')
|
||||
assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案')
|
||||
assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案')
|
||||
})
|
||||
|
||||
@@ -8,10 +8,11 @@ import { toShopDataZipRequest } from '../src/pages/tasks/shop-data-download.ts'
|
||||
|
||||
test('test_task_267_filter_normal_primary_path', () => {
|
||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||
assert.match(page, /店铺(模糊搜索)/, '需店铺模糊筛选')
|
||||
assert.match(page, /分组(模糊搜索)/, '需分组模糊筛选')
|
||||
assert.match(page, /店铺/, '需店铺模糊筛选')
|
||||
assert.match(page, /分组/, '需分组模糊筛选')
|
||||
assert.match(page, /国家/, '需国家筛选')
|
||||
assert.match(page, /创建时间/, '需创建时间范围')
|
||||
assert.match(page, /创建开始/, '需创建开始时间')
|
||||
assert.match(page, /创建结束/, '需创建结束时间')
|
||||
assert.match(page, /查询/, '需查询')
|
||||
assert.match(page, /reset/, '需重置')
|
||||
})
|
||||
|
||||
@@ -75,7 +75,7 @@ test('test_task_059_data_group_list_dependency_failure_returns_actionable_messag
|
||||
assert.match(api, /shop-manages\/groups/)
|
||||
assert.match(api, /parseShopGroupList/)
|
||||
const page = readSource('src/pages/account/GroupsPage.vue')
|
||||
assert.match(page, /shop-group-api|fetchShopGroups/)
|
||||
assert.match(page, /shopGroupSummary|summary/)
|
||||
assert.match(page, /shop-group-api|fetchShopGroups/, '页面走分组适配接口')
|
||||
assert.match(page, /filterKeyword|filterLeader/, '分组列表已加名称/组长筛选')
|
||||
assert.equal(/http\.(get|post)|unwrap\(/.test(page), false, '页面不内联请求')
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ test('test_task_062_dedupe_list_boundary_empty_input', () => {
|
||||
assert.deepEqual(page.items, [])
|
||||
assert.equal(page.total, 0)
|
||||
assert.equal(page.page, 1)
|
||||
assert.equal(page.pageSize, 15)
|
||||
assert.equal(page.pageSize, 10)
|
||||
})
|
||||
|
||||
test('test_task_062_dedupe_list_boundary_single_item', () => {
|
||||
@@ -84,7 +84,7 @@ test('test_task_062_dedupe_list_boundary_limit_or_missing_field', () => {
|
||||
// 边界上限/缺字段:缺 page/pageSize 用默认;空 dataValue 保留空串。
|
||||
const page = parseDedupeTotalPage({ items: [{ id: 9, dataValue: '' }], total: 0, page: 1 })
|
||||
assert.equal(page.items[0].dataValue, '')
|
||||
assert.equal(page.pageSize, 15)
|
||||
assert.equal(page.pageSize, 10)
|
||||
assert.equal(toDedupeTotalItem({ id: 10, dataValue: 'v' })?.dataValue, 'v')
|
||||
})
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ test('test_task_068_brand_db_list_boundary_empty_input', () => {
|
||||
assert.deepEqual(page.items, [])
|
||||
assert.equal(page.total, 0)
|
||||
assert.equal(page.page, 1)
|
||||
assert.equal(page.pageSize, 15)
|
||||
assert.equal(page.pageSize, 10)
|
||||
})
|
||||
|
||||
test('test_task_068_brand_db_list_boundary_single_item', () => {
|
||||
|
||||
@@ -95,7 +95,7 @@ test('test_task_071_query_asin_list_boundary_empty_input', () => {
|
||||
assert.deepEqual(page.items, [])
|
||||
assert.equal(page.total, 0)
|
||||
assert.equal(page.page, 1)
|
||||
assert.equal(page.pageSize, 15)
|
||||
assert.equal(page.pageSize, 10)
|
||||
})
|
||||
|
||||
test('test_task_071_query_asin_list_boundary_single_item', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../src/pages/asin/skip-price-model.ts'
|
||||
import { ASIN_COUNTRY_CODES, asinCountryLabel } from '../src/pages/asin/asin-country.ts'
|
||||
import {
|
||||
normalizePriceInput,
|
||||
skipPriceFilterActive,
|
||||
toSkipPriceParams,
|
||||
toSkipPriceQuery,
|
||||
@@ -84,7 +85,7 @@ test('test_task_075_skip_price_list_boundary_empty_input', () => {
|
||||
assert.deepEqual(page.items, [])
|
||||
assert.equal(page.total, 0)
|
||||
assert.equal(page.page, 1)
|
||||
assert.equal(page.pageSize, 15)
|
||||
assert.equal(page.pageSize, 10)
|
||||
})
|
||||
|
||||
test('test_task_075_skip_price_list_boundary_single_item', () => {
|
||||
@@ -136,6 +137,17 @@ test('test_task_075_skip_price_filter_normal_primary_and_price', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('test_task_075_skip_price_filter_number_input_compat', () => {
|
||||
// 原生 number input v-model 会把输入值转成 number;normalizePriceInput 需兼容(修复 "(value||'').trim is not a function")。
|
||||
const state: SkipPriceFilterState = { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: 12.34, minimumPriceTo: 56.78 }
|
||||
const query = toSkipPriceQuery(toSkipPriceParams(state, 1, 15))
|
||||
assert.equal(query.minimum_price_from, 12.34)
|
||||
assert.equal(query.minimum_price_to, 56.78)
|
||||
assert.equal(normalizePriceInput(0), 0)
|
||||
assert.equal(normalizePriceInput(-5), null)
|
||||
assert.equal(normalizePriceInput(null as unknown as string), null)
|
||||
})
|
||||
|
||||
test('test_task_075_skip_price_filter_invalid_input_rejected', () => {
|
||||
// 异常输入:非法价格/负数/非数值价格丢弃;success=false 抛 message;垃圾行不入列表。
|
||||
const state: SkipPriceFilterState = {
|
||||
|
||||
@@ -102,7 +102,7 @@ test('test_task_078_product_category_search_boundary_empty_and_default', () => {
|
||||
assert.deepEqual(page.items, [])
|
||||
assert.equal(page.total, 0)
|
||||
assert.equal(page.hasMore, false)
|
||||
assert.equal(page.pageSize, 20)
|
||||
assert.equal(page.pageSize, 10)
|
||||
})
|
||||
|
||||
test('test_task_078_product_category_invalid_input_rejected', () => {
|
||||
|
||||
+3
-2
@@ -38,11 +38,12 @@ public class AdminUserController {
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(name = "search", required = false) String search,
|
||||
@RequestParam(name = "created_by_id", required = false) Long createdById,
|
||||
@RequestParam(name = "admin_id", required = false) Long adminId) {
|
||||
@RequestParam(name = "admin_id", required = false) Long adminId,
|
||||
@RequestParam(name = "role", required = false) String role) {
|
||||
AdminUserEntity currentUser = adminAuthSupport.requireAdminOrInternal(request);
|
||||
String kw = (username == null || username.isBlank()) ? search : username;
|
||||
Long filterCreatedBy = createdById != null ? createdById : adminId;
|
||||
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy);
|
||||
AdminUserListVo vo = adminUserService.listUsers(currentUser, page, pageSize, kw, filterCreatedBy, role);
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
|
||||
|
||||
+37
-4
@@ -42,7 +42,7 @@ public class AdminUserService {
|
||||
private final PermissionMenuService permissionMenuService;
|
||||
|
||||
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
|
||||
String username, Long createdById) {
|
||||
String username, Long createdById, String roleFilter) {
|
||||
String role = adminAuthSupport.currentRole(currentUser);
|
||||
if (role == null) {
|
||||
throw new BusinessException(403, "需要管理员权限");
|
||||
@@ -60,6 +60,10 @@ public class AdminUserService {
|
||||
Long filterCreatedBy = "super_admin".equals(role) ? createdById : null;
|
||||
|
||||
LambdaQueryWrapper<AdminUserEntity> query = new LambdaQueryWrapper<>();
|
||||
String roleFilterValue = roleFilter == null ? "" : roleFilter.trim();
|
||||
if (!roleFilterValue.isEmpty()) {
|
||||
query.eq(AdminUserEntity::getRole, roleFilterValue);
|
||||
}
|
||||
if ("super_admin".equals(role)) {
|
||||
if (!kw.isEmpty()) {
|
||||
query.like(AdminUserEntity::getUsername, kw);
|
||||
@@ -230,11 +234,38 @@ public class AdminUserService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户管理授权树现在同时展示并勾选 后台(admin) + 前端客户端(app) 菜单:
|
||||
* 把勾选 id 按类型拆分后分区整树替换(internal 类型不在树里,出现即按无效处理)。
|
||||
*/
|
||||
private void replaceDirectPermissions(AdminUserEntity operator, Long userId, List<Long> columnIds) {
|
||||
List<Long> raw = columnIds == null ? List.of() : columnIds;
|
||||
Map<Long, String> typeById = permissionMenuService.resolveMenuTypes(raw);
|
||||
List<Long> adminIds = new ArrayList<>();
|
||||
List<Long> appIds = new ArrayList<>();
|
||||
for (Long id : raw) {
|
||||
if (id == null) {
|
||||
continue;
|
||||
}
|
||||
String type = typeById.getOrDefault(id, "");
|
||||
if (PermissionMenuService.MENU_TYPE_ADMIN.equals(type)) {
|
||||
adminIds.add(id);
|
||||
} else if (PermissionMenuService.MENU_TYPE_APP.equals(type)) {
|
||||
appIds.add(id);
|
||||
} else {
|
||||
throw new BusinessException("存在无效或不属于当前类型的菜单权限项");
|
||||
}
|
||||
}
|
||||
replaceDirectPermissionsByType(operator, userId, adminIds, PermissionMenuService.MENU_TYPE_ADMIN);
|
||||
replaceDirectPermissionsByType(operator, userId, appIds, PermissionMenuService.MENU_TYPE_APP);
|
||||
}
|
||||
|
||||
private void replaceDirectPermissionsByType(AdminUserEntity operator, Long userId, List<Long> columnIds,
|
||||
String menuType) {
|
||||
UserColumnPermissionUpdateRequest permissionRequest = new UserColumnPermissionUpdateRequest();
|
||||
permissionRequest.setColumnIds(columnIds == null ? List.of() : columnIds);
|
||||
permissionRequest.setColumnIds(columnIds);
|
||||
permissionMenuService.updateUserColumnPermissions(
|
||||
operator, userId, permissionRequest, PermissionMenuService.MENU_TYPE_APP);
|
||||
operator, userId, permissionRequest, menuType);
|
||||
}
|
||||
|
||||
public void deleteUser(AdminUserEntity currentUser, Long uid) {
|
||||
@@ -291,7 +322,9 @@ public class AdminUserService {
|
||||
vo.setAdmin(entity.getIsAdmin() != null && entity.getIsAdmin() == 1);
|
||||
vo.setRole(entity.getRole() == null || entity.getRole().isEmpty() ? "normal" : entity.getRole());
|
||||
vo.setCreatedById(entity.getCreatedById());
|
||||
vo.setCreatorUsername(creatorMap.getOrDefault(entity.getCreatedById(), ""));
|
||||
// 不可变 Map(Map.of) 的 getOrDefault 不允许 null key:createdById 为 null 时直接空串。
|
||||
Long creatorId = entity.getCreatedById();
|
||||
vo.setCreatorUsername(creatorId == null ? "" : creatorMap.getOrDefault(creatorId, ""));
|
||||
LocalDateTime createdAt = entity.getCreatedAt();
|
||||
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
|
||||
vo.setPinyinAbbr(PinyinAbbrUtil.abbr(entity.getUsername()));
|
||||
|
||||
+13
-13
@@ -78,16 +78,16 @@ public class DedupeTotalDataController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataPageVo.class)))
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataPageVo> page(
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "数据值模糊搜索关键字") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username,
|
||||
@Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "数据值模糊搜索关键字") @RequestParam(name = "keyword", required = false) String keyword,
|
||||
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(name = "username", required = false) String username,
|
||||
@Parameter(description = "开始日期(包含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam(name = "start_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(包含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "国家代码(如 DE、UK)") @RequestParam(required = false) String country,
|
||||
@RequestParam(name = "end_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "国家代码(如 DE、UK)") @RequestParam(name = "country", required = false) String country,
|
||||
HttpServletRequest request) {
|
||||
RequestOperator operator = requireDedupeTotalDataAccess(request);
|
||||
return ApiResponse.success(dedupeTotalDataService.page(
|
||||
@@ -97,13 +97,13 @@ public class DedupeTotalDataController {
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据;跨自然月时按月生成 XLSX 并打包为 ZIP。")
|
||||
public ResponseEntity<StreamingResponseBody> export(
|
||||
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username,
|
||||
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(name = "username", required = false) String username,
|
||||
@Parameter(description = "开始日期(包含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam(name = "start_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(包含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "国家代码(如 DE、UK)") @RequestParam(required = false) String country,
|
||||
@RequestParam(name = "end_date", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "国家代码(如 DE、UK)") @RequestParam(name = "country", required = false) String country,
|
||||
HttpServletRequest request) {
|
||||
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
|
||||
throw new BusinessException(400, "invalid export date range");
|
||||
|
||||
+6
-6
@@ -60,12 +60,12 @@ public class InvalidAsinDataController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = InvalidAsinDataPageVo.class)))
|
||||
})
|
||||
public ApiResponse<InvalidAsinDataPageVo> page(
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "模糊搜索关键字") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "ASIN 模糊搜索") @RequestParam(required = false) String dataValue,
|
||||
@Parameter(description = "品牌模糊搜索") @RequestParam(required = false) String brand,
|
||||
@Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "模糊搜索关键字") @RequestParam(name = "keyword", required = false) String keyword,
|
||||
@Parameter(description = "ASIN 模糊搜索") @RequestParam(name = "data_value", required = false) String dataValue,
|
||||
@Parameter(description = "品牌模糊搜索") @RequestParam(name = "brand", required = false) String brand,
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
HttpServletRequest request) {
|
||||
RequestOperator operator = requireInvalidAsinDataAccess(request);
|
||||
return ApiResponse.success(invalidAsinDataService.page(
|
||||
|
||||
+19
@@ -61,6 +61,25 @@ public class PermissionMenuService {
|
||||
return toItemVos(menus, menus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按菜单(column) id 解析所属类型;未知/无效 id 不在返回中。供用户创建/编辑把
|
||||
* 勾选的 后台(admin)+客户端(app) 菜单 id 拆分后分别落库。
|
||||
*/
|
||||
public Map<Long, String> resolveMenuTypes(List<Long> columnIds) {
|
||||
List<Long> ids = normalizeColumnIds(columnIds);
|
||||
if (ids.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<PermissionMenuEntity> rows = permissionMenuMapper.selectBatchIds(ids);
|
||||
Map<Long, String> typeById = new HashMap<>();
|
||||
for (PermissionMenuEntity row : rows) {
|
||||
if (row != null && row.getId() != null) {
|
||||
typeById.put(row.getId(), row.getMenuType() == null ? "" : row.getMenuType());
|
||||
}
|
||||
}
|
||||
return typeById;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PermissionMenuItemVo create(PermissionMenuCreateRequest request) {
|
||||
return create(null, request);
|
||||
|
||||
+3
-3
@@ -52,9 +52,9 @@ public class ProductCategoryController {
|
||||
@Operation(summary = "分页查询指定父级下的商品类目")
|
||||
public ApiResponse<ProductCategoryListVo> children(
|
||||
HttpServletRequest request,
|
||||
@RequestParam(value = "parentId", required = false) Long parentId,
|
||||
@RequestParam(value = "parent_id", required = false) Long parentId,
|
||||
@RequestParam(value = "page", defaultValue = "1") Long page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
|
||||
@RequestParam(value = "page_size", defaultValue = "20") Long pageSize) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success(productCategoryService.children(parentId, page, pageSize));
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public class ProductCategoryController {
|
||||
HttpServletRequest request,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", defaultValue = "1") Long page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) {
|
||||
@RequestParam(value = "page_size", defaultValue = "20") Long pageSize) {
|
||||
adminAuthSupport.requireAdminOrInternal(request);
|
||||
return ApiResponse.success(productCategoryService.search(keyword, page, pageSize));
|
||||
}
|
||||
|
||||
+22
-3
@@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto;
|
||||
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlAdminTasksService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -38,6 +39,7 @@ import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -75,6 +77,7 @@ public class AdminShopDataCrawlTasksController {
|
||||
private final AdminAuthSupport adminAuthSupport;
|
||||
private final PermissionMenuService permissionMenuService;
|
||||
private final ShopDataCrawlAdminTasksService adminTasksService;
|
||||
private final ShopDuplicateCheckSourceMapper duplicateCheckSourceMapper;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "店铺数据抓取记录:按店铺分组分页列表(每店最新结果文件)")
|
||||
@@ -87,12 +90,14 @@ public class AdminShopDataCrawlTasksController {
|
||||
@RequestParam(name = "country", required = false) String country,
|
||||
@RequestParam(name = "created_from", required = false) String createdFrom,
|
||||
@RequestParam(name = "created_to", required = false) String createdTo) {
|
||||
requireShopDataCrawlTaskAccess(request);
|
||||
AdminUserEntity operator = requireShopDataCrawlTaskAccess(request);
|
||||
int safePage = Math.max(1, parseInt(page, 1, "page 参数不合法"));
|
||||
int safePageSize = Math.min(PAGE_SIZE_MAX,
|
||||
Math.max(10, parseInt(pageSize, PAGE_SIZE_DEFAULT, "page_size 参数不合法")));
|
||||
boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator));
|
||||
List<String> visibleShopKeys = superAdmin ? null : resolveVisibleShopNameKeys(operator.getId());
|
||||
Map<String, Object> payload = adminTasksService.listShopGroups(
|
||||
safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo);
|
||||
safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo, visibleShopKeys);
|
||||
return ApiResponse.success(payload);
|
||||
}
|
||||
|
||||
@@ -235,7 +240,7 @@ public class AdminShopDataCrawlTasksController {
|
||||
return new ArrayList<>(ids);
|
||||
}
|
||||
|
||||
private void requireShopDataCrawlTaskAccess(HttpServletRequest request) {
|
||||
private AdminUserEntity requireShopDataCrawlTaskAccess(HttpServletRequest request) {
|
||||
AdminUserEntity operator;
|
||||
try {
|
||||
operator = adminAuthSupport.requireAdmin(request);
|
||||
@@ -246,6 +251,20 @@ public class AdminShopDataCrawlTasksController {
|
||||
}
|
||||
}
|
||||
permissionMenuService.requireShopDataCrawlTaskAccess(operator);
|
||||
return operator;
|
||||
}
|
||||
|
||||
/** 非超管返回其可管店铺名(trim+小写去重,与撞款可见口径一致);超管调用方传 null 表示全量。 */
|
||||
private List<String> resolveVisibleShopNameKeys(Long operatorId) {
|
||||
List<String> names = duplicateCheckSourceMapper.selectManagedShopNames(operatorId);
|
||||
if (names == null || names.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return names.stream()
|
||||
.filter(name -> name != null && !name.isBlank())
|
||||
.map(name -> name.trim().toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private AdminUserEntity resolveInternalOperator(HttpServletRequest request) {
|
||||
|
||||
+9
@@ -163,6 +163,15 @@ public interface ShopDataCrawlAdminTasksMapper {
|
||||
if (p.get("createdTo") != null) {
|
||||
sql.append(" AND t.created_at <= #{createdTo}");
|
||||
}
|
||||
Object visibleShopKeys = p.get("visibleShopKeys");
|
||||
if (visibleShopKeys instanceof List<?> keys) {
|
||||
if (keys.isEmpty()) {
|
||||
sql.append(" AND 1 = 0");
|
||||
} else {
|
||||
sql.append(" AND LOWER(TRIM(COALESCE(r.source_filename, ''))) IN (")
|
||||
.append(inPlaceholders(p, "visibleShopKeys", "lv")).append(')');
|
||||
}
|
||||
}
|
||||
return sql.toString();
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlItemEntity;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface ShopDataCrawlItemMapper extends BaseMapper<ShopDataCrawlItemEntity> {
|
||||
|
||||
/** 删除某店某批次(每日快照替换语义:先删后插)。 */
|
||||
@Delete("""
|
||||
DELETE FROM biz_shop_data_crawl_item
|
||||
WHERE shop_name = #{shopName} AND business_date = #{businessDate}
|
||||
""")
|
||||
int deleteBatch(@Param("shopName") String shopName, @Param("businessDate") java.time.LocalDate businessDate);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_shop_data_crawl_item")
|
||||
public class ShopDataCrawlItemEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long shopId;
|
||||
private String shopName;
|
||||
private String groupName;
|
||||
private LocalDate businessDate;
|
||||
private String country;
|
||||
private String asin;
|
||||
private String brand;
|
||||
private String price;
|
||||
private String itemDate;
|
||||
private String unitsSold;
|
||||
private String itemJson;
|
||||
private Long resultId;
|
||||
private Long taskId;
|
||||
private Long dailyFileId;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user