Compare commits
157 Commits
c086d402a7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b8105ec2b | |||
| 5fcb449946 | |||
| ff68426c69 | |||
| 46be044121 | |||
| e0303f9cba | |||
| 9d39705c77 | |||
| 986df86e89 | |||
| bd359411a9 | |||
| 3634ea1d62 | |||
| 3ce0569c59 | |||
| d2d95f0b71 | |||
| 2a51006888 | |||
| a89de129ea | |||
| ddefcbed56 | |||
| 3137299bfe | |||
| 1fe3368c5a | |||
| 7aea3a0a50 | |||
| 188aedec84 | |||
| d5952945dd | |||
| 6e1689dfe5 | |||
| 4f16a02658 | |||
| 5e9a59b327 | |||
| ab09cff427 | |||
| 47a9520a82 | |||
| 7a6c3c3fa3 | |||
| 803b5d583d | |||
| 1c52cd529b | |||
| ab28b168ec | |||
| 367b4b7553 | |||
| 25c7323c47 | |||
| aea0e16279 | |||
| f566573fce | |||
| 8fcceb3226 | |||
| ed0d6575c8 | |||
| f2ada02383 | |||
| 5e5816cd74 | |||
| d189d94c3b | |||
| 79b5d40327 | |||
| 228d481211 | |||
| d6f8368493 | |||
| b0f764b6b6 | |||
| 1403fec5fe | |||
| 07b4ebe983 | |||
| 0b2b9303d0 | |||
| 05a2c479a5 | |||
| 52b55df7b2 | |||
| b54f72d3f6 | |||
| 24c5a09c7f | |||
| 8cab9d4bad | |||
| 5ea52e5291 | |||
| 9166656673 | |||
| 1360a44e01 | |||
| 86c05e71a2 | |||
| fae26aa460 | |||
| 8803e22f39 | |||
| 375b89154b | |||
| 6a90adc765 | |||
| bcf66dc1d7 | |||
| c55c4a140b | |||
| 7643094f1d | |||
| 1b480f915f | |||
| ac36c08460 | |||
| ef3a2c9bd6 | |||
| da0f10f1cc | |||
| 9ba231dc4c | |||
| 9a6b57db58 | |||
| 67223f8950 | |||
| e76714c32e | |||
| 95dfb69a18 | |||
| 24ada70997 | |||
| ccce03b4d4 | |||
| 4daf235385 | |||
| 6d46506726 | |||
| c448f49e30 | |||
| 3f5a234c59 | |||
| b05bba50fa | |||
| 9d92fb4af2 | |||
| db6869b77e | |||
| 864c22ffc7 | |||
| fd614004b0 | |||
| ff1ffbbfa3 | |||
| 9ffd68bae5 | |||
| 14a723ab1c | |||
| dc6e8924a9 | |||
| 882ccdac12 | |||
| b70557a077 | |||
| a0f6582914 | |||
| 0c98c5bc15 | |||
| 1066625078 | |||
| fe052879bd | |||
| 51c5fe1129 | |||
| 3d208ea0e5 | |||
| a4f60ef21c | |||
| 3f6ad0c6ad | |||
| 0323f0bb7b | |||
| 1b733b23b9 | |||
| 05ae0c62d6 | |||
| 4bc4969e5a | |||
| 8cd8390d95 | |||
| 2a766efb83 | |||
| afa8560369 | |||
| c81ebb7053 | |||
| b7d4d325e0 | |||
| 82a782550e | |||
| d1b56918fa | |||
| 441917bfce | |||
| 037a689316 | |||
| db7cd22ed1 | |||
| 9962d9797e | |||
| 47459e2089 | |||
| e6021593ea | |||
| 540d6588e6 | |||
| 2a9110ec32 | |||
| 51fb77ec25 | |||
| 30d31fb318 | |||
| bb52574767 | |||
| cd055f8ccd | |||
| c2915036d6 | |||
| 8dc03df95b | |||
| bb74c9a192 | |||
| 32985c78f3 | |||
| 8e684b92dd | |||
| d54dbf72c9 | |||
| 94a0c287e0 | |||
| 47b2758fe8 | |||
| dd45ffe34c | |||
| 48ee76d60d | |||
| 5af4e1c23e | |||
| b9768aac5e | |||
| 08e78ebe55 | |||
| 6fe67b648f | |||
| 96e93944d9 | |||
| 0cf20527a4 | |||
| 83cb3ff84e | |||
| 9a487ae9a7 | |||
| 02441565a0 | |||
| b3f7b81b9c | |||
| 11d1951c7f | |||
| ffd8c718dd | |||
| 45fdd8a294 | |||
| 5fad0ae027 | |||
| 3f599547e6 | |||
| 7c1c13526e | |||
| 15222be2b5 | |||
| 3cc5a64609 | |||
| 197643a8fa | |||
| ba581ed095 | |||
| 630bfc3eee | |||
| b12acf7793 | |||
| 4ac8f8b472 | |||
| 7b6f62f1bc | |||
| f2906e8d6f | |||
| d9e3f34d37 | |||
| e248b6e43a | |||
| abcfa5bef7 | |||
| 83e38d4278 | |||
| 50d0d110a2 |
@@ -43,6 +43,6 @@ jobs:
|
|||||||
- name: Type check (vue-tsc)
|
- name: Type check (vue-tsc)
|
||||||
working-directory: frontend-vue
|
working-directory: frontend-vue
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Frontend contract tests (node --test)
|
- name: Frontend contract tests (npm test → node --test)
|
||||||
working-directory: frontend-vue
|
working-directory: frontend-vue
|
||||||
run: node --test tests/*.test.ts
|
run: npm test
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ ipython_config.py
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
|
||||||
# ===== Misc =====
|
# ===== Misc =====
|
||||||
|
.playwright-cli/
|
||||||
desktop/
|
desktop/
|
||||||
ERP-Demo/
|
ERP-Demo/
|
||||||
xlsx/
|
xlsx/
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -32,7 +32,9 @@ test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page })
|
|||||||
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
||||||
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
||||||
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||||
await expect(page.locator('.old-pagination')).toBeVisible()
|
// 台账 tab 下页面同时有「店铺上架分布」分页器(常显,见 tests/align-dup-console.test.ts)与台账分页器,
|
||||||
|
// 全页 .old-pagination 会命中 2 个元素触发 strict mode;限定到台账表格(.table-wrap)紧邻的分页器。
|
||||||
|
await expect(page.locator('.table-wrap + .old-pagination')).toBeVisible()
|
||||||
expect(errors).toEqual([])
|
expect(errors).toEqual([])
|
||||||
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
|
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// 后台管理列表「创建时间 + 更新时间」两列验收(2026-09-19 需求):
|
||||||
|
// 8 个实体管理列表统一展示创建/更新时间,数据取自各列表 VO。
|
||||||
|
// mock 夹具统一给 createdAt=2026-03-01 09:15:00、updatedAt=2026-09-18 16:42:30,
|
||||||
|
// 页面统一经 formatDateTime 归一为「YYYY-MM-DD HH:mm:ss」。
|
||||||
|
|
||||||
|
const CREATED = '2026-03-01 09:15:00'
|
||||||
|
const UPDATED = '2026-09-18 16:42:30'
|
||||||
|
|
||||||
|
const PAGES: Array<{ title: string; path: string; heading: string }> = [
|
||||||
|
{ title: '用户管理', path: '/admin-vue/account/users', heading: '用户列表' },
|
||||||
|
{ title: '菜单管理', path: '/admin-vue/account/menus', heading: '菜单' },
|
||||||
|
{ title: '密钥管理', path: '/admin-vue/account/user-secrets', heading: '用户密钥列表' },
|
||||||
|
{ title: '不符合ASIN数据', path: '/admin-vue/asin-center/invalid', heading: '品牌数据列表' },
|
||||||
|
{ title: '数据去重总数据', path: '/admin-vue/asin-center/registry', heading: '数据去重总数据' },
|
||||||
|
{ title: '查询ASIN', path: '/admin-vue/asin-center/query', heading: '查询' },
|
||||||
|
{ title: '最低价ASIN', path: '/admin-vue/asin-center/skip-price', heading: '最低价' },
|
||||||
|
{ title: '商品类目', path: '/admin-vue/asin-center/categories', heading: '商品类目' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function openPage(page: Page, path: string) {
|
||||||
|
await page.goto(path)
|
||||||
|
await expect(page.locator('.panel-box table tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const spec of PAGES) {
|
||||||
|
test(`${spec.title}:表格展示创建时间与更新时间`, async ({ page }) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message))
|
||||||
|
|
||||||
|
await openPage(page, spec.path)
|
||||||
|
|
||||||
|
const table = page.locator('.panel-box table').first()
|
||||||
|
// 表头出现两列
|
||||||
|
await expect(table.locator('thead')).toContainText('创建时间')
|
||||||
|
await expect(table.locator('thead')).toContainText('更新时间')
|
||||||
|
// 数据行渲染出真实时间值(非空、非占位符)
|
||||||
|
await expect(table.locator('tbody')).toContainText(CREATED)
|
||||||
|
await expect(table.locator('tbody')).toContainText(UPDATED)
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// 站内通知铃铛面板验收:关键字/年月日区间搜索、按天分组、分页。
|
||||||
|
// 依赖 scripts/mock-admin-server.mjs 的通知夹具(14 条:09-13 六条、09-12 四条、09-10 四条)。
|
||||||
|
|
||||||
|
async function openBell(page: Page) {
|
||||||
|
await page.goto('/admin-vue/')
|
||||||
|
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||||
|
await page.locator('.admin-notification-bell .bell-trigger').click()
|
||||||
|
await expect(page.locator('.bell-panel')).toBeVisible()
|
||||||
|
await expect(page.locator('.bell-item').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test_notification_panel_day_group_and_pagination', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
const panel = page.locator('.bell-panel')
|
||||||
|
const nextPage = panel.getByRole('button', { name: '下一页' })
|
||||||
|
|
||||||
|
// 每页 10 条,跨两个日期分组,页脚显示页码与总数
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||||
|
await expect(page.locator('.bell-day-head')).toHaveCount(2)
|
||||||
|
await expect(page.locator('.bell-page-info')).toHaveText('1 / 2')
|
||||||
|
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
|
||||||
|
await expect(page.locator('.bell-day-head').first()).toHaveText(/^(今天|昨天|\d{4}年\d{1,2}月\d{1,2}日)$/)
|
||||||
|
|
||||||
|
const page2Request = page.waitForRequest(
|
||||||
|
(request) => request.url().includes('/api/admin/notifications?') && request.url().includes('page=2'),
|
||||||
|
)
|
||||||
|
await nextPage.click()
|
||||||
|
await page2Request
|
||||||
|
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(4)
|
||||||
|
await expect(page.locator('.bell-page-info')).toHaveText('2 / 2')
|
||||||
|
await expect(page.locator('.bell-day-head')).toHaveCount(1)
|
||||||
|
await expect(nextPage).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_notification_panel_search_by_keyword', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
const keywordRequest = page.waitForRequest((request) =>
|
||||||
|
decodeURIComponent(request.url()).includes('keyword=余额不足'),
|
||||||
|
)
|
||||||
|
await page.locator('.bell-search-input').fill('余额不足')
|
||||||
|
await keywordRequest
|
||||||
|
|
||||||
|
// 关键字命中标题/内容:夹具里 5 条含「余额不足」
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(5)
|
||||||
|
await expect(page.locator('.bell-page-total')).toHaveText('共 5 条')
|
||||||
|
await expect(page.locator('.bell-search-reset')).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('.bell-search-reset').click()
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||||
|
await expect(page.locator('.bell-search-reset')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_notification_panel_search_by_day_range', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
const rangeRequest = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
request.url().includes('startDate=2026-09-12') && request.url().includes('endDate=2026-09-12'),
|
||||||
|
)
|
||||||
|
await page.locator('.bell-date-input').first().fill('2026-09-12')
|
||||||
|
await page.locator('.bell-date-input').nth(1).fill('2026-09-12')
|
||||||
|
await rangeRequest
|
||||||
|
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(4)
|
||||||
|
await expect(page.locator('.bell-day-head')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.bell-page-total')).toHaveText('共 4 条')
|
||||||
|
await expect(page.locator('.bell-empty')).toHaveCount(0)
|
||||||
|
|
||||||
|
// 起止倒挂自动交换:09-13 起 / 09-10 止 → 实际按 09-10 ~ 09-13 查询(全量 14 条)
|
||||||
|
await page.locator('.bell-date-input').first().fill('2026-09-13')
|
||||||
|
await page.locator('.bell-date-input').nth(1).fill('2026-09-10')
|
||||||
|
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_notification_panel_not_covered_by_page_content', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
// 回归:面板挂在顶栏内时曾被页面 el-select 压住(提 z-index 无效),现改为 Teleport 到 body。
|
||||||
|
// 这里在面板内取多点做命中测试,最上层元素必须属于面板自身。
|
||||||
|
const covered = await page.evaluate(() => {
|
||||||
|
const panel = document.querySelector('.bell-panel') as HTMLElement
|
||||||
|
const rect = panel.getBoundingClientRect()
|
||||||
|
const points: Array<[number, number]> = [
|
||||||
|
[rect.left + 30, rect.top + 130],
|
||||||
|
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||||
|
[rect.right - 30, rect.bottom - 60],
|
||||||
|
]
|
||||||
|
return points
|
||||||
|
.map(([x, y]) => document.elementsFromPoint(x, y)[0])
|
||||||
|
.filter((el) => !!el && el !== panel && !panel.contains(el))
|
||||||
|
.map((el) => `${el!.tagName}.${String((el as HTMLElement).className).slice(0, 40)}`)
|
||||||
|
})
|
||||||
|
expect(covered, '面板被页面元素遮挡').toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_notification_panel_reference_screenshot', async ({ page }) => {
|
||||||
|
await openBell(page)
|
||||||
|
|
||||||
|
// 同日多条不得重叠:逐条校验纵向排布严格递增(截图伪影与真实布局问题的分界)
|
||||||
|
const tops = await page
|
||||||
|
.locator('.bell-item')
|
||||||
|
.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top))
|
||||||
|
for (let i = 1; i < tops.length; i += 1) {
|
||||||
|
expect(tops[i], `第 ${i + 1} 条应排在第 ${i} 条下方`).toBeGreaterThan(tops[i - 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/notification-panel-admin-page.jpg', animations: 'disabled' })
|
||||||
|
await page.locator('.bell-panel').screenshot({ path: 'test-results/notification-panel-admin.jpg', animations: 'disabled' })
|
||||||
|
|
||||||
|
await page.locator('.bell-search-input').fill('余额不足')
|
||||||
|
await expect(page.locator('.bell-item')).toHaveCount(5)
|
||||||
|
await page.locator('.bell-panel').screenshot({
|
||||||
|
path: 'test-results/notification-panel-admin-search.jpg',
|
||||||
|
animations: 'disabled',
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// 教程管理页验收(module 记录与版本):列表新增「版本号」列 + 上传时间列,
|
||||||
|
// 默认按上传时间降序;版本号/上传时间表头可点击切换升降序;空版本历史行沉底。
|
||||||
|
// 依赖 scripts/mock-admin-server.mjs 的教程包夹具(4 条,其中 1 条无版本号、1 条 09-01 历史行)。
|
||||||
|
|
||||||
|
async function openTutorial(page: Page) {
|
||||||
|
await page.goto('/admin-vue/records/tutorial')
|
||||||
|
await expect(page.locator('.admin-topbar h1')).toHaveText('教程管理')
|
||||||
|
await expect(page.locator('.panel-box tbody tr').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionTexts = (page: Page) =>
|
||||||
|
page.locator('.panel-box tbody tr td:nth-child(3)').allTextContents()
|
||||||
|
|
||||||
|
const fileTexts = (page: Page) =>
|
||||||
|
page.locator('.panel-box tbody tr td:nth-child(2) .file-name').allTextContents()
|
||||||
|
|
||||||
|
test('test_tutorial_list_default_desc_by_upload_time', async ({ page }) => {
|
||||||
|
await openTutorial(page)
|
||||||
|
|
||||||
|
// 表头:版本号、上传时间(均带排序标记)
|
||||||
|
await expect(page.locator('.sort-version')).toHaveText(/版本号/)
|
||||||
|
await expect(page.locator('.sort-time')).toHaveText(/上传时间/)
|
||||||
|
// 默认排序状态:上传时间降序(▼),版本号未激活(▲▼)
|
||||||
|
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
|
||||||
|
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
|
||||||
|
|
||||||
|
// 默认按上传时间降序:09-14 → 09-12 → 09-10 → 09-01;空版本行沉底
|
||||||
|
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
|
||||||
|
expect(await fileTexts(page)).toEqual([
|
||||||
|
'数富AI-教学客户端-v4.zip',
|
||||||
|
'数富AI-教学客户端-v3.zip',
|
||||||
|
'数富AI-教学客户端-v2.zip',
|
||||||
|
'数富AI-教学客户端.zip',
|
||||||
|
])
|
||||||
|
// 当前生效 = 最新上传(夹具里 09-14 那条),不随列表排序变化
|
||||||
|
await expect(page.locator('.tag-active')).toHaveCount(1)
|
||||||
|
await expect(page.locator('.tag-active').locator('xpath=..')).toHaveText(/数富AI-教学客户端-v4\.zip/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_tutorial_sort_toggle_by_version_and_time', async ({ page }) => {
|
||||||
|
await openTutorial(page)
|
||||||
|
|
||||||
|
// 点「版本号」:首次为降序(v3.10.0 按自然序大于 v3.2.0),空版本行仍在末尾
|
||||||
|
await page.locator('.sort-version').click()
|
||||||
|
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▼')
|
||||||
|
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
|
||||||
|
|
||||||
|
// 再点一次切升序
|
||||||
|
await page.locator('.sort-version').click()
|
||||||
|
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲')
|
||||||
|
expect(await versionTexts(page)).toEqual(['v3.1.0', 'v3.2.0', 'v3.10.0', '—'])
|
||||||
|
|
||||||
|
// 点回「上传时间」:切换字段时回到降序默认
|
||||||
|
await page.locator('.sort-time').click()
|
||||||
|
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
|
||||||
|
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
|
||||||
|
expect(await fileTexts(page)).toEqual([
|
||||||
|
'数富AI-教学客户端-v4.zip',
|
||||||
|
'数富AI-教学客户端-v3.zip',
|
||||||
|
'数富AI-教学客户端-v2.zip',
|
||||||
|
'数富AI-教学客户端.zip',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_tutorial_upload_dialog_has_version_field', async ({ page }) => {
|
||||||
|
await openTutorial(page)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '上传教程包' }).click()
|
||||||
|
const dialog = page.locator('.el-dialog')
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.locator('.el-form-item').first()).toContainText('版本号')
|
||||||
|
await expect(dialog.locator('input').first()).toHaveAttribute('maxlength', '64')
|
||||||
|
|
||||||
|
// 不选文件直接提交:提示仍以 zip 为必填(版本号可空)
|
||||||
|
await dialog.getByRole('button', { name: '上传教程包' }).click()
|
||||||
|
await expect(dialog.locator('.el-alert')).toContainText('请选择 zip 压缩包')
|
||||||
|
})
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -142,6 +142,66 @@ function json(res, payload, status = 200) {
|
|||||||
res.end(body)
|
res.end(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== 教程包夹具(教程管理页:版本号列 + 上传时间排序验收用) ===================== */
|
||||||
|
// 故意乱序给出,且含一条无版本号的历史行:页面默认应按上传时间降序、空版本行沉底。
|
||||||
|
const TUTORIAL_PACKAGES = [
|
||||||
|
{ id: 3, file_name: '数富AI-教学客户端-v3.zip', version: 'v3.2.0', object_key: 'tutorial/20260912090000-数富AI-教学客户端-v3.zip', file_size: 1048576, file_url: 'https://oss.aishufu.top/client/tutorial/t3.zip', created_at: '2026-09-12 09:00' },
|
||||||
|
{ id: 1, file_name: '数富AI-教学客户端.zip', version: '', object_key: 'tutorial/数富AI-教学客户端.zip', file_size: 0, file_url: 'https://oss.aishufu.top/client/tutorial/legacy.zip', created_at: '2026-09-01 08:00' },
|
||||||
|
{ id: 4, file_name: '数富AI-教学客户端-v4.zip', version: 'v3.10.0', object_key: 'tutorial/20260914103000-数富AI-教学客户端-v4.zip', file_size: 2097152, file_url: 'https://oss.aishufu.top/client/tutorial/t4.zip', created_at: '2026-09-14 10:30' },
|
||||||
|
{ id: 2, file_name: '数富AI-教学客户端-v2.zip', version: 'v3.1.0', object_key: 'tutorial/20260910120000-数富AI-教学客户端-v2.zip', file_size: 524288, file_url: 'https://oss.aishufu.top/client/tutorial/t2.zip', created_at: '2026-09-10 12:00' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ===================== 站内通知夹具(铃铛面板:搜索/按天分组/分页验收用) ===================== */
|
||||||
|
const NOTIFICATIONS = [
|
||||||
|
...[6, 5, 4, 3, 2, 1].map((seq) => notif(seq, '2026-09-13', 22, seq, seq <= 3)),
|
||||||
|
...[4, 3, 2, 1].map((seq) => notif(20 + seq, '2026-09-12', 20, seq, true)),
|
||||||
|
...[4, 3, 2, 1].map((seq) => notif(30 + seq, '2026-09-10', 18, seq, true)),
|
||||||
|
]
|
||||||
|
|
||||||
|
function notif(id, day, hour, seq, read) {
|
||||||
|
const minutes = String(seq * 7).padStart(2, '0')
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
scene: id % 3 === 0 ? 'secret_balance' : 'task_failed',
|
||||||
|
level: id % 3 === 0 ? 'error' : 'warning',
|
||||||
|
title: id % 3 === 0 ? `用户密钥异常:测试用户${seq}` : `跟价任务失败`,
|
||||||
|
content: id % 3 === 0 ? `用户 测试用户${seq}(uid=${1100 + seq})的代理设置对应服务商余额不足` : `用户 测试用户${seq} 有 ${seq} 个跟价任务失败`,
|
||||||
|
read,
|
||||||
|
readAt: read ? `${day} ${hour}:${minutes}:00` : null,
|
||||||
|
createdAt: `${day} ${hour}:${minutes}:00`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知列表:关键字匹配标题/内容,日期按年月日区间(与 Java 侧同语义)。 */
|
||||||
|
function notificationPage(searchParams) {
|
||||||
|
const keyword = (searchParams.get('keyword') || '').trim()
|
||||||
|
const startDate = searchParams.get('startDate') || ''
|
||||||
|
const endDate = searchParams.get('endDate') || ''
|
||||||
|
const page = Math.max(1, Number(searchParams.get('page') || 1))
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('pageSize') || 20)))
|
||||||
|
let rows = NOTIFICATIONS
|
||||||
|
if (keyword) {
|
||||||
|
rows = rows.filter((item) => `${item.title}${item.content}`.includes(keyword))
|
||||||
|
}
|
||||||
|
if (startDate) {
|
||||||
|
rows = rows.filter((item) => item.createdAt.slice(0, 10) >= startDate)
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
rows = rows.filter((item) => item.createdAt.slice(0, 10) <= endDate)
|
||||||
|
}
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: rows.slice(start, start + pageSize),
|
||||||
|
total: rows.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
unreadCount: rows.filter((item) => !item.read).length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
const url = (req.url || '').split('?')[0]
|
const url = (req.url || '').split('?')[0]
|
||||||
const method = req.method || 'GET'
|
const method = req.method || 'GET'
|
||||||
@@ -160,13 +220,183 @@ const server = createServer((req, res) => {
|
|||||||
return json(res, {
|
return json(res, {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', createdAt: '2026-01-01 00:00:00' }],
|
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', created_at: '2026-03-01 09:15:00', updated_at: '2026-09-18 16:42:30' }],
|
||||||
total: 1,
|
total: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/admin/permission-menus') {
|
if (url === '/api/admin/permission-menus') {
|
||||||
return json(res, { success: true, data: { items: [] } })
|
// 菜单管理列表:一条根节点,带创建/更新时间(验收时间列)。
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '用户管理',
|
||||||
|
column_key: 'admin_users',
|
||||||
|
parent_id: null,
|
||||||
|
menu_type: 'admin',
|
||||||
|
route_path: 'account/users',
|
||||||
|
sort_order: 10,
|
||||||
|
created_at: '2026-03-01T09:15:00',
|
||||||
|
updated_at: '2026-09-18T16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/invalid-asin-data') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dataValue: 'B0INVALID1',
|
||||||
|
brand: '测试品牌',
|
||||||
|
groupId: 1,
|
||||||
|
groupName: '测试分组',
|
||||||
|
recordSource: 'MANUAL',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/dedupe-total-data') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
dataValue: 'B0DEDUPE01',
|
||||||
|
country: 'DE',
|
||||||
|
groupId: 1,
|
||||||
|
groupName: '测试分组',
|
||||||
|
uploaderUserId: 1,
|
||||||
|
username: 'admin',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/query-asins') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
groupId: 1,
|
||||||
|
groupName: '测试分组',
|
||||||
|
shopName: '测试店铺',
|
||||||
|
asinDe: 'B0QUERY001',
|
||||||
|
asinUk: '',
|
||||||
|
asinFr: '',
|
||||||
|
asinIt: '',
|
||||||
|
asinEs: '',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/skip-price-asins') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
groupId: 1,
|
||||||
|
groupName: '测试分组',
|
||||||
|
shopName: '测试店铺',
|
||||||
|
asinDe: 'B0SKIP0001',
|
||||||
|
minimumPriceDe: 9.99,
|
||||||
|
asinUk: '',
|
||||||
|
minimumPriceUk: null,
|
||||||
|
asinFr: '',
|
||||||
|
minimumPriceFr: null,
|
||||||
|
asinIt: '',
|
||||||
|
minimumPriceIt: null,
|
||||||
|
asinEs: '',
|
||||||
|
minimumPriceEs: null,
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/product-categories/children') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
parentId: null,
|
||||||
|
name: '护肤品',
|
||||||
|
categoryKey: 'skincare',
|
||||||
|
sortOrder: 10,
|
||||||
|
description: '测试备注',
|
||||||
|
isBuiltin: true,
|
||||||
|
childCount: 0,
|
||||||
|
level: 0,
|
||||||
|
path: '护肤品',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/user-secrets') {
|
||||||
|
const emptyModule = { moduleKey: '', moduleLabel: '', masked: '', full: '', exists: false, checkStatus: 'unknown', checkCode: '', checkMessage: '', checkLatencyMs: null, checkedAt: null, updatedAt: null }
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
userId: 1,
|
||||||
|
username: 'admin',
|
||||||
|
groups: [],
|
||||||
|
leaderUsername: '',
|
||||||
|
similarAsin: { ...emptyModule, moduleKey: 'similar-asin', moduleLabel: '货源查询密钥' },
|
||||||
|
appearancePatent: { ...emptyModule, moduleKey: 'appearance-patent', moduleLabel: '外观专利密钥' },
|
||||||
|
proxy: { ...emptyModule, moduleKey: 'proxy', moduleLabel: '代理设置' },
|
||||||
|
status: 'unknown',
|
||||||
|
statusMessage: '',
|
||||||
|
createdAt: '2026-03-01 09:15:00',
|
||||||
|
updatedAt: '2026-09-18 16:42:30',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 15,
|
||||||
|
groupOptions: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/admin/shop-manage-groups') {
|
if (url === '/api/admin/shop-manage-groups') {
|
||||||
return json(res, { success: true, data: { items: [] } })
|
return json(res, { success: true, data: { items: [] } })
|
||||||
@@ -180,6 +410,23 @@ const server = createServer((req, res) => {
|
|||||||
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
|
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (url === '/api/admin/notifications/summary') {
|
||||||
|
return json(res, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
unreadCount: NOTIFICATIONS.filter((item) => !item.read).length,
|
||||||
|
latestId: NOTIFICATIONS.reduce((max, item) => Math.max(max, item.id), 0),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/notifications') {
|
||||||
|
return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams))
|
||||||
|
}
|
||||||
|
if (url === '/api/admin/tutorials') {
|
||||||
|
// 与 Java 侧同契约:created_at 降序返回(页面"当前生效"取第一条)。
|
||||||
|
const items = [...TUTORIAL_PACKAGES].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
||||||
|
return json(res, { success: true, data: { items } })
|
||||||
|
}
|
||||||
if (url.startsWith('/api/')) {
|
if (url.startsWith('/api/')) {
|
||||||
return json(res, { success: true, data: { items: [], total: 0 } })
|
return json(res, { success: true, data: { items: [], total: 0 } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
|
/** 日志文件行(桌面客户端 / 麦象采集机上报)。 */
|
||||||
|
export interface DeviceLogFileRow {
|
||||||
|
id: number
|
||||||
|
source: string
|
||||||
|
deviceId: string
|
||||||
|
deviceName: string | null
|
||||||
|
username: string | null
|
||||||
|
uid: number | null
|
||||||
|
fileName: string
|
||||||
|
logDate: string
|
||||||
|
uploadedBytes: number
|
||||||
|
partCount: number
|
||||||
|
lastUploadAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogPage {
|
||||||
|
items: DeviceLogFileRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
/** 云端日志保留天数(页面提示用)。 */
|
||||||
|
retentionDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogContent {
|
||||||
|
fileId: number
|
||||||
|
fileName: string
|
||||||
|
content: string
|
||||||
|
totalBytes: number
|
||||||
|
shownBytes: number
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogDevice {
|
||||||
|
source: string
|
||||||
|
deviceId: string
|
||||||
|
deviceName: string | null
|
||||||
|
lastUploadAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogOverride {
|
||||||
|
id: number
|
||||||
|
source: string
|
||||||
|
deviceId: string
|
||||||
|
deviceName: string | null
|
||||||
|
mode: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogConfigData {
|
||||||
|
globalMode: string
|
||||||
|
overrides: DeviceLogOverride[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLogQuery {
|
||||||
|
source?: string
|
||||||
|
keyword?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询日志文件列表:GET /api/admin/device-logs/files */
|
||||||
|
export async function fetchDeviceLogFiles(params: DeviceLogQuery): Promise<DeviceLogPage> {
|
||||||
|
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||||
|
if (params.source) query.source = params.source
|
||||||
|
if (params.keyword) query.keyword = params.keyword
|
||||||
|
if (params.startDate) query.startDate = params.startDate
|
||||||
|
if (params.endDate) query.endDate = params.endDate
|
||||||
|
const { data } = await http.get('/api/admin/device-logs/files', { params: query })
|
||||||
|
return unwrap<DeviceLogPage>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看日志尾部内容:GET /api/admin/device-logs/content */
|
||||||
|
export async function fetchDeviceLogContent(fileId: number, maxBytes?: number): Promise<DeviceLogContent> {
|
||||||
|
const query: Record<string, number> = { fileId }
|
||||||
|
if (maxBytes) query.maxBytes = maxBytes
|
||||||
|
const { data } = await http.get('/api/admin/device-logs/content', { params: query })
|
||||||
|
return unwrap<DeviceLogContent>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整日志下载地址(同域 cookie 鉴权,直接给 a[href] 或 window.open 用)。 */
|
||||||
|
export function deviceLogDownloadUrl(fileId: number): string {
|
||||||
|
return `/api/admin/device-logs/download?fileId=${fileId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除日志文件(片段与元数据,不可恢复):DELETE /api/admin/device-logs/{id} */
|
||||||
|
export async function deleteDeviceLogFile(id: number): Promise<void> {
|
||||||
|
const { data } = await http.delete(`/api/admin/device-logs/${id}`)
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采集配置(全局默认 + 终端覆盖):GET /api/admin/device-logs/config */
|
||||||
|
export async function fetchDeviceLogConfig(keyword?: string): Promise<DeviceLogConfigData> {
|
||||||
|
const { data } = await http.get('/api/admin/device-logs/config', {
|
||||||
|
params: keyword ? { keyword } : undefined,
|
||||||
|
})
|
||||||
|
return unwrap<DeviceLogConfigData>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近上报过的终端(覆盖选择用):GET /api/admin/device-logs/devices */
|
||||||
|
export async function fetchDeviceLogDevices(): Promise<DeviceLogDevice[]> {
|
||||||
|
const { data } = await http.get('/api/admin/device-logs/devices')
|
||||||
|
return unwrap<DeviceLogDevice[]>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置全局采集模式:PUT /api/admin/device-logs/config/global */
|
||||||
|
export async function updateDeviceLogGlobalMode(mode: string): Promise<void> {
|
||||||
|
const { data } = await http.put('/api/admin/device-logs/config/global', undefined, { params: { mode } })
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置/更新终端覆盖:PUT /api/admin/device-logs/config/device */
|
||||||
|
export async function updateDeviceLogOverride(
|
||||||
|
source: string,
|
||||||
|
deviceId: string,
|
||||||
|
deviceName: string | null,
|
||||||
|
mode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { data } = await http.put('/api/admin/device-logs/config/device', undefined, {
|
||||||
|
params: { source, deviceId, deviceName: deviceName || undefined, mode },
|
||||||
|
})
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除终端覆盖(回落到全局默认):DELETE /api/admin/device-logs/config/device/{id} */
|
||||||
|
export async function deleteDeviceLogOverride(id: number): Promise<void> {
|
||||||
|
const { data } = await http.delete(`/api/admin/device-logs/config/device/${id}`)
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
@@ -46,6 +46,20 @@ export function isUnauthorized(payload: unknown): boolean {
|
|||||||
return [record.status, record.statusCode, record.code].some((v) => v === 401)
|
return [record.status, record.statusCode, record.code].some((v) => v === 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 负载/状态码是否 403(已登录但无后台权限)。 */
|
||||||
|
export function isForbidden(payload: unknown): boolean {
|
||||||
|
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
|
||||||
|
if (!record || typeof record !== 'object') return false
|
||||||
|
return [record.status, record.statusCode, record.code].some((v) => v === 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */
|
||||||
|
export function isKicked(payload: unknown): boolean {
|
||||||
|
const record = payload as { code?: unknown } | null
|
||||||
|
if (!record || typeof record !== 'object') return false
|
||||||
|
return record.code === 4011
|
||||||
|
}
|
||||||
|
|
||||||
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
|
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
|
||||||
export function requestErrorMessage(error: unknown): string {
|
export function requestErrorMessage(error: unknown): string {
|
||||||
const response = (error as { response?: { data?: unknown; status?: number } })?.response
|
const response = (error as { response?: { data?: unknown; status?: number } })?.response
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { isUnauthorized, loginRedirectTarget, shouldRedirectUnauthorized } from './envelope'
|
import {
|
||||||
|
isForbidden,
|
||||||
|
isKicked,
|
||||||
|
isLoginLocation,
|
||||||
|
isUnauthorized,
|
||||||
|
loginRedirectTarget,
|
||||||
|
shouldRedirectUnauthorized,
|
||||||
|
} from './envelope'
|
||||||
|
|
||||||
export { unwrap } from './envelope'
|
export { unwrap } from './envelope'
|
||||||
|
|
||||||
@@ -17,14 +24,34 @@ function redirectToLogin(requestUrl?: string): void {
|
|||||||
window.location.assign('/admin-vue/login?redirect=' + target)
|
window.location.assign('/admin-vue/login?redirect=' + target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单设备登录:被新设备顶下线,整页回登录页(整页 reload 顺带重置会话 store,避免守卫弹回)。 */
|
||||||
|
function redirectToLoginKicked(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
if (isLoginLocation(window.location.pathname)) return
|
||||||
|
window.location.assign('/admin-vue/login?kicked=1')
|
||||||
|
}
|
||||||
|
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理。
|
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理;
|
||||||
if (isUnauthorized(response.data)) redirectToLogin(response.config?.url)
|
// 403(已登录但无后台权限,如用工具前端账号 token 访问后台)与 401 同样跳登录页。
|
||||||
|
// 4011(账号已在其他设备登录)单独提示,不按普通 401 处理。
|
||||||
|
if (isKicked(response.data)) {
|
||||||
|
redirectToLoginKicked()
|
||||||
|
} else if (isUnauthorized(response.data) || isForbidden(response.data)) {
|
||||||
|
redirectToLogin(response.config?.url)
|
||||||
|
}
|
||||||
return response
|
return response
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error?.response?.status === 401 || isUnauthorized(error?.response?.data)) {
|
if (isKicked(error?.response?.data)) {
|
||||||
|
redirectToLoginKicked()
|
||||||
|
} else if (
|
||||||
|
error?.response?.status === 401 ||
|
||||||
|
error?.response?.status === 403 ||
|
||||||
|
isUnauthorized(error?.response?.data) ||
|
||||||
|
isForbidden(error?.response?.data)
|
||||||
|
) {
|
||||||
redirectToLogin(error?.config?.url)
|
redirectToLogin(error?.config?.url)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
|
/** 单条通知(后台铃铛)。 */
|
||||||
|
export interface AdminNotificationItem {
|
||||||
|
id: number
|
||||||
|
scene: string
|
||||||
|
level: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
read: boolean
|
||||||
|
readAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNotificationPage {
|
||||||
|
items: AdminNotificationItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNotificationSummary {
|
||||||
|
unreadCount: number
|
||||||
|
latestId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 铃铛轮询:未读数 + 最新通知 id。 */
|
||||||
|
export async function fetchNotificationSummary(): Promise<AdminNotificationSummary> {
|
||||||
|
const { data } = await http.get('/api/admin/notifications/summary')
|
||||||
|
return unwrap<AdminNotificationSummary>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */
|
||||||
|
export interface AdminNotificationListParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
onlyUnread?: boolean
|
||||||
|
keyword?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNotificationList(
|
||||||
|
params: AdminNotificationListParams = {},
|
||||||
|
): Promise<AdminNotificationPage> {
|
||||||
|
const keyword = (params.keyword ?? '').trim()
|
||||||
|
const { data } = await http.get('/api/admin/notifications', {
|
||||||
|
params: {
|
||||||
|
page: params.page ?? 1,
|
||||||
|
pageSize: params.pageSize ?? 20,
|
||||||
|
onlyUnread: params.onlyUnread ? 'true' : 'false',
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
startDate: params.startDate || undefined,
|
||||||
|
endDate: params.endDate || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return unwrap<AdminNotificationPage>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markNotificationRead(id: number): Promise<boolean> {
|
||||||
|
const { data } = await http.post(`/api/admin/notifications/${id}/read`)
|
||||||
|
return unwrap<boolean>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllNotificationsRead(): Promise<number> {
|
||||||
|
const { data } = await http.post('/api/admin/notifications/read-all')
|
||||||
|
return unwrap<number>(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
|
/** 单用户一行:三类密钥调用次数 + 合计(口径=真实对外请求次数)。 */
|
||||||
|
export interface AdminUserSecretUsageRow {
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
groups: string[]
|
||||||
|
similarAsinCount: number
|
||||||
|
appearancePatentCount: number
|
||||||
|
proxyCount: number
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserSecretUsagePage {
|
||||||
|
items: AdminUserSecretUsageRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
/** 所选范围内(不分页)三类总次数。 */
|
||||||
|
totalCalls: number
|
||||||
|
similarAsinCalls: number
|
||||||
|
appearancePatentCalls: number
|
||||||
|
proxyCalls: number
|
||||||
|
groupOptions?: Array<{ id: number; groupName: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSecretUsageQuery {
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
keyword?: string
|
||||||
|
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
|
||||||
|
groupId?: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询密钥用量:GET /api/admin/user-secret-usage(group_id 必须 snake_case)。 */
|
||||||
|
export async function fetchUserSecretUsage(params: UserSecretUsageQuery): Promise<AdminUserSecretUsagePage> {
|
||||||
|
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||||
|
if (params.startDate) query.startDate = params.startDate
|
||||||
|
if (params.endDate) query.endDate = params.endDate
|
||||||
|
if (params.keyword) query.keyword = params.keyword
|
||||||
|
if (params.groupId) query.group_id = params.groupId
|
||||||
|
const { data } = await http.get('/api/admin/user-secret-usage', { params: query })
|
||||||
|
return unwrap<AdminUserSecretUsagePage>(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import { unwrap } from './envelope'
|
||||||
|
|
||||||
|
/** 单模块状态(脱敏值 + 连通性)。 */
|
||||||
|
export interface AdminUserSecretModule {
|
||||||
|
moduleKey: string
|
||||||
|
moduleLabel: string
|
||||||
|
masked: string
|
||||||
|
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
|
||||||
|
full: string
|
||||||
|
exists: boolean
|
||||||
|
checkStatus: string
|
||||||
|
checkCode: string
|
||||||
|
checkMessage: string
|
||||||
|
checkLatencyMs: number | null
|
||||||
|
checkedAt: string | null
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行一用户:三个字段列 + 行级状态(三类都检测通过才算 passed)。 */
|
||||||
|
export interface AdminUserSecretRow {
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
/** 所属数据权限分组名(可能多个)。 */
|
||||||
|
groups: string[]
|
||||||
|
/** 所属主管(创建人)用户名;无分组时的兜底展示。 */
|
||||||
|
leaderUsername: string
|
||||||
|
similarAsin: AdminUserSecretModule
|
||||||
|
appearancePatent: AdminUserSecretModule
|
||||||
|
proxy: AdminUserSecretModule
|
||||||
|
status: string
|
||||||
|
statusMessage: string
|
||||||
|
/** 首次配置时间(三模块中最早);从未配置为 null。 */
|
||||||
|
createdAt: string | null
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserSecretPage {
|
||||||
|
items: AdminUserSecretRow[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
/** 分组筛选项(超管=全部;主管=自己带的分组)。 */
|
||||||
|
groupOptions?: GroupOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupOption {
|
||||||
|
id: number
|
||||||
|
groupName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSecretQuery {
|
||||||
|
keyword?: string
|
||||||
|
checkStatus?: string
|
||||||
|
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
|
||||||
|
groupId?: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets(筛选参数必须 snake_case,camel 会被后端静默忽略)。 */
|
||||||
|
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
||||||
|
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||||
|
if (params.keyword) query.keyword = params.keyword
|
||||||
|
if (params.checkStatus) query.checkStatus = params.checkStatus
|
||||||
|
if (params.groupId) query.group_id = params.groupId
|
||||||
|
const { data } = await http.get('/api/admin/user-secrets', { params: query })
|
||||||
|
return unwrap<AdminUserSecretPage>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check
|
||||||
|
* 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */
|
||||||
|
export async function checkUserSecret(userId: number) {
|
||||||
|
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`, undefined, { timeout: 180_000 })
|
||||||
|
return unwrap<
|
||||||
|
Array<{
|
||||||
|
moduleKey: string
|
||||||
|
checkStatus: string
|
||||||
|
checkCode: string
|
||||||
|
checkMessage: string
|
||||||
|
checkLatencyMs: number | null
|
||||||
|
checkedAt: string | null
|
||||||
|
viaProxy: boolean
|
||||||
|
}>
|
||||||
|
>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空该用户全部密钥与代理配置:DELETE /api/admin/user-secrets/{userId} */
|
||||||
|
export async function deleteUserSecret(userId: number): Promise<void> {
|
||||||
|
const { data } = await http.delete(`/api/admin/user-secrets/${userId}`)
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ export function toAdminUserItem(raw: unknown): AdminUser | null {
|
|||||||
if (createdById !== null) item.createdById = createdById
|
if (createdById !== null) item.createdById = createdById
|
||||||
const createdAt = text(r.created_at)
|
const createdAt = text(r.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(r.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
const creator = text(r.creator_username)
|
const creator = text(r.creator_username)
|
||||||
if (creator) item.creatorUsername = creator
|
if (creator) item.creatorUsername = creator
|
||||||
const abbr = text(r.pinyin_abbr)
|
const abbr = text(r.pinyin_abbr)
|
||||||
|
|||||||
@@ -0,0 +1,683 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="rootRef" class="admin-notification-bell">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bell-trigger"
|
||||||
|
:class="{ 'bell-trigger--active': panelOpen }"
|
||||||
|
:title="unreadCount > 0 ? `通知(${unreadCount} 条未读)` : '通知'"
|
||||||
|
aria-label="通知"
|
||||||
|
@click="togglePanel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="bell-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||||
|
</svg>
|
||||||
|
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 面板挂到 body 上(fixed 定位):顶栏内绝对定位时会被列表页 el-select 等页面内容压住,提 z-index 无效 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="panelOpen"
|
||||||
|
ref="panelRef"
|
||||||
|
class="bell-panel"
|
||||||
|
:style="panelStyle"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="通知列表"
|
||||||
|
>
|
||||||
|
<div class="bell-panel-head">
|
||||||
|
<span class="bell-panel-title">通知</span>
|
||||||
|
<button
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
type="button"
|
||||||
|
class="bell-read-all"
|
||||||
|
:disabled="markingAll"
|
||||||
|
@click="readAll"
|
||||||
|
>
|
||||||
|
{{ markingAll ? '处理中...' : '全部已读' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bell-search">
|
||||||
|
<input
|
||||||
|
v-model="keyword"
|
||||||
|
type="text"
|
||||||
|
class="bell-search-input"
|
||||||
|
placeholder="搜索标题或内容"
|
||||||
|
aria-label="搜索通知"
|
||||||
|
@input="onKeywordInput"
|
||||||
|
/>
|
||||||
|
<div class="bell-search-days">
|
||||||
|
<el-date-picker
|
||||||
|
:model-value="dateRangeValue"
|
||||||
|
type="daterange"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
range-separator="至"
|
||||||
|
size="small"
|
||||||
|
class="bell-date-picker"
|
||||||
|
popper-class="bell-date-popper"
|
||||||
|
:clearable="true"
|
||||||
|
@update:model-value="onDateRangeChange"
|
||||||
|
@change="reload"
|
||||||
|
/>
|
||||||
|
<button v-if="hasFilter" type="button" class="bell-search-reset" @click="resetFilters">
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
|
||||||
|
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
|
||||||
|
<div v-else-if="!items.length" class="bell-empty">{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}</div>
|
||||||
|
<ul v-else class="bell-list">
|
||||||
|
<template v-for="group in groupedItems" :key="group.day || 'unknown'">
|
||||||
|
<li class="bell-day-head">{{ group.label }}</li>
|
||||||
|
<li
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.id"
|
||||||
|
class="bell-item"
|
||||||
|
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||||
|
@click="onItemClick(item)"
|
||||||
|
>
|
||||||
|
<div class="bell-item-head">
|
||||||
|
<span class="bell-item-title">{{ item.title }}</span>
|
||||||
|
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bell-item-content">{{ item.content }}</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="bell-panel-foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bell-page-btn"
|
||||||
|
:disabled="page <= 1 || loading"
|
||||||
|
@click="goPage(page - 1)"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span class="bell-page-info">{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bell-page-btn"
|
||||||
|
:disabled="page >= totalPages || loading"
|
||||||
|
@click="goPage(page + 1)"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<span class="bell-page-total">共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchNotificationList,
|
||||||
|
fetchNotificationSummary,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
markNotificationRead,
|
||||||
|
type AdminNotificationItem,
|
||||||
|
} from '@/api/notifications'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
import {
|
||||||
|
NOTIFICATION_POLL_INTERVAL_MS,
|
||||||
|
formatNotificationTime,
|
||||||
|
formatUnreadBadge,
|
||||||
|
groupNotificationsByDay,
|
||||||
|
hasNewNotification,
|
||||||
|
readLastNotifiedId,
|
||||||
|
writeLastNotifiedId,
|
||||||
|
} from '@/layout/notification-bell-model'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
const panelStyle = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const items = ref<AdminNotificationItem[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const panelOpen = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const markingAll = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const keyword = ref('')
|
||||||
|
const startDate = ref('')
|
||||||
|
const endDate = ref('')
|
||||||
|
|
||||||
|
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
|
||||||
|
const groupedItems = computed(() => groupNotificationsByDay(items.value))
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||||||
|
const hasFilter = computed(() => Boolean(keyword.value.trim() || startDate.value || endDate.value))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* el-date-picker 的绑定桥:日期区间在内部仍用 startDate/endDate 两个 ref 表示
|
||||||
|
* (hasFilter/normalizedDayRange/resetFilters 都基于它们)。
|
||||||
|
* 此前模板写的是 v-model="dateRange",而 dateRange 从未声明 → vue-tsc 直接报 TS2339 构建失败,
|
||||||
|
* 且日期筛选完全不下发参数。
|
||||||
|
*/
|
||||||
|
const dateRangeValue = computed<[string, string] | null>(() =>
|
||||||
|
startDate.value && endDate.value ? [startDate.value, endDate.value] : null
|
||||||
|
)
|
||||||
|
|
||||||
|
function onDateRangeChange(value: [string, string] | null) {
|
||||||
|
const start = value?.[0]
|
||||||
|
const end = value?.[1]
|
||||||
|
startDate.value = start ? String(start) : ''
|
||||||
|
endDate.value = end ? String(end) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollTimer: number | null = null
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
|
||||||
|
function currentUid(): number | string {
|
||||||
|
return session.user?.id ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 起止日期倒挂时自动交换,避免用户只看到「没有符合条件的通知」不知原因。 */
|
||||||
|
function normalizedDayRange(): { startDate?: string; endDate?: string } {
|
||||||
|
let start = startDate.value
|
||||||
|
let end = endDate.value
|
||||||
|
if (start && end && start > end) {
|
||||||
|
const swapped = start
|
||||||
|
start = end
|
||||||
|
end = swapped
|
||||||
|
}
|
||||||
|
return { startDate: start || undefined, endDate: end || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */
|
||||||
|
async function refreshSummary() {
|
||||||
|
try {
|
||||||
|
const summary = await fetchNotificationSummary()
|
||||||
|
unreadCount.value = Number(summary?.unreadCount ?? 0)
|
||||||
|
const latestId = Number(summary?.latestId ?? 0)
|
||||||
|
if (hasNewNotification(latestId, readLastNotifiedId(currentUid()))) {
|
||||||
|
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
|
||||||
|
writeLastNotifiedId(currentUid(), latestId)
|
||||||
|
console.log('[admin-notification] 检测到新通知 latestId=', latestId)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 通知接口失败静默降级:不显示红点、不打扰使用者
|
||||||
|
console.warn('[admin-notification] 未读数刷新失败(静默降级):', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(targetPage: number) {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchNotificationList({
|
||||||
|
page: targetPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
keyword: keyword.value,
|
||||||
|
...normalizedDayRange(),
|
||||||
|
})
|
||||||
|
const list = Array.isArray(result?.items) ? result.items : []
|
||||||
|
items.value = list
|
||||||
|
total.value = Number(result?.total ?? list.length)
|
||||||
|
unreadCount.value = Number(result?.unreadCount ?? unreadCount.value)
|
||||||
|
page.value = targetPage
|
||||||
|
loadError.value = ''
|
||||||
|
// 无筛选时首页就是最新数据,顺带记录「已提醒过的最大 id」;有筛选时最新 id 不代表真实最新
|
||||||
|
if (targetPage <= 1 && !hasFilter.value && list.length) {
|
||||||
|
const latest = Number(list[0].id)
|
||||||
|
if (latest > 0) {
|
||||||
|
writeLastNotifiedId(currentUid(), Math.max(readLastNotifiedId(currentUid()), latest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 列表加载失败在面板内提示,不弹全局消息打扰用户
|
||||||
|
console.warn('[admin-notification] 通知列表加载失败:', error)
|
||||||
|
loadError.value = error instanceof Error ? error.message : '通知加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(targetPage: number) {
|
||||||
|
const safe = Math.min(Math.max(1, targetPage), totalPages.value)
|
||||||
|
if (safe === page.value || loading.value) return
|
||||||
|
void loadPage(safe)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索/日期变化后回到第一页重新查询。 */
|
||||||
|
function reload() {
|
||||||
|
void loadPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关键字输入防抖 300ms,避免每敲一个字都打一次接口。 */
|
||||||
|
function onKeywordInput() {
|
||||||
|
if (searchTimer != null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(() => {
|
||||||
|
searchTimer = null
|
||||||
|
void loadPage(1)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
keyword.value = ''
|
||||||
|
startDate.value = ''
|
||||||
|
endDate.value = ''
|
||||||
|
void loadPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 面板挂到 body 后用 fixed 定位,位置按触发器实时算(右对齐在铃铛下方)。 */
|
||||||
|
function syncPanelPosition() {
|
||||||
|
const trigger = rootRef.value
|
||||||
|
if (!trigger) return
|
||||||
|
const rect = trigger.getBoundingClientRect()
|
||||||
|
panelStyle.value = {
|
||||||
|
top: `${Math.round(rect.bottom + 10)}px`,
|
||||||
|
right: `${Math.max(8, Math.round(window.innerWidth - rect.right))}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
panelOpen.value = !panelOpen.value
|
||||||
|
if (panelOpen.value) {
|
||||||
|
syncPanelPosition()
|
||||||
|
void loadPage(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onItemClick(item: AdminNotificationItem) {
|
||||||
|
if (item.read) return
|
||||||
|
try {
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
item.read = true
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[admin-notification] 标记已读失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAll() {
|
||||||
|
if (markingAll.value || unreadCount.value === 0) return
|
||||||
|
markingAll.value = true
|
||||||
|
try {
|
||||||
|
await markAllNotificationsRead()
|
||||||
|
for (const item of items.value) {
|
||||||
|
item.read = true
|
||||||
|
}
|
||||||
|
unreadCount.value = 0
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[admin-notification] 全部已读失败:', error)
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
} finally {
|
||||||
|
markingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string | null) {
|
||||||
|
return formatNotificationTime(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击组件外部关闭面板(面板已 Teleport 到 body,需连同面板自身一起判断)。 */
|
||||||
|
function onDocumentMouseDown(event: MouseEvent) {
|
||||||
|
if (!panelOpen.value) return
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof Node)) return
|
||||||
|
if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) return
|
||||||
|
panelOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 窗口尺寸变化时重新对齐面板。 */
|
||||||
|
function onWindowResize() {
|
||||||
|
if (panelOpen.value) {
|
||||||
|
syncPanelPosition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面回到前台时立即刷新一次未读数。 */
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||||
|
void refreshSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', onDocumentMouseDown)
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
window.addEventListener('resize', onWindowResize)
|
||||||
|
void refreshSummary()
|
||||||
|
pollTimer = window.setInterval(() => {
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void refreshSummary()
|
||||||
|
}, NOTIFICATION_POLL_INTERVAL_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
window.removeEventListener('resize', onWindowResize)
|
||||||
|
if (pollTimer != null) {
|
||||||
|
window.clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
if (searchTimer != null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
searchTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-notification-bell {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-trigger {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--admin-muted, #5b6f83);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-trigger:hover,
|
||||||
|
.bell-trigger--active {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #cbd9e6;
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 0;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d64545;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3200;
|
||||||
|
width: 380px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
max-height: 520px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--admin-border, #d8e3ee);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 48px rgba(39, 67, 94, 0.22);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #edf1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-panel-title {
|
||||||
|
color: var(--admin-text, #24384d);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-read-all {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-read-all:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-empty {
|
||||||
|
padding: 36px 0;
|
||||||
|
color: #8a99a8;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-empty--error {
|
||||||
|
color: #b23c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-list {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-bottom: 1px solid #f0f3f7;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item:hover {
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--unread {
|
||||||
|
background: #f2f6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--unread .bell-item-title::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d64545;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item-title {
|
||||||
|
color: var(--admin-text, #24384d);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--error .bell-item-title {
|
||||||
|
color: #b23c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--warning .bell-item-title {
|
||||||
|
color: #a8793e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item--info .bell-item-title {
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #9aa6b3;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-item-content {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #667085;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #edf1f5;
|
||||||
|
background: #fafcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-input,
|
||||||
|
.bell-date-input {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--admin-text, #24384d);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-input:focus,
|
||||||
|
.bell-date-input:focus {
|
||||||
|
border-color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-days {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-date-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-date-sep {
|
||||||
|
color: #8a99a8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-reset {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-search-reset:hover {
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-day-head {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: #f4f7fa;
|
||||||
|
color: #7c8b9a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-panel-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-top: 1px solid #edf1f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-btn {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--admin-primary-strong, #2f5d8b);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-btn:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-info {
|
||||||
|
color: var(--admin-text, #24384d);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-page-total {
|
||||||
|
color: #9aa6b3;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
shouldShowEmptyMenu,
|
shouldShowEmptyMenu,
|
||||||
} from '@/layout/empty-state'
|
} from '@/layout/empty-state'
|
||||||
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||||
|
import NotificationBell from '@/components/NotificationBell.vue'
|
||||||
import OperationGuide from '@/layout/OperationGuide.vue'
|
import OperationGuide from '@/layout/OperationGuide.vue'
|
||||||
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +101,7 @@ async function signOut() {
|
|||||||
<h1>{{ pageTitle }}</h1>
|
<h1>{{ pageTitle }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-user">
|
<div class="admin-user">
|
||||||
|
<NotificationBell />
|
||||||
<div class="admin-user-meta">
|
<div class="admin-user-meta">
|
||||||
<strong>{{ userVm.username }}</strong>
|
<strong>{{ userVm.username }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 后台铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
|
||||||
|
* 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 未读轮询间隔(60 秒)。 */
|
||||||
|
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000
|
||||||
|
|
||||||
|
const NOTIFIED_KEY_PREFIX = 'admin-notification:last-notified-id'
|
||||||
|
|
||||||
|
/** 未读徽标文案:0 或非法值显示空串,>99 显示 99+。 */
|
||||||
|
export function formatUnreadBadge(count: number | null | undefined): string {
|
||||||
|
const value = Number(count ?? 0)
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return value > 99 ? '99+' : String(Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否出现新通知:latestId 大于上次已提醒过的 id。 */
|
||||||
|
export function hasNewNotification(
|
||||||
|
latestId: number | null | undefined,
|
||||||
|
lastNotifiedId: number | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const latest = Number(latestId ?? 0)
|
||||||
|
const notified = Number(lastNotifiedId ?? 0)
|
||||||
|
if (!Number.isFinite(latest) || latest <= 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return latest > (Number.isFinite(notified) && notified > 0 ? notified : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(uid: string | number | null | undefined): string {
|
||||||
|
const normalized = uid === null || uid === undefined || String(uid).trim() === '' ? '0' : String(uid)
|
||||||
|
return `${NOTIFIED_KEY_PREFIX}:${normalized}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取当前管理员「已提醒过的最大通知 id」。 */
|
||||||
|
export function readLastNotifiedId(uid: string | number | null | undefined): number {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(storageKey(uid))
|
||||||
|
const value = Number(raw ?? 0)
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLastNotifiedId(uid: string | number | null | undefined, id: number): void {
|
||||||
|
try {
|
||||||
|
if (Number.isFinite(id) && id > 0) {
|
||||||
|
window.localStorage.setItem(storageKey(uid), String(Math.floor(id)))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知所属日期键(本地时区 yyyy-MM-dd);时间缺失/非法返回空串。 */
|
||||||
|
export function notificationDayKey(value: string | null | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const pad = (input: number) => String(input).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期组头文案:今天 / 昨天 / 2026年9月11日;空键兜底「未知时间」。 */
|
||||||
|
export function formatDayLabel(day: string, now: Date = new Date()): string {
|
||||||
|
if (!day) {
|
||||||
|
return '未知时间'
|
||||||
|
}
|
||||||
|
if (day === notificationDayKey(now.toISOString())) {
|
||||||
|
return '今天'
|
||||||
|
}
|
||||||
|
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||||
|
if (day === notificationDayKey(yesterday.toISOString())) {
|
||||||
|
return '昨天'
|
||||||
|
}
|
||||||
|
const [year, month, date] = day.split('-').map((part) => Number(part))
|
||||||
|
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(date)) {
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
return `${year}年${month}月${date}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationDayGroup<T> {
|
||||||
|
day: string
|
||||||
|
label: string
|
||||||
|
items: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */
|
||||||
|
export function groupNotificationsByDay<T extends { createdAt: string | null }>(
|
||||||
|
items: T[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
): NotificationDayGroup<T>[] {
|
||||||
|
const groups: NotificationDayGroup<T>[] = []
|
||||||
|
const indexByDay = new Map<string, NotificationDayGroup<T>>()
|
||||||
|
for (const item of items ?? []) {
|
||||||
|
const day = notificationDayKey(item.createdAt)
|
||||||
|
let group = indexByDay.get(day)
|
||||||
|
if (!group) {
|
||||||
|
group = { day, label: formatDayLabel(day, now), items: [] }
|
||||||
|
indexByDay.set(day, group)
|
||||||
|
groups.push(group)
|
||||||
|
}
|
||||||
|
group.items.push(item)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */
|
||||||
|
export function formatNotificationTime(
|
||||||
|
value: string | null | undefined,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const pad = (input: number) => String(input).padStart(2, '0')
|
||||||
|
const hourMinute = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
const sameDay =
|
||||||
|
date.getFullYear() === now.getFullYear() &&
|
||||||
|
date.getMonth() === now.getMonth() &&
|
||||||
|
date.getDate() === now.getDate()
|
||||||
|
if (sameDay) {
|
||||||
|
return hourMinute
|
||||||
|
}
|
||||||
|
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||||
|
const isYesterday =
|
||||||
|
date.getFullYear() === yesterday.getFullYear() &&
|
||||||
|
date.getMonth() === yesterday.getMonth() &&
|
||||||
|
date.getDate() === yesterday.getDate()
|
||||||
|
if (isYesterday) {
|
||||||
|
return `昨天 ${hourMinute}`
|
||||||
|
}
|
||||||
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hourMinute}`
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
|
|||||||
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
|
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
|
||||||
},
|
},
|
||||||
admin_group_manage: OPERATION_GUIDE_FALLBACK,
|
admin_group_manage: OPERATION_GUIDE_FALLBACK,
|
||||||
|
admin_user_secrets: {
|
||||||
|
text: '用户密钥按账号绑定,列表只展示脱敏值。可对单条立即检测连通性;清空后该用户需要重新配置密钥。',
|
||||||
|
steps: ['筛选用户或状态', '立即检测连通性', '必要时清空'],
|
||||||
|
},
|
||||||
admin_dedupe_total_data: {
|
admin_dedupe_total_data: {
|
||||||
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
|
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
|
||||||
steps: ['选择分组', '筛选或导入', '核对并导出'],
|
steps: ['选择分组', '筛选或导入', '核对并导出'],
|
||||||
@@ -70,10 +74,18 @@ export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
|
|||||||
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
|
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
|
||||||
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
|
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
|
||||||
},
|
},
|
||||||
|
admin_tutorial: {
|
||||||
|
text: '上传新的教程压缩包后即刻生效:工具台首页「立即下载教程」以下载最新上传的包为准。确认旧包不再需要后再删除。',
|
||||||
|
steps: ['上传教程压缩包', '到工具台核对下载', '清理历史包'],
|
||||||
|
},
|
||||||
digital_human_version: {
|
digital_human_version: {
|
||||||
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
|
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
|
||||||
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
|
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
|
||||||
},
|
},
|
||||||
|
admin_user_secret_usage: {
|
||||||
|
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
|
||||||
|
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全部有独立/兜底提示的后台业务菜单 key。 */
|
/** 全部有独立/兜底提示的后台业务菜单 key。 */
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role:
|
|||||||
const errors = reactive<EditUserFormErrors>({})
|
const errors = reactive<EditUserFormErrors>({})
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const loadingAuth = ref(false)
|
const loadingAuth = ref(false)
|
||||||
|
/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */
|
||||||
|
const authReady = ref(false)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
@@ -28,6 +30,7 @@ watch(
|
|||||||
form.columnIds = []
|
form.columnIds = []
|
||||||
form.originalRole = base.originalRole
|
form.originalRole = base.originalRole
|
||||||
errors.password = undefined
|
errors.password = undefined
|
||||||
|
authReady.value = false
|
||||||
void refreshAuth()
|
void refreshAuth()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -38,7 +41,10 @@ async function refreshAuth(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const { checkedIds } = await loadUserMenuAuth(props.user.id)
|
const { checkedIds } = await loadUserMenuAuth(props.user.id)
|
||||||
form.columnIds = checkedIds
|
form.columnIds = checkedIds
|
||||||
|
authReady.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。
|
||||||
|
authReady.value = false
|
||||||
showAdminFeedback(actionableErrorText(error), 'error')
|
showAdminFeedback(actionableErrorText(error), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
loadingAuth.value = false
|
loadingAuth.value = false
|
||||||
@@ -50,6 +56,10 @@ function close(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save(): Promise<void> {
|
async function save(): Promise<void> {
|
||||||
|
if (!authReady.value) {
|
||||||
|
showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
const { valid, errors: errs } = validateEditUserForm(form)
|
const { valid, errors: errs } = validateEditUserForm(form)
|
||||||
Object.assign(errors, errs)
|
Object.assign(errors, errs)
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
@@ -103,12 +113,15 @@ async function save(): Promise<void> {
|
|||||||
<div class="auth-tree-box">
|
<div class="auth-tree-box">
|
||||||
<UserMenuAuthTree v-model:checked="form.columnIds" />
|
<UserMenuAuthTree v-model:checked="form.columnIds" />
|
||||||
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
|
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
|
||||||
|
<div v-else-if="!authReady" class="auth-loading auth-loading-error">
|
||||||
|
菜单权限加载失败,保存已禁用;请关闭后重试
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="close">取消</el-button>
|
<el-button @click="close">取消</el-button>
|
||||||
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
|
<el-button type="primary" :loading="busy" :disabled="!authReady" @click="save">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -126,4 +139,7 @@ async function save(): Promise<void> {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--admin-muted);
|
color: var(--admin-muted);
|
||||||
}
|
}
|
||||||
|
.auth-loading-error {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ onMounted(loadMenus)
|
|||||||
<th style="width: 100px">菜单类型</th>
|
<th style="width: 100px">菜单类型</th>
|
||||||
<th style="width: 120px">上级菜单</th>
|
<th style="width: 120px">上级菜单</th>
|
||||||
<th style="width: 170px">创建时间</th>
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 170px">操作</th>
|
<th style="width: 170px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -271,6 +272,7 @@ onMounted(loadMenus)
|
|||||||
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
|
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
|
||||||
<td>{{ parentNameOf(row) }}</td>
|
<td>{{ parentNameOf(row) }}</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button
|
<button
|
||||||
@@ -286,10 +288,10 @@ onMounted(loadMenus)
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="7" class="empty-tip">加载中...</td>
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
|
<td colspan="8" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import type { MenuOptionNode } from './user-menu-auth'
|
import type { MenuOptionNode } from './user-menu-auth'
|
||||||
import { compactDirectGrantIds } from './user-menu-auth'
|
import { compactDirectGrantIds } from './user-menu-auth'
|
||||||
import { fetchGrantableMenus } from './user-menu-auth-api'
|
import { fetchGrantableMenus } from './user-menu-auth-api'
|
||||||
@@ -43,8 +43,11 @@ async function load(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
data.value = await fetchGrantableMenus()
|
data.value = await fetchGrantableMenus()
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
applyChecked()
|
|
||||||
emit('load', true)
|
emit('load', true)
|
||||||
|
// el-tree 的 store 通过异步 watch 同步 props.data,setCheckedKeys 必须等树
|
||||||
|
// 渲染完成后再应用已勾选 id,否则作用在空 store 上导致已授权不回显。
|
||||||
|
await nextTick()
|
||||||
|
applyChecked()
|
||||||
} catch {
|
} catch {
|
||||||
loaded.value = false
|
loaded.value = false
|
||||||
emit('load', false)
|
emit('load', false)
|
||||||
@@ -74,7 +77,7 @@ watch(
|
|||||||
node-key="id"
|
node-key="id"
|
||||||
show-checkbox
|
show-checkbox
|
||||||
default-expand-all
|
default-expand-all
|
||||||
:props="{ label: 'name', children: 'children' }"
|
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||||
@check="onCheck"
|
@check="onCheck"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +89,7 @@ watch(
|
|||||||
node-key="id"
|
node-key="id"
|
||||||
show-checkbox
|
show-checkbox
|
||||||
default-expand-all
|
default-expand-all
|
||||||
:props="{ label: 'name', children: 'children' }"
|
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||||
@check="onCheck"
|
@check="onCheck"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import {
|
||||||
|
fetchUserSecretUsage,
|
||||||
|
type AdminUserSecretUsageRow,
|
||||||
|
} from '@/api/user-secret-usage'
|
||||||
|
import type { GroupOption } from '@/api/user-secrets'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<AdminUserSecretUsageRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(15)
|
||||||
|
const keyword = ref('')
|
||||||
|
const startDate = ref('')
|
||||||
|
const endDate = ref('')
|
||||||
|
const groupFilter = ref<number | null>(null)
|
||||||
|
const groupOptions = ref<GroupOption[]>([])
|
||||||
|
|
||||||
|
/** 所选范围内三类总次数(后端按筛选条件统计,不分页)。 */
|
||||||
|
const summary = ref({ totalCalls: 0, similarAsinCalls: 0, appearancePatentCalls: 0, proxyCalls: 0 })
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
function formatCount(value: number | undefined) {
|
||||||
|
const num = Number(value || 0)
|
||||||
|
return num.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchUserSecretUsage({
|
||||||
|
startDate: startDate.value || undefined,
|
||||||
|
endDate: endDate.value || undefined,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
groupId: groupFilter.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
rows.value = result?.items || []
|
||||||
|
total.value = Number(result?.total || 0)
|
||||||
|
groupOptions.value = result?.groupOptions || []
|
||||||
|
summary.value = {
|
||||||
|
totalCalls: Number(result?.totalCalls || 0),
|
||||||
|
similarAsinCalls: Number(result?.similarAsinCalls || 0),
|
||||||
|
appearancePatentCalls: Number(result?.appearancePatentCalls || 0),
|
||||||
|
proxyCalls: Number(result?.proxyCalls || 0),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
keyword.value = ''
|
||||||
|
startDate.value = ''
|
||||||
|
endDate.value = ''
|
||||||
|
groupFilter.value = null
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="secret-usage-view">
|
||||||
|
<section class="panel-box">
|
||||||
|
<div class="usage-head">
|
||||||
|
<h3>密钥用量统计</h3>
|
||||||
|
<span class="usage-note">口径:真实对外请求次数(LLM 每次请求、代理每次成功提取)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-row">
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">总调用次数</div>
|
||||||
|
<div class="summary-value">{{ formatCount(summary.totalCalls) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">货源查询密钥</div>
|
||||||
|
<div class="summary-value">{{ formatCount(summary.similarAsinCalls) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">外观专利密钥</div>
|
||||||
|
<div class="summary-value">{{ formatCount(summary.appearancePatentCalls) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-label">代理提取</div>
|
||||||
|
<div class="summary-value">{{ formatCount(summary.proxyCalls) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row usage-filter-row">
|
||||||
|
<div class="form-group" style="min-width: 160px">
|
||||||
|
<label>开始日期</label>
|
||||||
|
<input v-model="startDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 160px">
|
||||||
|
<label>结束日期</label>
|
||||||
|
<input v-model="endDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 200px">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
|
||||||
|
<label>分组</label>
|
||||||
|
<select v-model="groupFilter">
|
||||||
|
<option :value="null">全部分组</option>
|
||||||
|
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label> </label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn" type="button" @click="search">查询</button>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usage-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 58px">序号</th>
|
||||||
|
<th style="width: 220px">用户</th>
|
||||||
|
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
||||||
|
<th style="width: 170px">货源查询密钥</th>
|
||||||
|
<th style="width: 170px">外观专利密钥</th>
|
||||||
|
<th style="width: 150px">代理提取</th>
|
||||||
|
<th style="width: 130px">合计</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<tr v-for="(row, index) in rows" :key="row.userId">
|
||||||
|
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-name">{{ row.username || `UID ${row.userId}` }}</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="isSuperAdmin">
|
||||||
|
<span class="group-name">{{ row.groups?.length ? row.groups.join('、') : '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td><span class="count-value">{{ formatCount(row.similarAsinCount) }}</span></td>
|
||||||
|
<td><span class="count-value">{{ formatCount(row.appearancePatentCount) }}</span></td>
|
||||||
|
<td><span class="count-value">{{ formatCount(row.proxyCount) }}</span></td>
|
||||||
|
<td><span class="count-value count-total">{{ formatCount(row.totalCount) }}</span></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">
|
||||||
|
{{ keyword || startDate || endDate || groupFilter ? '暂无匹配记录' : '暂无用量记录' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 与密钥管理页保持同一视觉语言(像素复刻后台面板风格)。 */
|
||||||
|
.secret-usage-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.usage-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.usage-note {
|
||||||
|
color: #7d8fa2;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.summary-card {
|
||||||
|
flex: 1 1 160px;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f7fafd;
|
||||||
|
}
|
||||||
|
.summary-label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #2f5d8b;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.usage-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
color: #4f78a5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.usage-table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.usage-table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
min-width: 1020px;
|
||||||
|
}
|
||||||
|
.usage-table-scroll th,
|
||||||
|
.usage-table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.usage-table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.usage-table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.usage-table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
color: #24384d;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.group-name {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.count-value {
|
||||||
|
color: #2f5d8b;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.count-total {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
color: #8293a5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import {
|
||||||
|
checkUserSecret,
|
||||||
|
deleteUserSecret,
|
||||||
|
fetchUserSecretList,
|
||||||
|
type AdminUserSecretModule,
|
||||||
|
type AdminUserSecretRow,
|
||||||
|
type GroupOption,
|
||||||
|
} from '@/api/user-secrets'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<AdminUserSecretRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(15)
|
||||||
|
const keyword = ref('')
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const groupFilter = ref<number | null>(null)
|
||||||
|
/** 分组下拉(超管=全部;主管=自己带的分组,由列表接口带回)。 */
|
||||||
|
const groupOptions = ref<GroupOption[]>([])
|
||||||
|
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
||||||
|
const checkingId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'passed', label: '检测通过' },
|
||||||
|
{ value: 'failed', label: '检测失败' },
|
||||||
|
{ value: 'incomplete', label: '未配齐' },
|
||||||
|
{ value: 'error', label: '无法判定' },
|
||||||
|
{ value: 'unknown', label: '未检测' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
|
||||||
|
function rowStatusMeta(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'passed':
|
||||||
|
return { label: '检测通过', tone: 'is-allowed' }
|
||||||
|
case 'failed':
|
||||||
|
return { label: '检测失败', tone: 'is-blocked' }
|
||||||
|
case 'incomplete':
|
||||||
|
return { label: '未配齐', tone: 'is-warn' }
|
||||||
|
case 'error':
|
||||||
|
return { label: '无法判定', tone: 'is-warn' }
|
||||||
|
default:
|
||||||
|
return { label: '未检测', tone: 'is-unknown' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
|
||||||
|
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
||||||
|
if (!module || !module.exists) {
|
||||||
|
return { label: '未配置', tone: 'is-unknown' }
|
||||||
|
}
|
||||||
|
switch (module.checkStatus) {
|
||||||
|
case 'passed':
|
||||||
|
return { label: '通过', tone: 'is-allowed' }
|
||||||
|
case 'failed':
|
||||||
|
if (module.checkCode === 'insufficient_balance') {
|
||||||
|
return { label: '欠费', tone: 'is-warn' }
|
||||||
|
}
|
||||||
|
return { label: '失败', tone: 'is-blocked' }
|
||||||
|
case 'error':
|
||||||
|
return { label: '无法判定', tone: 'is-warn' }
|
||||||
|
default:
|
||||||
|
return { label: '未检测', tone: 'is-unknown' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */
|
||||||
|
function moduleTooltip(module: AdminUserSecretModule | undefined) {
|
||||||
|
if (!module || !module.exists) return '未配置'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (module.full) parts.push(module.full)
|
||||||
|
if (module.checkMessage) parts.push(module.checkMessage)
|
||||||
|
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
||||||
|
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
||||||
|
return parts.join(';') || '暂无检测记录'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分组列文案:优先分组名;未建分组的用户回退展示所属主管,便于判断归属。 */
|
||||||
|
function groupTextOf(row: AdminUserSecretRow) {
|
||||||
|
if (row.groups?.length) return row.groups.join('、')
|
||||||
|
if (row.leaderUsername) return `${row.leaderUsername}(未建分组)`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowStatusTooltip(row: AdminUserSecretRow) {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (row.statusMessage) parts.push(row.statusMessage)
|
||||||
|
if (row.updatedAt) parts.push(`最近更新:${formatDateTime(row.updatedAt)}`)
|
||||||
|
return parts.join(';') || '暂无检测记录'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchUserSecretList({
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
checkStatus: statusFilter.value || undefined,
|
||||||
|
groupId: groupFilter.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
rows.value = result?.items || []
|
||||||
|
total.value = Number(result?.total || 0)
|
||||||
|
groupOptions.value = result?.groupOptions || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
keyword.value = ''
|
||||||
|
statusFilter.value = ''
|
||||||
|
groupFilter.value = null
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
|
||||||
|
async function check(row: AdminUserSecretRow) {
|
||||||
|
checkingId.value = row.userId
|
||||||
|
try {
|
||||||
|
const results = await checkUserSecret(row.userId)
|
||||||
|
if (!results || !results.length) {
|
||||||
|
ElMessage.warning('该用户尚未配置任何密钥或代理')
|
||||||
|
} else {
|
||||||
|
const failed = results.filter((item) => item.checkStatus === 'failed')
|
||||||
|
const errors = results.filter((item) => item.checkStatus === 'error')
|
||||||
|
if (failed.length) {
|
||||||
|
ElMessage.warning(failed[0].checkMessage || '存在检测失败项')
|
||||||
|
} else if (errors.length) {
|
||||||
|
ElMessage.warning('部分配置本次无法判定')
|
||||||
|
} else {
|
||||||
|
ElMessage.success('检测通过')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '检测失败')
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: AdminUserSecretRow) {
|
||||||
|
const who = row.username || `UID ${row.userId}`
|
||||||
|
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
|
||||||
|
try {
|
||||||
|
await deleteUserSecret(row.userId)
|
||||||
|
ElMessage.success('已清空')
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '清空失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="user-secrets-view">
|
||||||
|
<section class="panel-box">
|
||||||
|
<div class="secrets-head">
|
||||||
|
<h3>用户密钥列表</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row secrets-filter-row">
|
||||||
|
<div class="form-group" style="min-width: 220px">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 170px">
|
||||||
|
<label>状态(三项都通过才算通过)</label>
|
||||||
|
<select v-model="statusFilter">
|
||||||
|
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
|
||||||
|
<label>分组</label>
|
||||||
|
<select v-model="groupFilter">
|
||||||
|
<option :value="null">全部分组</option>
|
||||||
|
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label> </label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn" type="button" @click="search">查询</button>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="secrets-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 58px">序号</th>
|
||||||
|
<th style="width: 260px">用户</th>
|
||||||
|
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
||||||
|
<th style="width: 230px">货源查询密钥</th>
|
||||||
|
<th style="width: 230px">外观专利密钥</th>
|
||||||
|
<th style="width: 380px">代理设置</th>
|
||||||
|
<th style="width: 130px">状态</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
|
<th style="width: 170px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<tr v-for="(row, index) in rows" :key="row.userId">
|
||||||
|
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="user-name">{{ row.username || '—' }}</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="isSuperAdmin">
|
||||||
|
<span class="creator-name">{{ groupTextOf(row) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="module-cell">
|
||||||
|
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
||||||
|
<span v-else class="empty-value">未配置</span>
|
||||||
|
<span class="wh-pill" :class="moduleStatusMeta(row.similarAsin).tone" :title="moduleTooltip(row.similarAsin)">
|
||||||
|
{{ moduleStatusMeta(row.similarAsin).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="module-cell">
|
||||||
|
<span v-if="row.appearancePatent?.exists" class="mono-mask" :title="moduleTooltip(row.appearancePatent)">{{ row.appearancePatent.masked }}</span>
|
||||||
|
<span v-else class="empty-value">未配置</span>
|
||||||
|
<span class="wh-pill" :class="moduleStatusMeta(row.appearancePatent).tone" :title="moduleTooltip(row.appearancePatent)">
|
||||||
|
{{ moduleStatusMeta(row.appearancePatent).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="module-cell module-cell--stack">
|
||||||
|
<span v-if="row.proxy?.exists" class="mono-mask mono-full" :title="moduleTooltip(row.proxy)">{{ row.proxy.full || row.proxy.masked }}</span>
|
||||||
|
<span v-else class="empty-value">未配置</span>
|
||||||
|
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
||||||
|
{{ moduleStatusMeta(row.proxy).label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
|
||||||
|
{{ rowStatusMeta(row.status).label }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="checkingId === row.userId"
|
||||||
|
@click="check(row)"
|
||||||
|
>
|
||||||
|
{{ checkingId === row.userId ? '检测中…' : '立即检测' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 像素复刻旧版 admin.html 面板风格(与店铺密钥页同一视觉语言)。 */
|
||||||
|
.user-secrets-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.secrets-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.secrets-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.form-group input:hover,
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
color: #4f78a5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
min-width: 1440px;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll th,
|
||||||
|
.secrets-table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.secrets-table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
color: #24384d;
|
||||||
|
font-weight: 600;
|
||||||
|
/* 用户名(含中文)可完整换行展示,不截断 */
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.creator-name {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.module-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
|
||||||
|
.module-cell--stack {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.module-cell .mono-mask {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
/* 代理列展示完整明文:允许折行显示,不截断、不省略 */
|
||||||
|
.module-cell .mono-full {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.45;
|
||||||
|
cursor: text;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.mono-mask {
|
||||||
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.empty-value {
|
||||||
|
color: #a7b4c1;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
color: #8293a5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.wh-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
cursor: help;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.wh-pill.is-allowed {
|
||||||
|
background: #e8f6ee;
|
||||||
|
border-color: #b9e0c9;
|
||||||
|
color: #2f7d52;
|
||||||
|
}
|
||||||
|
.wh-pill.is-blocked {
|
||||||
|
background: #fdecef;
|
||||||
|
border-color: #f2c4cd;
|
||||||
|
color: #b04a5a;
|
||||||
|
}
|
||||||
|
.wh-pill.is-warn {
|
||||||
|
background: #fdf6e3;
|
||||||
|
border-color: #f0dfae;
|
||||||
|
color: #8f6d1e;
|
||||||
|
}
|
||||||
|
.wh-pill.is-unknown {
|
||||||
|
background: #f0f3f6;
|
||||||
|
border-color: #d6dee6;
|
||||||
|
color: #6b7d8f;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -169,6 +169,7 @@ onMounted(loadUsers)
|
|||||||
<th style="width: 140px">角色</th>
|
<th style="width: 140px">角色</th>
|
||||||
<th style="width: 160px">所属管理员</th>
|
<th style="width: 160px">所属管理员</th>
|
||||||
<th style="width: 180px">创建时间</th>
|
<th style="width: 180px">创建时间</th>
|
||||||
|
<th style="width: 180px">更新时间</th>
|
||||||
<th style="width: 160px">操作</th>
|
<th style="width: 160px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -180,6 +181,7 @@ onMounted(loadUsers)
|
|||||||
<td>{{ roleLabel(row.role) }}</td>
|
<td>{{ roleLabel(row.role) }}</td>
|
||||||
<td>{{ row.creatorUsername || '-' }}</td>
|
<td>{{ row.creatorUsername || '-' }}</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
@@ -203,10 +205,10 @@ onMounted(loadUsers)
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="6" class="empty-tip">加载中...</td>
|
<td colspan="7" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="6" class="empty-tip">{{ userListEmptyHint() }}</td>
|
<td colspan="7" class="empty-tip">{{ userListEmptyHint() }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface MenuManageNode {
|
|||||||
routePath: string
|
routePath: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
children?: MenuManageNode[]
|
children?: MenuManageNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +91,8 @@ export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
|
|||||||
if (rootColumnKey) node.rootColumnKey = rootColumnKey
|
if (rootColumnKey) node.rootColumnKey = rootColumnKey
|
||||||
const createdAt = text(record.created_at)
|
const createdAt = text(record.created_at)
|
||||||
if (createdAt) node.createdAt = createdAt
|
if (createdAt) node.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updated_at)
|
||||||
|
if (updatedAt) node.updatedAt = updatedAt
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export interface MenuOptionNode {
|
|||||||
parentId: number | null
|
parentId: number | null
|
||||||
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
|
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
|
||||||
type: string
|
type: string
|
||||||
|
/**
|
||||||
|
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
|
||||||
|
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
|
||||||
|
*/
|
||||||
|
disabled?: boolean
|
||||||
children?: MenuOptionNode[]
|
children?: MenuOptionNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +44,8 @@ export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode
|
|||||||
sort: sortRaw === null ? 0 : sortRaw,
|
sort: sortRaw === null ? 0 : sortRaw,
|
||||||
parentId: parentId === null ? null : parentId,
|
parentId: parentId === null ? null : parentId,
|
||||||
type,
|
type,
|
||||||
|
// 缺省(菜单管理页等未标记的接口)按可授予处理,保持旧行为
|
||||||
|
disabled: record.grantable === false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,9 +183,9 @@ onMounted(() => {
|
|||||||
<label>品牌</label>
|
<label>品牌</label>
|
||||||
<input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
|
<input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="min-width: 180px">
|
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组" :disabled="!isSuperAdmin">
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,9 +199,10 @@ onMounted(() => {
|
|||||||
<th style="width: 70px">ID</th>
|
<th style="width: 70px">ID</th>
|
||||||
<th style="width: 170px">ASIN</th>
|
<th style="width: 170px">ASIN</th>
|
||||||
<th style="width: 150px">品牌</th>
|
<th style="width: 150px">品牌</th>
|
||||||
<th style="width: 140px">分组</th>
|
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
|
||||||
<th style="width: 110px">来源</th>
|
<th style="width: 110px">来源</th>
|
||||||
<th style="width: 170px">创建时间</th>
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 150px">操作</th>
|
<th style="width: 150px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -211,13 +212,14 @@ onMounted(() => {
|
|||||||
<td>{{ row.id }}</td>
|
<td>{{ row.id }}</td>
|
||||||
<td class="mono-cell">{{ row.dataValue }}</td>
|
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||||
<td>{{ row.brand || '—' }}</td>
|
<td>{{ row.brand || '—' }}</td>
|
||||||
<td>{{ row.groupName || '未分组' }}</td>
|
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
|
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
|
||||||
{{ recordSourceLabel(row.recordSource) }}
|
{{ recordSourceLabel(row.recordSource) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||||
@@ -225,10 +227,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="7" class="empty-tip">加载中...</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './de
|
|||||||
import type { DedupeImportProgress } from './dedupe-import-model.ts'
|
import type { DedupeImportProgress } from './dedupe-import-model.ts'
|
||||||
import { toExportUrl } from './dedupe-total-export.ts'
|
import { toExportUrl } from './dedupe-total-export.ts'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<DedupeTotalItem[]>([])
|
const rows = ref<DedupeTotalItem[]>([])
|
||||||
@@ -32,7 +37,16 @@ const page = ref(1)
|
|||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const filter = reactive(createDedupeTotalFilterState())
|
const filter = reactive(createDedupeTotalFilterState())
|
||||||
const groups = ref<DedupeGroupOption[]>([])
|
const groups = ref<DedupeGroupOption[]>([])
|
||||||
|
/** 非超管仅有一个可访问分组时,编辑/导入弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||||
|
const lockedGroupId = computed<number | null>(() => {
|
||||||
|
if (isSuperAdmin.value) return null
|
||||||
|
return groups.value.length === 1 ? groups.value[0].id : null
|
||||||
|
})
|
||||||
const jumpPage = ref('')
|
const jumpPage = ref('')
|
||||||
|
/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */
|
||||||
|
const pageCursor = ref<number | null>(null)
|
||||||
|
/** 本次请求实际使用的游标(load 时决定) */
|
||||||
|
let pendingCursor: number | null = null
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
@@ -75,10 +89,15 @@ function stopExportWait(): void {
|
|||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize.value))
|
const result = await fetchDedupeTotalList(
|
||||||
|
toDedupeListParams(filter, page.value, pageSize.value, pendingCursor),
|
||||||
|
)
|
||||||
rows.value = result.items
|
rows.value = result.items
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
if (result.page >= 1) page.value = result.page
|
if (result.page >= 1) page.value = result.page
|
||||||
|
// 本页响应回传的游标留作"下一页"用;空页则清空(没有更多)
|
||||||
|
pageCursor.value = result.nextLastId ?? null
|
||||||
|
pendingCursor = null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,11 +107,15 @@ async function load(): Promise<void> {
|
|||||||
|
|
||||||
function apply(): void {
|
function apply(): void {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
pageCursor.value = null
|
||||||
|
pendingCursor = null
|
||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePage(next: number): void {
|
function changePage(next: number): void {
|
||||||
if (next < 1 || next > totalPages.value) return
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
// 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变
|
||||||
|
pendingCursor = next === page.value + 1 ? pageCursor.value : null
|
||||||
page.value = next
|
page.value = next
|
||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
@@ -109,13 +132,15 @@ function goJump(): void {
|
|||||||
function changeSize(size: number) {
|
function changeSize(size: number) {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
pageCursor.value = null
|
||||||
|
pendingCursor = null
|
||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: DedupeTotalItem): void {
|
function openEdit(row: DedupeTotalItem): void {
|
||||||
editTarget.value = row
|
editTarget.value = row
|
||||||
editValue.value = row.dataValue
|
editValue.value = row.dataValue
|
||||||
editGroupId.value = row.groupId
|
editGroupId.value = row.groupId ?? lockedGroupId.value
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +182,7 @@ async function removeRow(row: DedupeTotalItem): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetImport(): void {
|
function resetImport(): void {
|
||||||
importGroupId.value = null
|
importGroupId.value = lockedGroupId.value
|
||||||
importFile.value = null
|
importFile.value = null
|
||||||
importProgress.value = ''
|
importProgress.value = ''
|
||||||
importRunning.value = false
|
importRunning.value = false
|
||||||
@@ -306,7 +331,7 @@ onMounted(() => {
|
|||||||
<label>用户名(模糊搜索)</label>
|
<label>用户名(模糊搜索)</label>
|
||||||
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
|
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div v-if="isSuperAdmin" class="form-group">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||||
@@ -348,8 +373,9 @@ onMounted(() => {
|
|||||||
<th>ASIN</th>
|
<th>ASIN</th>
|
||||||
<th>国家</th>
|
<th>国家</th>
|
||||||
<th>用户名</th>
|
<th>用户名</th>
|
||||||
<th>分组</th>
|
<th v-if="isSuperAdmin">分组</th>
|
||||||
<th>创建时间</th>
|
<th>创建时间</th>
|
||||||
|
<th>更新时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -360,8 +386,9 @@ onMounted(() => {
|
|||||||
<td class="mono-cell">{{ row.dataValue }}</td>
|
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||||
<td>{{ row.username || '-' }}</td>
|
<td>{{ row.username || '-' }}</td>
|
||||||
<td>{{ row.groupName || '未分组' }}</td>
|
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
|
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
|
||||||
@@ -369,10 +396,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="7" class="empty-tip">加载中...</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">暂无总数据</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无总数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -390,7 +417,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<select v-model="editGroupId">
|
<select v-model="editGroupId" :disabled="lockedGroupId != null">
|
||||||
<option disabled :value="null">请选择分组</option>
|
<option disabled :value="null">请选择分组</option>
|
||||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -411,7 +438,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<select v-model="importGroupId" :disabled="importRunning">
|
<select v-model="importGroupId" :disabled="importRunning || lockedGroupId != null">
|
||||||
<option disabled :value="null">请选择分组</option>
|
<option disabled :value="null">请选择分组</option>
|
||||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from './product-category-model.ts'
|
} from './product-category-model.ts'
|
||||||
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
|
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
|
||||||
import { buildProductCategoryExportUrl } from './product-category-export.ts'
|
import { buildProductCategoryExportUrl } from './product-category-export.ts'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -330,6 +331,8 @@ onMounted(loadTree)
|
|||||||
<th style="width: 90px">排序</th>
|
<th style="width: 90px">排序</th>
|
||||||
<th style="width: 120px">来源</th>
|
<th style="width: 120px">来源</th>
|
||||||
<th>备注</th>
|
<th>备注</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 150px">操作</th>
|
<th style="width: 150px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -337,7 +340,7 @@ onMounted(loadTree)
|
|||||||
<template v-if="treeRows.length">
|
<template v-if="treeRows.length">
|
||||||
<template v-for="row in treeRows" :key="String(row.node.id)">
|
<template v-for="row in treeRows" :key="String(row.node.id)">
|
||||||
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
|
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
|
||||||
<td colspan="6">
|
<td colspan="8">
|
||||||
<div class="load-more-cell">
|
<div class="load-more-cell">
|
||||||
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
|
<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)">
|
<button class="btn btn-sm btn-secondary" type="button" :disabled="moreLoading(row.node)" @click="loadMoreChildren(row.node)">
|
||||||
@@ -376,6 +379,8 @@ onMounted(loadTree)
|
|||||||
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
|
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
|
||||||
<span v-else class="dim">—</span>
|
<span v-else class="dim">—</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.node.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.node.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
|
||||||
<button
|
<button
|
||||||
@@ -392,10 +397,10 @@ onMounted(loadTree)
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="6" class="empty-tip">加载中...</td>
|
<td colspan="8" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="6" class="empty-tip">暂无商品类目</td>
|
<td colspan="8" class="empty-tip">暂无商品类目</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tbody v-else>
|
<tbody v-else>
|
||||||
@@ -411,6 +416,8 @@ onMounted(loadTree)
|
|||||||
<span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
|
<span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
|
||||||
<span v-else class="dim">—</span>
|
<span v-else class="dim">—</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
<button
|
<button
|
||||||
@@ -426,10 +433,10 @@ onMounted(loadTree)
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="searchLoading">
|
<tr v-else-if="searchLoading">
|
||||||
<td colspan="6" class="empty-tip">搜索中...</td>
|
<td colspan="8" class="empty-tip">搜索中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="6" class="empty-tip">暂无搜索结果</td>
|
<td colspan="8" class="empty-tip">暂无搜索结果</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import CopyText from '@/components/CopyText.vue'
|
import CopyText from '@/components/CopyText.vue'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
|
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||||
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
|
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
|
||||||
import { asinCountryLabel } from './asin-country.ts'
|
import { asinCountryLabel } from './asin-country.ts'
|
||||||
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
|
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
|
||||||
|
import { toQueryAsinExportUrl } from './query-asin-export.ts'
|
||||||
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
|
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
|
||||||
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
|
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
|
||||||
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
|
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
|
||||||
@@ -15,6 +17,11 @@ import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
|||||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<QueryAsinItem[]>([])
|
const rows = ref<QueryAsinItem[]>([])
|
||||||
@@ -22,6 +29,11 @@ const total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
|
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||||
|
const lockedGroupId = computed<number | null>(() => {
|
||||||
|
if (isSuperAdmin.value) return null
|
||||||
|
return groups.value.length === 1 ? groups.value[0].id : null
|
||||||
|
})
|
||||||
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||||
const jumpPage = ref('')
|
const jumpPage = ref('')
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
@@ -114,7 +126,7 @@ const creating = ref(false)
|
|||||||
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
createGroupId.value = null
|
createGroupId.value = lockedGroupId.value
|
||||||
createShopName.value = ''
|
createShopName.value = ''
|
||||||
createCountry.value = ''
|
createCountry.value = ''
|
||||||
createAsin.value = ''
|
createAsin.value = ''
|
||||||
@@ -122,6 +134,8 @@ function openCreate(): void {
|
|||||||
createMsgOk.value = false
|
createMsgOk.value = false
|
||||||
shopNames.value = []
|
shopNames.value = []
|
||||||
createVisible.value = true
|
createVisible.value = true
|
||||||
|
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||||||
|
if (createGroupId.value != null) void onCreateGroupChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCreateGroupChange(): Promise<void> {
|
async function onCreateGroupChange(): Promise<void> {
|
||||||
@@ -188,7 +202,7 @@ const importRunning = ref(false)
|
|||||||
const importProgress = ref('')
|
const importProgress = ref('')
|
||||||
|
|
||||||
function resetImport(): void {
|
function resetImport(): void {
|
||||||
importGroupId.value = null
|
importGroupId.value = lockedGroupId.value
|
||||||
importFile.value = null
|
importFile.value = null
|
||||||
importProgress.value = ''
|
importProgress.value = ''
|
||||||
importRunning.value = false
|
importRunning.value = false
|
||||||
@@ -263,8 +277,9 @@ async function submitImport(): Promise<void> {
|
|||||||
|
|
||||||
// ---- 导出 ----
|
// ---- 导出 ----
|
||||||
function doExport(): void {
|
function doExport(): void {
|
||||||
|
// 带当前筛选(分组/店铺名/ASIN/国家)导出,范围与列表筛选一致(此前不带参数导致导出恒为全量)。
|
||||||
const anchor = document.createElement('a')
|
const anchor = document.createElement('a')
|
||||||
anchor.href = '/api/admin/query-asins/export'
|
anchor.href = toQueryAsinExportUrl(filter)
|
||||||
document.body.appendChild(anchor)
|
document.body.appendChild(anchor)
|
||||||
anchor.click()
|
anchor.click()
|
||||||
anchor.remove()
|
anchor.remove()
|
||||||
@@ -346,7 +361,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row query-filter-row">
|
<div class="form-row query-filter-row">
|
||||||
<div class="form-group">
|
<div v-if="isSuperAdmin" class="form-group">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
@@ -376,10 +391,12 @@ onMounted(() => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 6%">序号</th>
|
<th style="width: 6%">序号</th>
|
||||||
<th style="width: 13%">分组</th>
|
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||||||
<th style="width: 15%">店铺名</th>
|
<th style="width: 15%">店铺名</th>
|
||||||
<th style="width: 24%">ASIN</th>
|
<th style="width: 24%">ASIN</th>
|
||||||
<th style="width: 12%">国家</th>
|
<th style="width: 12%">国家</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 9%">操作</th>
|
<th style="width: 9%">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -388,7 +405,7 @@ onMounted(() => {
|
|||||||
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||||
<template v-if="row.isFirst">
|
<template v-if="row.isFirst">
|
||||||
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||||
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||||
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||||
</template>
|
</template>
|
||||||
<td>
|
<td>
|
||||||
@@ -399,6 +416,8 @@ onMounted(() => {
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||||
<template v-if="row.isFirst">
|
<template v-if="row.isFirst">
|
||||||
|
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
|
||||||
|
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
|
||||||
<td :rowspan="row.rowspan" class="asin-col-actions">
|
<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" 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>
|
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
|
||||||
@@ -407,10 +426,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="6" class="empty-tip">加载中...</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="6" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -421,7 +440,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
|
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
|
||||||
<el-form label-position="top" @submit.prevent>
|
<el-form label-position="top" @submit.prevent>
|
||||||
<el-form-item label="分组">
|
<el-form-item label="分组">
|
||||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
|
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -473,7 +492,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
|
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
|
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
|
||||||
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
|
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%" :disabled="lockedGroupId != null">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import CopyText from '@/components/CopyText.vue'
|
import CopyText from '@/components/CopyText.vue'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
|
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
|
||||||
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
|
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
|
||||||
import { asinCountryLabel } from './asin-country.ts'
|
import { asinCountryLabel } from './asin-country.ts'
|
||||||
@@ -15,6 +16,11 @@ import { fetchShopNamesByGroup } from './query-asin-api.ts'
|
|||||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
|
|
||||||
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<SkipPriceItem[]>([])
|
const rows = ref<SkipPriceItem[]>([])
|
||||||
@@ -22,6 +28,11 @@ const total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
|
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||||
|
const lockedGroupId = computed<number | null>(() => {
|
||||||
|
if (isSuperAdmin.value) return null
|
||||||
|
return groups.value.length === 1 ? groups.value[0].id : null
|
||||||
|
})
|
||||||
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
||||||
const jumpPage = ref('')
|
const jumpPage = ref('')
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
@@ -57,7 +68,7 @@ const creating = ref(false)
|
|||||||
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
||||||
|
|
||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
createGroupId.value = null
|
createGroupId.value = lockedGroupId.value
|
||||||
createShopName.value = ''
|
createShopName.value = ''
|
||||||
createCountry.value = ''
|
createCountry.value = ''
|
||||||
createAsin.value = ''
|
createAsin.value = ''
|
||||||
@@ -66,6 +77,8 @@ function openCreate(): void {
|
|||||||
createMsgOk.value = false
|
createMsgOk.value = false
|
||||||
shopNames.value = []
|
shopNames.value = []
|
||||||
createVisible.value = true
|
createVisible.value = true
|
||||||
|
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||||||
|
if (createGroupId.value != null) void onCreateGroupChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCreateGroupChange(): Promise<void> {
|
async function onCreateGroupChange(): Promise<void> {
|
||||||
@@ -238,7 +251,7 @@ const importRunning = ref(false)
|
|||||||
const importProgress = ref('')
|
const importProgress = ref('')
|
||||||
|
|
||||||
function resetImport(): void {
|
function resetImport(): void {
|
||||||
importGroupId.value = null
|
importGroupId.value = lockedGroupId.value
|
||||||
importFile.value = null
|
importFile.value = null
|
||||||
importProgress.value = ''
|
importProgress.value = ''
|
||||||
importRunning.value = false
|
importRunning.value = false
|
||||||
@@ -392,7 +405,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row skip-filter-row">
|
<div class="form-row skip-filter-row">
|
||||||
<div class="form-group">
|
<div v-if="isSuperAdmin" class="form-group">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
@@ -430,11 +443,13 @@ onMounted(() => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 6%">序号</th>
|
<th style="width: 6%">序号</th>
|
||||||
<th style="width: 13%">分组</th>
|
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||||||
<th style="width: 15%">店铺名</th>
|
<th style="width: 15%">店铺名</th>
|
||||||
<th style="width: 24%">ASIN</th>
|
<th style="width: 24%">ASIN</th>
|
||||||
<th style="width: 12%">国家</th>
|
<th style="width: 12%">国家</th>
|
||||||
<th style="width: 10%">最低价</th>
|
<th style="width: 10%">最低价</th>
|
||||||
|
<th style="width: 170px">创建时间</th>
|
||||||
|
<th style="width: 170px">更新时间</th>
|
||||||
<th style="width: 9%">操作</th>
|
<th style="width: 9%">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -443,7 +458,7 @@ onMounted(() => {
|
|||||||
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||||
<template v-if="row.isFirst">
|
<template v-if="row.isFirst">
|
||||||
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||||
<td :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||||
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||||
</template>
|
</template>
|
||||||
<td>
|
<td>
|
||||||
@@ -455,6 +470,8 @@ onMounted(() => {
|
|||||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||||
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
|
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
|
||||||
<template v-if="row.isFirst">
|
<template v-if="row.isFirst">
|
||||||
|
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
|
||||||
|
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
|
||||||
<td :rowspan="row.rowspan" class="asin-col-actions">
|
<td :rowspan="row.rowspan" class="asin-col-actions">
|
||||||
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
|
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
|
||||||
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
|
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
|
||||||
@@ -463,10 +480,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="7" class="empty-tip">加载中...</td>
|
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">暂无数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -477,7 +494,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
|
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
|
||||||
<el-form label-position="top" @submit.prevent>
|
<el-form label-position="top" @submit.prevent>
|
||||||
<el-form-item label="分组">
|
<el-form-item label="分组">
|
||||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" @change="onCreateGroupChange">
|
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -535,7 +552,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
|
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="分组(必选)">
|
<el-form-item label="分组(必选)">
|
||||||
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%" :disabled="lockedGroupId != null">
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface AsinListParams {
|
|||||||
groupId?: number | null
|
groupId?: number | null
|
||||||
/** 国家代码(如 DE、UK)。 */
|
/** 国家代码(如 DE、UK)。 */
|
||||||
country?: string
|
country?: string
|
||||||
|
/** 顺序翻页游标(上一页返回的 nextLastId):传了就忽略 page 偏移,走 keyset。 */
|
||||||
|
lastId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */
|
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */
|
||||||
@@ -36,6 +38,8 @@ export interface AsinPageQuery {
|
|||||||
end_date?: string
|
end_date?: string
|
||||||
group_id?: number
|
group_id?: number
|
||||||
country?: string
|
country?: string
|
||||||
|
/** 顺序翻页游标(keyset):传了就忽略 page 偏移。 */
|
||||||
|
last_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function finiteInt(value: unknown): number | null {
|
function finiteInt(value: unknown): number | null {
|
||||||
@@ -77,5 +81,6 @@ export function toAsinPageQuery(params: AsinListParams): AsinPageQuery {
|
|||||||
if (params.endDate) query.end_date = params.endDate
|
if (params.endDate) query.end_date = params.endDate
|
||||||
if (params.groupId != null) query.group_id = params.groupId
|
if (params.groupId != null) query.group_id = params.groupId
|
||||||
if (params.country) query.country = params.country
|
if (params.country) query.country = params.country
|
||||||
|
if (typeof params.lastId === 'number' && params.lastId > 0) query.last_id = params.lastId
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ export function toDedupeListParams(
|
|||||||
state: DedupeTotalFilterState,
|
state: DedupeTotalFilterState,
|
||||||
page: number,
|
page: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
|
lastId?: number | null,
|
||||||
): AsinListParams {
|
): AsinListParams {
|
||||||
const params: AsinListParams = { page, pageSize }
|
const params: AsinListParams = { page, pageSize }
|
||||||
|
if (typeof lastId === 'number' && lastId > 0) params.lastId = lastId
|
||||||
const keyword = (state.keyword || '').trim()
|
const keyword = (state.keyword || '').trim()
|
||||||
const username = (state.username || '').trim()
|
const username = (state.username || '').trim()
|
||||||
const country = (state.country || '').trim()
|
const country = (state.country || '').trim()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface DedupeTotalItem {
|
|||||||
uploaderUserId: number | null
|
uploaderUserId: number | null
|
||||||
username: string
|
username: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DedupeTotalPageResult {
|
export interface DedupeTotalPageResult {
|
||||||
@@ -18,6 +19,8 @@ export interface DedupeTotalPageResult {
|
|||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
/** 顺序翻页游标:本页最后一行 id;下一页回传它即可走 keyset。 */
|
||||||
|
nextLastId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyDedupeTotalPage(): DedupeTotalPageResult {
|
export function emptyDedupeTotalPage(): DedupeTotalPageResult {
|
||||||
@@ -49,6 +52,8 @@ export function toDedupeTotalItem(raw: unknown): DedupeTotalItem | null {
|
|||||||
}
|
}
|
||||||
const createdAt = text(record.createdAt ?? record.created_at)
|
const createdAt = text(record.createdAt ?? record.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +70,8 @@ export function parseDedupeTotalPage(payload: unknown): DedupeTotalPageResult {
|
|||||||
}
|
}
|
||||||
if (typeof record.total === 'number') out.total = Math.floor(record.total)
|
if (typeof record.total === 'number') out.total = Math.floor(record.total)
|
||||||
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
||||||
|
const rawNextLastId = record.nextLastId ?? record.next_last_id
|
||||||
|
if (typeof rawNextLastId === 'number' && rawNextLastId > 0) out.nextLastId = Math.floor(rawNextLastId)
|
||||||
const rawSize = record.pageSize ?? record.page_size
|
const rawSize = record.pageSize ?? record.page_size
|
||||||
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface ProductCategoryNode {
|
|||||||
childCount: number
|
childCount: number
|
||||||
level: number | null
|
level: number | null
|
||||||
path: string
|
path: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
children: ProductCategoryNode[]
|
children: ProductCategoryNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,10 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null
|
|||||||
path: text(record.path),
|
path: text(record.path),
|
||||||
children: [],
|
children: [],
|
||||||
}
|
}
|
||||||
|
const createdAt = text(record.createdAt ?? record.created_at)
|
||||||
|
if (createdAt) node.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||||
|
if (updatedAt) node.updatedAt = updatedAt
|
||||||
if (Array.isArray(record.children)) {
|
if (Array.isArray(record.children)) {
|
||||||
node.children = record.children
|
node.children = record.children
|
||||||
.map((child) => toProductCategoryNode(child))
|
.map((child) => toProductCategoryNode(child))
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
|
|
||||||
export const QUERY_ASIN_DELETE_IMPORT_ENDPOINT = '/api/admin/query-asins/delete-import'
|
export const QUERY_ASIN_DELETE_IMPORT_ENDPOINT = '/api/admin/query-asins/delete-import'
|
||||||
|
|
||||||
/** 启动导入删除;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */
|
/** 启动导入删除;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。
|
||||||
|
* 注意:后端 deleteImportExcel 仍是隐式 camel 参数名(groupId),此处必须发 groupId;
|
||||||
|
* 与 import 的 group_id 不同源,后端统一为 snake 时需两端同步改。 */
|
||||||
export async function startQueryAsinDeleteImport(file: File, groupId?: number): Promise<string> {
|
export async function startQueryAsinDeleteImport(file: File, groupId?: number): Promise<string> {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file, file.name)
|
form.append('file', file, file.name)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** 查询 ASIN 导出 URL 构建:按当前筛选下发 group_id/shop_name/asin/country(对齐 Java export @RequestParam snake_case)。 */
|
||||||
|
import { normalizeQueryAsinCountry, type QueryAsinFilterState } from './query-asin-filter.ts'
|
||||||
|
|
||||||
|
export const QUERY_ASIN_EXPORT_ENDPOINT = '/api/admin/query-asins/export'
|
||||||
|
|
||||||
|
/** 按导出可用筛选(分组/店铺名/ASIN/国家)构建查询串;空白/非法条件不下发,避免导出范围被误解。 */
|
||||||
|
export function buildQueryAsinExportQuery(state: QueryAsinFilterState): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
const groupId = state.groupId
|
||||||
|
if (typeof groupId === 'number' && groupId >= 1) params.set('group_id', String(Math.floor(groupId)))
|
||||||
|
const shopName = (state.shopName || '').trim()
|
||||||
|
if (shopName) params.set('shop_name', shopName)
|
||||||
|
const asin = (state.asin || '').trim()
|
||||||
|
if (asin) params.set('asin', asin)
|
||||||
|
const country = normalizeQueryAsinCountry(state.country)
|
||||||
|
if (country) params.set('country', country)
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出下载地址:浏览器直接访问该 URL 触发流式下载;无筛选时为全量导出。 */
|
||||||
|
export function toQueryAsinExportUrl(state: QueryAsinFilterState): string {
|
||||||
|
const query = buildQueryAsinExportQuery(state)
|
||||||
|
return query ? `${QUERY_ASIN_EXPORT_ENDPOINT}?${query}` : QUERY_ASIN_EXPORT_ENDPOINT
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
/** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;Java 参数名 camelCase。 */
|
/** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;
|
||||||
|
* Java 参数名 snake_case(与 asin-filter 共享契约一致):page_size/group_id/shop_name,裸 camel 后端收不到会导致筛选失效。 */
|
||||||
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
|
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
|
||||||
import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts'
|
import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts'
|
||||||
|
|
||||||
@@ -24,9 +25,9 @@ export interface QueryAsinListParams {
|
|||||||
|
|
||||||
export interface QueryAsinQuery {
|
export interface QueryAsinQuery {
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
page_size: number
|
||||||
groupId?: number
|
group_id?: number
|
||||||
shopName?: string
|
shop_name?: string
|
||||||
asin?: string
|
asin?: string
|
||||||
country?: string
|
country?: string
|
||||||
}
|
}
|
||||||
@@ -92,11 +93,11 @@ export function toQueryAsinParams(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(camel) 对齐,仅下发非空字段。 */
|
/** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(snake_case: page_size/group_id/shop_name/asin/country) 对齐,仅下发非空字段。 */
|
||||||
export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery {
|
export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery {
|
||||||
const query: QueryAsinQuery = { page: params.page, pageSize: params.pageSize }
|
const query: QueryAsinQuery = { page: params.page, page_size: params.pageSize }
|
||||||
if (typeof params.groupId === 'number' && params.groupId >= 1) query.groupId = params.groupId
|
if (typeof params.groupId === 'number' && params.groupId >= 1) query.group_id = params.groupId
|
||||||
if (params.shopName) query.shopName = params.shopName
|
if (params.shopName) query.shop_name = params.shopName
|
||||||
if (params.asin) query.asin = params.asin
|
if (params.asin) query.asin = params.asin
|
||||||
if (params.country) query.country = params.country
|
if (params.country) query.country = params.country
|
||||||
return query
|
return query
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
|
|
||||||
export const QUERY_ASIN_IMPORT_ENDPOINT = '/api/admin/query-asins/import'
|
export const QUERY_ASIN_IMPORT_ENDPOINT = '/api/admin/query-asins/import'
|
||||||
|
|
||||||
/** 启动导入添加;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */
|
/** 启动导入添加;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。
|
||||||
|
* 参数名必须用 group_id:Java importExcel @RequestParam(name="group_id"),发 groupId 会被丢弃致兜底分组失效。 */
|
||||||
export async function startQueryAsinImport(file: File, groupId?: number): Promise<string> {
|
export async function startQueryAsinImport(file: File, groupId?: number): Promise<string> {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file, file.name)
|
form.append('file', file, file.name)
|
||||||
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
|
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
|
||||||
form.append('groupId', String(Math.floor(groupId)))
|
form.append('group_id', String(Math.floor(groupId)))
|
||||||
}
|
}
|
||||||
const { data } = await http.post<unknown>(QUERY_ASIN_IMPORT_ENDPOINT, form)
|
const { data } = await http.post<unknown>(QUERY_ASIN_IMPORT_ENDPOINT, form)
|
||||||
return parseQueryAsinImportStart(data)
|
return parseQueryAsinImportStart(data)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface InvalidAsinItem {
|
|||||||
groupName: string
|
groupName: string
|
||||||
recordSource: string
|
recordSource: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvalidAsinPageResult {
|
export interface InvalidAsinPageResult {
|
||||||
@@ -74,6 +75,8 @@ export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | null {
|
|||||||
}
|
}
|
||||||
const createdAt = text(record.createdAt ?? record.created_at)
|
const createdAt = text(record.createdAt ?? record.created_at)
|
||||||
if (createdAt) item.createdAt = createdAt
|
if (createdAt) item.createdAt = createdAt
|
||||||
|
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||||
|
if (updatedAt) item.updatedAt = updatedAt
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const errorMessage = ref('')
|
|||||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
||||||
|
|
||||||
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
|
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
|
||||||
|
/** 单设备登录:被新设备顶下线后跳回登录页(整页跳转携带 ?kicked=1) */
|
||||||
|
const kickedNotice = computed(() => route.query.kicked === '1')
|
||||||
|
|
||||||
function deviceId(): string {
|
function deviceId(): string {
|
||||||
try {
|
try {
|
||||||
@@ -120,6 +122,7 @@ onMounted(() => {
|
|||||||
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
||||||
</header>
|
</header>
|
||||||
<form novalidate @submit.prevent="submit">
|
<form novalidate @submit.prevent="submit">
|
||||||
|
<p v-if="kickedNotice" class="kicked-msg" role="alert">该账号已在其他设备登录,本设备已下线。如非本人操作,请及时修改密码。</p>
|
||||||
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="loginUsername">用户名</label>
|
<label for="loginUsername">用户名</label>
|
||||||
@@ -296,6 +299,20 @@ button, input { font: inherit; }
|
|||||||
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 单设备登录:被新设备顶下线的提示 */
|
||||||
|
.kicked-msg {
|
||||||
|
display: flex; align-items: flex-start; gap: 9px;
|
||||||
|
margin: -4px 0 18px; padding: 11px 12px;
|
||||||
|
border: 1px solid #e0cf9f; border-radius: 11px; background: #fbf4e2; color: #8a6a1f;
|
||||||
|
font-size: 13px; line-height: 1.55;
|
||||||
|
}
|
||||||
|
.kicked-msg::before {
|
||||||
|
content: "!";
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
flex: 0 0 18px; width: 18px; height: 18px;
|
||||||
|
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-login {
|
.btn-login {
|
||||||
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||||
width: 100%; min-height: 48px; padding: 12px 18px;
|
width: 100%; min-height: 48px; padding: 12px 18px;
|
||||||
|
|||||||
@@ -0,0 +1,719 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 日志管理页:桌面客户端 / 麦象采集机上报的日志浏览(仅超管)。
|
||||||
|
* 支持来源/日期/关键字筛选,尾部内容查看(自动滚到底、可向前加载更早内容)、
|
||||||
|
* 完整下载、删除;「采集配置」可调全量/精选模式(全局默认 + 终端覆盖)。 */
|
||||||
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
import {
|
||||||
|
deleteDeviceLogFile,
|
||||||
|
deleteDeviceLogOverride,
|
||||||
|
deviceLogDownloadUrl,
|
||||||
|
fetchDeviceLogConfig,
|
||||||
|
fetchDeviceLogContent,
|
||||||
|
fetchDeviceLogDevices,
|
||||||
|
fetchDeviceLogFiles,
|
||||||
|
updateDeviceLogGlobalMode,
|
||||||
|
updateDeviceLogOverride,
|
||||||
|
type DeviceLogContent,
|
||||||
|
type DeviceLogDevice,
|
||||||
|
type DeviceLogFileRow,
|
||||||
|
type DeviceLogOverride,
|
||||||
|
} from '@/api/device-logs'
|
||||||
|
|
||||||
|
const SOURCE_OPTIONS = [
|
||||||
|
{ value: '', label: '全部来源' },
|
||||||
|
{ value: 'client', label: '桌面客户端' },
|
||||||
|
{ value: 'maixiang', label: '麦象采集机' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function sourceLabel(source: string): string {
|
||||||
|
if (source === 'client') return '桌面客户端'
|
||||||
|
if (source === 'maixiang') return '麦象采集机'
|
||||||
|
return source || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null | undefined): string {
|
||||||
|
if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const units = ['KB', 'MB', 'GB']
|
||||||
|
let value = bytes / 1024
|
||||||
|
let unitIndex = 0
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const rows = ref<DeviceLogFileRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(15)
|
||||||
|
const sourceFilter = ref('')
|
||||||
|
const keyword = ref('')
|
||||||
|
const startDate = ref('')
|
||||||
|
const endDate = ref('')
|
||||||
|
const retentionDays = ref(7)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchDeviceLogFiles({
|
||||||
|
source: sourceFilter.value || undefined,
|
||||||
|
keyword: keyword.value.trim() || undefined,
|
||||||
|
startDate: startDate.value || undefined,
|
||||||
|
endDate: endDate.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
})
|
||||||
|
rows.value = result?.items || []
|
||||||
|
total.value = Number(result?.total || 0)
|
||||||
|
if (result?.retentionDays) retentionDays.value = result.retentionDays
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '日志列表加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
sourceFilter.value = ''
|
||||||
|
keyword.value = ''
|
||||||
|
startDate.value = ''
|
||||||
|
endDate.value = ''
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(next: number) {
|
||||||
|
if (next < 1 || next > totalPages.value) return
|
||||||
|
page.value = next
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 内容查看
|
||||||
|
|
||||||
|
const TAIL_STEP = 256 * 1024
|
||||||
|
const TAIL_MAX = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
const viewerVisible = ref(false)
|
||||||
|
const viewerLoading = ref(false)
|
||||||
|
const viewerFile = ref<DeviceLogFileRow | null>(null)
|
||||||
|
const viewerContent = ref<DeviceLogContent | null>(null)
|
||||||
|
const viewerMaxBytes = ref(TAIL_STEP)
|
||||||
|
const viewerPre = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
async function openViewer(row: DeviceLogFileRow) {
|
||||||
|
viewerFile.value = row
|
||||||
|
viewerMaxBytes.value = TAIL_STEP
|
||||||
|
viewerContent.value = null
|
||||||
|
viewerVisible.value = true
|
||||||
|
await loadContent(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContent(scrollToBottom: boolean) {
|
||||||
|
if (!viewerFile.value) return
|
||||||
|
viewerLoading.value = true
|
||||||
|
try {
|
||||||
|
viewerContent.value = await fetchDeviceLogContent(viewerFile.value.id, viewerMaxBytes.value)
|
||||||
|
if (scrollToBottom) {
|
||||||
|
await nextTick()
|
||||||
|
if (viewerPre.value) viewerPre.value.scrollTop = viewerPre.value.scrollHeight
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '日志内容加载失败')
|
||||||
|
} finally {
|
||||||
|
viewerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
viewerMaxBytes.value = Math.min(viewerMaxBytes.value * 2, TAIL_MAX)
|
||||||
|
loadContent(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: DeviceLogFileRow) {
|
||||||
|
if (!window.confirm(`确认删除「${row.fileName}」(${row.deviceName || row.deviceId})的云端日志?删除后不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteDeviceLogFile(row.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 采集配置
|
||||||
|
|
||||||
|
const configVisible = ref(false)
|
||||||
|
const configLoading = ref(false)
|
||||||
|
const globalMode = ref('full')
|
||||||
|
const overrides = ref<DeviceLogOverride[]>([])
|
||||||
|
const devices = ref<DeviceLogDevice[]>([])
|
||||||
|
const newOverrideKey = ref('')
|
||||||
|
const newOverrideMode = ref('selected')
|
||||||
|
|
||||||
|
const MODE_OPTIONS = [
|
||||||
|
{ value: 'full', label: '全量采集' },
|
||||||
|
{ value: 'selected', label: '精选采集' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function modeLabel(mode: string): string {
|
||||||
|
return mode === 'selected' ? '精选' : '全量'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfig() {
|
||||||
|
configVisible.value = true
|
||||||
|
await loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
configLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchDeviceLogConfig()
|
||||||
|
globalMode.value = data.globalMode || 'full'
|
||||||
|
overrides.value = data.overrides || []
|
||||||
|
devices.value = (await fetchDeviceLogDevices()) || []
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '采集配置加载失败')
|
||||||
|
} finally {
|
||||||
|
configLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGlobalMode(mode: string) {
|
||||||
|
if (mode === globalMode.value) return
|
||||||
|
try {
|
||||||
|
await updateDeviceLogGlobalMode(mode)
|
||||||
|
globalMode.value = mode
|
||||||
|
ElMessage.success(`全局采集模式已切换为「${modeLabel(mode)}」`)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOverride() {
|
||||||
|
if (!newOverrideKey.value) {
|
||||||
|
ElMessage.warning('请先选择终端')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [source, deviceId] = newOverrideKey.value.split('|')
|
||||||
|
const device = devices.value.find((item) => item.source === source && item.deviceId === deviceId)
|
||||||
|
try {
|
||||||
|
await updateDeviceLogOverride(source, deviceId, device?.deviceName || null, newOverrideMode.value)
|
||||||
|
ElMessage.success('终端覆盖已保存')
|
||||||
|
newOverrideKey.value = ''
|
||||||
|
await loadConfig()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeOverride(row: DeviceLogOverride) {
|
||||||
|
const who = row.deviceName || row.deviceId
|
||||||
|
if (!window.confirm(`确认删除终端「${who}」的采集覆盖(回落到全局默认)?`)) return
|
||||||
|
try {
|
||||||
|
await deleteDeviceLogOverride(row.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
await loadConfig()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="device-logs-view">
|
||||||
|
<section class="panel-box">
|
||||||
|
<div class="logs-head">
|
||||||
|
<h3>日志文件列表</h3>
|
||||||
|
<div class="logs-head-tools">
|
||||||
|
<span class="retention-tip">云端仅保留 {{ retentionDays }} 天</span>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="openConfig">采集配置</button>
|
||||||
|
<button class="btn" type="button" @click="load">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row logs-filter-row">
|
||||||
|
<div class="form-group" style="min-width: 150px">
|
||||||
|
<label>来源</label>
|
||||||
|
<select v-model="sourceFilter">
|
||||||
|
<option v-for="option in SOURCE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 220px">
|
||||||
|
<label>设备 / 文件名</label>
|
||||||
|
<input v-model="keyword" type="text" placeholder="模糊搜索设备名、设备ID、文件名" @keyup.enter="search" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 150px">
|
||||||
|
<label>日志日期(起)</label>
|
||||||
|
<input v-model="startDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="min-width: 150px">
|
||||||
|
<label>日志日期(止)</label>
|
||||||
|
<input v-model="endDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label> </label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn" type="button" @click="search">查询</button>
|
||||||
|
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="logs-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 110px">来源</th>
|
||||||
|
<th style="width: 230px">设备</th>
|
||||||
|
<th style="width: 130px">用户</th>
|
||||||
|
<th style="width: 230px">文件名</th>
|
||||||
|
<th style="width: 110px">日志日期</th>
|
||||||
|
<th style="width: 100px">已收大小</th>
|
||||||
|
<th style="width: 80px">片段数</th>
|
||||||
|
<th style="width: 170px">最后更新</th>
|
||||||
|
<th style="width: 190px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="rows.length">
|
||||||
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td>
|
||||||
|
<span class="source-pill" :class="row.source === 'maixiang' ? 'is-maixiang' : 'is-client'">
|
||||||
|
{{ sourceLabel(row.source) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="device-name" :title="row.deviceId">{{ row.deviceName || '—' }}</span>
|
||||||
|
<span class="device-id" :title="row.deviceId">{{ row.deviceId }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.username" class="user-name">{{ row.username }}</span>
|
||||||
|
<span v-else-if="row.uid" class="user-name">UID {{ row.uid }}</span>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.logDate }}</td>
|
||||||
|
<td>{{ formatBytes(row.uploadedBytes) }}</td>
|
||||||
|
<td>{{ row.partCount ?? 0 }}</td>
|
||||||
|
<td>{{ row.lastUploadAt ? formatDateTime(row.lastUploadAt) : '—' }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button class="btn btn-sm" type="button" @click="openViewer(row)">查看</button>
|
||||||
|
<a class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(row.id)" download>下载</a>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td colspan="9" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td colspan="9" class="empty-tip">
|
||||||
|
{{ keyword || sourceFilter || startDate || endDate ? '暂无匹配日志' : '暂无日志上报(客户端/采集机上报后自动出现在这里)' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="viewerVisible" :title="viewerFile ? `${viewerFile.fileName}(${viewerFile.deviceName || viewerFile.deviceId})` : '日志内容'" width="900px" top="5vh">
|
||||||
|
<div class="viewer-toolbar">
|
||||||
|
<span class="viewer-meta">
|
||||||
|
云端已收 {{ formatBytes(viewerContent?.totalBytes ?? viewerFile?.uploadedBytes) }}
|
||||||
|
<template v-if="viewerContent"> · 当前展示 {{ formatBytes(viewerContent.shownBytes) }}</template>
|
||||||
|
<template v-if="viewerContent?.truncated"> · 更早内容未加载</template>
|
||||||
|
</span>
|
||||||
|
<span class="viewer-actions">
|
||||||
|
<button class="btn btn-sm btn-ghost" type="button" :disabled="viewerLoading" @click="loadContent(true)">刷新</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="viewerLoading || !viewerContent?.truncated || viewerMaxBytes >= TAIL_MAX"
|
||||||
|
@click="loadMore"
|
||||||
|
>加载更早</button>
|
||||||
|
<a v-if="viewerFile" class="btn btn-sm btn-ghost dl-link" :href="deviceLogDownloadUrl(viewerFile.id)" download>下载完整日志</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre ref="viewerPre" class="log-pre">{{ viewerLoading && !viewerContent ? '加载中...' : (viewerContent?.content || '(暂无内容)') }}</pre>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="configVisible" title="采集配置" width="720px">
|
||||||
|
<div class="config-block">
|
||||||
|
<h4>全局默认模式</h4>
|
||||||
|
<p class="config-desc">对未单独配置的终端生效。全量=上传日志目录内全部文件;精选=排除低价值大日志(客户端排除 pywebview;麦象排除 kk-browser / 控制台 / 测试日志)。终端在下一次上报周期(约 1 分钟内)跟随新配置。</p>
|
||||||
|
<div class="mode-switch">
|
||||||
|
<button
|
||||||
|
v-for="option in MODE_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
class="mode-btn"
|
||||||
|
:class="{ 'is-active': globalMode === option.value }"
|
||||||
|
type="button"
|
||||||
|
@click="saveGlobalMode(option.value)"
|
||||||
|
>{{ option.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-block">
|
||||||
|
<h4>终端覆盖</h4>
|
||||||
|
<div class="override-add">
|
||||||
|
<select v-model="newOverrideKey" class="override-select">
|
||||||
|
<option value="">选择终端(最近上报的设备)</option>
|
||||||
|
<option v-for="device in devices" :key="`${device.source}|${device.deviceId}`" :value="`${device.source}|${device.deviceId}`">
|
||||||
|
{{ sourceLabel(device.source) }} · {{ device.deviceName || device.deviceId }}({{ device.deviceId }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="newOverrideMode">
|
||||||
|
<option v-for="option in MODE_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm" type="button" @click="addOverride">添加/更新覆盖</button>
|
||||||
|
</div>
|
||||||
|
<table class="override-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 100px">来源</th>
|
||||||
|
<th>设备</th>
|
||||||
|
<th style="width: 80px">模式</th>
|
||||||
|
<th style="width: 160px">更新时间</th>
|
||||||
|
<th style="width: 90px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in overrides" :key="row.id">
|
||||||
|
<td>{{ sourceLabel(row.source) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="device-name">{{ row.deviceName || row.deviceId }}</span>
|
||||||
|
<span class="device-id">{{ row.deviceId }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ modeLabel(row.mode) }}</td>
|
||||||
|
<td>{{ row.updatedAt ? formatDateTime(row.updatedAt) : '—' }}</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeOverride(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!overrides.length">
|
||||||
|
<td colspan="5" class="empty-tip">{{ configLoading ? '加载中...' : '暂无终端覆盖(全部跟随全局默认)' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="configVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 沿用「记录与版本」系列的旧后台面板视觉语言。 */
|
||||||
|
.device-logs-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.logs-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.logs-head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.retention-tip {
|
||||||
|
color: #8598ab;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.logs-filter-row {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
color: #4f78a5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.logs-table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 12.5px;
|
||||||
|
border-bottom: 1px solid #dbe6f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #eaf1f7;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.source-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.source-pill.is-client {
|
||||||
|
background: #e8f1fb;
|
||||||
|
color: #37618f;
|
||||||
|
}
|
||||||
|
.source-pill.is-maixiang {
|
||||||
|
background: #eef7ec;
|
||||||
|
color: #3f7a42;
|
||||||
|
}
|
||||||
|
.device-name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.device-id {
|
||||||
|
display: block;
|
||||||
|
color: #8598ab;
|
||||||
|
font-size: 11.5px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.file-name {
|
||||||
|
display: block;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #9db0c2;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 26px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #8598ab;
|
||||||
|
}
|
||||||
|
.dl-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.viewer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.viewer-meta {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.viewer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.log-pre {
|
||||||
|
max-height: 62vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #0f1c29;
|
||||||
|
color: #d7e4f1;
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.config-block {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.config-block h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.config-desc {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #66798d;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.mode-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.mode-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 1px solid #c7d7e5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #4f78a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mode-btn.is-active {
|
||||||
|
border-color: #4f78a5;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.override-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.override-add select {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
.override-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.override-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@ import { formatDateTime } from '@/utils/datetime'
|
|||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts'
|
import { fetchSoftwareVersions, uploadSoftwareVersion, deleteSoftwareVersions } from './version-api.ts'
|
||||||
import type { SoftwareVersionItem } from './version-dto.ts'
|
import type { SoftwareVersionItem } from './version-dto.ts'
|
||||||
import { isNonEmptyVersion } from './version-dto.ts'
|
import { isNonEmptyVersion } from './version-dto.ts'
|
||||||
|
|
||||||
@@ -29,8 +29,53 @@ watch(filteredItems, () => {
|
|||||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
|
page.value = Math.min(page.value, Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
|
||||||
|
const selectedIds = ref<Set<number>>(new Set())
|
||||||
|
const selectedCount = computed(() => selectedIds.value.size)
|
||||||
|
function toggleSelect(id: number) {
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
function toggleSelectAllPage() {
|
||||||
|
const pageRows = pagedVersions.value
|
||||||
|
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (allOn) pageRows.forEach((row) => next.delete(row.id))
|
||||||
|
else pageRows.forEach((row) => next.add(row.id))
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
async function removeSelected() {
|
||||||
|
if (!selectedCount.value) return
|
||||||
|
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
|
||||||
|
const sample = rows.slice(0, 3).map((row) => row.version).join('、')
|
||||||
|
const summary = rows.length > 3 ? `${sample} 等 ${rows.length} 个` : sample
|
||||||
|
if (!window.confirm(`确认删除选中的 ${rows.length} 个版本(${summary})?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteSoftwareVersions(Array.from(selectedIds.value))
|
||||||
|
ElMessage.success(`已删除 ${rows.length} 个版本`)
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function removeOne(row: SoftwareVersionItem) {
|
||||||
|
if (!window.confirm(`确认删除版本 ${row.version}?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteSoftwareVersions([row.id])
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uploadVisible = ref(false)
|
const uploadVisible = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
const newVersion = ref('')
|
const newVersion = ref('')
|
||||||
const pickedFile = ref<File | null>(null)
|
const pickedFile = ref<File | null>(null)
|
||||||
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
|
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
|
||||||
@@ -63,6 +108,7 @@ function openUpload() {
|
|||||||
async function submitUpload() {
|
async function submitUpload() {
|
||||||
uploadMsg.value = ''
|
uploadMsg.value = ''
|
||||||
uploadMsgOk.value = false
|
uploadMsgOk.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
if (!isNonEmptyVersion(newVersion.value)) {
|
if (!isNonEmptyVersion(newVersion.value)) {
|
||||||
uploadMsg.value = '请填写版本号'
|
uploadMsg.value = '请填写版本号'
|
||||||
return
|
return
|
||||||
@@ -75,16 +121,23 @@ async function submitUpload() {
|
|||||||
uploadMsg.value = '仅支持 .zip 格式'
|
uploadMsg.value = '仅支持 .zip 格式'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (pickedFile.value.size > 512 * 1024 * 1024) {
|
||||||
|
uploadMsg.value = '文件超过允许的大小限制'
|
||||||
|
return
|
||||||
|
}
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
const created = await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name)
|
// 浏览器直传 MinIO:presign → PUT(进度条)→ confirm 落库。
|
||||||
const version = created?.version || newVersion.value.trim()
|
await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name, (p) => {
|
||||||
const link = created?.fileUrl
|
uploadPercent.value = p
|
||||||
// 对齐 admin.js:6484-6488:弹窗保持打开,成功文案(含链接)留驻可复制,仅清空输入并刷新列表。
|
uploadMsg.value = p >= 100 ? '上传完成,正在登记版本...' : `正在上传:${p}%`
|
||||||
uploadMsg.value = link ? `发布成功。版本:${version},链接:${link}` : `发布成功。版本:${version}`
|
})
|
||||||
|
// 成功仅提示“发布成功”,弹窗留驻,不再展示版本号与链接。
|
||||||
|
uploadMsg.value = '发布成功'
|
||||||
uploadMsgOk.value = true
|
uploadMsgOk.value = true
|
||||||
newVersion.value = ''
|
newVersion.value = ''
|
||||||
pickedFile.value = null
|
pickedFile.value = null
|
||||||
|
uploadPercent.value = 0
|
||||||
load()
|
load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
||||||
@@ -103,6 +156,7 @@ onMounted(load)
|
|||||||
<h3>版本列表</h3>
|
<h3>版本列表</h3>
|
||||||
<div class="version-head-tools">
|
<div class="version-head-tools">
|
||||||
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
|
<input v-model="versionKeyword" type="text" placeholder="搜索版本号" class="version-keyword" />
|
||||||
|
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
|
||||||
<button class="btn" type="button" @click="openUpload">上传新版本</button>
|
<button class="btn" type="button" @click="openUpload">上传新版本</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,32 +165,39 @@ onMounted(load)
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 120px">版本号</th>
|
<th style="width: 40px" class="th-select">
|
||||||
|
<input type="checkbox" :checked="pagedVersions.length > 0 && pagedVersions.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedVersions.length" />
|
||||||
|
</th>
|
||||||
|
<th style="width: 110px">版本号</th>
|
||||||
<th>下载链接</th>
|
<th>下载链接</th>
|
||||||
<th style="width: 160px">创建时间</th>
|
<th style="width: 150px">创建时间</th>
|
||||||
<th style="width: 120px">操作</th>
|
<th style="width: 150px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="filteredItems.length">
|
<template v-if="filteredItems.length">
|
||||||
<tr v-for="row in pagedVersions" :key="row.version + row.id">
|
<tr v-for="row in pagedVersions" :key="row.version + row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
|
||||||
|
<td class="td-select">
|
||||||
|
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
|
||||||
|
</td>
|
||||||
<td>{{ row.version }}</td>
|
<td>{{ row.version }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
|
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
|
||||||
<span v-else class="dim">—</span>
|
<span v-else class="dim">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
<td>
|
<td class="ops-cell">
|
||||||
<a v-if="row.fileUrl" class="btn btn-sm btn-secondary dl-btn" :href="row.fileUrl" download>下载</a>
|
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
|
||||||
<span v-else class="dim">—</span>
|
<span v-else class="dim">—</span>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="4" class="empty-tip">加载中...</td>
|
<td colspan="5" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="4" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
<td colspan="5" class="empty-tip">{{ versionKeyword ? '暂无匹配版本' : '暂无版本记录' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -157,6 +218,7 @@ onMounted(load)
|
|||||||
<p class="zip-hint">仅支持 .zip 格式</p>
|
<p class="zip-hint">仅支持 .zip 格式</p>
|
||||||
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
|
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
|
||||||
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -276,7 +338,7 @@ h3 {
|
|||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
.version-table-scroll > table {
|
.version-table-scroll > table {
|
||||||
min-width: 760px;
|
min-width: 840px;
|
||||||
}
|
}
|
||||||
.table-scroll th,
|
.table-scroll th,
|
||||||
.table-scroll td {
|
.table-scroll td {
|
||||||
@@ -338,6 +400,36 @@ h3 {
|
|||||||
color: #5b6f83;
|
color: #5b6f83;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-selected td {
|
||||||
|
background: #fdf0ee;
|
||||||
|
}
|
||||||
|
.th-select,
|
||||||
|
.td-select {
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 4px !important;
|
||||||
|
padding-right: 4px !important;
|
||||||
|
}
|
||||||
|
.th-select input,
|
||||||
|
.td-select input {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #b33a2e;
|
||||||
|
}
|
||||||
|
.upload-progress {
|
||||||
|
margin: 2px 0 10px;
|
||||||
|
}
|
||||||
.upload-msg {
|
.upload-msg {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatDateTime } from '@/utils/datetime'
|
||||||
|
/** 教程管理页:工具台首页「立即下载教程」的包体管理(上传新包 / 列表 / 下载 / 删除)。
|
||||||
|
* 上传走浏览器直传 MinIO(presign → PUT 带进度 → confirm 落库),与软件版本管理页同一套链路;
|
||||||
|
* 上传时可填版本号(仅展示与排序用);工具台始终下载"最新上传"的包(不随列表排序变化)。
|
||||||
|
* 列表默认按上传时间降序,「版本号」「上传时间」表头可点击切换升降序。 */
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { fetchTutorialPackages, uploadTutorialPackage, deleteTutorialPackages } from './tutorial-api.ts'
|
||||||
|
import { formatFileSize } from './tutorial-model.ts'
|
||||||
|
import type { TutorialPackageItem } from './tutorial-dto.ts'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const items = ref<TutorialPackageItem[]>([])
|
||||||
|
/** 文件名搜索(客户端过滤)。 */
|
||||||
|
const keyword = ref('')
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
const kw = (keyword.value || '').trim().toLowerCase()
|
||||||
|
if (!kw) return items.value
|
||||||
|
return items.value.filter((item) => item.fileName.toLowerCase().includes(kw))
|
||||||
|
})
|
||||||
|
/** 全站分页统一:客户端分页(10/20/50/100)。 */
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
/** 排序:默认按上传时间降序(最新上传在最前,与后端返回顺序一致);点表头切换升降序。 */
|
||||||
|
type SortKey = 'version' | 'createdAt'
|
||||||
|
const sortKey = ref<SortKey>('createdAt')
|
||||||
|
const sortAsc = ref(false)
|
||||||
|
|
||||||
|
function toggleSort(key: SortKey) {
|
||||||
|
if (sortKey.value === key) {
|
||||||
|
sortAsc.value = !sortAsc.value
|
||||||
|
} else {
|
||||||
|
sortKey.value = key
|
||||||
|
sortAsc.value = false
|
||||||
|
}
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序标记:未激活 ▲▼,激活时只留方向箭头(比 ⇅/↕ 字形支持好,避免 Windows 缺字形显示方框)。 */
|
||||||
|
function sortMark(key: SortKey): string {
|
||||||
|
if (sortKey.value !== key) return '▲▼'
|
||||||
|
return sortAsc.value ? '▲' : '▼'
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareText(a: string, b: string): number {
|
||||||
|
return a.localeCompare(b, 'zh-Hans-CN', { numeric: true, sensitivity: 'base' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedItems = computed(() => {
|
||||||
|
const rows = [...filteredItems.value]
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
// 版本号为空的历史行始终排在末尾,避免切换排序时"无版本"占满首页。
|
||||||
|
if (sortKey.value === 'version') {
|
||||||
|
const av = a.version
|
||||||
|
const bv = b.version
|
||||||
|
if (!av || !bv) {
|
||||||
|
if (!av && !bv) return 0
|
||||||
|
return av ? -1 : 1
|
||||||
|
}
|
||||||
|
const diff = compareText(av, bv)
|
||||||
|
return sortAsc.value ? diff : -diff
|
||||||
|
}
|
||||||
|
const at = a.createdAt
|
||||||
|
const bt = b.createdAt
|
||||||
|
if (!at || !bt) {
|
||||||
|
if (!at && !bt) return 0
|
||||||
|
return at ? -1 : 1
|
||||||
|
}
|
||||||
|
const diff = compareText(at, bt)
|
||||||
|
return sortAsc.value ? diff : -diff
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
const pagedItems = computed(() => sortedItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||||
|
function changePage(p: number) { page.value = p }
|
||||||
|
function changeSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||||
|
// 搜索/排序导致数据收缩时回钳页码,避免停在空页。
|
||||||
|
watch(sortedItems, () => {
|
||||||
|
page.value = Math.min(page.value, Math.max(1, Math.ceil(sortedItems.value.length / pageSize.value)))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前生效包 = 列表第一条(工具台下发的就是它)。 */
|
||||||
|
const activeId = computed(() => items.value[0]?.id ?? null)
|
||||||
|
|
||||||
|
// 勾选删除:选中跨页累计;表头复选只作用于当前页,避免误选整表。
|
||||||
|
const selectedIds = ref<Set<number>>(new Set())
|
||||||
|
const selectedCount = computed(() => selectedIds.value.size)
|
||||||
|
function toggleSelect(id: number) {
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
function toggleSelectAllPage() {
|
||||||
|
const pageRows = pagedItems.value
|
||||||
|
const allOn = pageRows.length > 0 && pageRows.every((row) => selectedIds.value.has(row.id))
|
||||||
|
const next = new Set(selectedIds.value)
|
||||||
|
if (allOn) pageRows.forEach((row) => next.delete(row.id))
|
||||||
|
else pageRows.forEach((row) => next.add(row.id))
|
||||||
|
selectedIds.value = next
|
||||||
|
}
|
||||||
|
async function removeSelected() {
|
||||||
|
if (!selectedCount.value) return
|
||||||
|
const rows = items.value.filter((row) => selectedIds.value.has(row.id))
|
||||||
|
const sample = rows.slice(0, 3).map((row) => row.fileName).join('、')
|
||||||
|
const summary = rows.length > 3 ? `${sample} 等 ${rows.length} 个` : sample
|
||||||
|
if (!window.confirm(`确认删除选中的 ${rows.length} 个教程包(${summary})?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteTutorialPackages(Array.from(selectedIds.value))
|
||||||
|
ElMessage.success(`已删除 ${rows.length} 个教程包`)
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function removeOne(row: TutorialPackageItem) {
|
||||||
|
if (!window.confirm(`确认删除教程包 ${row.fileName}?此操作不可恢复。`)) return
|
||||||
|
try {
|
||||||
|
await deleteTutorialPackages([row.id])
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
selectedIds.value = new Set([...selectedIds.value].filter((id) => id !== row.id))
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadVisible = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
|
const newVersion = ref('')
|
||||||
|
const pickedFile = ref<File | null>(null)
|
||||||
|
/** 弹窗内成功/失败文案留驻(对齐软件版本页交互)。 */
|
||||||
|
const uploadMsg = ref('')
|
||||||
|
const uploadMsgOk = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
items.value = (await fetchTutorialPackages()).items
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '教程包列表加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(file: File) {
|
||||||
|
pickedFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpload() {
|
||||||
|
uploadMsg.value = ''
|
||||||
|
uploadMsgOk.value = false
|
||||||
|
newVersion.value = ''
|
||||||
|
pickedFile.value = null
|
||||||
|
uploadVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUpload() {
|
||||||
|
uploadMsg.value = ''
|
||||||
|
uploadMsgOk.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
|
if (!pickedFile.value) {
|
||||||
|
uploadMsg.value = '请选择 zip 压缩包'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/\.zip$/i.test(pickedFile.value.name)) {
|
||||||
|
uploadMsg.value = '仅支持 .zip 格式'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pickedFile.value.size > 512 * 1024 * 1024) {
|
||||||
|
uploadMsg.value = '文件超过允许的大小限制'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
// 浏览器直传 MinIO:presign → PUT(进度条)→ confirm 落库。
|
||||||
|
await uploadTutorialPackage(pickedFile.value, pickedFile.value.name, newVersion.value.trim(), (p) => {
|
||||||
|
uploadPercent.value = p
|
||||||
|
uploadMsg.value = p >= 100 ? '上传完成,正在登记教程包...' : `正在上传:${p}%`
|
||||||
|
})
|
||||||
|
uploadMsg.value = '上传成功,工具台「立即下载教程」已切换为该包'
|
||||||
|
uploadMsgOk.value = true
|
||||||
|
newVersion.value = ''
|
||||||
|
pickedFile.value = null
|
||||||
|
uploadPercent.value = 0
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tutorial-view">
|
||||||
|
<section class="panel-box">
|
||||||
|
<div class="tutorial-head">
|
||||||
|
<h3>教程包列表</h3>
|
||||||
|
<div class="tutorial-head-tools">
|
||||||
|
<input v-model="keyword" type="text" placeholder="搜索文件名" class="tutorial-keyword" />
|
||||||
|
<button class="btn btn-danger" type="button" :disabled="!selectedCount" @click="removeSelected">删除选中{{ selectedCount ? `(${selectedCount})` : '' }}</button>
|
||||||
|
<button class="btn" type="button" @click="openUpload">上传教程包</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-scroll tutorial-table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40px" class="th-select">
|
||||||
|
<input type="checkbox" :checked="pagedItems.length > 0 && pagedItems.every((r) => selectedIds.has(r.id))" @change="toggleSelectAllPage" :disabled="!pagedItems.length" />
|
||||||
|
</th>
|
||||||
|
<th style="width: 260px">文件名</th>
|
||||||
|
<th style="width: 110px">
|
||||||
|
<button class="sort-th sort-version" type="button" @click="toggleSort('version')">版本号<span class="sort-mark">{{ sortMark('version') }}</span></button>
|
||||||
|
</th>
|
||||||
|
<th style="width: 110px">大小</th>
|
||||||
|
<th style="width: 150px">
|
||||||
|
<button class="sort-th sort-time" type="button" @click="toggleSort('createdAt')">上传时间<span class="sort-mark">{{ sortMark('createdAt') }}</span></button>
|
||||||
|
</th>
|
||||||
|
<th>下载链接</th>
|
||||||
|
<th style="width: 170px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-if="filteredItems.length">
|
||||||
|
<tr v-for="row in pagedItems" :key="row.id" :class="{ 'row-selected': selectedIds.has(row.id) }">
|
||||||
|
<td class="td-select">
|
||||||
|
<input type="checkbox" :checked="selectedIds.has(row.id)" @change="toggleSelect(row.id)" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="file-name" :title="row.fileName">{{ row.fileName }}</span>
|
||||||
|
<span v-if="row.id === activeId" class="tag-active">当前生效</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.version" class="version-cell" :title="row.version">{{ row.version }}</span>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ formatFileSize(row.fileSize) }}</td>
|
||||||
|
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||||
|
<td>
|
||||||
|
<a v-if="row.fileUrl" class="link-cell" :href="row.fileUrl" target="_blank" rel="noopener" :title="row.fileUrl">{{ row.fileUrl }}</a>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="ops-cell">
|
||||||
|
<a v-if="row.fileUrl" class="btn btn-sm dl-btn" :href="row.fileUrl" download>下载</a>
|
||||||
|
<span v-else class="dim">—</span>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" @click="removeOne(row)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else-if="loading">
|
||||||
|
<td colspan="7" class="empty-tip">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else>
|
||||||
|
<td colspan="7" class="empty-tip">{{ keyword ? '暂无匹配教程包' : '暂无教程包记录' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<OldPagination v-if="filteredItems.length > 0" :total="filteredItems.length" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="uploadVisible" title="上传教程包" width="520px">
|
||||||
|
<p class="upload-desc">上传教程 ZIP 包后,工具台首页「立即下载教程」将以下载该包为准(以最新上传的为主)。</p>
|
||||||
|
<el-form label-width="110px">
|
||||||
|
<el-form-item label="版本号">
|
||||||
|
<el-input v-model="newVersion" placeholder="例如:v2026.09 或留空" maxlength="64" />
|
||||||
|
<p class="zip-hint">仅作展示与排序用,可留空;不影响工具台按最新上传下载</p>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="ZIP 压缩包" required>
|
||||||
|
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (pickedFile = null)">
|
||||||
|
<el-button>选择文件</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<p class="zip-hint">仅支持 .zip 格式,最大 512MB</p>
|
||||||
|
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}({{ formatFileSize(pickedFile.size) }})</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-progress v-if="uploading && uploadPercent > 0" :percentage="uploadPercent" :stroke-width="10" :status="uploadPercent >= 100 ? 'success' : undefined" class="upload-progress" />
|
||||||
|
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="uploadVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="uploading" @click="submitUpload">上传教程包</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 沿用软件版本管理页(旧版 admin.html panel-version)的样式语言。 */
|
||||||
|
.tutorial-view {
|
||||||
|
font-family: inherit;
|
||||||
|
color: #24384d;
|
||||||
|
}
|
||||||
|
.panel-box {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 20px 22px 24px;
|
||||||
|
border: 1px solid #d8e3ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||||
|
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #24384d;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.tutorial-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.tutorial-head-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.tutorial-keyword {
|
||||||
|
width: 200px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
color: #24384d;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
color-scheme: light;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
.tutorial-keyword:hover {
|
||||||
|
border-color: #9fb7cd;
|
||||||
|
}
|
||||||
|
.tutorial-keyword:focus {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #5f85ad;
|
||||||
|
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #4f78a5;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||||
|
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||||
|
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||||
|
border-color: #b35f6a;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe5ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.table-scroll table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.tutorial-table-scroll > table {
|
||||||
|
min-width: 940px;
|
||||||
|
}
|
||||||
|
.table-scroll th,
|
||||||
|
.table-scroll td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px solid #e0e8ef;
|
||||||
|
vertical-align: middle;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.table-scroll th {
|
||||||
|
background: #edf4fa;
|
||||||
|
color: #4e6479;
|
||||||
|
border-bottom-color: #d5e1eb;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:hover td {
|
||||||
|
background: #f1f7fb;
|
||||||
|
}
|
||||||
|
.table-scroll tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.file-name {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
/* 表头排序:按钮铺满单元格,仅 hover 时加深字色,保持旧版表头观感。 */
|
||||||
|
.sort-th {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sort-th:hover {
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.sort-mark {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sort-th:hover .sort-mark {
|
||||||
|
color: #5f85ad;
|
||||||
|
}
|
||||||
|
.sort-version {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
.sort-time {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
.version-cell {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.tag-active {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid #4f9a72;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eaf6ef;
|
||||||
|
color: #2f7a55;
|
||||||
|
font-size: 11.5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.link-cell {
|
||||||
|
display: block;
|
||||||
|
color: #2f5d8b;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.link-cell:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.dl-btn {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
padding: 44px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.upload-desc {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.zip-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ops-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.row-selected td {
|
||||||
|
background: #fdf0ee;
|
||||||
|
}
|
||||||
|
.th-select,
|
||||||
|
.td-select {
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 4px !important;
|
||||||
|
padding-right: 4px !important;
|
||||||
|
}
|
||||||
|
.th-select input,
|
||||||
|
.td-select input {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #b33a2e;
|
||||||
|
}
|
||||||
|
.upload-progress {
|
||||||
|
margin: 2px 0 10px;
|
||||||
|
}
|
||||||
|
.upload-msg {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.upload-msg :deep(.el-alert__title) {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/** 教程包接口适配(教程管理页):列表 / 直传预签名 / 确认 / 删除。 */
|
||||||
|
import axios from 'axios'
|
||||||
|
import { http, unwrap } from '@/api/http'
|
||||||
|
import { parseTutorialPackageList, parseTutorialPackageUpload } from './tutorial-model.ts'
|
||||||
|
import type { TutorialPackageItem, TutorialPackageList } from './tutorial-dto.ts'
|
||||||
|
|
||||||
|
export const TUTORIALS_ENDPOINT = '/api/admin/tutorials'
|
||||||
|
export const TUTORIAL_UPLOAD_ENDPOINT = '/api/admin/tutorial'
|
||||||
|
export const TUTORIAL_PRESIGN_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/presign`
|
||||||
|
export const TUTORIAL_CONFIRM_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/confirm`
|
||||||
|
export const TUTORIAL_DELETE_ENDPOINT = `${TUTORIAL_UPLOAD_ENDPOINT}/delete`
|
||||||
|
|
||||||
|
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
|
||||||
|
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
|
||||||
|
|
||||||
|
export async function fetchTutorialPackages(): Promise<TutorialPackageList> {
|
||||||
|
const { data } = await http.get<unknown>(TUTORIALS_ENDPOINT)
|
||||||
|
return parseTutorialPackageList(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorialUploadTarget {
|
||||||
|
objectKey: string
|
||||||
|
uploadUrl: string
|
||||||
|
fileUrl: string
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第一步:向后端申请直传 PUT 预签名地址(登录会话签发,3 分钟有效)。 */
|
||||||
|
export async function requestTutorialPresign(fileName: string, version = ''): Promise<TutorialUploadTarget> {
|
||||||
|
const { data } = await http.post<unknown>(TUTORIAL_PRESIGN_ENDPOINT, null, { params: { file_name: fileName, version } })
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
|
||||||
|
const objectKey = typeof core.object_key === 'string' ? core.object_key : ''
|
||||||
|
if (!uploadUrl || !objectKey) {
|
||||||
|
throw new Error('后端未返回直传地址,请重试')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
objectKey,
|
||||||
|
uploadUrl,
|
||||||
|
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
|
||||||
|
version: typeof core.version === 'string' ? core.version : version.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第三步:直传完成后通知后端校验对象并写入记录(data.item 为新记录行)。 */
|
||||||
|
export async function confirmTutorialPackage(objectKey: string, fileName: string, version = ''): Promise<TutorialPackageItem | null> {
|
||||||
|
const { data } = await http.post<unknown>(TUTORIAL_CONFIRM_ENDPOINT, null, {
|
||||||
|
params: { object_key: objectKey, file_name: fileName, version },
|
||||||
|
})
|
||||||
|
return parseTutorialPackageUpload(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 id 批量删除教程包(后端会顺带清理无引用对象的 MinIO 包体);返回实际删除行数。 */
|
||||||
|
export async function deleteTutorialPackages(ids: number[]): Promise<number> {
|
||||||
|
if (!ids.length) return 0
|
||||||
|
const { data } = await http.post<unknown>(TUTORIAL_DELETE_ENDPOINT, ids)
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
return typeof core.deleted === 'number' ? core.deleted : ids.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传教程包(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
|
||||||
|
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
|
||||||
|
*/
|
||||||
|
export async function uploadTutorialPackage(
|
||||||
|
file: Blob,
|
||||||
|
fileName: string,
|
||||||
|
version = '',
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<TutorialPackageItem | null> {
|
||||||
|
const target = await requestTutorialPresign(fileName, version)
|
||||||
|
await directPut.put(target.uploadUrl, file, {
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (!onProgress) return
|
||||||
|
const total = event.total || 0
|
||||||
|
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return confirmTutorialPackage(target.objectKey, fileName, target.version)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/** 教程包 DTO(教程管理页):工具台下载入口对应的教程压缩包行类型;纯逻辑。 */
|
||||||
|
|
||||||
|
/** 教程包行(GET /api/admin/tutorials data.items,Java TutorialPackageService snake)。 */
|
||||||
|
export interface TutorialPackageItem {
|
||||||
|
id: number
|
||||||
|
fileName: string
|
||||||
|
/** 版本号(上传时填写,V126 之前的历史行为空串) */
|
||||||
|
version: string
|
||||||
|
objectKey: string
|
||||||
|
fileSize: number
|
||||||
|
fileUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorialPackageList {
|
||||||
|
items: TutorialPackageItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyTutorialPackageList(): TutorialPackageList {
|
||||||
|
return { items: [] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/** 教程包列表加载模型:解析 /api/admin/tutorials data.items(snake);纯逻辑。 */
|
||||||
|
import { unwrap } from '../../api/envelope.ts'
|
||||||
|
import { type TutorialPackageItem, type TutorialPackageList } from './tutorial-dto.ts'
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析单条教程包行;缺 id 视为无效。 */
|
||||||
|
export function toTutorialPackageItem(raw: unknown): TutorialPackageItem | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const r = raw as Record<string, unknown>
|
||||||
|
const id = numberOrNull(r.id)
|
||||||
|
if (id === null) return null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
fileName: text(r.file_name ?? r.fileName),
|
||||||
|
version: text(r.version),
|
||||||
|
objectKey: text(r.object_key ?? r.objectKey),
|
||||||
|
fileSize: numberOrNull(r.file_size ?? r.fileSize) ?? 0,
|
||||||
|
fileUrl: text(r.file_url ?? r.fileUrl),
|
||||||
|
createdAt: text(r.created_at ?? r.createdAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化教程包列表负载为前端结果;缺省回空列表。 */
|
||||||
|
export function parseTutorialPackageList(payload: unknown): TutorialPackageList {
|
||||||
|
const data = unwrap<unknown>(payload)
|
||||||
|
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : {}
|
||||||
|
const items = Array.isArray(record.items)
|
||||||
|
? record.items.map((raw) => toTutorialPackageItem(raw)).filter((item): item is TutorialPackageItem => item !== null)
|
||||||
|
: []
|
||||||
|
return { items }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传成功后返回的新记录行(data.item);无 item 回 null。 */
|
||||||
|
export function parseTutorialPackageUpload(payload: unknown): TutorialPackageItem | null {
|
||||||
|
const data = unwrap<unknown>(payload)
|
||||||
|
if (!data || typeof data !== 'object') return null
|
||||||
|
const item = (data as Record<string, unknown>).item
|
||||||
|
return item ? toTutorialPackageItem(item) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字节数展示:0=未知展示为 -,其余按 KB/MB/GB 人性化。 */
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const units = ['KB', 'MB', 'GB']
|
||||||
|
let value = bytes / 1024
|
||||||
|
let unitIndex = 0
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
@@ -1,21 +1,83 @@
|
|||||||
/** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */
|
/** 软件版本列表加载适配(任务 127):GET /api/admin/versions。 */
|
||||||
import { http } from '@/api/http'
|
import axios from 'axios'
|
||||||
|
import { http, unwrap } from '@/api/http'
|
||||||
import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts'
|
import { parseSoftwareVersionList, parseSoftwareVersionUpload } from './version-model.ts'
|
||||||
import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
|
import type { SoftwareVersionItem, SoftwareVersionList } from './version-dto.ts'
|
||||||
|
|
||||||
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
|
export const SOFTWARE_VERSIONS_ENDPOINT = '/api/admin/versions'
|
||||||
export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version'
|
export const SOFTWARE_VERSION_UPLOAD_ENDPOINT = '/api/admin/version'
|
||||||
|
export const SOFTWARE_VERSION_PRESIGN_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/presign`
|
||||||
|
export const SOFTWARE_VERSION_CONFIRM_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/confirm`
|
||||||
|
export const SOFTWARE_VERSION_DELETE_ENDPOINT = `${SOFTWARE_VERSION_UPLOAD_ENDPOINT}/delete`
|
||||||
|
|
||||||
|
/** MinIO 直传专用实例:不带 cookie、不挂 401 跳登录拦截器(预签名过期/签名失败不能误判会话过期);大 zip 给足超时。 */
|
||||||
|
const directPut = axios.create({ timeout: 600_000, withCredentials: false })
|
||||||
|
|
||||||
export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> {
|
export async function fetchSoftwareVersions(): Promise<SoftwareVersionList> {
|
||||||
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
|
const { data } = await http.get<unknown>(SOFTWARE_VERSIONS_ENDPOINT)
|
||||||
return parseSoftwareVersionList(data)
|
return parseSoftwareVersionList(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传客户端软件版本(multipart version + file);返回新版本行(含下载链接)。 */
|
export interface SoftwareVersionUploadTarget {
|
||||||
export async function uploadSoftwareVersion(version: string, file: Blob, filename = ''): Promise<SoftwareVersionItem | null> {
|
uploadUrl: string
|
||||||
const form = new FormData()
|
fileUrl: string
|
||||||
form.append('version', version)
|
version: string
|
||||||
form.append('file', file, filename || (file instanceof File ? file.name : 'version.zip'))
|
}
|
||||||
const { data } = await http.post<unknown>(SOFTWARE_VERSION_UPLOAD_ENDPOINT, form)
|
|
||||||
|
export interface SoftwareVersionUploadResult {
|
||||||
|
version: string
|
||||||
|
fileUrl: string
|
||||||
|
item: SoftwareVersionItem | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第一步:向后端申请直传 PUT 预签名地址与公开下载 URL(登录会话签发,3 分钟有效)。 */
|
||||||
|
export async function requestSoftwareVersionPresign(version: string): Promise<SoftwareVersionUploadTarget> {
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_PRESIGN_ENDPOINT, null, { params: { version } })
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
const uploadUrl = typeof core.upload_url === 'string' ? core.upload_url : ''
|
||||||
|
if (!uploadUrl) {
|
||||||
|
throw new Error('后端未返回直传地址,请重试')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uploadUrl,
|
||||||
|
fileUrl: typeof core.file_url === 'string' ? core.file_url : '',
|
||||||
|
version: typeof core.version === 'string' ? core.version : version.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第三步:直传完成后通知后端校验对象并写入版本记录(data.item 为新版本行)。 */
|
||||||
|
export async function confirmSoftwareVersion(version: string): Promise<SoftwareVersionItem | null> {
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_CONFIRM_ENDPOINT, null, { params: { version } })
|
||||||
return parseSoftwareVersionUpload(data)
|
return parseSoftwareVersionUpload(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按 id 批量删除版本(后端会顺带清理无引用对象的 MinIO 安装包);返回实际删除行数。 */
|
||||||
|
export async function deleteSoftwareVersions(ids: number[]): Promise<number> {
|
||||||
|
if (!ids.length) return 0
|
||||||
|
const { data } = await http.post<unknown>(SOFTWARE_VERSION_DELETE_ENDPOINT, ids)
|
||||||
|
const core = (unwrap(data) ?? {}) as Record<string, unknown>
|
||||||
|
return typeof core.deleted === 'number' ? core.deleted : ids.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传客户端软件版本(浏览器直传):presign 申请 → 直接 PUT 到 MinIO client 桶(不走 Java 中转)
|
||||||
|
* → confirm 校验落库。onProgress 回传 0-100 上传进度。
|
||||||
|
*/
|
||||||
|
export async function uploadSoftwareVersion(
|
||||||
|
version: string,
|
||||||
|
file: Blob,
|
||||||
|
_filename = '',
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<SoftwareVersionUploadResult> {
|
||||||
|
const target = await requestSoftwareVersionPresign(version)
|
||||||
|
await directPut.put(target.uploadUrl, file, {
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (!onProgress) return
|
||||||
|
const total = event.total || 0
|
||||||
|
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const item = await confirmSoftwareVersion(target.version)
|
||||||
|
return { version: target.version, fileUrl: item?.fileUrl || target.fileUrl, item }
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { formatDateTime } from '@/utils/datetime'
|
|||||||
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
|
* script 逻辑沿用现有 Vue 实现(编辑留空令牌=沿用);弹窗保留现有组件。 */
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey } from './shop-key-api.ts'
|
import { fetchShopKeyList, submitCreateShopKey, updateShopKey, deleteShopKey, checkShopKeyWhitelist } from './shop-key-api.ts'
|
||||||
import { emptyShopKeyForm, normalizeZiniaoToken } from './shop-key-form-model.ts'
|
import { emptyShopKeyForm, normalizeZiniaoToken, validateProxyUrl } from './shop-key-form-model.ts'
|
||||||
import type { ShopKeyItem } from './shop-dto.ts'
|
import type { ShopKeyItem } from './shop-dto.ts'
|
||||||
import { whitelistStatusMeta } from './shop-key-model.ts'
|
import { whitelistStatusMeta } from './shop-key-model.ts'
|
||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
@@ -30,6 +30,8 @@ const filteredRows = computed(() => {
|
|||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
/** 正在检测白名单的行 id,用于按钮 loading 态。 */
|
||||||
|
const checkingId = ref<number | null>(null)
|
||||||
const form = reactive(emptyShopKeyForm())
|
const form = reactive(emptyShopKeyForm())
|
||||||
|
|
||||||
function maskToken(token: string): string {
|
function maskToken(token: string): string {
|
||||||
@@ -46,6 +48,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
|
|||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
|
if (row.ipWhitelistCheckedAt) parts.push(`检测时间:${formatDateTime(row.ipWhitelistCheckedAt)}`)
|
||||||
if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage)
|
if (row.ipWhitelistMessage) parts.push(row.ipWhitelistMessage)
|
||||||
|
if (row.ipWhitelistFailCount > 0) parts.push(`连续失败 ${row.ipWhitelistFailCount} 次`)
|
||||||
if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`)
|
if (parts.length === 0) parts.push(`白名单状态:${whitelistStatusMeta(row.ipWhitelistStatus).label}`)
|
||||||
return parts.join('\n')
|
return parts.join('\n')
|
||||||
}
|
}
|
||||||
@@ -76,9 +79,30 @@ function openEdit(row: ShopKeyItem) {
|
|||||||
Object.assign(form, emptyShopKeyForm())
|
Object.assign(form, emptyShopKeyForm())
|
||||||
form.remarkName = row.remarkName
|
form.remarkName = row.remarkName
|
||||||
form.ziniaoAccountName = row.ziniaoAccountName
|
form.ziniaoAccountName = row.ziniaoAccountName
|
||||||
|
form.proxyUrl = row.proxyUrl || ''
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手动检测白名单:绕过缓存真实请求一次紫鸟,并重置自动重试次数。 */
|
||||||
|
async function checkWhitelist(row: ShopKeyItem) {
|
||||||
|
checkingId.value = row.id
|
||||||
|
try {
|
||||||
|
const updated = await checkShopKeyWhitelist(row.id)
|
||||||
|
if (updated.ipWhitelistStatus === 'ALLOWED') {
|
||||||
|
ElMessage.success('白名单检测通过')
|
||||||
|
} else if (updated.ipWhitelistStatus === 'BLOCKED') {
|
||||||
|
ElMessage.error(`白名单未放行:${updated.ipWhitelistMessage || '请求被拒绝'}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(updated.ipWhitelistMessage || '检测未通过')
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '检测失败')
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const token = normalizeZiniaoToken(form.ziniaoToken)
|
const token = normalizeZiniaoToken(form.ziniaoToken)
|
||||||
const creating = editingId.value == null
|
const creating = editingId.value == null
|
||||||
@@ -90,6 +114,11 @@ async function submit() {
|
|||||||
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
|
ElMessage.warning('请完整填写紫鸟账号名称、紫鸟令牌')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const proxyError = validateProxyUrl(form.proxyUrl)
|
||||||
|
if (proxyError) {
|
||||||
|
ElMessage.warning(proxyError)
|
||||||
|
return
|
||||||
|
}
|
||||||
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
|
// 编辑留空令牌 = 沿用原令牌(令牌不明文展示)。
|
||||||
form.ziniaoToken = creating ? token : token || originalToken.value
|
form.ziniaoToken = creating ? token : token || originalToken.value
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -169,6 +198,7 @@ onMounted(load)
|
|||||||
<th style="width: 150px">备注名</th>
|
<th style="width: 150px">备注名</th>
|
||||||
<th style="width: 160px">紫鸟账号名称</th>
|
<th style="width: 160px">紫鸟账号名称</th>
|
||||||
<th style="width: 220px">紫鸟令牌</th>
|
<th style="width: 220px">紫鸟令牌</th>
|
||||||
|
<th style="width: 190px">代理</th>
|
||||||
<th style="width: 150px">白名单状态 <span class="table-help" aria-hidden="true">?</span></th>
|
<th style="width: 150px">白名单状态 <span class="table-help" aria-hidden="true">?</span></th>
|
||||||
<th style="width: 170px">创建时间</th>
|
<th style="width: 170px">创建时间</th>
|
||||||
<th style="width: 170px">修改时间</th>
|
<th style="width: 170px">修改时间</th>
|
||||||
@@ -184,6 +214,9 @@ onMounted(load)
|
|||||||
<td>
|
<td>
|
||||||
<span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
|
<span class="mono-mask" :title="row.ziniaoToken">{{ maskToken(row.ziniaoToken) }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="mono-mask" :title="row.proxyUrl || '直连'">{{ row.proxyUrl || '直连' }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
class="wh-pill"
|
class="wh-pill"
|
||||||
@@ -201,15 +234,23 @@ onMounted(load)
|
|||||||
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
<td>{{ formatDateTime(row.updatedAt) }}</td>
|
||||||
<td class="ops-cell">
|
<td class="ops-cell">
|
||||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="checkingId === row.id"
|
||||||
|
@click="checkWhitelist(row)"
|
||||||
|
>
|
||||||
|
{{ checkingId === row.id ? '检测中…' : '检测白名单' }}
|
||||||
|
</button>
|
||||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="8" class="empty-tip">加载中...</td>
|
<td colspan="9" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="8" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
|
<td colspan="9" class="empty-tip">{{ filterKeyword ? '暂无匹配密钥' : '暂无店铺密钥' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -233,6 +274,15 @@ onMounted(load)
|
|||||||
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
|
:placeholder="editingId == null ? '粘贴紫鸟令牌(将自动去除 Bearer 前缀)' : '留空则沿用原令牌;如需更换请粘贴新令牌(将自动去除 Bearer 前缀)'"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="代理地址">
|
||||||
|
<el-input
|
||||||
|
v-model="form.proxyUrl"
|
||||||
|
placeholder="留空直连;形如 http://host:port 或 http://user:pass@host:port"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">
|
||||||
|
仅当该账号的服务器出口 IP 不在紫鸟白名单时填写:代理 IP 需已由紫鸟公司加白,留空则直连。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
@@ -348,6 +398,23 @@ h3 {
|
|||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||||
}
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #c7d7e5;
|
||||||
|
color: #4f78a5;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: #edf5fb;
|
||||||
|
border-color: #95b1cb;
|
||||||
|
color: #2f5d8b;
|
||||||
|
}
|
||||||
|
.form-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #8293a5;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
@@ -364,7 +431,7 @@ h3 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
min-width: 1000px;
|
min-width: 1200px;
|
||||||
}
|
}
|
||||||
.shop-key-table-scroll th,
|
.shop-key-table-scroll th,
|
||||||
.shop-key-table-scroll td {
|
.shop-key-table-scroll td {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import type { ShopCredential, ShopGroupOption, ShopSummary } from './shop-dto.ts
|
|||||||
import OldPagination from '@/components/OldPagination.vue'
|
import OldPagination from '@/components/OldPagination.vue'
|
||||||
|
|
||||||
const session = useAdminSessionStore()
|
const session = useAdminSessionStore()
|
||||||
|
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||||
|
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rows = ref<ShopSummary[]>([])
|
const rows = ref<ShopSummary[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -42,6 +44,11 @@ const revealedPasswordCache = ref<Record<number, string>>({})
|
|||||||
const revealedRowId = ref<number | null>(null)
|
const revealedRowId = ref<number | null>(null)
|
||||||
|
|
||||||
const groupOptions = computed(() => groups.value)
|
const groupOptions = computed(() => groups.value)
|
||||||
|
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||||
|
const lockedGroupId = computed<number | null>(() => {
|
||||||
|
if (isSuperAdmin.value) return null
|
||||||
|
return groups.value.length === 1 ? groups.value[0].id : null
|
||||||
|
})
|
||||||
|
|
||||||
function shownPassword(row: ShopSummary): string {
|
function shownPassword(row: ShopSummary): string {
|
||||||
if (revealedRowId.value !== row.id) return row.passwordMasked || '******'
|
if (revealedRowId.value !== row.id) return row.passwordMasked || '******'
|
||||||
@@ -123,13 +130,14 @@ function goJump() {
|
|||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
Object.assign(form, emptyShopManageForm())
|
Object.assign(form, emptyShopManageForm())
|
||||||
|
if (lockedGroupId.value != null) form.groupId = lockedGroupId.value
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: ShopSummary) {
|
function openEdit(row: ShopSummary) {
|
||||||
editingId.value = row.id
|
editingId.value = row.id
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
groupId: row.groupId,
|
groupId: row.groupId ?? lockedGroupId.value,
|
||||||
shopName: row.shopName,
|
shopName: row.shopName,
|
||||||
mallName: row.mallName,
|
mallName: row.mallName,
|
||||||
znUsername: row.znUsername,
|
znUsername: row.znUsername,
|
||||||
@@ -214,7 +222,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row shop-filter-row">
|
<div class="form-row shop-filter-row">
|
||||||
<div class="form-group" style="min-width: 180px">
|
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
@@ -232,7 +240,7 @@ onMounted(() => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 58px">序号</th>
|
<th style="width: 58px">序号</th>
|
||||||
<th style="width: 120px">分组</th>
|
<th v-if="isSuperAdmin" style="width: 120px">分组</th>
|
||||||
<th style="width: 150px">店铺名</th>
|
<th style="width: 150px">店铺名</th>
|
||||||
<th style="width: 130px">店铺商城名</th>
|
<th style="width: 130px">店铺商城名</th>
|
||||||
<th style="width: 130px">自动化账号</th>
|
<th style="width: 130px">自动化账号</th>
|
||||||
@@ -247,7 +255,7 @@ onMounted(() => {
|
|||||||
<template v-if="rows.length">
|
<template v-if="rows.length">
|
||||||
<tr v-for="(row, index) in rows" :key="row.id">
|
<tr v-for="(row, index) in rows" :key="row.id">
|
||||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||||
<td data-text :title="row.groupName || '—'">{{ row.groupName || '—' }}</td>
|
<td v-if="isSuperAdmin" data-text :title="row.groupName || '—'">{{ row.groupName || '—' }}</td>
|
||||||
<td :title="row.shopName">{{ row.shopName }}</td>
|
<td :title="row.shopName">{{ row.shopName }}</td>
|
||||||
<td :title="row.mallName || '—'">{{ row.mallName || '—' }}</td>
|
<td :title="row.mallName || '—'">{{ row.mallName || '—' }}</td>
|
||||||
<td :title="row.znUsername || '—'">{{ row.znUsername || '—' }}</td>
|
<td :title="row.znUsername || '—'">{{ row.znUsername || '—' }}</td>
|
||||||
@@ -273,10 +281,10 @@ onMounted(() => {
|
|||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<tr v-else-if="loading">
|
<tr v-else-if="loading">
|
||||||
<td colspan="10" class="empty-tip">加载中...</td>
|
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="10" class="empty-tip">暂无店铺</td>
|
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">暂无店铺</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -287,7 +295,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
|
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增店铺' : '编辑店铺'" width="600px">
|
||||||
<el-form label-width="120px">
|
<el-form label-width="120px">
|
||||||
<el-form-item label="所属分组" required>
|
<el-form-item label="所属分组" required>
|
||||||
<el-select filterable v-model="form.groupId" placeholder="选择分组">
|
<el-select filterable v-model="form.groupId" placeholder="选择分组" :disabled="lockedGroupId != null">
|
||||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -316,7 +324,7 @@ onMounted(() => {
|
|||||||
<el-dialog v-model="credentialVisible" title="店铺明文凭据" width="560px">
|
<el-dialog v-model="credentialVisible" title="店铺明文凭据" width="560px">
|
||||||
<el-descriptions v-if="credential" :column="1" border>
|
<el-descriptions v-if="credential" :column="1" border>
|
||||||
<el-descriptions-item label="店铺名">{{ credential.shopName }}</el-descriptions-item>
|
<el-descriptions-item label="店铺名">{{ credential.shopName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="分组">{{ credential.groupName || '—' }}</el-descriptions-item>
|
<el-descriptions-item v-if="isSuperAdmin" label="分组">{{ credential.groupName || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="店铺商城名">{{ credential.mallName || '—' }}</el-descriptions-item>
|
<el-descriptions-item label="店铺商城名">{{ credential.mallName || '—' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="账号">{{ credential.account }}</el-descriptions-item>
|
<el-descriptions-item label="账号">{{ credential.account }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="密码">
|
<el-descriptions-item label="密码">
|
||||||
|
|||||||
@@ -22,9 +22,13 @@ export interface ShopKeyItem {
|
|||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
|
/** 紫鸟令牌:敏感字段,列表展示必须掩码。 */
|
||||||
ziniaoToken: SensitiveString
|
ziniaoToken: SensitiveString
|
||||||
|
/** 静态代理地址;为空表示直连。用于出口 IP 不在紫鸟白名单的 key。 */
|
||||||
|
proxyUrl: string
|
||||||
ipWhitelistStatus: IpWhitelistStatus
|
ipWhitelistStatus: IpWhitelistStatus
|
||||||
ipWhitelistCheckedAt?: string
|
ipWhitelistCheckedAt?: string
|
||||||
ipWhitelistMessage?: string
|
ipWhitelistMessage?: string
|
||||||
|
/** 连续白名单失败次数;达上限后不再自动重试,需人工检测。 */
|
||||||
|
ipWhitelistFailCount: number
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,3 +55,13 @@ export async function updateShopKey(id: number, form: ShopKeyFormValues): Promis
|
|||||||
export async function deleteShopKey(id: number): Promise<void> {
|
export async function deleteShopKey(id: number): Promise<void> {
|
||||||
await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`)
|
await http.delete<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手动检测 IP 白名单:POST /api/admin/shop-keys/{id}/check-whitelist;返回更新后的记录。 */
|
||||||
|
export async function checkShopKeyWhitelist(id: number): Promise<ShopKeyItem> {
|
||||||
|
const { data } = await http.post<unknown>(`${SHOP_KEYS_ENDPOINT}/${id}/check-whitelist`)
|
||||||
|
const item = toShopKeyItem(unwrap<unknown>(data))
|
||||||
|
if (!item) {
|
||||||
|
throw new Error('白名单检测响应异常:未返回有效记录')
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ export function buildShopKeyEditForm(item: ShopKeyItem | null | undefined): Shop
|
|||||||
remarkName: typeof item.remarkName === 'string' ? item.remarkName : '',
|
remarkName: typeof item.remarkName === 'string' ? item.remarkName : '',
|
||||||
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
|
ziniaoAccountName: typeof item.ziniaoAccountName === 'string' ? item.ziniaoAccountName : '',
|
||||||
ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '',
|
ziniaoToken: typeof item.ziniaoToken === 'string' ? item.ziniaoToken : '',
|
||||||
|
proxyUrl: typeof item.proxyUrl === 'string' ? item.proxyUrl : '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,22 @@
|
|||||||
export const SHOP_KEY_REMARK_MAX_LEN = 128
|
export const SHOP_KEY_REMARK_MAX_LEN = 128
|
||||||
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
|
export const SHOP_KEY_ACCOUNT_MAX_LEN = 128
|
||||||
export const SHOP_KEY_TOKEN_MAX_LEN = 512
|
export const SHOP_KEY_TOKEN_MAX_LEN = 512
|
||||||
|
export const SHOP_KEY_PROXY_MAX_LEN = 255
|
||||||
|
|
||||||
export interface ShopKeyFormValues {
|
export interface ShopKeyFormValues {
|
||||||
remarkName: string
|
remarkName: string
|
||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
|
/** 紫鸟令牌:敏感输入,提交前需去除 "Bearer " 前缀与首尾空白。 */
|
||||||
ziniaoToken: string
|
ziniaoToken: string
|
||||||
|
/** 静态代理地址(http://host:port 或 http://user:pass@host:port);留空直连。 */
|
||||||
|
proxyUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopKeyFormErrors {
|
export interface ShopKeyFormErrors {
|
||||||
remarkName?: string
|
remarkName?: string
|
||||||
ziniaoAccountName?: string
|
ziniaoAccountName?: string
|
||||||
ziniaoToken?: string
|
ziniaoToken?: string
|
||||||
|
proxyUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
|
/** 发送 POST/PUT /api/admin/shop-keys 的请求体(与 Java 请求 DTO camelCase 对齐)。 */
|
||||||
@@ -23,10 +27,33 @@ export interface ShopKeyPayload {
|
|||||||
remarkName?: string
|
remarkName?: string
|
||||||
ziniaoAccountName: string
|
ziniaoAccountName: string
|
||||||
ziniaoToken: string
|
ziniaoToken: string
|
||||||
|
proxyUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyShopKeyForm(): ShopKeyFormValues {
|
export function emptyShopKeyForm(): ShopKeyFormValues {
|
||||||
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '' }
|
return { remarkName: '', ziniaoAccountName: '', ziniaoToken: '', proxyUrl: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验代理地址(镜像后端 normalizeProxyUrl):留空合法,非空须为 http(s)://host:port。 */
|
||||||
|
export function validateProxyUrl(value: string): string | null {
|
||||||
|
const proxy = (value || '').trim()
|
||||||
|
if (!proxy) return null
|
||||||
|
if (proxy.length > SHOP_KEY_PROXY_MAX_LEN) {
|
||||||
|
return `代理地址长度不能超过${SHOP_KEY_PROXY_MAX_LEN}个字符`
|
||||||
|
}
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(proxy)
|
||||||
|
} catch {
|
||||||
|
return '代理地址格式不合法,应形如 http://host:port'
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return '代理地址仅支持 http/https 协议'
|
||||||
|
}
|
||||||
|
if (!parsed.hostname || !parsed.port) {
|
||||||
|
return '代理地址缺少主机或端口,应形如 http://host:port'
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
|
/** 归一紫鸟令牌:去首尾空白并去除大小写不敏感的 "Bearer " 前缀(镜像后端 normalizeToken)。 */
|
||||||
@@ -61,6 +88,10 @@ export function validateShopKeyForm(form: ShopKeyFormValues): { valid: boolean;
|
|||||||
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
|
} else if (token.length > SHOP_KEY_TOKEN_MAX_LEN) {
|
||||||
errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符`
|
errors.ziniaoToken = `紫鸟令牌长度不能超过${SHOP_KEY_TOKEN_MAX_LEN}个字符`
|
||||||
}
|
}
|
||||||
|
const proxyError = validateProxyUrl(form.proxyUrl)
|
||||||
|
if (proxyError) {
|
||||||
|
errors.proxyUrl = proxyError
|
||||||
|
}
|
||||||
return { valid: Object.keys(errors).length === 0, errors }
|
return { valid: Object.keys(errors).length === 0, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,5 +103,7 @@ export function toShopKeyCreateRequest(form: ShopKeyFormValues): ShopKeyPayload
|
|||||||
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
|
ziniaoToken: normalizeZiniaoToken(form.ziniaoToken),
|
||||||
}
|
}
|
||||||
if (remark) payload.remarkName = remark
|
if (remark) payload.remarkName = remark
|
||||||
|
const proxy = (form.proxyUrl || '').trim()
|
||||||
|
if (proxy) payload.proxyUrl = proxy
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ export function toShopKeyItem(raw: unknown): ShopKeyItem | null {
|
|||||||
remarkName: text(record.remarkName ?? record.remark_name),
|
remarkName: text(record.remarkName ?? record.remark_name),
|
||||||
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
|
ziniaoAccountName: text(record.ziniaoAccountName ?? record.ziniao_account_name),
|
||||||
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
|
ziniaoToken: toSensitiveString(text(record.ziniaoToken ?? record.ziniao_token)),
|
||||||
|
proxyUrl: text(record.proxyUrl ?? record.proxy_url),
|
||||||
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
|
ipWhitelistStatus: normalizeIpWhitelistStatus(record.ipWhitelistStatus ?? record.ip_whitelist_status),
|
||||||
ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message),
|
ipWhitelistMessage: text(record.ipWhitelistMessage ?? record.ip_whitelist_message),
|
||||||
|
ipWhitelistFailCount: numberOrNull(record.ipWhitelistFailCount ?? record.ip_whitelist_fail_count) ?? 0,
|
||||||
}
|
}
|
||||||
const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at)
|
const checkedAt = text(record.ipWhitelistCheckedAt ?? record.ip_whitelist_checked_at)
|
||||||
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt
|
if (checkedAt) item.ipWhitelistCheckedAt = checkedAt
|
||||||
|
|||||||
@@ -588,7 +588,7 @@ onMounted(() => {
|
|||||||
<div class="dup-body">
|
<div class="dup-body">
|
||||||
<div v-for="block in shopBlocks(item)" :key="block.shopName" class="store-col">
|
<div v-for="block in shopBlocks(item)" :key="block.shopName" class="store-col">
|
||||||
<div class="st-name">{{ block.shopName }}<span class="st-count">{{ block.count }} 条</span></div>
|
<div class="st-name">{{ block.shopName }}<span class="st-count">{{ block.count }} 条</span></div>
|
||||||
<div v-if="block.groupName" class="st-grp">分组:{{ block.groupName }}</div>
|
<div v-if="isSuper && block.groupName" class="st-grp">分组:{{ block.groupName }}</div>
|
||||||
<template v-for="site in block.sites" :key="site.country">
|
<template v-for="site in block.sites" :key="site.country">
|
||||||
<div class="c-site"><span class="m-dot" :style="{ background: siteColor(site.country) }"></span>{{ asinCountryLabel(site.country) }} · {{ site.times.length }} 次</div>
|
<div class="c-site"><span class="m-dot" :style="{ background: siteColor(site.country) }"></span>{{ asinCountryLabel(site.country) }} · {{ site.times.length }} 次</div>
|
||||||
<div class="c-times"><span v-for="(time, index) in site.times" :key="index" class="c-time">{{ time }}</span></div>
|
<div class="c-times"><span v-for="(time, index) in site.times" :key="index" class="c-time">{{ time }}</span></div>
|
||||||
@@ -666,12 +666,12 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<table v-if="drawerAggRows.length" class="d-table">
|
<table v-if="drawerAggRows.length" class="d-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>店铺</th><th>分组</th><th>站点</th><th>上架时间(按时间升序)</th><th>次数</th></tr>
|
<tr><th>店铺</th><th v-if="isSuper">分组</th><th>站点</th><th>上架时间(按时间升序)</th><th>次数</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in drawerRows" :key="row.shopName + '|' + row.country">
|
<tr v-for="row in drawerRows" :key="row.shopName + '|' + row.country">
|
||||||
<td class="cell-strong">{{ row.shopName }}</td>
|
<td class="cell-strong">{{ row.shopName }}</td>
|
||||||
<td><span class="d-stgrp">{{ row.groupName || '—' }}</span></td>
|
<td v-if="isSuper"><span class="d-stgrp">{{ row.groupName || '—' }}</span></td>
|
||||||
<td><span class="site-chip" :style="{ background: siteColor(row.country) + '1F', color: siteColor(row.country) }">{{ asinCountryLabel(row.country || '') }}</span></td>
|
<td><span class="site-chip" :style="{ background: siteColor(row.country) + '1F', color: siteColor(row.country) }">{{ asinCountryLabel(row.country || '') }}</span></td>
|
||||||
<td><template v-for="(time, index) in row.times" :key="index"><span class="d-time">{{ time }}</span></template></td>
|
<td><template v-for="(time, index) in row.times" :key="index"><span class="d-time">{{ time }}</span></template></td>
|
||||||
<td class="num-cell">{{ row.count }}</td>
|
<td class="num-cell">{{ row.count }}</td>
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<div class="iv-card-info">
|
<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.username || '-' }}</span></div>
|
||||||
<div class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
|
<div v-if="session.isSuperAdmin" class="iv-info-row"><label>所属分组</label><span>{{ task.groupName || '-' }}</span></div>
|
||||||
<div class="iv-info-row"><label>任务模式</label><span>{{ task.mode || '-' }}</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><span>{{ formatDateTime(imageVideoGeneratedAt(task)) }}</span></div>
|
||||||
<div class="iv-info-row">
|
<div class="iv-info-row">
|
||||||
|
|||||||
@@ -56,6 +56,25 @@ const pagedPermissionItems = computed(() =>
|
|||||||
const busyKey = ref('')
|
const busyKey = ref('')
|
||||||
const deletingKey = ref('')
|
const deletingKey = ref('')
|
||||||
|
|
||||||
|
/** 下载进度弹窗:percent=0~100;indeterminate=true 表示后端未回 Content-Length,只显示流动条。 */
|
||||||
|
const downloadProgress = reactive({ visible: false, percent: 0, indeterminate: false, label: '' })
|
||||||
|
|
||||||
|
function startDownloadProgress(label: string) {
|
||||||
|
downloadProgress.label = label
|
||||||
|
downloadProgress.percent = 0
|
||||||
|
downloadProgress.indeterminate = false
|
||||||
|
downloadProgress.visible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDownloadProgress(percent: number) {
|
||||||
|
if (percent < 0) {
|
||||||
|
downloadProgress.indeterminate = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downloadProgress.indeterminate = false
|
||||||
|
downloadProgress.percent = percent
|
||||||
|
}
|
||||||
|
|
||||||
/** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */
|
/** 旧版状态 label 映射(对齐 admin.js 状态徽章)。 */
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -185,9 +204,11 @@ async function downloadRow(row: ShopDataResultRow) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
busyKey.value = row.resultId
|
busyKey.value = row.resultId
|
||||||
|
startDownloadProgress(`正在下载「${row.shopName || '-'}」的数据文件`)
|
||||||
try {
|
try {
|
||||||
const { blob, filename } = await fetchShopDataResultDownload(row.resultId)
|
const { blob, filename } = await fetchShopDataResultDownload(row.resultId, updateDownloadProgress)
|
||||||
saveBlob(blob, filename)
|
saveBlob(blob, filename)
|
||||||
|
downloadProgress.visible = false
|
||||||
ElMessage.success('下载已开始')
|
ElMessage.success('下载已开始')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
||||||
@@ -203,9 +224,11 @@ async function downloadBatch() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
busyKey.value = 'batch'
|
busyKey.value = 'batch'
|
||||||
|
startDownloadProgress(`正在打包下载 ${resultIds.length} 个结果文件`)
|
||||||
try {
|
try {
|
||||||
const zip = await requestShopDataZipDownload(resultIds)
|
const zip = await requestShopDataZipDownload(resultIds, updateDownloadProgress)
|
||||||
saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`)
|
saveBlob(zip.blob, `店铺数据批量下载_${resultIds.length}.zip`)
|
||||||
|
downloadProgress.visible = false
|
||||||
const note = zip.errorCount ? `,${zip.errorCount} 个失败(详见包内错误清单)` : ''
|
const note = zip.errorCount ? `,${zip.errorCount} 个失败(详见包内错误清单)` : ''
|
||||||
ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`)
|
ElMessage.success(`已下载 ${zip.fileCount ?? resultIds.length} 个文件${note}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -216,15 +239,15 @@ async function downloadBatch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeRowByRow(row: ShopDataResultRow) {
|
async function removeRowByRow(row: ShopDataResultRow) {
|
||||||
// 对齐旧版:原生 confirm。
|
// 管理端真删:删除该店这条店铺数据记录(累计档 + 文件 + 明细),前台任务记录仍保留但文件不可再下载
|
||||||
if (!window.confirm(`确认删除店铺“${row.shopName}”的任务 ${row.taskId || row.taskNo || row.resultId} 及结果文件?`)) return
|
if (!window.confirm(`确认删除店铺“${row.shopName}”这条店铺数据记录及其数据文件?此操作不可恢复。`)) return
|
||||||
deletingKey.value = row.resultId
|
deletingKey.value = row.resultId
|
||||||
try {
|
try {
|
||||||
await deleteShopDataResultHistory(row.resultId)
|
await deleteShopDataResultHistory(row.resultId)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
await load()
|
await load()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : `正在删除任务 ${row.taskId || row.taskNo || row.resultId}...`)
|
ElMessage.error(error instanceof Error ? error.message : `删除店铺“${row.shopName}”的记录失败`)
|
||||||
} finally {
|
} finally {
|
||||||
deletingKey.value = ''
|
deletingKey.value = ''
|
||||||
}
|
}
|
||||||
@@ -282,7 +305,7 @@ onMounted(load)
|
|||||||
<label>店铺</label>
|
<label>店铺</label>
|
||||||
<input v-model="filter.shopName" type="text" placeholder="模糊搜索店铺名" @keyup.enter="apply" />
|
<input v-model="filter.shopName" type="text" placeholder="模糊搜索店铺名" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="min-width: 150px">
|
<div v-if="session.isSuperAdmin" class="form-group" style="min-width: 150px">
|
||||||
<label>分组</label>
|
<label>分组</label>
|
||||||
<input v-model="filter.groupName" type="text" placeholder="模糊搜索分组名" @keyup.enter="apply" />
|
<input v-model="filter.groupName" type="text" placeholder="模糊搜索分组名" @keyup.enter="apply" />
|
||||||
</div>
|
</div>
|
||||||
@@ -338,7 +361,7 @@ onMounted(load)
|
|||||||
<span class="shop-card-task-no" :title="`任务 ${row.taskId || row.taskNo || row.resultId}`">任务 {{ row.taskId || row.taskNo || row.resultId }}</span>
|
<span class="shop-card-task-no" :title="`任务 ${row.taskId || row.taskNo || row.resultId}`">任务 {{ row.taskId || row.taskNo || row.resultId }}</span>
|
||||||
<span class="st-pill" :class="`is-${statusType(row.status)}`">{{ statusLabel(row.status) }}</span>
|
<span class="st-pill" :class="`is-${statusType(row.status)}`">{{ statusLabel(row.status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sd-card-row" v-if="row.groupName">
|
<div v-if="session.isSuperAdmin && row.groupName" class="sd-card-row">
|
||||||
<span class="sd-card-label">分组</span>
|
<span class="sd-card-label">分组</span>
|
||||||
<span class="sd-card-value">{{ row.groupName }}</span>
|
<span class="sd-card-value">{{ row.groupName }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -401,6 +424,16 @@ onMounted(load)
|
|||||||
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
|
<el-button type="primary" :loading="permissionSaving" @click="savePermission">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="downloadProgress.visible" :title="downloadProgress.label" width="440px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||||||
|
<el-progress
|
||||||
|
:percentage="downloadProgress.indeterminate ? 50 : downloadProgress.percent"
|
||||||
|
:indeterminate="downloadProgress.indeterminate"
|
||||||
|
:duration="downloadProgress.indeterminate ? 3 : undefined"
|
||||||
|
:stroke-width="14"
|
||||||
|
/>
|
||||||
|
<p class="sd-progress-hint">{{ downloadProgress.indeterminate ? '正在接收数据,大小未知,请稍候…' : `已完成 ${downloadProgress.percent}%` }}</p>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -712,6 +745,12 @@ h3 {
|
|||||||
color: #8293a5;
|
color: #8293a5;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.sd-progress-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
.empty-tip {
|
.empty-tip {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
padding: 44px 24px;
|
padding: 44px 24px;
|
||||||
|
|||||||
@@ -27,10 +27,15 @@ export interface ShopDataZipDownloadResult {
|
|||||||
errorCount?: number
|
errorCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量下载选中结果文件为 zip:POST /download-zip(blob);从响应头读成功/失败计数。 */
|
/** 批量下载选中结果文件为 zip:POST /download-zip(blob);从响应头读成功/失败计数。
|
||||||
export async function requestShopDataZipDownload(resultIds: readonly (number | string)[]): Promise<ShopDataZipDownloadResult> {
|
* onProgress: 可选下载进度回调(0-100,后端未回 Content-Length 时退化为不定进度)。 */
|
||||||
|
export async function requestShopDataZipDownload(
|
||||||
|
resultIds: readonly (number | string)[],
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<ShopDataZipDownloadResult> {
|
||||||
const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), {
|
const { data, headers } = await http.post<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/download-zip`, toShopDataZipRequest(resultIds), {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
|
||||||
})
|
})
|
||||||
const counts = downloadZipHeaderCounts(headers as Record<string, string>)
|
const counts = downloadZipHeaderCounts(headers as Record<string, string>)
|
||||||
return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount }
|
return { blob: data, fileCount: counts.fileCount, errorCount: counts.errorCount }
|
||||||
@@ -41,18 +46,34 @@ export interface ShopDataResultDownload {
|
|||||||
filename: string
|
filename: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载单个结果文件(服务端原文件名):GET /api/admin/shop-data-crawl/results/{resultId}/download。 */
|
/** 下载单店最新采集文件(按每日累计档 id):GET /api/admin/shop-data-crawl-tasks/daily-files/{id}/download。
|
||||||
export async function fetchShopDataResultDownload(resultId: number | string): Promise<ShopDataResultDownload> {
|
* onProgress: 可选下载进度回调(0-100)。 */
|
||||||
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_SINGLE_ENDPOINT}/results/${resultId}/download`, {
|
export async function fetchShopDataResultDownload(
|
||||||
|
resultId: number | string,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<ShopDataResultDownload> {
|
||||||
|
const { data, headers } = await http.get<Blob>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}/download`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
...(onProgress ? { onDownloadProgress: toAxiosProgress(onProgress) } : {}),
|
||||||
})
|
})
|
||||||
const disposition = (headers as Record<string, string> | undefined)?.['content-disposition']
|
const disposition = (headers as Record<string, string> | undefined)?.['content-disposition']
|
||||||
return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) }
|
return { blob: data, filename: shopDataFilenameFromDisposition(disposition, `shop-data-task-${resultId}.xlsx`) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除单条结果历史(仅终态):DELETE /api/admin/shop-data-crawl/history/{resultId}。 */
|
/** 将 axios 进度事件转为 0-100 百分比;响应未回 Content-Length 时报 -1,前端显示不定进度。 */
|
||||||
|
function toAxiosProgress(onProgress: (percent: number) => void) {
|
||||||
|
return (event: { loaded: number; total?: number }) => {
|
||||||
|
if (event.total) {
|
||||||
|
onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
|
||||||
|
} else {
|
||||||
|
onProgress(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除该店这条店铺数据记录(按每日累计档 id,管理端显式操作):DELETE .../daily-files/{id}。 */
|
||||||
export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> {
|
export async function deleteShopDataResultHistory(resultId: number | string): Promise<void> {
|
||||||
await http.delete<unknown>(`${SHOP_DATA_SINGLE_ENDPOINT}/history/${resultId}`)
|
await http.delete<unknown>(`${SHOP_DATA_CRAWL_TASKS_ENDPOINT}/daily-files/${resultId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 加载店铺数据任务数据范围授权用户:GET /api/admin/shop-data-crawl-task-permissions。 */
|
/** 加载店铺数据任务数据范围授权用户:GET /api/admin/shop-data-crawl-task-permissions。 */
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ export const adminPages: AdminPageDef[] = [
|
|||||||
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
|
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
|
||||||
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
||||||
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
||||||
|
{ path: 'account/user-secrets', menuKey: 'admin_user_secrets', title: '密钥管理', load: () => import('@/pages/account/UserSecretsPage.vue') },
|
||||||
|
{ path: 'account/user-secret-usage', menuKey: 'admin_user_secret_usage', title: '密钥用量统计', load: () => import('@/pages/account/UserSecretUsagePage.vue') },
|
||||||
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
|
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
|
||||||
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
|
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
|
||||||
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
|
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
|
||||||
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
|
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
|
||||||
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
|
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
|
||||||
|
{ path: 'records/tutorial', menuKey: 'admin_tutorial', title: '教程管理', load: () => import('@/pages/records/RecordsTutorialPage.vue') },
|
||||||
|
{ path: 'records/device-logs', menuKey: 'admin_device_logs', title: '日志管理', load: () => import('@/pages/records/DeviceLogsPage.vue') },
|
||||||
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
|
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
|
||||||
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
|
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
|
||||||
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ button, input, textarea, select { font: inherit; }
|
|||||||
.el-tag { border-radius: 6px; }
|
.el-tag { border-radius: 6px; }
|
||||||
.el-button { border-radius: 9px; }
|
.el-button { border-radius: 9px; }
|
||||||
.el-message-box { border-radius: 14px; }
|
.el-message-box { border-radius: 14px; }
|
||||||
|
/* 消息提示层级:Element 弹层 z-index 自 2000 递增,低于页面自定义 modal-mask(3000/3100);
|
||||||
|
遮罩弹窗内触发的 ElMessage 警告(如去重台账导入/编辑未选分组)会被浮层挡住看不见,
|
||||||
|
统一把消息提示抬到 4000(高于全部自定义浮层),!important 覆盖实例内联层级。
|
||||||
|
消息盒确认弹窗同类隐患一并抬到 4100(z-index 落在 .el-overlay.is-message-box 内联样式)。 */
|
||||||
|
.el-message { z-index: 4000 !important; }
|
||||||
|
.el-overlay.is-message-box { z-index: 4100 !important; }
|
||||||
|
|
||||||
/* 分组筛选下拉(可模糊搜索):对齐旧版控件观感(42px/9px/浅底/focus ring)。 */
|
/* 分组筛选下拉(可模糊搜索):对齐旧版控件观感(42px/9px/浅底/focus ring)。 */
|
||||||
.el-select.admin-filter {
|
.el-select.admin-filter {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AdminUser {
|
|||||||
createdById?: number | null
|
createdById?: number | null
|
||||||
creatorUsername?: string
|
creatorUsername?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
pinyinAbbr?: string
|
pinyinAbbr?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
|
// 回归(生产:用户菜单权限“自己没掉”):编辑用户弹窗打开时先把 columnIds 清空,再异步回填;
|
||||||
|
// 若授权尚未加载完成(或加载失败)就点保存,会把空数组提交成“清空授权”,整树权限被抹掉。
|
||||||
|
// 修复:授权未就绪时禁用保存并明确提示。
|
||||||
|
|
||||||
|
test('edit_user_dialog_blocks_save_until_auth_loaded', () => {
|
||||||
|
const dialog = readSource('src/pages/account/EditUserDialog.vue')
|
||||||
|
|
||||||
|
assert.match(dialog, /authReady/, '存在授权就绪标记')
|
||||||
|
assert.match(dialog, /authReady\.value = false/, '打开/失败时标记为未就绪')
|
||||||
|
|
||||||
|
// 加载失败分支必须保持未就绪:不能回退成“可以保存空授权”
|
||||||
|
const refreshBlock = dialog.slice(
|
||||||
|
dialog.indexOf('async function refreshAuth'),
|
||||||
|
dialog.indexOf('function close'),
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
refreshBlock,
|
||||||
|
/catch \(error\) \{[\s\S]*?authReady\.value = false/,
|
||||||
|
'授权加载失败保持未就绪,不用空数组覆盖服务端授权',
|
||||||
|
)
|
||||||
|
assert.match(refreshBlock, /authReady\.value = true/, '仅在加载成功后置为就绪')
|
||||||
|
|
||||||
|
// save() 自身兜底拦截,不依赖按钮禁用
|
||||||
|
const saveBlock = dialog.slice(dialog.indexOf('async function save'), dialog.indexOf('async function save') + 400)
|
||||||
|
assert.match(saveBlock, /if \(!authReady\.value\)/, '未就绪时直接拦截保存')
|
||||||
|
|
||||||
|
// 保存按钮在未就绪时禁用
|
||||||
|
assert.match(dialog, /:disabled="!authReady"/, '未就绪时保存按钮禁用')
|
||||||
|
assert.match(dialog, /加载中/, '加载中有提示')
|
||||||
|
assert.match(dialog, /保存已禁用/, '加载失败有明确提示')
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组维度仅超管可见:非超管(管理员/普通用户)数据已由后端裁剪到本人分组,
|
||||||
|
* 后台各页的分组筛选与分组列一律不展示,只有超管需要按分组筛选/区分。
|
||||||
|
* 参考实现见撞款检测页(DuplicateCheckPage.vue 分组筛选用 isSuper 门控)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 页面 → 分组筛选门控片段(正则不做宽松匹配,确保门控真实存在)。 */
|
||||||
|
const FILTER_GATES: Array<[string, RegExp]> = [
|
||||||
|
['src/pages/asin/AsinInvalidPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/asin/QueryAsinPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/asin/SkipPricePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/asin/DedupeRegistryPage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/shop/ShopManagePage.vue', /v-if="isSuperAdmin"[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
['src/pages/tasks/ShopDataTasksPage.vue', /v-if="session\.isSuperAdmin"[\s\S]{0,160}模糊搜索分组名/],
|
||||||
|
['src/pages/account/UserSecretsPage.vue', /v-if="isSuperAdmin[\s\S]{0,160}<label>分组<\/label>/],
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 页面 → 分组列表头门控片段。 */
|
||||||
|
const COLUMN_GATES: Array<[string, RegExp]> = [
|
||||||
|
['src/pages/asin/AsinInvalidPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/asin/QueryAsinPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/asin/SkipPricePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/asin/DedupeRegistryPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/shop/ShopManagePage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/tasks/DuplicateCheckPage.vue', /<th v-if="isSuper"[^>]*>分组<\/th>/],
|
||||||
|
['src/pages/account/UserSecretsPage.vue', /<th v-if="isSuperAdmin"[^>]*>分组<\/th>/],
|
||||||
|
]
|
||||||
|
|
||||||
|
test('align_group_visibility_filter_gated_by_super_admin', () => {
|
||||||
|
for (const [file, pattern] of FILTER_GATES) {
|
||||||
|
assert.match(readSource(file), pattern, `${file} 分组筛选未按超管门控`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_group_visibility_column_gated_by_super_admin', () => {
|
||||||
|
for (const [file, pattern] of COLUMN_GATES) {
|
||||||
|
assert.match(readSource(file), pattern, `${file} 分组列未按超管门控`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_group_visibility_no_ungated_group_column_header', () => {
|
||||||
|
// 不变量:以上页面的“分组”数据列表头必须带 v-if 门控,防止后续改动误放开。
|
||||||
|
// DuplicateCheckPage 的分组说明抽屉内另有一张分组统计表,该抽屉由已门控的按钮打开,
|
||||||
|
// 非超管无法触达,故不纳入本不变量(单独在 detail_cards 用例中校验按钮门控)。
|
||||||
|
const ungated = /<th(?![^>]*v-if)[^>]*>分组<\/th>/
|
||||||
|
for (const [file] of COLUMN_GATES) {
|
||||||
|
if (file.endsWith('DuplicateCheckPage.vue')) continue
|
||||||
|
assert.doesNotMatch(readSource(file), ungated, `${file} 存在未门控的分组列表头`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_group_visibility_detail_cards_gated', () => {
|
||||||
|
// 明细卡片/信息行里的分组展示同样只给超管。
|
||||||
|
assert.match(
|
||||||
|
readSource('src/pages/tasks/ImageVideoTasksPage.vue'),
|
||||||
|
/v-if="session\.isSuperAdmin"[\s\S]{0,120}所属分组/,
|
||||||
|
'图片视频任务页「所属分组」未门控',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
readSource('src/pages/tasks/ShopDataTasksPage.vue'),
|
||||||
|
/v-if="session\.isSuperAdmin && row\.groupName"/,
|
||||||
|
'店铺数据记录页卡片分组行未门控',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
readSource('src/pages/tasks/DuplicateCheckPage.vue'),
|
||||||
|
/v-if="isSuper && block\.groupName"/,
|
||||||
|
'撞款检测页卡片分组标签未门控',
|
||||||
|
)
|
||||||
|
// 分组说明抽屉整体为分组维度内容:入口按钮与分组范围切换条均已按超管门控。
|
||||||
|
const dup = readSource('src/pages/tasks/DuplicateCheckPage.vue')
|
||||||
|
assert.match(dup, /<button v-if="isSuper"[^>]*@click="groupInfoVisible = true"/, '分组说明入口未门控')
|
||||||
|
assert.match(dup, /<div v-if="isSuper" class="scope-bar"/, '分组撞款范围条未门控')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_group_visibility_dialogs_keep_locked_selector', () => {
|
||||||
|
// 弹窗分组选择保留展示但非超管锁定(单分组时),不隐藏。
|
||||||
|
const lockedPages = [
|
||||||
|
'src/pages/asin/QueryAsinPage.vue',
|
||||||
|
'src/pages/asin/SkipPricePage.vue',
|
||||||
|
'src/pages/asin/DedupeRegistryPage.vue',
|
||||||
|
'src/pages/shop/ShopManagePage.vue',
|
||||||
|
]
|
||||||
|
for (const file of lockedPages) {
|
||||||
|
const src = readSource(file)
|
||||||
|
assert.match(src, /lockedGroupId/, `${file} 缺少弹窗分组锁定计算`)
|
||||||
|
assert.match(src, /:disabled="lockedGroupId != null"/, `${file} 弹窗分组未锁定`)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
|
// 维护修复:页面自定义 modal-mask(z-index:3000) / 导出等待遮罩(3100) 高于 Element 默认
|
||||||
|
// 弹层计数(自 2000 递增),遮罩弹窗内触发的 ElMessage 警告会被浮层挡住看不见——
|
||||||
|
// 已在整站层面把消息提示抬到 4000、消息盒确认弹窗抬到 4100,统一高于全部自定义浮层。
|
||||||
|
|
||||||
|
test('el_message_above_custom_overlays', () => {
|
||||||
|
const css = readSource('src/styles/main.css')
|
||||||
|
assert.match(
|
||||||
|
css,
|
||||||
|
/\.el-message\s*\{[^}]*z-index:\s*4000\s*!important/,
|
||||||
|
'消息提示需抬升到 4000 且 !important(覆盖 Element 实例内联层级)',
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
css,
|
||||||
|
/\.el-overlay\.is-message-box\s*\{[^}]*z-index:\s*4100\s*!important/,
|
||||||
|
'消息盒确认弹窗需抬升到 4100(z-index 落在 .el-overlay.is-message-box 内联样式)',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dedupe_overlay_and_warning_kept', () => {
|
||||||
|
const page = readSource('src/pages/asin/DedupeRegistryPage.vue')
|
||||||
|
assert.match(page, /\.modal-mask\s*\{[^}]*z-index:\s*3000/, '页内遮罩仍为 3000,消息 4000 需保持在其上')
|
||||||
|
assert.match(page, /\.dedupe-export-wait-mask\s*\{[^}]*z-index:\s*3100/, '导出等待遮罩 3100 同样低于消息 4000')
|
||||||
|
assert.match(page, /请选择数据权限分组|请选择分组/, '导入/编辑未选分组需保留警告岔口')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('records_preview_overlay_below_message', () => {
|
||||||
|
const page = readSource('src/pages/records/RecordsHistoryPage.vue')
|
||||||
|
assert.match(page, /\.modal-mask\s*\{[^}]*z-index:\s*3000/, '记录预览遮罩 3000 同样低于消息 4000')
|
||||||
|
})
|
||||||
|
|
||||||
@@ -82,7 +82,9 @@ test('align_query_asin_page_layout_wiring', () => {
|
|||||||
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
||||||
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
||||||
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
||||||
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
|
// 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。
|
||||||
|
assert.match(page, /创建时间/, '表格含创建时间列')
|
||||||
|
assert.match(page, /更新时间/, '表格含更新时间列')
|
||||||
assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型')
|
assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型')
|
||||||
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
||||||
assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐')
|
assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐')
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ test('align_skip_price_page_layout_wiring', () => {
|
|||||||
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮')
|
||||||
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)')
|
||||||
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
assert.match(page, /CopyText/, 'ASIN 单元格点击复制')
|
||||||
assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)')
|
// 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。
|
||||||
|
assert.match(page, /创建时间/, '表格含创建时间列')
|
||||||
|
assert.match(page, /更新时间/, '表格含更新时间列')
|
||||||
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
|
assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型')
|
||||||
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐')
|
||||||
assert.match(page, /最低价格式不正确/, '最低价格式校验对齐')
|
assert.match(page, /最低价格式不正确/, '最低价格式校验对齐')
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ test('align_software_version_page_wiring', () => {
|
|||||||
// 下载按钮:<a download> 强制下载(对齐 admin.js:6301)。
|
// 下载按钮:<a download> 强制下载(对齐 admin.js:6301)。
|
||||||
assert.match(page, /download/, '下载链接带 download 强制下载')
|
assert.match(page, /download/, '下载链接带 download 强制下载')
|
||||||
assert.match(page, /download\s*>\s*下载\s*<\/a>/, '操作列下载为强制下载链接')
|
assert.match(page, /download\s*>\s*下载\s*<\/a>/, '操作列下载为强制下载链接')
|
||||||
// 上传成功:弹窗保持打开,成功文案留驻可复制(对齐 admin.js:6484-6488)。
|
// 上传成功:弹窗保持打开,成功提示为“发布成功”(对齐新版精简提示)。
|
||||||
assert.match(page, /发布成功。版本:/, '成功文案留驻弹窗')
|
assert.match(page, /发布成功/, '成功提示留驻弹窗')
|
||||||
assert.match(page, /uploadMsg/, '成功/失败文案在弹窗内展示')
|
assert.match(page, /uploadMsg/, '成功/失败文案在弹窗内展示')
|
||||||
// 上传弹窗文案(对齐 admin.html:6094-6110)。
|
// 上传弹窗文案(对齐 admin.html:6094-6110)。
|
||||||
assert.match(page, /上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。/, '弹窗描述对齐')
|
assert.match(page, /上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。/, '弹窗描述对齐')
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import { adminPages } from '../src/router/routes.ts'
|
||||||
|
|
||||||
|
/** 教程管理页(工具台「立即下载教程」的后台入口):上传走 presign 直传、列表取最新、下载/删除留驻反馈。 */
|
||||||
|
|
||||||
|
test('align_tutorial_page_registered', () => {
|
||||||
|
const page = adminPages.find((p) => p.path === 'records/tutorial')
|
||||||
|
assert.ok(page, '路由注册表应包含 records/tutorial')
|
||||||
|
assert.equal(page.menuKey, 'admin_tutorial')
|
||||||
|
assert.equal(page.title, '教程管理')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_tutorial_page_wiring', () => {
|
||||||
|
const page = readSource('src/pages/records/RecordsTutorialPage.vue')
|
||||||
|
// 上传入口:版本号(可空)→ 选择 zip → 直传(进度条)→ 成功提示留驻弹窗。
|
||||||
|
assert.match(page, /上传教程包/, '存在上传入口按钮')
|
||||||
|
assert.match(page, /uploadTutorialPackage/, '上传走教程包直传链路')
|
||||||
|
assert.match(page, /label="版本号"/, '上传弹窗提供版本号输入')
|
||||||
|
assert.match(page, /newVersion\.value\.trim\(\)/, '版本号去空白后随上传提交')
|
||||||
|
assert.match(page, /请选择 zip 压缩包/, '未选文件时提示')
|
||||||
|
assert.match(page, /仅支持 \.zip 格式/, '格式校验提示')
|
||||||
|
assert.match(page, /上传成功,工具台「立即下载教程」已切换为该包/, '成功提示说明生效位置')
|
||||||
|
assert.match(page, /上传完成,正在登记教程包/, '进度文案')
|
||||||
|
// 列表:版本号列 + 上传时间列 + 当前生效标记 + 下载 + 删除 + 空态。
|
||||||
|
assert.match(page, /当前生效/, '最新上传的包标记当前生效')
|
||||||
|
assert.match(page, /formatFileSize/, '展示包体大小')
|
||||||
|
assert.match(page, /row\.version/, '展示版本号列')
|
||||||
|
assert.match(page, /toggleSort\('version'\)/, '版本号表头可点击排序')
|
||||||
|
assert.match(page, /toggleSort\('createdAt'\)/, '上传时间表头可点击排序')
|
||||||
|
assert.match(page, /const sortKey = ref<SortKey>\('createdAt'\)/, '默认排序字段为上传时间')
|
||||||
|
assert.match(page, /const sortAsc = ref\(false\)/, '默认降序')
|
||||||
|
assert.match(page, /下载/, '操作列提供下载')
|
||||||
|
assert.match(page, /<a v-if="row\.fileUrl" class="btn btn-sm dl-btn"[^>]*download/, '下载链接带 download 强制下载')
|
||||||
|
assert.match(page, /确认删除选中的/, '批量删除二次确认')
|
||||||
|
assert.match(page, /暂无教程包记录/, '空态文案')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_tutorial_api_contract', () => {
|
||||||
|
const api = readSource('src/pages/records/tutorial-api.ts')
|
||||||
|
assert.match(api, /\/api\/admin\/tutorials/, '列表端点')
|
||||||
|
assert.match(api, /TUTORIAL_UPLOAD_ENDPOINT = '\/api\/admin\/tutorial'/, '上传端点前缀')
|
||||||
|
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/presign/, '直传预签名端点')
|
||||||
|
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/confirm/, '确认端点')
|
||||||
|
assert.match(api, /\$\{TUTORIAL_UPLOAD_ENDPOINT\}\/delete/, '删除端点')
|
||||||
|
assert.match(api, /params: \{ file_name: fileName, version \}/, '预签名回传文件名与版本号')
|
||||||
|
assert.match(api, /params: \{ object_key: objectKey, file_name: fileName, version \}/, '确认回传对象 key、文件名与版本号')
|
||||||
|
assert.match(api, /withCredentials: false/, '直传实例不挂会话拦截器')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_tutorial_model_parsers', () => {
|
||||||
|
const model = readSource('src/pages/records/tutorial-model.ts')
|
||||||
|
assert.match(model, /r\.file_name \?\? r\.fileName/, '兼容 snake/camel 文件名')
|
||||||
|
assert.match(model, /r\.file_url \?\? r\.fileUrl/, '兼容 snake/camel 下载链接')
|
||||||
|
assert.match(model, /r\.file_size \?\? r\.fileSize/, '兼容 snake/camel 文件大小')
|
||||||
|
assert.match(model, /version: text\(r\.version\)/, '解析版本号(缺失为空串,兼容历史行)')
|
||||||
|
})
|
||||||
@@ -36,3 +36,15 @@ test('align_user_menu_auth_both_types_fetch_and_submit_source', () => {
|
|||||||
const auth = readSource('src/pages/account/user-menu-auth.ts')
|
const auth = readSource('src/pages/account/user-menu-auth.ts')
|
||||||
assert.match(auth, /type: string/, '授权节点携带 type')
|
assert.match(auth, /type: string/, '授权节点携带 type')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('align_user_menu_auth_echo_waits_tree_render', () => {
|
||||||
|
// 验收反馈:编辑用户时已授权菜单无回显——load() 在 data 赋值后同步 setCheckedKeys,
|
||||||
|
// el-tree 的 store 异步同步 props.data,勾选作用在空 store 上丢失。
|
||||||
|
// 修复:data 就绪后先 await nextTick() 再 applyChecked()。
|
||||||
|
const tree = readSource('src/pages/account/UserMenuAuthTree.vue')
|
||||||
|
const loadBlock = tree.slice(tree.indexOf('async function load'), tree.indexOf('catch {', tree.indexOf('async function load')))
|
||||||
|
assert.match(loadBlock, /await nextTick\(\).*applyChecked\(\)/s, '树数据就绪后等渲染完成再应用已勾选 id')
|
||||||
|
assert.ok(loadBlock.indexOf('await nextTick') < loadBlock.indexOf('applyChecked()'), 'nextTick 先于 applyChecked')
|
||||||
|
assert.match(tree, /watch\(/, 'checked 变化时仍兜底回显')
|
||||||
|
assert.match(tree, /if \(loaded\.value\) applyChecked\(\)/, '兜底回显仅在树加载完成后')
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import {
|
||||||
|
buildMenuOptionTree,
|
||||||
|
compactDirectGrantIds,
|
||||||
|
parseMenuOptionList,
|
||||||
|
parsePermissionMenuItem,
|
||||||
|
} from '../src/pages/account/user-menu-auth.ts'
|
||||||
|
|
||||||
|
// 2026-09-16 线上事故:非超管(普通管理员)在授权树里勾到自己无权授予的菜单后,
|
||||||
|
// 后端 ensureGrantable 抛 403 并回滚整笔事务——创建用户与保存权限双双失败,
|
||||||
|
// 前端只显示「普通管理员只能分配自己已有的菜单权限」。
|
||||||
|
// 修复:菜单列表按操作者标记 grantable,前端把不可授予的节点置灰不可勾。
|
||||||
|
//
|
||||||
|
// 注意:这里是「置灰」而非「隐藏」。授权保存是整树替换,隐藏会让超管早先授予、
|
||||||
|
// 而操作者自己没有的菜单在提交时被当作取消勾选删掉(与 09-13「权限自己没掉」同类)。
|
||||||
|
|
||||||
|
test('align_user_menu_grantable_false_maps_to_disabled_node', () => {
|
||||||
|
const locked = parsePermissionMenuItem(
|
||||||
|
{ id: 5, name: '查询ASIN', parent_id: null, sort_order: 1, grantable: false },
|
||||||
|
'admin',
|
||||||
|
)
|
||||||
|
assert.equal(locked?.disabled, true, 'grantable=false → 节点置灰')
|
||||||
|
|
||||||
|
const allowed = parsePermissionMenuItem(
|
||||||
|
{ id: 6, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
|
||||||
|
'admin',
|
||||||
|
)
|
||||||
|
assert.equal(allowed?.disabled, false, 'grantable=true → 可勾选')
|
||||||
|
|
||||||
|
// 菜单管理页等未标记 grantable 的接口必须保持旧行为(全部可勾选)
|
||||||
|
const unmarked = parsePermissionMenuItem({ id: 7, name: '菜单权限配置', parent_id: null, sort_order: 3 }, 'admin')
|
||||||
|
assert.equal(unmarked?.disabled, false, '缺省 grantable 视为可授予')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_user_menu_grantable_survives_tree_build', () => {
|
||||||
|
const nodes = parseMenuOptionList(
|
||||||
|
[
|
||||||
|
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
|
||||||
|
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
|
||||||
|
{ id: 100, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
|
||||||
|
],
|
||||||
|
'admin',
|
||||||
|
)
|
||||||
|
const tree = buildMenuOptionTree(nodes)
|
||||||
|
const account = tree.find((node) => node.id === 98)
|
||||||
|
assert.equal(account?.disabled, true, '分组节点置灰')
|
||||||
|
assert.equal(account?.children?.[0]?.disabled, true, '子节点置灰随树保留')
|
||||||
|
assert.equal(tree.find((node) => node.id === 100)?.disabled, false, '可授予节点不受影响')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_user_menu_grantable_disabled_node_still_compactable', () => {
|
||||||
|
// 已持有但无权授予的节点会保持勾选并原样提交,压缩逻辑不能因 disabled 漏掉它
|
||||||
|
const tree = buildMenuOptionTree(
|
||||||
|
parseMenuOptionList(
|
||||||
|
[
|
||||||
|
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
|
||||||
|
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
|
||||||
|
],
|
||||||
|
'admin',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert.deepEqual(compactDirectGrantIds([98, 7], tree), [98], '父级已勾选时仍压缩掉后代')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('align_user_menu_grantable_wired_end_to_end', () => {
|
||||||
|
const tree = readSource('src/pages/account/UserMenuAuthTree.vue')
|
||||||
|
assert.match(tree, /disabled: 'disabled'/, 'el-tree 按 disabled 键置灰节点')
|
||||||
|
|
||||||
|
const vo = readSource(
|
||||||
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java',
|
||||||
|
)
|
||||||
|
assert.match(vo, /private Boolean grantable;/, 'VO 暴露 grantable')
|
||||||
|
|
||||||
|
const controller = readSource(
|
||||||
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java',
|
||||||
|
)
|
||||||
|
assert.match(controller, /permissionMenuService\.list\(requireAdmin\(request\), menuType\)/, '列表接口传入操作者')
|
||||||
|
|
||||||
|
const service = readSource(
|
||||||
|
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java',
|
||||||
|
)
|
||||||
|
assert.match(service, /resolveGrantableMenuIds/, '按操作者计算可授予集')
|
||||||
|
assert.match(service, /ensureGrantable\(operator, grantIds, userId\)/, '保存校验传入目标用户以放行既有授权')
|
||||||
|
})
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
formatNotificationTime,
|
||||||
|
formatUnreadBadge,
|
||||||
|
groupNotificationsByDay,
|
||||||
|
hasNewNotification,
|
||||||
|
readLastNotifiedId,
|
||||||
|
writeLastNotifiedId,
|
||||||
|
} from '../src/layout/notification-bell-model.ts'
|
||||||
|
|
||||||
|
function createStorage() {
|
||||||
|
const store = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null),
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
store.set(key, String(value))
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
store.delete(key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWindow() {
|
||||||
|
const localStorage = createStorage()
|
||||||
|
const globalScope = globalThis as Record<string, unknown>
|
||||||
|
const previous = globalScope.window
|
||||||
|
globalScope.window = { localStorage }
|
||||||
|
return {
|
||||||
|
localStorage,
|
||||||
|
restore: () => {
|
||||||
|
globalScope.window = previous
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('test_未读徽标:0 与非法值不显示、超过 99 显示 99+', () => {
|
||||||
|
assert.equal(formatUnreadBadge(0), '')
|
||||||
|
assert.equal(formatUnreadBadge(-3), '')
|
||||||
|
assert.equal(formatUnreadBadge(null), '')
|
||||||
|
assert.equal(formatUnreadBadge(undefined), '')
|
||||||
|
assert.equal(formatUnreadBadge(Number.NaN), '')
|
||||||
|
assert.equal(formatUnreadBadge(1), '1')
|
||||||
|
assert.equal(formatUnreadBadge(99), '99')
|
||||||
|
assert.equal(formatUnreadBadge(100), '99+')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_新通知判定:latestId 需大于已提醒 id', () => {
|
||||||
|
assert.equal(hasNewNotification(0, 0), false)
|
||||||
|
assert.equal(hasNewNotification(null, 5), false)
|
||||||
|
assert.equal(hasNewNotification(5, 5), false)
|
||||||
|
assert.equal(hasNewNotification(4, 5), false)
|
||||||
|
assert.equal(hasNewNotification(6, 5), true)
|
||||||
|
assert.equal(hasNewNotification(6, 0), true)
|
||||||
|
assert.equal(hasNewNotification(6, null), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_已提醒 id 按管理员读写并容错', () => {
|
||||||
|
setupWindow()
|
||||||
|
assert.equal(readLastNotifiedId(7), 0, '未写入时按 0 处理')
|
||||||
|
|
||||||
|
writeLastNotifiedId(7, 88)
|
||||||
|
assert.equal(readLastNotifiedId(7), 88)
|
||||||
|
assert.equal(readLastNotifiedId(8), 0, '不同管理员互不影响')
|
||||||
|
|
||||||
|
writeLastNotifiedId(7, 0)
|
||||||
|
assert.equal(readLastNotifiedId(7), 88, '非法 id 不覆盖已有值')
|
||||||
|
|
||||||
|
window.localStorage.setItem('admin-notification:last-notified-id:7', 'broken')
|
||||||
|
assert.equal(readLastNotifiedId(7), 0, '损坏值按 0 处理')
|
||||||
|
|
||||||
|
assert.equal(readLastNotifiedId(null), readLastNotifiedId('0'), '空 uid 归一为 0 号键')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_时间展示:今天/昨天/更早', () => {
|
||||||
|
const now = new Date(2026, 8, 13, 15, 30)
|
||||||
|
assert.equal(formatNotificationTime(new Date(2026, 8, 13, 9, 5).toISOString(), now), '09:05')
|
||||||
|
assert.equal(formatNotificationTime(new Date(2026, 8, 12, 23, 59).toISOString(), now), '昨天 23:59')
|
||||||
|
assert.equal(formatNotificationTime(new Date(2026, 8, 1, 8, 0).toISOString(), now), '09-01 08:00')
|
||||||
|
assert.equal(formatNotificationTime(null, now), '')
|
||||||
|
assert.equal(formatNotificationTime('not-a-date', now), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_按年月日分组:今天/昨天/更早,组内保持原顺序', () => {
|
||||||
|
const now = new Date(2026, 8, 13, 15, 30)
|
||||||
|
const items = [
|
||||||
|
{ id: 4, createdAt: new Date(2026, 8, 13, 15, 0).toISOString() },
|
||||||
|
{ id: 3, createdAt: new Date(2026, 8, 13, 9, 0).toISOString() },
|
||||||
|
{ id: 2, createdAt: new Date(2026, 8, 12, 23, 0).toISOString() },
|
||||||
|
{ id: 1, createdAt: new Date(2026, 8, 1, 8, 0).toISOString() },
|
||||||
|
]
|
||||||
|
|
||||||
|
const groups = groupNotificationsByDay(items, now)
|
||||||
|
|
||||||
|
assert.equal(groups.length, 3)
|
||||||
|
assert.deepEqual(
|
||||||
|
groups.map((group) => [group.day, group.label]),
|
||||||
|
[
|
||||||
|
['2026-09-13', '今天'],
|
||||||
|
['2026-09-12', '昨天'],
|
||||||
|
['2026-09-01', '2026年9月1日'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
groups[0].items.map((item) => item.id),
|
||||||
|
[4, 3],
|
||||||
|
'同一天的多条合为一组且保持倒序',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_按年月日分组:时间缺失/非法归入未知时间组', () => {
|
||||||
|
const now = new Date(2026, 8, 13, 15, 30)
|
||||||
|
const groups = groupNotificationsByDay(
|
||||||
|
[
|
||||||
|
{ id: 2, createdAt: null },
|
||||||
|
{ id: 1, createdAt: 'not-a-date' },
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(groups.length, 1)
|
||||||
|
assert.equal(groups[0].day, '')
|
||||||
|
assert.equal(groups[0].label, '未知时间')
|
||||||
|
assert.deepEqual(
|
||||||
|
groups[0].items.map((item) => item.id),
|
||||||
|
[2, 1],
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import { isForbidden, isKicked, isUnauthorized } from '../src/api/envelope.ts'
|
||||||
|
|
||||||
|
test('单设备登录:4011 只认业务码,不被 401/403 判定吞掉', () => {
|
||||||
|
assert.equal(isKicked({ code: 4011 }), true)
|
||||||
|
assert.equal(isKicked({ code: 401 }), false)
|
||||||
|
assert.equal(isKicked({ status: 4011 }), false)
|
||||||
|
assert.equal(isKicked(null), false)
|
||||||
|
assert.equal(isKicked('4011'), false)
|
||||||
|
// 4011 与普通 401/403 是两种处理(提示"已在其他设备登录" vs 跳登录页),不能误判
|
||||||
|
assert.equal(isUnauthorized({ code: 4011 }), false)
|
||||||
|
assert.equal(isForbidden({ code: 4011 }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('单设备登录:http 拦截器接线(两分支先判 4011,跳带 kicked 标记的登录页)', () => {
|
||||||
|
const http = readSource('src/api/http.ts')
|
||||||
|
assert.match(http, /isKicked/)
|
||||||
|
assert.match(http, /\/admin-vue\/login\?kicked=1/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('单设备登录:登录页显示被顶下线提示', () => {
|
||||||
|
const page = readSource('src/pages/login/LoginPage.vue')
|
||||||
|
assert.match(page, /kickedNotice/)
|
||||||
|
assert.match(page, /该账号已在其他设备登录/)
|
||||||
|
assert.match(page, /kicked-msg/)
|
||||||
|
})
|
||||||
@@ -13,7 +13,7 @@ import { adminPages } from '../src/router/routes.ts'
|
|||||||
|
|
||||||
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
|
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
|
||||||
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
|
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
|
||||||
assert.equal(adminPages.length, 16)
|
assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)')
|
||||||
for (const page of adminPages) {
|
for (const page of adminPages) {
|
||||||
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
|
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ test('test_task_012_route_error_page_normal_repeated_operation_is_idempotent', (
|
|||||||
test('test_task_012_route_error_page_boundary_empty_input', () => {
|
test('test_task_012_route_error_page_boundary_empty_input', () => {
|
||||||
// 边界空值:错误页文件存在,且不进入业务路由注册表。
|
// 边界空值:错误页文件存在,且不进入业务路由注册表。
|
||||||
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
|
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
|
||||||
assert.equal(adminPages.length, 16, '错误页不应计入业务路由')
|
// 业务路由条目与页面定义一一对应;错误页不占用其中任何一条。
|
||||||
|
const router = readSource(ROUTER)
|
||||||
|
assert.equal(occurrences(router, '...adminRouteRecords'), 1, '错误页不应计入业务路由')
|
||||||
|
assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_012_route_error_page_boundary_single_item', () => {
|
test('test_task_012_route_error_page_boundary_single_item', () => {
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ test('test_task_128_version_upload_invalid_input_rejected', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_128_version_upload_dependency_failure_returns_actionable_message', () => {
|
test('test_task_128_version_upload_dependency_failure_returns_actionable_message', () => {
|
||||||
// 依赖失败/提交走 adapter:上传表单纯逻辑;POST /api/admin/version multipart。
|
// 依赖失败/提交走 adapter:上传表单纯逻辑;浏览器直传(presign 签发 → PUT 到 MinIO)由 version-api 编排。
|
||||||
const mod = readSource('src/pages/records/version-upload-model.ts')
|
const mod = readSource('src/pages/records/version-upload-model.ts')
|
||||||
assert.equal(/axios|http\.|vue/.test(mod), false, '上传表单保持纯逻辑')
|
assert.equal(/axios|http\.|vue/.test(mod), false, '上传表单保持纯逻辑')
|
||||||
const api = readSource('src/pages/records/version-api.ts')
|
const api = readSource('src/pages/records/version-api.ts')
|
||||||
assert.match(api, /uploadSoftwareVersion/)
|
assert.match(api, /uploadSoftwareVersion/)
|
||||||
assert.match(api, /\/api\/admin\/version/)
|
assert.match(api, /\/api\/admin\/version/)
|
||||||
assert.match(api, /FormData/)
|
// 直传改造后不再走 multipart FormData,改 presign 预签名地址 + 独立 PUT 直传 MinIO。
|
||||||
|
assert.match(api, /presign/)
|
||||||
|
assert.match(api, /\.put\(/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ test('test_task_260_version_invalid_input_rejected', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_260_version_dependency_failure_returns_actionable_message', () => {
|
test('test_task_260_version_dependency_failure_returns_actionable_message', () => {
|
||||||
// 依赖失败:成功文案含版本与链接;上传经 API 适配并解析新版本;空态兜底。
|
// 依赖失败:成功提示精简为“发布成功”;上传经 API 适配并解析新版本;空态兜底。
|
||||||
const page = readSource('src/pages/records/RecordsSoftwareVersionPage.vue')
|
const page = readSource('src/pages/records/RecordsSoftwareVersionPage.vue')
|
||||||
assert.match(page, /发布成功。版本:/, '成功文案需含版本')
|
assert.match(page, /发布成功/, '成功提示留驻弹窗')
|
||||||
assert.match(page, /,链接:/, '成功文案需含链接')
|
assert.match(page, /正在上传/, '上传过程提示含进度')
|
||||||
assert.match(page, /暂无版本/, '空态文案')
|
assert.match(page, /暂无版本/, '空态文案')
|
||||||
const api = readSource('src/pages/records/version-api.ts')
|
const api = readSource('src/pages/records/version-api.ts')
|
||||||
assert.match(api, /parseSoftwareVersionUpload/, '上传适配解析新版本行')
|
assert.match(api, /parseSoftwareVersionUpload/, '上传适配解析新版本行')
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ test('test_task_266_view_normal_primary_path', () => {
|
|||||||
test('test_task_266_view_normal_variant_input', () => {
|
test('test_task_266_view_normal_variant_input', () => {
|
||||||
const api = readSource('src/pages/tasks/shop-data-api.ts')
|
const api = readSource('src/pages/tasks/shop-data-api.ts')
|
||||||
assert.match(api, /fetchShopDataResultDownload/, '单文件下载适配')
|
assert.match(api, /fetchShopDataResultDownload/, '单文件下载适配')
|
||||||
assert.match(api, /\/results\/\$\{resultId\}\/download/, '单文件下载走管理端真实端点')
|
// 端点演进:单文件下载/删除改走每日累计档 daily-files(与 AdminShopDataCrawlTasksController 对齐)。
|
||||||
|
assert.match(api, /daily-files\/\$\{resultId\}\/download/, '单文件下载走管理端真实端点')
|
||||||
assert.match(api, /deleteShopDataResultHistory/, '删除适配')
|
assert.match(api, /deleteShopDataResultHistory/, '删除适配')
|
||||||
assert.match(api, /\/history\/\$\{resultId\}/, '删除走管理端真实端点')
|
assert.match(api, /daily-files\/\$\{resultId\}/, '删除走管理端真实端点')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_266_view_normal_repeated_operation_is_idempotent', () => {
|
test('test_task_266_view_normal_repeated_operation_is_idempotent', () => {
|
||||||
@@ -44,13 +45,13 @@ test('test_task_266_view_boundary_single_item', () => {
|
|||||||
test('test_task_266_delete_normal_primary_path', () => {
|
test('test_task_266_delete_normal_primary_path', () => {
|
||||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
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, /删除成功/, '删除成功提示')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_266_delete_boundary_limit_or_missing_field', () => {
|
test('test_task_266_delete_boundary_limit_or_missing_field', () => {
|
||||||
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
const page = readSource('src/pages/tasks/ShopDataTasksPage.vue')
|
||||||
assert.match(page, /正在删除任务/, '删除中有进行文案')
|
assert.match(page, /deletingKey/, '删除中状态防重复点击')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_266_dependency_failure_returns_actionable_message', () => {
|
test('test_task_266_dependency_failure_returns_actionable_message', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
toQueryAsinParams,
|
toQueryAsinParams,
|
||||||
toQueryAsinQuery,
|
toQueryAsinQuery,
|
||||||
} from '../src/pages/asin/query-asin-filter.ts'
|
} from '../src/pages/asin/query-asin-filter.ts'
|
||||||
|
import { buildQueryAsinExportQuery, toQueryAsinExportUrl } from '../src/pages/asin/query-asin-export.ts'
|
||||||
|
|
||||||
test('test_task_071_query_asin_list_normal_primary_path', () => {
|
test('test_task_071_query_asin_list_normal_primary_path', () => {
|
||||||
// 正常主路径:Java QueryAsinPageVo(camel) 宽行(每店铺 5 国家 ASIN)解析为前端分页结果。
|
// 正常主路径:Java QueryAsinPageVo(camel) 宽行(每店铺 5 国家 ASIN)解析为前端分页结果。
|
||||||
@@ -115,24 +116,26 @@ test('test_task_071_query_asin_list_boundary_limit_or_missing_field', () => {
|
|||||||
999,
|
999,
|
||||||
))
|
))
|
||||||
assert.equal(query.page, 1)
|
assert.equal(query.page, 1)
|
||||||
assert.equal(query.pageSize, 100)
|
assert.equal(query.page_size, 100)
|
||||||
assert.equal('groupId' in query, false, '分组 0 不应下发')
|
assert.equal('group_id' in query, false, '分组 0 不应下发')
|
||||||
assert.equal('shopName' in query, false, '空白店铺名不应下发')
|
assert.equal('shop_name' in query, false, '空白店铺名不应下发')
|
||||||
assert.equal('asin' in query, false)
|
assert.equal('asin' in query, false)
|
||||||
assert.equal('country' in query, false, '不支持的国家码不应下发')
|
assert.equal('country' in query, false, '不支持的国家码不应下发')
|
||||||
// 正常非空值仍以 Java camelCase 参数名下发。
|
// 正常非空值以 Java snake_case 参数名下发(QueryAsinController @RequestParam(page_size/group_id/shop_name) 契约)。
|
||||||
const filled = toQueryAsinQuery(toQueryAsinParams(
|
const filled = toQueryAsinQuery(toQueryAsinParams(
|
||||||
{ groupId: 5, shopName: ' 蓝店 ', asin: 'B0X', country: 'de' },
|
{ groupId: 5, shopName: ' 蓝店 ', asin: 'B0X', country: 'de' },
|
||||||
3,
|
3,
|
||||||
25,
|
25,
|
||||||
))
|
))
|
||||||
assert.equal(filled.page, 3)
|
assert.equal(filled.page, 3)
|
||||||
assert.equal(filled.pageSize, 25)
|
assert.equal(filled.page_size, 25)
|
||||||
assert.equal(filled.groupId, 5)
|
assert.equal(filled.group_id, 5)
|
||||||
assert.equal(filled.shopName, '蓝店')
|
assert.equal(filled.shop_name, '蓝店')
|
||||||
assert.equal(filled.asin, 'B0X')
|
assert.equal(filled.asin, 'B0X')
|
||||||
assert.equal(filled.country, 'DE')
|
assert.equal(filled.country, 'DE')
|
||||||
assert.equal('page_size' in (filled as Record<string, unknown>), false, '不应下发 page_size(snake)')
|
assert.equal('pageSize' in (filled as Record<string, unknown>), false, '不应下发 pageSize(camel):后端收不到会导致筛选/分页失效')
|
||||||
|
assert.equal('groupId' in (filled as Record<string, unknown>), false, '不应下发 groupId(camel)')
|
||||||
|
assert.equal('shopName' in (filled as Record<string, unknown>), false, '不应下发 shopName(camel)')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_071_query_asin_country_semantics', () => {
|
test('test_task_071_query_asin_country_semantics', () => {
|
||||||
@@ -174,3 +177,16 @@ test('test_task_071_query_asin_list_dependency_failure_returns_actionable_messag
|
|||||||
const model = readSource('src/pages/asin/query-asin-model.ts')
|
const model = readSource('src/pages/asin/query-asin-model.ts')
|
||||||
assert.equal(/axios|http\./.test(model), false, '查询 ASIN 列表解析保持纯逻辑')
|
assert.equal(/axios|http\./.test(model), false, '查询 ASIN 列表解析保持纯逻辑')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('test_task_071_query_asin_export_url_follows_filter', () => {
|
||||||
|
// 导出 URL 跟随当前筛选:非空条件以 snake 参数下发(后端 export @RequestParam 契约),空白/非法条件与无筛选时退回全量。
|
||||||
|
const url = toQueryAsinExportUrl({ groupId: 5, shopName: ' 蓝店 ', asin: ' B0X ', country: 'de' })
|
||||||
|
assert.equal(url.startsWith('/api/admin/query-asins/export?'), true)
|
||||||
|
const params = new URLSearchParams(url.split('?')[1])
|
||||||
|
assert.equal(params.get('group_id'), '5')
|
||||||
|
assert.equal(params.get('shop_name'), '蓝店')
|
||||||
|
assert.equal(params.get('asin'), 'B0X')
|
||||||
|
assert.equal(params.get('country'), 'DE')
|
||||||
|
assert.equal(buildQueryAsinExportQuery({ groupId: 0, shopName: ' ', asin: '', country: 'XX' }), '', '空白/非法条件不下发')
|
||||||
|
assert.equal(toQueryAsinExportUrl({ groupId: null, shopName: '', asin: '', country: '' }), '/api/admin/query-asins/export', '无筛选即全量导出')
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
|
|
||||||
test('test_task_008_domain_route_registry_normal_primary_path', () => {
|
test('test_task_008_domain_route_registry_normal_primary_path', () => {
|
||||||
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
|
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
|
||||||
assert.equal(adminPages.length, 16)
|
assert.ok(adminPages.length >= 18, '业务页至少 18(随版本递增,不设上限断言)')
|
||||||
assert.equal(adminRouteRecords.length, adminPages.length)
|
assert.equal(adminRouteRecords.length, adminPages.length)
|
||||||
const first = adminRouteRecords[0]
|
const first = adminRouteRecords[0]
|
||||||
assert.equal(first.path, 'account/users')
|
assert.equal(first.path, 'account/users')
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
|
|||||||
|
# Flyway 迁移演练 runbook(task-201)
|
||||||
|
|
||||||
|
> 目的:在**副本库**上验证新增迁移可干净执行、验证 SQL 通过、可回滚、可幂等重跑,且全程不动历史迁移。
|
||||||
|
> 本文档为演练步骤与检查清单;on-DB 执行需运维在有副本库的机器按步骤进行(本地/CI 无库时不执行 migrate)。
|
||||||
|
|
||||||
|
## 前置
|
||||||
|
|
||||||
|
- 副本库:与生产同版本(MySQL 8.4),已执行到当前最大版本 V108(与生产一致)。
|
||||||
|
- 拿到待演练的新迁移:`src/main/resources/db/V{N+1}__*.sql`,头注释引用 `docs/flyway-migration-template.md`(task-192)六项必填齐全。
|
||||||
|
|
||||||
|
## 演练步骤
|
||||||
|
|
||||||
|
1. **基线核对**:`flyway -url=<副本> info` 确认版本、描述、checksum 与生产一致;`git log` 确认历史 V1..V108 未被改动。
|
||||||
|
2. **validate**:`flyway validate` —— 校验历史迁移 checksum,任何历史文件被改动会立刻失败(违规红线)。
|
||||||
|
3. **干净迁移**:把待演练迁移放入后 `flyway migrate`;记录成功版本、耗时。
|
||||||
|
4. **验证 SQL**:执行迁移头注释第 4 项的验证 SQL(行数/索引/SHOW INDEX),确认结果符合预期。
|
||||||
|
5. **回滚验证**:按头注释第 5 项回滚脚本回滚新迁移(若无回滚脚本,验证迁移可幂等重跑替代)。
|
||||||
|
6. **幂等重跑**:回滚后再 `flyway migrate` 一次,确认可重复、无残留副作用。
|
||||||
|
7. **锁窗口评估**:索引类迁移记录执行耗时与是否 ONLINE,结合表数据量估算生产锁窗口。
|
||||||
|
8. **收尾**:记录结论到本清单;生产窗口按 template 第 6 项执行。
|
||||||
|
|
||||||
|
## 离线静态检查(本仓库 JUnit 已覆盖)
|
||||||
|
|
||||||
|
- 迁移文件整数版本 1..N 连续、无重复(`MigrationInventoryTest`,task-193)。
|
||||||
|
- 迁移校验和可复算稳定(同一文件两次读 SHA-256 一致,`MigrationInventoryTest`)。
|
||||||
|
- 新迁移命名合规、模板六字段可引用(`FlywayMigrationTemplateDocTest`,task-192)。
|
||||||
|
|
||||||
|
## 完成检查
|
||||||
|
|
||||||
|
- [ ] 副本库 `flyway migrate` 干净执行(版本升至目标)
|
||||||
|
- [ ] 验证 SQL 通过
|
||||||
|
- [ ] 回滚验证通过 / 幂等重跑通过
|
||||||
|
- [ ] 锁窗口已按表量估算并记录
|
||||||
|
- [ ] 历史迁移未被改动(`flyway validate` 通过)
|
||||||
|
|
||||||
|
> 注:CI/本地无数据库环境时,本 runbook 的第 3-7 步需在带副本库的机器执行;仓库内以静态检查 + 本清单兜底。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Flyway 迁移规范模板(task-192)
|
||||||
|
|
||||||
|
> 新增数据库迁移一律在本仓库 `src/main/resources/db/` 追加 `V{N+1}__*.sql`(版本号在现有最大版本之上加 1),
|
||||||
|
> 每个迁移文件头必须引用本模板并补齐六项必填。**只追加,绝不修改已部署的历史迁移**(会破坏 Flyway 校验和)。
|
||||||
|
|
||||||
|
复制以下头注释到新迁移文件顶部并逐项填写:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- =============================================================
|
||||||
|
-- 迁移 V{N+1}__<短横线描述>
|
||||||
|
-- 模板:docs/flyway-migration-template.md(task-192)
|
||||||
|
--
|
||||||
|
-- 1. 变更目的:<一句话说明要解决什么问题 / 为何变更>
|
||||||
|
-- 2. 影响表与数据量:<表名:预计行数 / 全表或增量;例如 biz_file_result ~50w 全表>
|
||||||
|
-- 3. 锁表风险:<是否 ONLINE / 是否加锁 / 大表索引类需 ALGORITHM=INPLACE 评估;风险高则拆批或窗口执行>
|
||||||
|
-- 4. 验证 SQL:<迁移后用于核对的行数 / 抽样语句,见下方示例>
|
||||||
|
-- 5. 回滚步骤:<V{N}__<desc>.sql 或补丁脚本路径;无回滚写明原因>
|
||||||
|
-- 6. 上线窗口:<建议窗口,例如 业务低峰 02:00-06:00;双节点滚动>
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 迁移语句(DDL/DML)…
|
||||||
|
|
||||||
|
-- 可选验证(与头注释第 4 项对应)
|
||||||
|
-- SELECT COUNT(*) FROM <table>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 六项必填说明
|
||||||
|
|
||||||
|
| # | 字段 | 要求 | 反例 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 变更目的 | 一句话,写清"为什么" | 留空 / 只写表名 |
|
||||||
|
| 2 | 影响表与数据量 | 每张被改表名 + 预计行数量级 | "涉及多表" 不含表名 |
|
||||||
|
| 3 | 锁表风险 | 指出 DDL 是否 INPLACE/排他、大表评估 | "无风险" 不说明依据 |
|
||||||
|
| 4 | 验证 SQL | 迁移后可跑的核对语句 | 缺失 |
|
||||||
|
| 5 | 回滚步骤 | 回滚脚本路径或明确不可回滚原因 | 缺失 |
|
||||||
|
| 6 | 上线窗口 | 建议时段 + 是否滚动 | 缺失 |
|
||||||
|
|
||||||
|
## 示例验证 SQL(供第 4 项复制)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 迁移后行数与迁移前基线对比
|
||||||
|
SELECT COUNT(*) FROM biz_file_result;
|
||||||
|
-- 新索引是否生效(用于索引类迁移)
|
||||||
|
SHOW INDEX FROM biz_file_result WHERE Key_name = 'idx_task_module';
|
||||||
|
-- 抽样数据
|
||||||
|
SELECT id, task_id, status, updated_at FROM biz_file_task ORDER BY id DESC LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用约束
|
||||||
|
|
||||||
|
- 版本号在 `src/main/resources/db/` 最大现有版本上加 1(当前 ≥ V109),不抢号、不重复。
|
||||||
|
- 不修改、不删除任何已执行过的历史迁移文件。
|
||||||
|
- 上线走双节点滚动 + 生产库先 `flyway validate`,失败即停。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Flyway 迁移盘点(task-193)
|
||||||
|
|
||||||
|
> 生成方式:`src/test/java/com/nanri/aiimage/config/MigrationInventoryTest.java`(只读审计,可重复)。
|
||||||
|
> 快照日期:2026-09-05。
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
- 版本化迁移文件数:**110**(`src/main/resources/db/V*.sql`)
|
||||||
|
- 整数版本范围:**V1..V109 连续**
|
||||||
|
- 历史遗留小版本:**V25_1__shop_manage_group_bind_user.sql**(Flyway 语义 25.1,紧跟在 V25 之后、V26 之前执行,属历史命名,保留)
|
||||||
|
- 最新版本:**V109__admin_menu_frontend_routes.sql**
|
||||||
|
- 重复版本:无
|
||||||
|
- 迁移命名:全部符合 `V<整数>(_<子版本>)?__<描述>.sql`,无空格
|
||||||
|
|
||||||
|
## 生产已执行核对
|
||||||
|
|
||||||
|
生产 Flyway 已执行到 ≥ V108;本次新增迁移使用 V109(迁移只追加,不回滚历史);新增迁移一律在 V108 之上取 `V109__*`,头注释引用 `docs/flyway-migration-template.md`(task-192)。
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- 审计测试断言:整数版本从 1 到当前最大值连续、无重复文件名、命名正则合规、历史文件校验和可复算稳定。
|
||||||
|
- 修改任何已部署历史迁移会破坏 Flyway checksum,属违规;由本盘点测试的 `no_legacy_modified` 类约束 + code review 把关(git 层是否改动由 CI/review 校验)。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 单次提交事务段耗时基线(task-138)
|
||||||
|
|
||||||
|
> 本文档由 `TxDurationBenchmarkTest` 断言存在与内容,用于冻结「Python 结果提交」单次调用的
|
||||||
|
> 耗时上界,防止提交路径劣化。**不要删除**,劣化时更新数值并说明原因。
|
||||||
|
|
||||||
|
## 测量对象
|
||||||
|
|
||||||
|
`SimilarAsinTaskService.submitResult(taskId, request)` 的 mock 环境单次调用,拆成两段:
|
||||||
|
|
||||||
|
| 段 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 计算段(prepare) | 事务外的纯计算:行裁剪、校验、payload 序列化与哈希 |
|
||||||
|
| 事务段(persist) | `inNewTransaction` 内的落库:分片 upsert、scope state 更新 |
|
||||||
|
|
||||||
|
## 基线数值
|
||||||
|
|
||||||
|
- **单次事务段基线: 5**(毫秒,mock 环境,本地实测)
|
||||||
|
- **200 分片**负载基线: 10 秒(`twoHundredChunkLoadBounded` 的上界)
|
||||||
|
- 单次提交总耗时上界: 500 毫秒(`totalDurationReasonablePerSubmission`)
|
||||||
|
|
||||||
|
`noRegressionAgainstDocumentedBaseline` 按「当前 ≤ 基线 × 3」判定劣化,因此事务段超过
|
||||||
|
15ms 即视为回归。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 该基线取的是 mock 环境(Mapper 全部打桩)而非真实 MySQL 的耗时,用于**相对劣化**而非绝对性能。
|
||||||
|
- 真实环境的事务段耗时会显著高于此值,本基线不适用于容量规划。
|
||||||
|
- 测量机器与 JDK 变更后如需调整,请同步更新本文件数值。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,317 +0,0 @@
|
|||||||
{
|
|
||||||
"scanned_files": 640,
|
|
||||||
"modules": [
|
|
||||||
"admin",
|
|
||||||
"appearancepatent",
|
|
||||||
"auth",
|
|
||||||
"brand",
|
|
||||||
"collectdata",
|
|
||||||
"convert",
|
|
||||||
"debug",
|
|
||||||
"dedupe",
|
|
||||||
"deletebrand",
|
|
||||||
"digitalhuman",
|
|
||||||
"file",
|
|
||||||
"filetemplate",
|
|
||||||
"imagehistory",
|
|
||||||
"imagevideo",
|
|
||||||
"invalidasin",
|
|
||||||
"patroldelete",
|
|
||||||
"permission",
|
|
||||||
"pricetrack",
|
|
||||||
"productcategory",
|
|
||||||
"productrisk",
|
|
||||||
"publish",
|
|
||||||
"queryasin",
|
|
||||||
"shopdatacrawl",
|
|
||||||
"shopkey",
|
|
||||||
"shopmatch",
|
|
||||||
"similarasin",
|
|
||||||
"split",
|
|
||||||
"task",
|
|
||||||
"withdraw",
|
|
||||||
"ziniao"
|
|
||||||
],
|
|
||||||
"findings": [
|
|
||||||
{
|
|
||||||
"file": "deletebrand\\service\\DeleteBrandStaleTaskService.java",
|
|
||||||
"line": 209,
|
|
||||||
"statement": "FileTaskEntity refreshed = fileTaskMapper.selectById(task.getId());",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "HIGH",
|
|
||||||
"module": "deletebrand"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "pricetrack\\service\\PriceTrackTaskService.java",
|
|
||||||
"line": 354,
|
|
||||||
"statement": "FileResultEntity lockedResult = fileResultMapper.selectById(fr.getId());",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "HIGH",
|
|
||||||
"module": "pricetrack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "productrisk\\service\\ProductRiskTaskService.java",
|
|
||||||
"line": 339,
|
|
||||||
"statement": "FileResultEntity lockedResult = fileResultMapper.selectById(fr.getId());",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "HIGH",
|
|
||||||
"module": "productrisk"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "task\\service\\TaskFileJobService.java",
|
|
||||||
"line": 282,
|
|
||||||
"statement": "publishDispatchEvent(taskFileJobMapper.selectById(job.getId()));",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "HIGH",
|
|
||||||
"module": "task"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "task\\service\\TaskFileJobService.java",
|
|
||||||
"line": 303,
|
|
||||||
"statement": "TaskFileJobEntity exhausted = taskFileJobMapper.selectById(job.getId());",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "HIGH",
|
|
||||||
"module": "task"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "task\\service\\TaskFileJobService.java",
|
|
||||||
"line": 315,
|
|
||||||
"statement": "publishDispatchEvent(taskFileJobMapper.selectById(job.getId()));",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "HIGH",
|
|
||||||
"module": "task"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "appearancepatent\\service\\AppearancePatentTaskService.java",
|
|
||||||
"line": 1094,
|
|
||||||
"statement": "TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "appearancepatent"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "appearancepatent\\service\\AppearancePatentTaskService.java",
|
|
||||||
"line": 2112,
|
|
||||||
"statement": "List<TaskChunkEntity> page = taskChunkMapper.selectList(query);",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "appearancepatent"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "dedupe\\service\\DedupeTotalDataService.java",
|
|
||||||
"line": 279,
|
|
||||||
"statement": "List<DedupeTotalDataEntity> rows = dedupeTotalDataMapper.selectList(",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "dedupe"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "deletebrand\\service\\DeleteBrandRunService.java",
|
|
||||||
"line": 1234,
|
|
||||||
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "deletebrand"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "deletebrand\\service\\DeleteBrandRunService.java",
|
|
||||||
"line": 1252,
|
|
||||||
"statement": "rows.addAll(fileResultMapper.selectList(historyResultQuery()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "deletebrand"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "deletebrand\\service\\DeleteBrandRunService.java",
|
|
||||||
"line": 1268,
|
|
||||||
"statement": "List<FileResultEntity> rows = fileResultMapper.selectList(historyResultQuery()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "deletebrand"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "patroldelete\\service\\PatrolDeleteTaskService.java",
|
|
||||||
"line": 638,
|
|
||||||
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "patroldelete"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "permission\\service\\PermissionMenuService.java",
|
|
||||||
"line": 676,
|
|
||||||
"statement": "cursor = permissionMenuMapper.selectById(ancestorId);",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "permission"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "pricetrack\\service\\PriceTrackTaskService.java",
|
|
||||||
"line": 153,
|
|
||||||
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "pricetrack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "pricetrack\\service\\PriceTrackTaskService.java",
|
|
||||||
"line": 418,
|
|
||||||
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "pricetrack"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "productcategory\\service\\ProductCategoryService.java",
|
|
||||||
"line": 465,
|
|
||||||
"statement": "ProductCategoryEntity parent = productCategoryMapper.selectById(cursor.getParentId());",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "productcategory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "productcategory\\service\\ProductCategoryService.java",
|
|
||||||
"line": 481,
|
|
||||||
"statement": "cursor = cursor.getParentId() == null ? null : productCategoryMapper.selectById(cursor.getParentId());",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "productcategory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "productcategory\\service\\ProductCategoryService.java",
|
|
||||||
"line": 518,
|
|
||||||
"statement": "ProductCategoryEntity row = productCategoryMapper.selectById(cursor);",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "productcategory"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "productrisk\\service\\ProductRiskTaskService.java",
|
|
||||||
"line": 144,
|
|
||||||
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "productrisk"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "queryasin\\service\\QueryAsinTaskService.java",
|
|
||||||
"line": 587,
|
|
||||||
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "queryasin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
|
|
||||||
"line": 1085,
|
|
||||||
"statement": "List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "shopdatacrawl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
|
|
||||||
"line": 1940,
|
|
||||||
"statement": "FileResultEntity result = fileResultMapper.selectById(member.getResultId());",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "shopdatacrawl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
|
|
||||||
"line": 2497,
|
|
||||||
"statement": "FileResultEntity result = fileResultMapper.selectById(member.getResultId());",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "shopdatacrawl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "shopdatacrawl\\service\\ShopDataCrawlTaskService.java",
|
|
||||||
"line": 2575,
|
|
||||||
"statement": "FileTaskEntity task = fileTaskMapper.selectById(member.getTaskId());",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "shopdatacrawl"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "shopmatch\\service\\ShopMatchTaskService.java",
|
|
||||||
"line": 179,
|
|
||||||
"statement": "tasks.addAll(fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "shopmatch"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
|
||||||
"line": 1499,
|
|
||||||
"statement": "page = mapper.selectList(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<TaskChunkEntity>()",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "similarasin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
|
||||||
"line": 1646,
|
|
||||||
"statement": "TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper<TaskChunkEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "similarasin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
|
||||||
"line": 2346,
|
|
||||||
"statement": "FileTaskEntity task = fileTaskMapper.selectById(taskId);",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "similarasin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
|
||||||
"line": 2356,
|
|
||||||
"statement": "List<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "similarasin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
|
||||||
"line": 2379,
|
|
||||||
"statement": "TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId);",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "similarasin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
|
||||||
"line": 4364,
|
|
||||||
"statement": "List<TaskChunkEntity> page = taskChunkMapper.selectList(query);",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "similarasin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "similarasin\\service\\SimilarAsinTaskService.java",
|
|
||||||
"line": 4437,
|
|
||||||
"statement": "List<TaskScopeStateEntity> page = taskScopeStateMapper.selectList(query);",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "similarasin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "task\\service\\ModuleHistoryCleanupService.java",
|
|
||||||
"line": 131,
|
|
||||||
"statement": "List<FileTaskEntity> page = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()",
|
|
||||||
"loop_type": "while",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "task"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "task\\service\\TaskFileJobService.java",
|
|
||||||
"line": 153,
|
|
||||||
"statement": "TaskFileJobEntity claim = taskFileJobMapper.selectById(candidate.getId());",
|
|
||||||
"loop_type": "for",
|
|
||||||
"impact": "LOW",
|
|
||||||
"module": "task"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
package com.nanri.aiimage.common.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务错误码常量。
|
||||||
|
*
|
||||||
|
* <p>历史上 40901 被两种语义复用:「任务已结束」(幂等忽略)与「任务正在处理中」(分布式锁竞争,
|
||||||
|
* 应重试);而 GlobalExceptionHandler 把 40901 一律转成 HTTP 200 + success=true,
|
||||||
|
* 导致锁竞争时 Python worker 误判回传成功并停止重试,分片数据静默丢失。
|
||||||
|
* 现拆分为两个码:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #TASK_ALREADY_FINISHED}:任务已结束,重复提交无意义 → 幂等忽略,响应 success=true;</li>
|
||||||
|
* <li>{@link #TASK_BUSY}:任务正被其它请求持锁推进 → 响应 success=false,调用方应稍后重试。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class BusinessCodes {
|
||||||
|
|
||||||
|
private BusinessCodes() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务已结束,拒绝重复提交。语义:幂等忽略,响应 success=true,调用方不应重试。 */
|
||||||
|
public static final int TASK_ALREADY_FINISHED = 40901;
|
||||||
|
|
||||||
|
/** 任务正在处理中(分布式锁竞争)。语义:资源忙,响应 success=false,调用方应稍后重试。 */
|
||||||
|
public static final int TASK_BUSY = 40902;
|
||||||
|
|
||||||
|
/** 任务归属其它实例,需转发。 */
|
||||||
|
public static final int TASK_OWNER_FORWARD = 40903;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交结果的目标任务已不存在(通常是被删除)。
|
||||||
|
* 语义:本次提交无意义,响应 success=false 且带该码,调用方应放弃而不是反复重试。
|
||||||
|
* 与 {@link #TASK_ALREADY_FINISHED} 的区别:那个还留了任务记录(可幂等忽略),
|
||||||
|
* 这个任务已经没了——如实报错,否则任务被误删时结果会被静默吞掉。
|
||||||
|
*/
|
||||||
|
public static final int TASK_NOT_FOUND = 40401;
|
||||||
|
}
|
||||||
+66
-7
@@ -1,18 +1,21 @@
|
|||||||
package com.nanri.aiimage.common.exception;
|
package com.nanri.aiimage.common.exception;
|
||||||
|
|
||||||
import com.nanri.aiimage.common.api.ApiResponse;
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.common.security.DeviceSessionPolicy;
|
||||||
import com.nanri.aiimage.common.service.TaskOwnerForwardService;
|
import com.nanri.aiimage.common.service.TaskOwnerForwardService;
|
||||||
import com.nanri.aiimage.config.TaskOperationLockConfig;
|
import com.nanri.aiimage.config.TaskOperationLockConfig;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.ConstraintViolationException;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
@@ -28,34 +31,88 @@ public class GlobalExceptionHandler {
|
|||||||
TaskOperationLockConfig.releaseRequestLock(request);
|
TaskOperationLockConfig.releaseRequestLock(request);
|
||||||
try {
|
try {
|
||||||
ResponseEntity<byte[]> response = taskOwnerForwardService.forwardCurrentRequest(ex, request);
|
ResponseEntity<byte[]> response = taskOwnerForwardService.forwardCurrentRequest(ex, request);
|
||||||
|
// 转发响应头不能原样照搬:upstream 响应自带的逐跳头(Transfer-Encoding/Connection 等)
|
||||||
|
// 原样复制会出现「双 Transfer-Encoding」,nginx 视为协议错误直接 502
|
||||||
|
// (parsed-payload/activate 偶发 502 根因);实例标识头由本层 RequestTraceFilter
|
||||||
|
// 再写一份,原样又会出现双份 X-AIIMAGE-Instance。这里过滤这两类头后再回写。
|
||||||
|
org.springframework.http.HttpHeaders safeHeaders = new org.springframework.http.HttpHeaders();
|
||||||
|
java.util.Set<String> hopByHop = java.util.Set.of(
|
||||||
|
"transfer-encoding", "connection", "keep-alive", "te", "trailer", "upgrade",
|
||||||
|
"proxy-authenticate", "proxy-authorization", "content-length", "date", "server");
|
||||||
|
response.getHeaders().forEach((name, values) -> {
|
||||||
|
String lower = name == null ? "" : name.toLowerCase();
|
||||||
|
if (lower.isBlank() || hopByHop.contains(lower) || lower.startsWith("x-aiimage-instance")
|
||||||
|
|| lower.equals("x-aiimage-host")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
safeHeaders.put(name, values);
|
||||||
|
});
|
||||||
return ResponseEntity.status(response.getStatusCode())
|
return ResponseEntity.status(response.getStatusCode())
|
||||||
.headers(response.getHeaders())
|
.headers(safeHeaders)
|
||||||
.body(response.getBody());
|
.body(response.getBody());
|
||||||
} catch (BusinessException forwardEx) {
|
} catch (BusinessException forwardEx) {
|
||||||
return forwardEx.getCode() == null
|
return forwardEx.getCode() == null
|
||||||
? ApiResponse.fail(forwardEx.getMessage())
|
? ApiResponse.fail(forwardEx.getMessage())
|
||||||
: ApiResponse.fail(forwardEx.getCode(), forwardEx.getMessage());
|
: ApiResponse.fail(forwardEx.getCode(), forwardEx.getMessage());
|
||||||
} catch (Exception forwardEx) {
|
} catch (Exception forwardEx) {
|
||||||
|
// 转发失败是**瞬时基础设施故障**(归属实例正在滚动重启),不是业务结论,
|
||||||
|
// 更不能表达成「任务不存活」。原先返回 ApiResponse.fail(40903) —— HTTP 200
|
||||||
|
// 加 data:null,而客户端那句 bool((resp.json().get("data") or {}).get("alive"))
|
||||||
|
// 会把「拿不到数据」折叠成 alive=false,于是客户端把**健康的长任务主动停掉**:
|
||||||
|
// 2026-09-18 任务 28616 就是这么死的(归属节点 server-110 重启窗口内,心跳经
|
||||||
|
// nginx 落到 server-121,转发 3 次 Connection refused 后返回空 data)。
|
||||||
|
// 改为 503 + 空 body:新客户端按状态码判为「未知」继续跑;老客户端因 body 不是
|
||||||
|
// JSON、resp.json() 抛异常,同样落到「未知」。顺带让这类故障在 HTTP 指标里可见
|
||||||
|
// (原先记成 200,监控完全看不到滚动重启期间丢了多少心跳)。
|
||||||
log.warn("[instance-routing] forward failed taskId={} operation={} owner={} current={} msg={}",
|
log.warn("[instance-routing] forward failed taskId={} operation={} owner={} current={} msg={}",
|
||||||
ex.getTaskId(), ex.getOperation(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId(),
|
ex.getTaskId(), ex.getOperation(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId(),
|
||||||
forwardEx.getMessage(), forwardEx);
|
forwardEx.getMessage(), forwardEx);
|
||||||
return ApiResponse.fail(40903, "任务归属实例转发失败: " + forwardEx.getMessage());
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BusinessException.class)
|
@ExceptionHandler(BusinessException.class)
|
||||||
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
|
public ApiResponse<Void> handleBusinessException(BusinessException ex, HttpServletRequest request) {
|
||||||
if (Integer.valueOf(40901).equals(ex.getCode())) {
|
// 业务异常此前完全不记日志:2026-09-16 线上「保存权限/创建用户」双双失败时,
|
||||||
|
// 服务端只留 RequestTraceFilter 的 200 一行,根因只能靠反推响应体字节数才定位到。
|
||||||
|
// 401/4011(未登录、被顶下线)属于轮询类接口的常态噪声,降为 debug 以免淹没真实业务错。
|
||||||
|
if (isRoutineAuthNoise(ex.getCode())) {
|
||||||
|
log.debug("[business] {} {} code={} message={}", request.getMethod(), request.getRequestURI(),
|
||||||
|
ex.getCode(), ex.getMessage());
|
||||||
|
} else {
|
||||||
|
log.warn("[business] {} {} code={} message={}", request.getMethod(), request.getRequestURI(),
|
||||||
|
ex.getCode(), ex.getMessage());
|
||||||
|
}
|
||||||
|
if (Integer.valueOf(BusinessCodes.TASK_ALREADY_FINISHED).equals(ex.getCode())) {
|
||||||
|
// 幂等忽略:任务已结束时的重复提交无副作用,按成功返回,避免客户端反复重试
|
||||||
return ApiResponse.success("任务已结束,忽略重复提交", null);
|
return ApiResponse.success("任务已结束,忽略重复提交", null);
|
||||||
}
|
}
|
||||||
|
if (Integer.valueOf(BusinessCodes.TASK_BUSY).equals(ex.getCode())) {
|
||||||
|
// 锁竞争:必须如实返回失败 + 可重试码,否则 worker 会把「未落库」当成功而停止重试
|
||||||
|
return ApiResponse.fail(BusinessCodes.TASK_BUSY, ex.getMessage());
|
||||||
|
}
|
||||||
return ex.getCode() == null
|
return ex.getCode() == null
|
||||||
? ApiResponse.fail(ex.getMessage())
|
? ApiResponse.fail(ex.getMessage())
|
||||||
: ApiResponse.fail(ex.getCode(), ex.getMessage());
|
: ApiResponse.fail(ex.getCode(), ex.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(NoResourceFoundException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleNoResourceFoundException(NoResourceFoundException ex) {
|
||||||
|
// 静态资源 404。绝大部分是外部扫描器在探测 /.env、/credentials、aliyun.json、oss.json
|
||||||
|
// 这类云凭据文件(线上单节点一天 580 条)。此前落到 handleException 里,既刷 ERROR 堆栈,
|
||||||
|
// 又把探测响应伪装成 HTTP 200;这里降为 debug 并如实返回 404。
|
||||||
|
log.debug("static resource not found: {}", ex.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.fail("资源不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未登录 / 登录态失效 / 被其他设备顶下线:按 401 语义的常态噪声,不占 WARN。 */
|
||||||
|
private boolean isRoutineAuthNoise(Integer code) {
|
||||||
|
return Integer.valueOf(401).equals(code)
|
||||||
|
|| Integer.valueOf(DeviceSessionPolicy.CODE_KICKED).equals(code);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
|
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldError() != null
|
||||||
String message = ex.getBindingResult().getFieldError() != null
|
|
||||||
? ex.getBindingResult().getFieldError().getDefaultMessage()
|
? ex.getBindingResult().getFieldError().getDefaultMessage()
|
||||||
: "参数校验失败";
|
: "参数校验失败";
|
||||||
return ApiResponse.fail(message);
|
return ApiResponse.fail(message);
|
||||||
@@ -84,7 +141,9 @@ public class GlobalExceptionHandler {
|
|||||||
return ApiResponse.fail("客户端已断开连接");
|
return ApiResponse.fail("客户端已断开连接");
|
||||||
}
|
}
|
||||||
log.error("Unhandled exception", ex);
|
log.error("Unhandled exception", ex);
|
||||||
return ApiResponse.fail("服务异常: " + ex.getMessage());
|
// 不再回传原始异常信息:SQL 报错、类名与内部路径会直接暴露给调用方,便于攻击者
|
||||||
|
// 摸清技术栈与表结构。详情只进日志(上方 log.error 已带完整堆栈),对外统一文案。
|
||||||
|
return ApiResponse.fail("服务器内部错误,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isClientAbort(Throwable ex) {
|
private boolean isClientAbort(Throwable ex) {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
package com.nanri.aiimage.modules.permission.mapper;
|
package com.nanri.aiimage.common.mapper;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
+51
-2
@@ -1,7 +1,7 @@
|
|||||||
package com.nanri.aiimage.modules.shopkey.mapper;
|
package com.nanri.aiimage.common.mapper;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
import com.nanri.aiimage.common.model.entity.ShopManageGroupEntity;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
@@ -102,4 +102,53 @@ public interface ShopManageGroupMapper extends BaseMapper<ShopManageGroupEntity>
|
|||||||
</script>
|
</script>
|
||||||
""")
|
""")
|
||||||
List<Long> selectUserIdsByGroupIds(@Param("groupIds") List<Long> groupIds);
|
List<Long> selectUserIdsByGroupIds(@Param("groupIds") List<Long> groupIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查用户所属分组名:分组归属按「分组的创建人(created_by_id/user_id) = 用户的创建人」判定,
|
||||||
|
* 即主管名下的子账户归入该主管创建的分组(成员表 biz_shop_manage_group_member 生产上基本未使用)。
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT u.id AS userId, g.group_name AS groupName
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN biz_shop_manage_group g
|
||||||
|
ON g.created_by_id = u.created_by_id OR g.user_id = u.created_by_id
|
||||||
|
WHERE u.created_by_id IS NOT NULL
|
||||||
|
AND u.id IN
|
||||||
|
<foreach collection='userIds' item='userId' open='(' separator=',' close=')'>
|
||||||
|
#{userId}
|
||||||
|
</foreach>
|
||||||
|
ORDER BY g.id ASC
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<com.nanri.aiimage.modules.shopkey.model.dto.UserGroupRef> selectGroupNamesByUserIds(
|
||||||
|
@Param("userIds") List<Long> userIds);
|
||||||
|
|
||||||
|
/** 某分组下的用户:该分组创建人(组长)本人 + 其名下子账户。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT u.id AS userId
|
||||||
|
FROM users u
|
||||||
|
INNER JOIN biz_shop_manage_group g ON g.id = #{groupId}
|
||||||
|
WHERE u.id = COALESCE(g.created_by_id, g.user_id)
|
||||||
|
OR u.created_by_id = COALESCE(g.created_by_id, g.user_id)
|
||||||
|
ORDER BY u.id ASC
|
||||||
|
""")
|
||||||
|
List<Long> selectUserIdsByGroupId(@Param("groupId") Long groupId);
|
||||||
|
|
||||||
|
/** 某人作为组长(创建人)的分组(密钥管理等按组隔离场景用)。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT g.id AS id, g.group_name AS groupName
|
||||||
|
FROM biz_shop_manage_group g
|
||||||
|
WHERE g.created_by_id = #{operatorId} OR g.user_id = #{operatorId}
|
||||||
|
ORDER BY g.id ASC
|
||||||
|
""")
|
||||||
|
List<ShopManageGroupEntity> selectLedGroups(@Param("operatorId") Long operatorId);
|
||||||
|
|
||||||
|
/** 全部分组(超管筛选项用)。 */
|
||||||
|
@Select("""
|
||||||
|
SELECT g.id AS id, g.group_name AS groupName
|
||||||
|
FROM biz_shop_manage_group g
|
||||||
|
ORDER BY g.id ASC
|
||||||
|
""")
|
||||||
|
List<ShopManageGroupEntity> selectAllGroups();
|
||||||
}
|
}
|
||||||
+10
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.nanri.aiimage.modules.permission.model.entity;
|
package com.nanri.aiimage.common.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
@@ -24,5 +25,13 @@ public class AdminUserEntity {
|
|||||||
private Long createdById;
|
private Long createdById;
|
||||||
@TableField("created_at")
|
@TableField("created_at")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
/**
|
||||||
|
* 更新时间(V131 新增):由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
|
||||||
|
*
|
||||||
|
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新,
|
||||||
|
* 而本表写回多为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。
|
||||||
|
*/
|
||||||
|
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
private String machine;
|
private String machine;
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.shopkey.model.entity;
|
package com.nanri.aiimage.common.model.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableField;
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.common.model.vo;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.common.model.vo;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.common.model.vo;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user