Compare commits
99 Commits
b7d4d325e0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||||
|
|||||||
@@ -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,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 压缩包')
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
@@ -180,6 +240,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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ export interface AdminUserSecretModule {
|
|||||||
moduleKey: string
|
moduleKey: string
|
||||||
moduleLabel: string
|
moduleLabel: string
|
||||||
masked: string
|
masked: string
|
||||||
|
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
|
||||||
|
full: string
|
||||||
exists: boolean
|
exists: boolean
|
||||||
checkStatus: string
|
checkStatus: string
|
||||||
checkCode: string
|
checkCode: string
|
||||||
@@ -19,6 +21,10 @@ export interface AdminUserSecretModule {
|
|||||||
export interface AdminUserSecretRow {
|
export interface AdminUserSecretRow {
|
||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
|
/** 所属数据权限分组名(可能多个)。 */
|
||||||
|
groups: string[]
|
||||||
|
/** 所属主管(创建人)用户名;无分组时的兜底展示。 */
|
||||||
|
leaderUsername: string
|
||||||
similarAsin: AdminUserSecretModule
|
similarAsin: AdminUserSecretModule
|
||||||
appearancePatent: AdminUserSecretModule
|
appearancePatent: AdminUserSecretModule
|
||||||
proxy: AdminUserSecretModule
|
proxy: AdminUserSecretModule
|
||||||
@@ -32,24 +38,38 @@ export interface AdminUserSecretPage {
|
|||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
/** 分组筛选项(超管=全部;主管=自己带的分组)。 */
|
||||||
|
groupOptions?: GroupOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupOption {
|
||||||
|
id: number
|
||||||
|
groupName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSecretQuery {
|
export interface UserSecretQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
checkStatus?: string
|
checkStatus?: string
|
||||||
|
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
|
||||||
|
groupId?: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets */
|
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets(筛选参数必须 snake_case,camel 会被后端静默忽略)。 */
|
||||||
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
||||||
const { data } = await http.get('/api/admin/user-secrets', { params })
|
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)
|
return unwrap<AdminUserSecretPage>(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check */
|
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check
|
||||||
|
* 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */
|
||||||
export async function checkUserSecret(userId: number) {
|
export async function checkUserSecret(userId: number) {
|
||||||
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`)
|
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`, undefined, { timeout: 180_000 })
|
||||||
return unwrap<
|
return unwrap<
|
||||||
Array<{
|
Array<{
|
||||||
moduleKey: string
|
moduleKey: string
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -74,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>
|
||||||
|
|||||||
@@ -77,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>
|
||||||
@@ -89,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>
|
||||||
@@ -9,7 +9,13 @@ import {
|
|||||||
fetchUserSecretList,
|
fetchUserSecretList,
|
||||||
type AdminUserSecretModule,
|
type AdminUserSecretModule,
|
||||||
type AdminUserSecretRow,
|
type AdminUserSecretRow,
|
||||||
|
type GroupOption,
|
||||||
} from '@/api/user-secrets'
|
} from '@/api/user-secrets'
|
||||||
|
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<AdminUserSecretRow[]>([])
|
const rows = ref<AdminUserSecretRow[]>([])
|
||||||
@@ -18,6 +24,9 @@ const page = ref(1)
|
|||||||
const pageSize = ref(15)
|
const pageSize = ref(15)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const statusFilter = ref('')
|
const statusFilter = ref('')
|
||||||
|
const groupFilter = ref<number | null>(null)
|
||||||
|
/** 分组下拉(超管=全部;主管=自己带的分组,由列表接口带回)。 */
|
||||||
|
const groupOptions = ref<GroupOption[]>([])
|
||||||
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
||||||
const checkingId = ref<number | null>(null)
|
const checkingId = ref<number | null>(null)
|
||||||
|
|
||||||
@@ -48,7 +57,7 @@ function rowStatusMeta(status: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单格状态药丸:未配置时统一灰色。 */
|
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
|
||||||
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
||||||
if (!module || !module.exists) {
|
if (!module || !module.exists) {
|
||||||
return { label: '未配置', tone: 'is-unknown' }
|
return { label: '未配置', tone: 'is-unknown' }
|
||||||
@@ -57,6 +66,9 @@ function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
|||||||
case 'passed':
|
case 'passed':
|
||||||
return { label: '通过', tone: 'is-allowed' }
|
return { label: '通过', tone: 'is-allowed' }
|
||||||
case 'failed':
|
case 'failed':
|
||||||
|
if (module.checkCode === 'insufficient_balance') {
|
||||||
|
return { label: '欠费', tone: 'is-warn' }
|
||||||
|
}
|
||||||
return { label: '失败', tone: 'is-blocked' }
|
return { label: '失败', tone: 'is-blocked' }
|
||||||
case 'error':
|
case 'error':
|
||||||
return { label: '无法判定', tone: 'is-warn' }
|
return { label: '无法判定', tone: 'is-warn' }
|
||||||
@@ -65,16 +77,24 @@ function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单格悬停详情:检测消息 + 检测时间 + 耗时。 */
|
/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */
|
||||||
function moduleTooltip(module: AdminUserSecretModule | undefined) {
|
function moduleTooltip(module: AdminUserSecretModule | undefined) {
|
||||||
if (!module || !module.exists) return '未配置'
|
if (!module || !module.exists) return '未配置'
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
if (module.full) parts.push(module.full)
|
||||||
if (module.checkMessage) parts.push(module.checkMessage)
|
if (module.checkMessage) parts.push(module.checkMessage)
|
||||||
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
||||||
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
||||||
return parts.join(';') || '暂无检测记录'
|
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) {
|
function rowStatusTooltip(row: AdminUserSecretRow) {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (row.statusMessage) parts.push(row.statusMessage)
|
if (row.statusMessage) parts.push(row.statusMessage)
|
||||||
@@ -88,11 +108,13 @@ async function load() {
|
|||||||
const result = await fetchUserSecretList({
|
const result = await fetchUserSecretList({
|
||||||
keyword: keyword.value.trim() || undefined,
|
keyword: keyword.value.trim() || undefined,
|
||||||
checkStatus: statusFilter.value || undefined,
|
checkStatus: statusFilter.value || undefined,
|
||||||
|
groupId: groupFilter.value || undefined,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
})
|
})
|
||||||
rows.value = result?.items || []
|
rows.value = result?.items || []
|
||||||
total.value = Number(result?.total || 0)
|
total.value = Number(result?.total || 0)
|
||||||
|
groupOptions.value = result?.groupOptions || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,6 +130,7 @@ function search() {
|
|||||||
function reset() {
|
function reset() {
|
||||||
keyword.value = ''
|
keyword.value = ''
|
||||||
statusFilter.value = ''
|
statusFilter.value = ''
|
||||||
|
groupFilter.value = null
|
||||||
page.value = 1
|
page.value = 1
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
@@ -183,6 +206,13 @@ onMounted(load)
|
|||||||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label> </label>
|
<label> </label>
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
@@ -197,10 +227,11 @@ onMounted(load)
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 58px">序号</th>
|
<th style="width: 58px">序号</th>
|
||||||
<th style="width: 190px">用户</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: 230px">外观专利密钥</th>
|
<th style="width: 230px">外观专利密钥</th>
|
||||||
<th style="width: 230px">代理设置</th>
|
<th style="width: 380px">代理设置</th>
|
||||||
<th style="width: 130px">状态</th>
|
<th style="width: 130px">状态</th>
|
||||||
<th style="width: 170px">操作</th>
|
<th style="width: 170px">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -212,6 +243,9 @@ onMounted(load)
|
|||||||
<td>
|
<td>
|
||||||
<span class="user-name">{{ row.username || '—' }}</span>
|
<span class="user-name">{{ row.username || '—' }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td v-if="isSuperAdmin">
|
||||||
|
<span class="creator-name">{{ groupTextOf(row) }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="module-cell">
|
<div class="module-cell">
|
||||||
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
||||||
@@ -231,8 +265,8 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="module-cell">
|
<div class="module-cell module-cell--stack">
|
||||||
<span v-if="row.proxy?.exists" class="mono-mask" :title="moduleTooltip(row.proxy)">{{ row.proxy.masked }}</span>
|
<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 v-else class="empty-value">未配置</span>
|
||||||
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
||||||
{{ moduleStatusMeta(row.proxy).label }}
|
{{ moduleStatusMeta(row.proxy).label }}
|
||||||
@@ -258,10 +292,10 @@ onMounted(load)
|
|||||||
</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">{{ keyword || statusFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -408,7 +442,7 @@ h3 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
min-width: 1240px;
|
min-width: 1440px;
|
||||||
}
|
}
|
||||||
.secrets-table-scroll th,
|
.secrets-table-scroll th,
|
||||||
.secrets-table-scroll td {
|
.secrets-table-scroll td {
|
||||||
@@ -439,6 +473,13 @@ h3 {
|
|||||||
.user-name {
|
.user-name {
|
||||||
color: #24384d;
|
color: #24384d;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
/* 用户名(含中文)可完整换行展示,不截断 */
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.creator-name {
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.module-cell {
|
.module-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -446,11 +487,29 @@ h3 {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
|
||||||
|
.module-cell--stack {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
.module-cell .mono-mask {
|
.module-cell .mono-mask {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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 {
|
.mono-mask {
|
||||||
font-family: Consolas, "Cascadia Mono", monospace;
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|||||||
@@ -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,7 +199,7 @@ 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: 150px">操作</th>
|
<th style="width: 150px">操作</th>
|
||||||
@@ -211,7 +211,7 @@ 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) }}
|
||||||
@@ -225,10 +225,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 ? 7 : 6" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 7 : 6" 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,7 +373,7 @@ 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>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -360,7 +385,7 @@ 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 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>
|
||||||
@@ -369,10 +394,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 ? 7 : 6" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">暂无总数据</td>
|
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无总数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -390,7 +415,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 +436,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>
|
||||||
|
|||||||
@@ -16,6 +16,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[]>([])
|
||||||
@@ -23,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<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)))
|
||||||
@@ -115,7 +125,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 = ''
|
||||||
@@ -123,6 +133,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> {
|
||||||
@@ -189,7 +201,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
|
||||||
@@ -348,7 +360,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" />
|
||||||
@@ -378,7 +390,7 @@ 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>
|
||||||
@@ -390,7 +402,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>
|
||||||
@@ -409,10 +421,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 ? 6 : 5" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="6" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 6 : 5" class="empty-tip">暂无数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -423,7 +435,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>
|
||||||
@@ -475,7 +487,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>
|
||||||
|
|||||||
@@ -15,6 +15,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 +27,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 +67,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 +76,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 +250,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 +404,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,7 +442,7 @@ 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>
|
||||||
@@ -443,7 +455,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>
|
||||||
@@ -463,10 +475,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 ? 7 : 6" class="empty-tip">加载中...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-else>
|
<tr v-else>
|
||||||
<td colspan="7" class="empty-tip">暂无数据</td>
|
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -477,7 +489,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 +547,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()
|
||||||
|
|||||||
@@ -18,6 +18,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 {
|
||||||
@@ -65,6 +67,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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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]}`
|
||||||
|
}
|
||||||
@@ -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="密码">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -305,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>
|
||||||
@@ -361,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>
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ export const adminPages: AdminPageDef[] = [
|
|||||||
{ 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-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') },
|
||||||
|
|||||||
@@ -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,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\)/, '解析版本号(缺失为空串,兼容历史行)')
|
||||||
|
})
|
||||||
@@ -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, 17)
|
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, 17, '错误页不应计入业务路由')
|
// 业务路由条目与页面定义一一对应;错误页不占用其中任何一条。
|
||||||
|
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', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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, 17)
|
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')
|
||||||
|
|||||||
@@ -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 变更后如需调整,请同步更新本文件数值。
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.permission.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.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;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.common.model.vo;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.common.model.vo;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
+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.JsonAlias;
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
+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.JsonAlias;
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.common.model.vo;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.productrisk.model.vo;
|
package com.nanri.aiimage.common.model.vo;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
+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 com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchTaskStageVo;
|
import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchTaskStageVo;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.nanri.aiimage.common.module;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务模块清单的**单一来源**(2026-09 全维度审查 G6)。
|
||||||
|
*
|
||||||
|
* 背景:新增一个工具模块此前要同时改 5 处枚举(结果文件 Job 白名单、按天清理清单、
|
||||||
|
* 站内通知模块名、任务心跳分支、陈旧判死巡检线),其中 4 处没有任何自检,
|
||||||
|
* 漏改表现为"功能静默不生效"。现在把**数据驱动的三处**(结果文件 Job、按天清理、通知中文名)
|
||||||
|
* 统一从这里派生,并由 {@code TaskModuleCoverageTest} 守住覆盖面。
|
||||||
|
*
|
||||||
|
* 心跳与陈旧判死两处是代码分支驱动(switch / 逐模块委派),无法纯数据派生,
|
||||||
|
* 新增模块时仍需实现对应 Handler,测试会提示缺失。
|
||||||
|
*/
|
||||||
|
public final class TaskModuleRegistry {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个任务模块的声明。
|
||||||
|
*
|
||||||
|
* @param type moduleType(与 biz_file_task.module_type 一致)
|
||||||
|
* @param label 站内通知/界面用中文名
|
||||||
|
* @param resultFileJob 是否产出结果文件(需要注册 ResultFileJobHandler)
|
||||||
|
* @param ageCleanup 是否参与按天清理(ModuleCleanupProperties)
|
||||||
|
* @param delegatedStaleCheck 是否以"委派"方式并入 stale-check 巡检线
|
||||||
|
* (DeleteBrandStaleTaskService.delegatedStaleChecks,必须逐模块登记动作)
|
||||||
|
* @param selfScheduledStaleCheck 该模块的陈旧判死自带更快的调度(publish 60s / collect-data 30s),
|
||||||
|
* 刻意不并入 2 分钟一轮的巡检线——并入会显著拉长判死时延。
|
||||||
|
* 这类模块的"不在委托名单"是设计差异,不是漏接;由覆盖面测试固定。
|
||||||
|
*/
|
||||||
|
public record Module(String type, String label, boolean resultFileJob, boolean ageCleanup,
|
||||||
|
boolean delegatedStaleCheck, boolean selfScheduledStaleCheck) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<Module> MODULES = List.of(
|
||||||
|
new Module("PUBLISH", "上架", true, false, false, true),
|
||||||
|
new Module("DEDUPE", "数据去重", false, true, false, false),
|
||||||
|
new Module("SPLIT", "数据拆分", false, true, false, false),
|
||||||
|
new Module("CONVERT", "格式转换", false, true, false, false),
|
||||||
|
new Module("DELETE_BRAND", "删除ASIN", true, true, false, false),
|
||||||
|
new Module("PRODUCT_RISK_RESOLVE", "商品风险解决", true, true, false, false),
|
||||||
|
new Module("PRICE_TRACK", "跟价", true, true, false, false),
|
||||||
|
new Module("SHOP_MATCH", "定时匹配", true, true, false, false),
|
||||||
|
new Module("PATROL_DELETE", "巡店删除", true, true, false, false),
|
||||||
|
new Module("QUERY_ASIN", "查询ASIN", true, true, false, false),
|
||||||
|
new Module("WITHDRAW", "取款", true, true, false, false),
|
||||||
|
new Module("APPEARANCE_PATENT", "外观专利检测", true, true, true, false),
|
||||||
|
new Module("SIMILAR_ASIN", "货源查询", true, true, true, false),
|
||||||
|
new Module("COLLECT_DATA", "采集数据", true, true, false, true),
|
||||||
|
new Module("SHOP_DATA_CRAWL", "店铺数据抓取", true, false, true, false),
|
||||||
|
new Module("BRAND", "品牌检测", true, false, true, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
private TaskModuleRegistry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Module> modules() {
|
||||||
|
return MODULES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部 moduleType。 */
|
||||||
|
public static Set<String> moduleTypes() {
|
||||||
|
return MODULES.stream().map(Module::type).collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** moduleType → 中文名(站内通知等展示用)。 */
|
||||||
|
public static Map<String, String> labels() {
|
||||||
|
Map<String, String> labels = new LinkedHashMap<>();
|
||||||
|
for (Module module : MODULES) {
|
||||||
|
labels.put(module.type(), module.label());
|
||||||
|
}
|
||||||
|
return Map.copyOf(labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产出结果文件的模块(需要注册 Handler 的模块)。 */
|
||||||
|
public static Set<String> resultFileJobModuleTypes() {
|
||||||
|
return MODULES.stream().filter(Module::resultFileJob).map(Module::type)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 参与按天清理的模块。 */
|
||||||
|
public static Set<String> ageCleanupModuleTypes() {
|
||||||
|
return MODULES.stream().filter(Module::ageCleanup).map(Module::type)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自带更快巡检节奏、刻意不并入集中巡检线的模块。 */
|
||||||
|
public static Set<String> selfScheduledStaleCheckModuleTypes() {
|
||||||
|
return MODULES.stream().filter(Module::selfScheduledStaleCheck).map(Module::type)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以"委派"方式并入 stale-check 巡检线的模块(DeleteBrandStaleTaskService 必须逐模块登记动作)。 */
|
||||||
|
public static Set<String> delegatedStaleCheckModuleTypes() {
|
||||||
|
return MODULES.stream().filter(Module::delegatedStaleCheck).map(Module::type)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String labelOf(String moduleType) {
|
||||||
|
for (Module module : MODULES) {
|
||||||
|
if (module.type().equals(moduleType)) {
|
||||||
|
return module.label();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moduleType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.nanri.aiimage.common.retry;
|
||||||
|
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM 重试等待策略:指数退避 + 抖动。
|
||||||
|
*
|
||||||
|
* <p>背景(2026-09-13 生产实测):上游 ai.t8star.org 存在持续数十秒的劣化窗口,
|
||||||
|
* 窗口内所有请求都不应答;原来的 1.5s/3s 密集重试会整批落在窗口内,三连失败后
|
||||||
|
* 行降级(外观识别异常)。退避拉到 2s/10s 并带 ±30% 抖动,可覆盖更长的窗口,
|
||||||
|
* 同时避免同批多行在同一时刻齐发重试形成尖峰。
|
||||||
|
*/
|
||||||
|
public final class LlmRetryBackoff {
|
||||||
|
|
||||||
|
private static final long[] BASE_DELAYS_MILLIS = {2_000L, 10_000L};
|
||||||
|
private static final double JITTER_RATIO = 0.3d;
|
||||||
|
private static final long MIN_DELAY_MILLIS = 200L;
|
||||||
|
|
||||||
|
private LlmRetryBackoff() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第 attemptIndex 次失败后的等待毫秒数(attemptIndex 从 1 开始);超出档位取最后一档。 */
|
||||||
|
public static long delayMillis(int attemptIndex) {
|
||||||
|
return delayMillis(attemptIndex, ThreadLocalRandom.current().nextDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 固定抖动入口(测试用):random 取值 [0,1),0.5 表示无抖动。 */
|
||||||
|
static long delayMillis(int attemptIndex, double random) {
|
||||||
|
int index = Math.min(Math.max(1, attemptIndex), BASE_DELAYS_MILLIS.length) - 1;
|
||||||
|
long base = BASE_DELAYS_MILLIS[index];
|
||||||
|
double bounded = Math.min(Math.max(random, 0d), 1d);
|
||||||
|
double factor = 1d + (bounded * 2d - 1d) * JITTER_RATIO;
|
||||||
|
return Math.max(MIN_DELAY_MILLIS, Math.round(base * factor));
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-20
@@ -1,23 +1,26 @@
|
|||||||
package com.nanri.aiimage.modules.admin.support;
|
package com.nanri.aiimage.common.security;
|
||||||
|
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.modules.auth.service.JwtService;
|
import com.nanri.aiimage.common.security.JwtService;
|
||||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
import com.nanri.aiimage.common.security.DeviceSessionPolicy;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.common.mapper.AdminUserMapper;
|
||||||
|
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
import com.nanri.aiimage.common.security.AuthProperties;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AdminAuthSupport {
|
public class AdminAuthSupport {
|
||||||
@@ -52,11 +55,39 @@ public class AdminAuthSupport {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw new BusinessException(401, "用户不存在");
|
throw new BusinessException(401, "用户不存在");
|
||||||
}
|
}
|
||||||
|
// 单设备登录:被新设备顶下线的旧 token 在此统一拦截(全站 requireUser 调用点自动生效)
|
||||||
|
if (authProperties.isSingleDeviceEnabled()) {
|
||||||
|
DeviceSessionPolicy.assertSameDevice(user.getMachine(), DeviceSessionPolicy.claimDeviceId(claims),
|
||||||
|
DeviceSessionPolicy.isSuperAdmin(user.getRole(), user.getIsAdmin(), user.getCreatedById()),
|
||||||
|
user.getId(), user.getUsername());
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */
|
/**
|
||||||
public AdminUserEntity requireAdmin(HttpServletRequest request) {
|
* 解析当前请求 JWT 中**签名的**设备标识(deviceId claim);识别不出时返回空串。
|
||||||
|
*
|
||||||
|
* <p>无 token、token 过期/非法、内部令牌通道调用一律返回空串——调用方必须把空串
|
||||||
|
* 当作"来源不明"做保守判定,绝不据此放宽任何限制。只认签名 claim,不接受
|
||||||
|
* X-Device-Id 请求头(头由客户端可控,见 {@link DeviceSessionPolicy} 类注释)。</p>
|
||||||
|
*
|
||||||
|
* <p>本方法只做识别、不做鉴权,因此解析失败不抛异常,仅记日志后返回空串,
|
||||||
|
* 避免把匿名/内部调用直接升级成 401。</p>
|
||||||
|
*/
|
||||||
|
public String currentDeviceId(HttpServletRequest request) {
|
||||||
|
String token = resolveToken(request);
|
||||||
|
if (token == null || token.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return DeviceSessionPolicy.claimDeviceId(jwtService.parse(token));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[auth] 解析 token 取设备标识失败,按来源不明处理: {}", ex.getMessage());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户必须是管理员或超级管理员,否则抛 403。 */ public AdminUserEntity requireAdmin(HttpServletRequest request) {
|
||||||
AdminUserEntity user = requireUser(request);
|
AdminUserEntity user = requireUser(request);
|
||||||
String role = currentRole(user);
|
String role = currentRole(user);
|
||||||
if (role == null) {
|
if (role == null) {
|
||||||
@@ -70,18 +101,7 @@ public class AdminAuthSupport {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String storedRole = user.getRole() == null ? "" : user.getRole().trim().toLowerCase();
|
return DeviceSessionPolicy.resolveRole(user.getRole(), user.getIsAdmin(), user.getCreatedById());
|
||||||
if ("super_admin".equals(storedRole)) {
|
|
||||||
return "super_admin";
|
|
||||||
}
|
|
||||||
if ("admin".equals(storedRole)) {
|
|
||||||
return "admin";
|
|
||||||
}
|
|
||||||
boolean isAdminFlag = user.getIsAdmin() != null && user.getIsAdmin() == 1;
|
|
||||||
if (storedRole.isEmpty() && isAdminFlag) {
|
|
||||||
return user.getCreatedById() == null ? "super_admin" : "admin";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** JWT 优先;无 JWT 时以可信内部代理身份(X-Internal-Token + operatorId)回退,仍要求管理员角色。 */
|
/** JWT 优先;无 JWT 时以可信内部代理身份(X-Internal-Token + operatorId)回退,仍要求管理员角色。 */
|
||||||
@@ -147,7 +167,10 @@ public class AdminAuthSupport {
|
|||||||
if (expectedToken.isBlank() || suppliedToken == null || suppliedToken.isBlank()) {
|
if (expectedToken.isBlank() || suppliedToken == null || suppliedToken.isBlank()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return expectedToken.equals(suppliedToken.trim());
|
// 常量时间比较:逐字节 equals 可被计时侧信道逐位试探出内部令牌
|
||||||
|
return java.security.MessageDigest.isEqual(
|
||||||
|
expectedToken.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||||
|
suppliedToken.trim().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+3
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.nanri.aiimage.modules.auth.config;
|
package com.nanri.aiimage.common.security;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
@@ -14,4 +14,6 @@ public class AuthProperties {
|
|||||||
private String cookieName = "aiimage_token";
|
private String cookieName = "aiimage_token";
|
||||||
private boolean cookieSecure = false;
|
private boolean cookieSecure = false;
|
||||||
private String cookieSameSite = "Lax";
|
private String cookieSameSite = "Lax";
|
||||||
|
/** 单设备登录(互踢)总开关:关闭后恢复为多设备同时在线(回滚用)。 */
|
||||||
|
private boolean singleDeviceEnabled = true;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.nanri.aiimage.common.security;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单设备登录(互踢)策略。
|
||||||
|
*
|
||||||
|
* <p>账号当前绑定的设备存放在 users.machine(登录成功即覆盖,last-login-wins);
|
||||||
|
* 非超管账号仅允许「token 内签名的 deviceId」与绑定设备一致的请求通过,
|
||||||
|
* 被新设备顶下线的旧 token 在下一次受保护请求时抛 4011。</p>
|
||||||
|
*
|
||||||
|
* <p>校验只认 token 内签名的 deviceId,绝不使用 X-Device-Id 请求头(头是客户端可控的)。</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class DeviceSessionPolicy {
|
||||||
|
|
||||||
|
/** 账号已在其他设备登录(前端据此提示并下线本设备)。 */
|
||||||
|
public static final int CODE_KICKED = 4011;
|
||||||
|
|
||||||
|
private DeviceSessionPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算用户管理角色:super_admin / admin / null(含老数据 role 为空时按 created_by_id 推断)。 */
|
||||||
|
public static String resolveRole(String role, Integer isAdmin, Long createdById) {
|
||||||
|
String storedRole = role == null ? "" : role.trim().toLowerCase();
|
||||||
|
if ("super_admin".equals(storedRole)) {
|
||||||
|
return "super_admin";
|
||||||
|
}
|
||||||
|
if ("admin".equals(storedRole)) {
|
||||||
|
return "admin";
|
||||||
|
}
|
||||||
|
boolean isAdminFlag = isAdmin != null && isAdmin == 1;
|
||||||
|
if (storedRole.isEmpty() && isAdminFlag) {
|
||||||
|
return createdById == null ? "super_admin" : "admin";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否超级管理员(互踢的唯一豁免角色)。 */
|
||||||
|
public static boolean isSuperAdmin(String role, Integer isAdmin, Long createdById) {
|
||||||
|
return "super_admin".equals(resolveRole(role, isAdmin, createdById));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 JWT claims 中提取签名的设备号;缺失返回空串。 */
|
||||||
|
public static String claimDeviceId(Claims claims) {
|
||||||
|
Object raw = claims == null ? null : claims.get("deviceId");
|
||||||
|
return raw == null ? "" : raw.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验请求携带的 token 是否仍属于账号当前绑定的设备。
|
||||||
|
* 超管豁免;machine 为空(尚未绑定)放行;不匹配抛 4011。
|
||||||
|
*/
|
||||||
|
public static void assertSameDevice(String storedMachine, String claimDeviceId, boolean exempt,
|
||||||
|
Long userId, String username) {
|
||||||
|
if (exempt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String bound = storedMachine == null ? "" : storedMachine.trim();
|
||||||
|
if (bound.isEmpty()) {
|
||||||
|
// 尚未绑定(首次登录前 / V118 清空后首个登录前),不做限制
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String claimed = claimDeviceId == null ? "" : claimDeviceId.trim();
|
||||||
|
if (claimed.isEmpty()) {
|
||||||
|
log.warn("[auth] 单设备登录校验:token 缺少设备标识,拒绝 userId={} username={}", userId, username);
|
||||||
|
throw new BusinessException(401, "登录态无效");
|
||||||
|
}
|
||||||
|
if (!bound.equals(claimed)) {
|
||||||
|
log.warn("[auth] 单设备登录拦截:用户 {} 已被其他设备顶下线,token设备={} 当前绑定设备={}",
|
||||||
|
userId, claimed, bound);
|
||||||
|
throw new BusinessException(CODE_KICKED, "该账号已在其他设备登录,本设备已下线");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录绑定时的中文日志:首次绑定 / 换设备(顶下线)/ 同设备重登,便于线上排查互踢来源。 */
|
||||||
|
public static void logBindOnLogin(String previousMachine, String deviceId, Long userId, String username) {
|
||||||
|
String previous = previousMachine == null ? "" : previousMachine.trim();
|
||||||
|
if (previous.isEmpty()) {
|
||||||
|
log.info("[auth] 单设备登录:用户 {}({})首次绑定设备 {}", userId, username, deviceId);
|
||||||
|
} else if (!previous.equals(deviceId)) {
|
||||||
|
log.warn("[auth] 单设备登录:用户 {}({})在设备 {} 登录,原设备 {} 已被顶下线",
|
||||||
|
userId, username, deviceId, previous);
|
||||||
|
} else {
|
||||||
|
log.info("[auth] 单设备登录:用户 {}({})同设备重新登录 device={}", userId, username, deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-5
@@ -1,6 +1,6 @@
|
|||||||
package com.nanri.aiimage.modules.auth.service;
|
package com.nanri.aiimage.common.security;
|
||||||
|
|
||||||
import com.nanri.aiimage.modules.auth.config.AuthProperties;
|
import com.nanri.aiimage.common.security.AuthProperties;
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import io.jsonwebtoken.JwtException;
|
import io.jsonwebtoken.JwtException;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
@@ -26,6 +26,26 @@ public class JwtService {
|
|||||||
|
|
||||||
private volatile SecretKey cachedKey;
|
private volatile SecretKey cachedKey;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Value("${spring.profiles.active:}")
|
||||||
|
private String activeProfiles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生产 profile 下密钥缺失即拒绝启动(2026-09 全维度审查)。
|
||||||
|
*
|
||||||
|
* <p>此前未配置时静默回退到内置默认密钥——那是公开值,任何人可据此伪造 token;
|
||||||
|
* 一次 env 丢失就会让全站鉴权形同虚设,只留一行 warn 日志不足以拦住发布。
|
||||||
|
* 本地/测试 profile 保持宽松(否则开发无法启动)。
|
||||||
|
*/
|
||||||
|
@jakarta.annotation.PostConstruct
|
||||||
|
void requireSecretInServerProfile() {
|
||||||
|
boolean serverProfile = activeProfiles != null && activeProfiles.contains("server");
|
||||||
|
if (serverProfile && (props.getJwtSecret() == null || props.getJwtSecret().isBlank())) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"server profile 下必须配置 aiimage.auth.jwt-secret(环境变量 AIIMAGE_JWT_SECRET)——"
|
||||||
|
+ "缺失时会回退到公开的默认密钥,token 可被任意伪造");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private SecretKey signingKey() {
|
private SecretKey signingKey() {
|
||||||
SecretKey key = cachedKey;
|
SecretKey key = cachedKey;
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
@@ -35,11 +55,12 @@ public class JwtService {
|
|||||||
String configured = props.getJwtSecret();
|
String configured = props.getJwtSecret();
|
||||||
byte[] keyBytes;
|
byte[] keyBytes;
|
||||||
if (configured == null || configured.isBlank()) {
|
if (configured == null || configured.isBlank()) {
|
||||||
// 已分发到用户机器的桌面客户端内置同一个默认密钥,服务端必须保持一致,
|
// 兜底:未配置时使用内置默认密钥(公开值,等同无防护,任何人可伪造 token)。
|
||||||
// 不能随机生成,否则所有旧客户端 /api/auth/sync 验签失败导致无法登录。
|
// 桌面客户端本地 Flask 已退役,服务端可独立轮换密钥;若恢复客户端侧验签,
|
||||||
|
// 需与客户端同步下发密钥后才能轮换。
|
||||||
keyBytes = INSECURE_DEFAULT_SECRET.getBytes(StandardCharsets.UTF_8);
|
keyBytes = INSECURE_DEFAULT_SECRET.getBytes(StandardCharsets.UTF_8);
|
||||||
log.warn("[auth] AIIMAGE_JWT_SECRET 未配置,使用内置默认密钥;"
|
log.warn("[auth] AIIMAGE_JWT_SECRET 未配置,使用内置默认密钥;"
|
||||||
+ "生产环境请通过环境变量 AIIMAGE_JWT_SECRET 配置固定密钥(需与桌面客户端一致)");
|
+ "生产环境请在 application-server.yml 配置 aiimage.auth.jwt-secret");
|
||||||
} else {
|
} else {
|
||||||
keyBytes = configured.getBytes(StandardCharsets.UTF_8);
|
keyBytes = configured.getBytes(StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.nanri.aiimage.common.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨节点的小容量状态存储(2026-09 全维度审查 D2)。
|
||||||
|
*
|
||||||
|
* <p>用途:导入/导出任务的进度、归属用户、分组等状态。原先只存节点本地内存,
|
||||||
|
* 客户端轮询落到另一节点就报"任务不存在"(nginx 的 user_id 亲和只覆盖常态)。
|
||||||
|
* 现在本地 Map 作快路径、Redis 作跨节点真源,任何节点都能读到。
|
||||||
|
*
|
||||||
|
* <p>写节流:调用方可能**逐行**刷新进度(数十万行),逐次写 Redis 不可接受;
|
||||||
|
* 默认 500ms 内只写一次(终态用 {@link #putNow} 立即落库)。
|
||||||
|
*
|
||||||
|
* <p>语义取舍:读-改-写不保证原子(进度类状态可接受);{@code redis}/{@code objectMapper}
|
||||||
|
* 为空时退化为纯本地(单测与未注入场景)。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class NodeSharedStore<K, V> {
|
||||||
|
|
||||||
|
private static final long DEFAULT_WRITE_THROTTLE_MILLIS = 500L;
|
||||||
|
|
||||||
|
private final String keyPrefix;
|
||||||
|
private final Duration ttl;
|
||||||
|
private final Class<V> valueType;
|
||||||
|
private final StringRedisTemplate redis;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final long writeThrottleMillis;
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<K, V> local = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<K, AtomicLong> lastWriteAt = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public NodeSharedStore(String keyPrefix,
|
||||||
|
Duration ttl,
|
||||||
|
Class<V> valueType,
|
||||||
|
StringRedisTemplate redis,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this(keyPrefix, ttl, valueType, redis, objectMapper, DEFAULT_WRITE_THROTTLE_MILLIS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public NodeSharedStore(String keyPrefix,
|
||||||
|
Duration ttl,
|
||||||
|
Class<V> valueType,
|
||||||
|
StringRedisTemplate redis,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
long writeThrottleMillis) {
|
||||||
|
this.keyPrefix = keyPrefix;
|
||||||
|
this.ttl = ttl;
|
||||||
|
this.valueType = valueType;
|
||||||
|
this.redis = redis;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.writeThrottleMillis = Math.max(0L, writeThrottleMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地优先;本地没有则读 Redis 并回填本地(跨节点可见)。 */
|
||||||
|
public V get(K key) {
|
||||||
|
if (key == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
V cached = local.get(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
V remote = readRemote(key);
|
||||||
|
if (remote != null) {
|
||||||
|
local.put(key, remote);
|
||||||
|
}
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean containsKey(K key) {
|
||||||
|
return get(key) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入并(按节流)同步到 Redis。 */
|
||||||
|
public void put(K key, V value) {
|
||||||
|
if (key == null || value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
local.put(key, value);
|
||||||
|
if (!throttleAllowsWrite(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeRemote(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即写入 Redis(终态、归属等一次性状态用)。 */
|
||||||
|
public void putNow(K key, V value) {
|
||||||
|
if (key == null || value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
local.put(key, value);
|
||||||
|
writeRemote(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除(本地 + Redis),返回删除前的值(可能为 null)。 */
|
||||||
|
public V remove(K key) {
|
||||||
|
if (key == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
V previous = local.remove(key);
|
||||||
|
lastWriteAt.remove(key);
|
||||||
|
if (redis != null) {
|
||||||
|
try {
|
||||||
|
redis.delete(fullKey(key));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[node-shared-store] 删除远端状态失败 key={} msg={}", fullKey(key), ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地快照(仅用于日志/统计;不含其它节点的写入)。 */
|
||||||
|
public int localSize() {
|
||||||
|
return local.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本节点已知条目的快照(副本,可安全遍历)。
|
||||||
|
*
|
||||||
|
* <p>用途:保留期清理等维护动作需要遍历键;跨节点的过期回收由 Redis TTL 兜底,
|
||||||
|
* 因此这里只返回本节点写入过的条目即可。
|
||||||
|
*/
|
||||||
|
public java.util.Map<K, V> localEntriesSnapshot() {
|
||||||
|
return new java.util.LinkedHashMap<>(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean throttleAllowsWrite(K key) {
|
||||||
|
if (writeThrottleMillis <= 0L) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
AtomicLong last = lastWriteAt.computeIfAbsent(key, ignored -> new AtomicLong(0L));
|
||||||
|
long previous = last.get();
|
||||||
|
if (now - previous < writeThrottleMillis) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return last.compareAndSet(previous, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private V readRemote(K key) {
|
||||||
|
if (redis == null || objectMapper == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String json = redis.opsForValue().get(fullKey(key));
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(json, valueType);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[node-shared-store] 读取远端状态失败 key={} msg={}", fullKey(key), ex.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeRemote(K key, V value) {
|
||||||
|
if (redis == null || objectMapper == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(value);
|
||||||
|
redis.opsForValue().set(fullKey(key), json, ttl);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// 写失败不影响本地进度(下次 put 会重试),只留线索
|
||||||
|
log.warn("[node-shared-store] 写入远端状态失败 key={} msg={}", fullKey(key), ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fullKey(K key) {
|
||||||
|
return keyPrefix + ":" + key;
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package com.nanri.aiimage.common.service;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结果文件下载直链解析(2026-09 全维度审查去重)。
|
||||||
|
*
|
||||||
|
* <p>此前 11 个业务模块各写一份逐字相同的 {@code resolveResultDownloadUrl},下载鉴权口径
|
||||||
|
* 或 OSS 直链规则一调整就要改 11 处;similarasin 已自行分叉成返回 record 的第 12 种写法,
|
||||||
|
* 说明"改的时候漏一个"已经开始发生。
|
||||||
|
*
|
||||||
|
* <p>文件名解析({@code resolveResultDownloadFilename})**未**收口:各模块兜底文件名策略
|
||||||
|
* 确实不同(模块名+id / 源文件名派生 stem),属业务差异而非重复。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ResultDownloadResolver {
|
||||||
|
|
||||||
|
private final FileResultMapper fileResultMapper;
|
||||||
|
private final OssStorageService ossStorageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析结果文件下载直链。
|
||||||
|
*
|
||||||
|
* @param resultId 结果行 id(biz_file_result)
|
||||||
|
* @param userId 当前用户 id,必须与结果行归属一致
|
||||||
|
* @param moduleType 期望的模块类型
|
||||||
|
*/
|
||||||
|
public String resolveUrl(Long resultId, Long userId, String moduleType) {
|
||||||
|
FileResultEntity row = fileResultMapper.selectById(resultId);
|
||||||
|
if (row == null || !moduleType.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) {
|
||||||
|
throw new BusinessException("记录不存在");
|
||||||
|
}
|
||||||
|
if (row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) {
|
||||||
|
throw new BusinessException("暂无可下载文件");
|
||||||
|
}
|
||||||
|
return ossStorageService.generateFreshDownloadUrl(row.getResultFileUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-6
@@ -13,10 +13,12 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StreamUtils;
|
import org.springframework.util.StreamUtils;
|
||||||
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.util.ContentCachingRequestWrapper;
|
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.ConnectException;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -28,6 +30,11 @@ public class TaskOwnerForwardService {
|
|||||||
|
|
||||||
public static final String FORWARDED_HEADER = "X-AIIMAGE-Owner-Forwarded";
|
public static final String FORWARDED_HEADER = "X-AIIMAGE-Owner-Forwarded";
|
||||||
|
|
||||||
|
/** 连接类失败的重试次数(含首次)。对端滚动重启时通常几秒内即可恢复。 */
|
||||||
|
private static final int CONNECT_RETRY_TIMES = 3;
|
||||||
|
/** 第 n 次重试前的退避:1s、2s(总等待不超过 3s,不长时间占用请求线程)。 */
|
||||||
|
private static final long CONNECT_RETRY_BACKOFF_MILLIS = 1000L;
|
||||||
|
|
||||||
private static final Set<String> HOP_BY_HOP_HEADERS = Set.of(
|
private static final Set<String> HOP_BY_HOP_HEADERS = Set.of(
|
||||||
"connection",
|
"connection",
|
||||||
"keep-alive",
|
"keep-alive",
|
||||||
@@ -58,12 +65,60 @@ public class TaskOwnerForwardService {
|
|||||||
HttpHeaders headers = copyForwardHeaders(request, ex.getCurrentInstanceId());
|
HttpHeaders headers = copyForwardHeaders(request, ex.getCurrentInstanceId());
|
||||||
log.info("[instance-routing] forward {} taskId={} operation={} owner={} current={} url={}",
|
log.info("[instance-routing] forward {} taskId={} operation={} owner={} current={} url={}",
|
||||||
method, ex.getTaskId(), ex.getOperation(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId(), url);
|
method, ex.getTaskId(), ex.getOperation(), ex.getOwnerInstanceId(), ex.getCurrentInstanceId(), url);
|
||||||
return restClient().method(method)
|
return forwardWithConnectRetry(method, url, headers, body, ex);
|
||||||
.uri(url)
|
}
|
||||||
.headers(target -> target.addAll(headers))
|
|
||||||
.body(body)
|
/**
|
||||||
.retrieve()
|
* 转发带连接级重试。
|
||||||
.toEntity(byte[].class);
|
*
|
||||||
|
* <p>对端实例在部署窗口(原地换 JAR + 两节点滚动重启)内会有几秒的 Connection refused。
|
||||||
|
* 连接都没建立起来说明请求没到达对端,此时重放是安全的;而读超时不重试——对端可能
|
||||||
|
* 已经在处理,盲目重放会造成重复提交。线上由此丢过用户提交的结果。
|
||||||
|
*/
|
||||||
|
private ResponseEntity<byte[]> forwardWithConnectRetry(HttpMethod method, String url,
|
||||||
|
HttpHeaders headers, byte[] body,
|
||||||
|
TaskOwnerMismatchException ex) {
|
||||||
|
RuntimeException lastError = null;
|
||||||
|
for (int attempt = 1; attempt <= CONNECT_RETRY_TIMES; attempt++) {
|
||||||
|
try {
|
||||||
|
return restClient().method(method)
|
||||||
|
.uri(url)
|
||||||
|
.headers(target -> target.addAll(headers))
|
||||||
|
.body(body)
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(byte[].class);
|
||||||
|
} catch (ResourceAccessException accessError) {
|
||||||
|
if (!isConnectFailure(accessError)) {
|
||||||
|
throw accessError;
|
||||||
|
}
|
||||||
|
lastError = accessError;
|
||||||
|
log.warn("[instance-routing] 转发连接失败,第 {}/{} 次 url={} taskId={} 原因={}",
|
||||||
|
attempt, CONNECT_RETRY_TIMES, url, ex.getTaskId(), accessError.getMessage());
|
||||||
|
if (attempt < CONNECT_RETRY_TIMES) {
|
||||||
|
sleepQuietly(CONNECT_RETRY_BACKOFF_MILLIS * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isConnectFailure(Throwable error) {
|
||||||
|
Throwable cursor = error;
|
||||||
|
while (cursor != null) {
|
||||||
|
if (cursor instanceof ConnectException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
cursor = cursor.getCause();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sleepQuietly(long millis) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(millis);
|
||||||
|
} catch (InterruptedException interrupted) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveUrl(TaskOwnerMismatchException ex, String path) {
|
private String resolveUrl(TaskOwnerMismatchException ex, String path) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.nanri.aiimage.common.util;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界 LRU 缓存:容量超限时自动淘汰最久未使用的条目。
|
||||||
|
*
|
||||||
|
* <p>用于按「外部端点」缓存长生命周期资源(HttpClient / RestClient)。这类资源各自持有
|
||||||
|
* 连接池与 selector 线程,无界累积会持续泄漏线程与内存:代理端点每次提取往往是新的
|
||||||
|
* IP:port(jikip 提取),无上限的缓存只增不减。
|
||||||
|
*
|
||||||
|
* <p>淘汰时只从缓存移除引用,不做显式关闭:JDK 的 HttpClientImpl 注册了 Cleaner,
|
||||||
|
* 对象不可达后由 GC 回收并关闭其 selector 线程;显式关闭反而可能打断仍在途的请求。
|
||||||
|
*/
|
||||||
|
public final class BoundedLruCache<K, V> {
|
||||||
|
|
||||||
|
/** 默认容量:代理端点数量级远小于此,足够覆盖热点端点又不至于累积。 */
|
||||||
|
public static final int DEFAULT_MAX_SIZE = 64;
|
||||||
|
|
||||||
|
private final int maxSize;
|
||||||
|
private final Map<K, V> store;
|
||||||
|
|
||||||
|
public BoundedLruCache() {
|
||||||
|
this(DEFAULT_MAX_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BoundedLruCache(int maxSize) {
|
||||||
|
this.maxSize = Math.max(1, maxSize);
|
||||||
|
// accessOrder=true 使 get 也刷新顺序(真正的 LRU);synchronizedMap 保证其线程安全
|
||||||
|
this.store = Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {
|
||||||
|
@Override
|
||||||
|
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
|
||||||
|
return size() > BoundedLruCache.this.maxSize;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取缓存值,缺失时用 loader 计算并放入。
|
||||||
|
*
|
||||||
|
* <p>与 {@code ConcurrentHashMap.computeIfAbsent} 不同,此处不保证 loader 的原子性:
|
||||||
|
* 并发首次访问同一 key 时可能各自构造一次,随后其中一个覆盖另一个。对
|
||||||
|
* HttpClient/RestClient 这类构造廉价且幂等的资源可接受,换来的是锁粒度更小。
|
||||||
|
*/
|
||||||
|
public V computeIfAbsent(K key, Function<K, V> loader) {
|
||||||
|
V existing = store.get(key);
|
||||||
|
if (existing != null) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
V created = loader.apply(key);
|
||||||
|
store.put(key, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size() {
|
||||||
|
return store.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前容量上限,供日志与测试断言使用。 */
|
||||||
|
public int maxSize() {
|
||||||
|
return maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clear() {
|
||||||
|
store.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ public final class ExcelStreamReader {
|
|||||||
default void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) throws Exception {
|
default void onHeader(String sheetName, Integer sheetNo, Map<Integer, String> headerMap) throws Exception {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表头之后回调本次 sheet 的近似总行数(EasyExcel 基于 sheet 尺寸,可能为 null)。
|
||||||
|
* 流式解析无法在读完前得到精确行数,需要展示进度/做前置上限校验的调用方可用它近似。
|
||||||
|
*/
|
||||||
|
default void onSheetTotal(String sheetName, Integer sheetNo, Integer approximateTotalRows) throws Exception {
|
||||||
|
}
|
||||||
|
|
||||||
void onRow(String sheetName, Integer sheetNo, int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) throws Exception;
|
void onRow(String sheetName, Integer sheetNo, int rowIndex, Map<Integer, String> headerMap, Map<Integer, String> rowMap) throws Exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +84,7 @@ public final class ExcelStreamReader {
|
|||||||
currentHeaderMap = normalizedHeadMap;
|
currentHeaderMap = normalizedHeadMap;
|
||||||
try {
|
try {
|
||||||
handler.onHeader(sheetName(context), sheetNo(context), currentHeaderMap);
|
handler.onHeader(sheetName(context), sheetNo(context), currentHeaderMap);
|
||||||
|
handler.onSheetTotal(sheetName(context), sheetNo(context), approximateTotalRows(context));
|
||||||
} catch (RuntimeException ex) {
|
} catch (RuntimeException ex) {
|
||||||
throw ex;
|
throw ex;
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
@@ -109,6 +117,15 @@ public final class ExcelStreamReader {
|
|||||||
public void doAfterAllAnalysed(AnalysisContext context) {
|
public void doAfterAllAnalysed(AnalysisContext context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Integer approximateTotalRows(AnalysisContext context) {
|
||||||
|
try {
|
||||||
|
return context.readSheetHolder() == null ? null
|
||||||
|
: context.readSheetHolder().getApproximateTotalRowNumber();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String sheetName(AnalysisContext context) {
|
private String sheetName(AnalysisContext context) {
|
||||||
return context.readSheetHolder() == null ? "" : context.readSheetHolder().getSheetName();
|
return context.readSheetHolder() == null ? "" : context.readSheetHolder().getSheetName();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.nanri.aiimage.common.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待删对象队列的本地落盘日志:进程重启后仍能恢复「待删除对象」清单。
|
||||||
|
*
|
||||||
|
* <p>使用场景:对象存储删除失败后的补偿队列原本只在节点内存里,重启即丢,
|
||||||
|
* 对应对象会一直残留在桶里直到生命周期规则过期。这里用一行一个 objectKey 的
|
||||||
|
* 追加日志做最小持久化——入队追加、队列收敛后整体重写、启动时回放。
|
||||||
|
*
|
||||||
|
* <p>并发:所有方法内部同步,调用方无需额外加锁;文件损坏/读写异常只记日志不抛出,
|
||||||
|
* 保证补偿链路本身不会因为落盘失败而中断业务。
|
||||||
|
*
|
||||||
|
* <p>上限:{@code maxEntries} 用于防止日志在异常堆积时无界增长(超出后丢弃最早的记录,
|
||||||
|
* 与内存队列的容量准入语义一致——丢的是「待删对象」,最坏结果是对象残留)。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class PendingDeleteJournal {
|
||||||
|
|
||||||
|
private final Path path;
|
||||||
|
private final int maxEntries;
|
||||||
|
private final Object lock = new Object();
|
||||||
|
|
||||||
|
public PendingDeleteJournal(Path path, int maxEntries) {
|
||||||
|
this.path = path;
|
||||||
|
this.maxEntries = Math.max(1, maxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path getPath() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加一条待删对象(重复追加由回放时的 Set 语义去重)。 */
|
||||||
|
public void record(String objectKey) {
|
||||||
|
if (objectKey == null || objectKey.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (lock) {
|
||||||
|
try {
|
||||||
|
Path parent = path.getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
Files.writeString(path, sanitize(objectKey) + System.lineSeparator(),
|
||||||
|
StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[delete-journal] 追加待删对象失败 path={} msg={}", path, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动回放:返回日志中的待删对象(按首次出现顺序去重)。 */
|
||||||
|
public List<String> readAll() {
|
||||||
|
synchronized (lock) {
|
||||||
|
if (!Files.isRegularFile(path)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> lines;
|
||||||
|
try {
|
||||||
|
lines = Files.readAllLines(path, StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[delete-journal] 读取待删对象日志失败 path={} msg={}", path, ex.getMessage());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Set<String> unique = new LinkedHashSet<>();
|
||||||
|
for (String line : lines) {
|
||||||
|
if (line == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = line.trim();
|
||||||
|
if (!key.isEmpty()) {
|
||||||
|
unique.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(unique);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 队列收敛(成功删除/引用仍在)后整体重写为剩余的待删对象;剩余为空则删除日志。 */
|
||||||
|
public void rewrite(Collection<String> remaining) {
|
||||||
|
synchronized (lock) {
|
||||||
|
Set<String> keep = new LinkedHashSet<>();
|
||||||
|
if (remaining != null) {
|
||||||
|
for (String key : remaining) {
|
||||||
|
if (key != null && !key.isBlank()) {
|
||||||
|
keep.add(sanitize(key));
|
||||||
|
if (keep.size() >= maxEntries) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (keep.isEmpty()) {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path parent = path.getParent();
|
||||||
|
if (parent != null) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
Path tmp = path.resolveSibling(path.getFileName() + ".tmp");
|
||||||
|
StringBuilder content = new StringBuilder();
|
||||||
|
for (String key : keep) {
|
||||||
|
content.append(key).append(System.lineSeparator());
|
||||||
|
}
|
||||||
|
Files.writeString(tmp, content.toString(), StandardCharsets.UTF_8,
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
try {
|
||||||
|
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (IOException atomicUnsupported) {
|
||||||
|
// 少数文件系统不支持 ATOMIC_MOVE,退化为普通替换
|
||||||
|
Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("[delete-journal] 重写待删对象日志失败 path={} msg={}", path, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单行一条记录:去掉换行避免破坏行结构。 */
|
||||||
|
private static String sanitize(String objectKey) {
|
||||||
|
return objectKey.replace('\n', ' ').replace('\r', ' ').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.nanri.aiimage.common.util;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 敏感串脱敏工具(2026-09 全维度审查后收口)。
|
||||||
|
*
|
||||||
|
* <p>背景:代理提取链接形态为 {@code http://user:pass@host:port},代码中曾有多处
|
||||||
|
* 直接把整串打进日志,导致用户代理账号密码长期留在应用日志与 docker logs 里。
|
||||||
|
*/
|
||||||
|
public final class SecretMasking {
|
||||||
|
|
||||||
|
private SecretMasking() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用掩码:保留前 4 与后 4 字符;过短则整体掩掉。 */
|
||||||
|
public static String mask(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String text = value.trim();
|
||||||
|
if (text.length() <= 8) {
|
||||||
|
return "****";
|
||||||
|
}
|
||||||
|
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 代理掩码:隐去账号密码,保留 scheme://host:port 便于运维核对。 */
|
||||||
|
public static String maskProxy(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(value.trim());
|
||||||
|
if (uri.getHost() == null || uri.getHost().isBlank()) {
|
||||||
|
return mask(value);
|
||||||
|
}
|
||||||
|
StringBuilder masked = new StringBuilder();
|
||||||
|
masked.append(uri.getScheme() == null ? "http" : uri.getScheme()).append("://");
|
||||||
|
if (uri.getUserInfo() != null && !uri.getUserInfo().isBlank()) {
|
||||||
|
masked.append("***@");
|
||||||
|
}
|
||||||
|
masked.append(uri.getHost());
|
||||||
|
if (uri.getPort() > 0) {
|
||||||
|
masked.append(':').append(uri.getPort());
|
||||||
|
}
|
||||||
|
return masked.toString();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
return mask(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.nanri.aiimage.common.util;
|
||||||
|
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有界线程池工厂(2026-09 全维度审查补)。
|
||||||
|
*
|
||||||
|
* <p>业务里多处使用 {@code Executors.newFixedThreadPool}:它内部是**无界**
|
||||||
|
* {@code LinkedBlockingQueue},任务堆积时永远不会触发拒绝策略,会把内存吃满
|
||||||
|
* (表现为 OOM,或整机因 GC 变慢导致所有任务一起劣化)。
|
||||||
|
* 统一改为有界队列 + {@code CallerRunsPolicy}:队列满时在提交线程执行,形成天然背压。
|
||||||
|
*/
|
||||||
|
public final class ThreadPools {
|
||||||
|
|
||||||
|
private ThreadPools() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认队列容量:足以吸收突发,又不至于无界堆积。 */
|
||||||
|
public static final int DEFAULT_QUEUE_CAPACITY = 512;
|
||||||
|
|
||||||
|
/** 有界固定线程池(daemon 线程,空闲可回收)。 */
|
||||||
|
public static ExecutorService boundedFixed(String threadNamePrefix, int threads) {
|
||||||
|
return boundedFixed(threadNamePrefix, threads, DEFAULT_QUEUE_CAPACITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有界固定线程池(显式队列容量)。 */
|
||||||
|
public static ExecutorService boundedFixed(String threadNamePrefix, int threads, int queueCapacity) {
|
||||||
|
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||||
|
Math.max(1, threads),
|
||||||
|
Math.max(1, threads),
|
||||||
|
// keepAliveTime 必须 > 0:下面开了 allowCoreThreadTimeOut,
|
||||||
|
// 传 0 会让构造器直接抛 "Core threads must have nonzero keep alive times"
|
||||||
|
60L, TimeUnit.SECONDS,
|
||||||
|
new LinkedBlockingQueue<>(Math.max(1, queueCapacity)),
|
||||||
|
runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, threadNamePrefix);
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
},
|
||||||
|
new ThreadPoolExecutor.CallerRunsPolicy());
|
||||||
|
executor.allowCoreThreadTimeOut(true);
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ package com.nanri.aiimage.config;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.nanri.aiimage.common.api.ApiResponse;
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
import com.nanri.aiimage.common.exception.BusinessException;
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
import com.nanri.aiimage.common.security.AdminAuthSupport;
|
||||||
|
import com.nanri.aiimage.common.security.DeviceSessionPolicy;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -63,6 +64,24 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
|||||||
private static final String[] USER_TOOL_PREFIXES = {
|
private static final String[] USER_TOOL_PREFIXES = {
|
||||||
"/api/collect-data",
|
"/api/collect-data",
|
||||||
"/api/price-track",
|
"/api/price-track",
|
||||||
|
// 2026-09 全维度审查补:以下前缀此前完全在守卫范围之外。
|
||||||
|
// /api/files 匿名可上传(2GB/次,可耗尽临时盘);/api/digital-human 匿名可发布/删除版本;
|
||||||
|
// /api/brand 的 fileUrl 曾可直接请求任意地址(/api/image-video 与 /api/task-file-jobs
|
||||||
|
// 客户端零调用,已移入下方无条件名单)。
|
||||||
|
"/api/files",
|
||||||
|
"/api/digital-human",
|
||||||
|
"/api/brand",
|
||||||
|
"/api/appearance-patent",
|
||||||
|
"/api/similar-asin",
|
||||||
|
"/api/query-asin",
|
||||||
|
"/api/patrol-delete",
|
||||||
|
"/api/product-risk-resolve",
|
||||||
|
"/api/shop-match",
|
||||||
|
"/api/shop-data-crawl",
|
||||||
|
"/api/withdraw",
|
||||||
|
// /api/tasks/{taskId}/interrupted 仅凭 taskId 即可把 RUNNING 任务置为 FAILED,
|
||||||
|
// 匿名遍历 taskId 就能批量打断线上任务
|
||||||
|
"/api/tasks",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +90,17 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
|||||||
*/
|
*/
|
||||||
private static final String[] SELF_SERVICE_PREFIXES = {
|
private static final String[] SELF_SERVICE_PREFIXES = {
|
||||||
"/api/user-secrets",
|
"/api/user-secrets",
|
||||||
|
"/api/notifications",
|
||||||
|
// 2026-09-14:这三组前缀**桌面端 Python 侧零调用**(实测),只有带 JWT 的网页端在用,
|
||||||
|
// 因此不必等客户端铺开即可无条件收紧(其余用户态前缀仍在 user-tool-guard-enabled 开关后面)
|
||||||
|
"/api/image-video",
|
||||||
|
"/api/task-file-jobs",
|
||||||
|
// 其全部接口已在 controller 内 requireAdmin(含可换取员工店铺登录令牌的 /shops/open),
|
||||||
|
// 纳入守卫是"鉴权失败返回统一 401 体"的第二层
|
||||||
|
"/api/ziniao",
|
||||||
|
// 2026-09 全维度审查补:内部端点此前仅靠 controller 自校验令牌,纳入守卫后
|
||||||
|
// 不带令牌的请求直接 401(带可信令牌的仍由 doFilterInternal 放行)
|
||||||
|
"/api/internal",
|
||||||
};
|
};
|
||||||
|
|
||||||
private final AdminAuthSupport adminAuthSupport;
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
@@ -124,7 +154,14 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
|||||||
ApiResponse<Void> body = ex.getCode() == null
|
ApiResponse<Void> body = ex.getCode() == null
|
||||||
? ApiResponse.fail(ex.getMessage())
|
? ApiResponse.fail(ex.getMessage())
|
||||||
: ApiResponse.fail(ex.getCode(), ex.getMessage());
|
: ApiResponse.fail(ex.getCode(), ex.getMessage());
|
||||||
log.warn("[admin-guard] {} {} rejected: {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
|
// 401(登录已过期)与被顶下线是前端定时轮询(/api/notifications/summary、/api/user-secrets 等)
|
||||||
|
// 的常态:线上单节点一天近 3000 条,会把真实业务错误淹没。与 GlobalExceptionHandler
|
||||||
|
// 的 isRoutineAuthNoise 同一口径降为 debug。
|
||||||
|
if (isRoutineAuthNoise(ex.getCode())) {
|
||||||
|
log.debug("[admin-guard] {} {} rejected: {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
|
||||||
|
} else {
|
||||||
|
log.warn("[admin-guard] {} {} rejected: {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
|
||||||
|
}
|
||||||
response.setStatus(HttpServletResponse.SC_OK);
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
response.setContentType("application/json;charset=UTF-8");
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
response.getWriter().write(objectMapper.writeValueAsString(body));
|
response.getWriter().write(objectMapper.writeValueAsString(body));
|
||||||
@@ -139,6 +176,12 @@ public class AdminApiGuardFilter extends OncePerRequestFilter {
|
|||||||
chain.doFilter(request, response);
|
chain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 登录态过期 / 被其他设备顶下线:前端轮询的常态噪声,不占 WARN。 */
|
||||||
|
private static boolean isRoutineAuthNoise(Integer code) {
|
||||||
|
return Integer.valueOf(401).equals(code)
|
||||||
|
|| Integer.valueOf(DeviceSessionPolicy.CODE_KICKED).equals(code);
|
||||||
|
}
|
||||||
|
|
||||||
/** 命中受保护前缀(/api/admin、/debug、自助接口、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */
|
/** 命中受保护前缀(/api/admin、/debug、自助接口、用户态工具前缀及其子路径)才进入鉴权,其余请求直接放行。 */
|
||||||
private boolean isGuarded(String uri) {
|
private boolean isGuarded(String uri) {
|
||||||
if (matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX)) {
|
if (matchesPrefix(uri, ADMIN_API_PREFIX) || matchesPrefix(uri, DEBUG_PREFIX)) {
|
||||||
|
|||||||
+17
-2
@@ -22,17 +22,32 @@ public class AppearancePatentProperties {
|
|||||||
private int llmMaxTokens = 64000;
|
private int llmMaxTokens = 64000;
|
||||||
private int llmConnectTimeoutMillis = 10000;
|
private int llmConnectTimeoutMillis = 10000;
|
||||||
private int llmReadTimeoutMillis = 180000;
|
private int llmReadTimeoutMillis = 180000;
|
||||||
|
/**
|
||||||
|
* 首次尝试读超时(毫秒):上游偶发单请求不应答(挂满超时才失败),
|
||||||
|
* 首查用更短预算快速失败并重试;生产实测成功调用 p99≈34s、超 60s 仅 0.02%,
|
||||||
|
* 故默认 60s 既覆盖正常慢响应又能把单次挂死代价从 90s 降到 60s。
|
||||||
|
*/
|
||||||
|
private int llmFirstAttemptReadTimeoutMillis = 60000;
|
||||||
private int llmBatchSize = 10;
|
private int llmBatchSize = 10;
|
||||||
/**
|
/**
|
||||||
* 批内行级并发数,默认等于批量大小
|
* 批内行级并发数。批次串行提交,每行串行发 2 个 LLM 请求,
|
||||||
|
* 故该值≈单任务对 LLM 网关的瞬时并发;默认与品牌检测同为 5,避免多任务并行时成倍放大。
|
||||||
*/
|
*/
|
||||||
private int llmRowConcurrency = 10;
|
private int llmRowConcurrency = 5;
|
||||||
/**
|
/**
|
||||||
* 每行每个 LLM 请求的重试次数(含首次)
|
* 每行每个 LLM 请求的重试次数(含首次)
|
||||||
*/
|
*/
|
||||||
private int llmRetryTimes = 3;
|
private int llmRetryTimes = 3;
|
||||||
private int staleTimeoutMinutes = 30;
|
private int staleTimeoutMinutes = 30;
|
||||||
private String staleFinalizeCron = "0 */2 * * * *";
|
private String staleFinalizeCron = "0 */2 * * * *";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二次判死线(心跳正常但连续 N 分钟无结果分片上报)阈值,分钟;<=0 关闭本线。
|
||||||
|
* 既有判定以 Redis heartbeat stale 为主信号,但心跳随 Python HTTP 心跳每分钟刷新——
|
||||||
|
* 主线程卡死时心跳线程照发,任务永远不 stale(与生产 28131 同型缺口)。
|
||||||
|
* 本线改看 biz_task_scope_state.last_chunk_at(仅分片上传时刷新)。
|
||||||
|
*/
|
||||||
|
private int noResultUploadTimeoutMinutes = 180;
|
||||||
/**
|
/**
|
||||||
* 末尾不足一批的数据等待该时长后强制提交检测。
|
* 末尾不足一批的数据等待该时长后强制提交检测。
|
||||||
* Python 回传超时后会直接封口;这个配置处理 Python 慢回传但仍未超时的零头批次。
|
* Python 回传超时后会直接封口;这个配置处理 Python 慢回传但仍未超时的零头批次。
|
||||||
|
|||||||
@@ -10,10 +10,27 @@ public class BrandCheckProperties {
|
|||||||
private String path = "/brand_check";
|
private String path = "/brand_check";
|
||||||
private String token = "";
|
private String token = "";
|
||||||
private String defaultStrategy = "Terms";
|
private String defaultStrategy = "Terms";
|
||||||
/** 单品牌商标查询的重试次数(含首次),连续失败超过该次数才判定为查询失败。 */
|
/**
|
||||||
private int retryTimes = 3;
|
* 单品牌商标查询的重试次数(含首次),连续失败超过该次数才判定为查询失败。
|
||||||
/** 每次查询失败后到下一次重试前的等待毫秒数。 */
|
* 原为 3:16890 偶发限流几秒内即恢复,3 次(前两次间隔各 1s)恢复不了就把结论
|
||||||
|
* 写成「查询失败」,对客户是硬伤;2026-09-14 与客户端品牌一致提到 10 次。
|
||||||
|
*/
|
||||||
|
private int retryTimes = 10;
|
||||||
|
/** 每次查询失败后到下一次重试前的等待毫秒数(基准值,按重试轮次递增)。 */
|
||||||
private int retryIntervalMillis = 1000;
|
private int retryIntervalMillis = 1000;
|
||||||
|
/**
|
||||||
|
* 单次重试等待的上限毫秒数。等待按 retryIntervalMillis × 第几次重试 递增后封顶,
|
||||||
|
* 10 次尝试合计约 45s,既能覆盖限流窗口又不会让单个品牌长时间占住查询线程。
|
||||||
|
*/
|
||||||
|
private int retryMaxIntervalMillis = 10000;
|
||||||
|
/**
|
||||||
|
* 一次批量检查的总耗时上限(毫秒):整批用尽后不再重试,剩余品牌按「查询失败」收尾。
|
||||||
|
* 上限的意义不是省时间,而是给「分片回传」这类同步调用方一个时延上界——上游卡死时
|
||||||
|
* 单品牌 10 次重试曾把一次回传拖到 103.5 秒(taskId 28599),客户端重试预算耗尽后
|
||||||
|
* 中止了整个采集。首轮请求始终执行,故上游只是略慢时不会误降级。
|
||||||
|
* 设为 0 或负数表示不限制。
|
||||||
|
*/
|
||||||
|
private int totalTimeoutMillis = 90000;
|
||||||
private int connectTimeoutMillis = 10000;
|
private int connectTimeoutMillis = 10000;
|
||||||
private int readTimeoutMillis = 60000;
|
private int readTimeoutMillis = 60000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,11 @@ public class BrandProgressProperties {
|
|||||||
private long failedTtlHours = 2;
|
private long failedTtlHours = 2;
|
||||||
private long heartbeatTimeoutMinutes = 15;
|
private long heartbeatTimeoutMinutes = 15;
|
||||||
private String staleCheckCron = "0 */2 * * * *";
|
private String staleCheckCron = "0 */2 * * * *";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二次判死线(心跳正常但连续 N 分钟无结果上报)阈值,分钟;<=0 关闭本线。
|
||||||
|
* 既有心跳线以 updated_at/last_heartbeat_at 陈旧为判据,而前端心跳会持续刷新它们——
|
||||||
|
* 主线程卡死时心跳线程照发,任务永远命不中。本线改看结果上报时写入的 last_result_at。
|
||||||
|
*/
|
||||||
|
private long noResultUploadTimeoutMinutes = 180;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -45,6 +45,19 @@ public class DeleteBrandProgressProperties {
|
|||||||
*/
|
*/
|
||||||
private long withdrawStaleTimeoutMinutes = 30;
|
private long withdrawStaleTimeoutMinutes = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「心跳正常但连续 N 分钟无结果分片上报」的二次判死阈值(分钟),默认 3 小时。
|
||||||
|
*
|
||||||
|
* <p>既有各模块心跳线的候选条件是 updated_at 陈旧,而客户端心跳会持续刷新 updated_at——
|
||||||
|
* 主线程卡死时心跳线程照发,任务永远命不中(生产 28131 卡死 12h+ 仍 RUNNING)。
|
||||||
|
* 本线改用 biz_task_scope_state.last_chunk_at(只随结果分片上报刷新)作判据,
|
||||||
|
* 从未上报(无 scope 行或全 NULL)的任务跳过,避免误杀首批较慢的正常任务。
|
||||||
|
*/
|
||||||
|
private long noResultUploadTimeoutMinutes = 180;
|
||||||
|
|
||||||
|
/** 二次判死线开关:false = 整段不扫描(观察期与回滚用,改环境变量即生效)。 */
|
||||||
|
private boolean noResultUploadCheckEnabled = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组装结果文件时允许缺失的最大分片数:缺失不超过该值时降级组装(缺失行状态写"未回传"),
|
* 组装结果文件时允许缺失的最大分片数:缺失不超过该值时降级组装(缺失行状态写"未回传"),
|
||||||
* 保证任务不因少量分片丢失整体白跑;超出时任务失败,但仍会在重试耗尽后尝试
|
* 保证任务不因少量分片丢失整体白跑;超出时任务失败,但仍会在重试耗尽后尝试
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备日志对象存储配置:指向主机B 独立部署的 MinIO 实例(非业务 MinIO)。
|
||||||
|
*
|
||||||
|
* <p>日志体积大、只保留 7 天,独立实例便于单独设生命周期规则与容量管理,
|
||||||
|
* 不挤占业务桶(nanri-ai-images 等)。endpoint 为空时上报接口直接失败,
|
||||||
|
* 不做静默回退(避免日志悄悄落到其他存储上而无人知情)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ConfigurationProperties(prefix = "aiimage.device-log-oss")
|
||||||
|
public class DeviceLogOssProperties {
|
||||||
|
|
||||||
|
private String endpoint;
|
||||||
|
private String accessKeyId;
|
||||||
|
private String accessKeySecret;
|
||||||
|
private String bucket;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志保留天数:查询侧按此过滤(早于今天的 N-1 天不展示),
|
||||||
|
* 对象过期由 MinIO 桶生命周期规则在部署时同步设置(两侧口径保持一致)。
|
||||||
|
*/
|
||||||
|
private Integer retentionDays;
|
||||||
|
|
||||||
|
public boolean configured() {
|
||||||
|
return endpoint != null && !endpoint.isBlank()
|
||||||
|
&& accessKeyId != null && !accessKeyId.isBlank()
|
||||||
|
&& accessKeySecret != null && !accessKeySecret.isBlank()
|
||||||
|
&& bucket != null && !bucket.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int retentionDaysOrDefault() {
|
||||||
|
return retentionDays == null || retentionDays < 1 ? 7 : retentionDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
package com.nanri.aiimage.config;
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.util.BoundedLruCache;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.net.Authenticator;
|
import java.net.Authenticator;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.PasswordAuthentication;
|
import java.net.PasswordAuthentication;
|
||||||
import java.net.ProxySelector;
|
import java.net.ProxySelector;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task 77:外部 HTTP 客户端统一连接复用池。
|
* Task 77:外部 HTTP 客户端统一连接复用池。
|
||||||
@@ -20,6 +24,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* 每次请求都重新建连。各客户端按自身超时创建独立的
|
* 每次请求都重新建连。各客户端按自身超时创建独立的
|
||||||
* JdkClientHttpRequestFactory(共享底层连接池),RestClient 单例懒加载。
|
* JdkClientHttpRequestFactory(共享底层连接池),RestClient 单例懒加载。
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
public class HttpClientPool {
|
public class HttpClientPool {
|
||||||
|
|
||||||
private static volatile HttpClient sharedHttpClient;
|
private static volatile HttpClient sharedHttpClient;
|
||||||
@@ -55,6 +60,38 @@ public class HttpClientPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开远程文件流(带超时),调用方负责关闭返回的流。
|
||||||
|
*
|
||||||
|
* <p>替代裸 {@code URI.create(url).toURL().openStream()}:后者走 JVM 默认超时(0 = 无限),
|
||||||
|
* 上游半开连接或挂起时会把 Tomcat 工作线程无限占用(管理端批量打包可同时挂多个)。
|
||||||
|
* 返回的流是流式的,适用于「服务端代理下载 OSS 文件转发给浏览器」这类不落盘场景。
|
||||||
|
*
|
||||||
|
* @param url 远程地址
|
||||||
|
* @param timeout 等待响应超时(连接建立 + 响应头);非法值钳制到 1 秒
|
||||||
|
* @throws IOException 非 2xx 响应或网络异常
|
||||||
|
* @throws InterruptedException 线程被中断
|
||||||
|
*/
|
||||||
|
public static InputStream openStreamWithTimeout(String url, Duration timeout) throws IOException, InterruptedException {
|
||||||
|
Duration effective = (timeout == null || timeout.isZero() || timeout.isNegative())
|
||||||
|
? Duration.ofSeconds(1)
|
||||||
|
: timeout;
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||||
|
.timeout(effective)
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<InputStream> response = sharedHttpClient().send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
if (response.statusCode() / 100 != 2) {
|
||||||
|
try {
|
||||||
|
response.body().close();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 关闭失败不影响错误上报
|
||||||
|
}
|
||||||
|
throw new IOException("远程文件返回非 2xx: HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
}
|
||||||
|
|
||||||
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
|
/** 按 readTimeout(毫秒)创建共享连接池工厂;非法值钳制到最小正数。 */
|
||||||
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) {
|
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis) {
|
||||||
return requestFactory(readTimeoutMillis, null);
|
return requestFactory(readTimeoutMillis, null);
|
||||||
@@ -67,8 +104,12 @@ public class HttpClientPool {
|
|||||||
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis, String proxyUrl) {
|
public static ClientHttpRequestFactory requestFactory(int readTimeoutMillis, String proxyUrl) {
|
||||||
long safeReadTimeout = Math.max(1L, readTimeoutMillis);
|
long safeReadTimeout = Math.max(1L, readTimeoutMillis);
|
||||||
long callTimeout = configuredCallTimeoutMillis;
|
long callTimeout = configuredCallTimeoutMillis;
|
||||||
if (callTimeout > 0L) {
|
if (callTimeout > 0L && safeReadTimeout > callTimeout) {
|
||||||
safeReadTimeout = Math.min(safeReadTimeout, callTimeout);
|
// 读超时以调用方显式值为准,不再被全局 call-timeout 截断:
|
||||||
|
// LLM 长思考配的是 180s(llm-read-timeout-millis),曾被静默压到 90s,
|
||||||
|
// 导致请求在 90s 被掐断 → 上层重试 → 付费网关二次计费(2026-09-15 修复)。
|
||||||
|
// 各调用方的超时已由各自的 HttpConfigResolver 钳制,此处不再二次收敛。
|
||||||
|
log.debug("读超时 {}ms 超过全局 call-timeout {}ms,按调用方显式值生效", safeReadTimeout, callTimeout);
|
||||||
}
|
}
|
||||||
JdkClientHttpRequestFactory factory =
|
JdkClientHttpRequestFactory factory =
|
||||||
new JdkClientHttpRequestFactory(httpClientFor(proxyUrl));
|
new JdkClientHttpRequestFactory(httpClientFor(proxyUrl));
|
||||||
@@ -130,5 +171,6 @@ public class HttpClientPool {
|
|||||||
private record ProxyEndpoint(String host, int port, String userInfo) {
|
private record ProxyEndpoint(String host, int port, String userInfo) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final Map<ProxyEndpoint, HttpClient> PROXY_CLIENTS = new ConcurrentHashMap<>();
|
private static final BoundedLruCache<ProxyEndpoint, HttpClient> PROXY_CLIENTS =
|
||||||
|
new BoundedLruCache<>(BoundedLruCache.DEFAULT_MAX_SIZE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.nanri.aiimage.config;
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.module.TaskModuleRegistry;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
@@ -19,5 +20,6 @@ public class ModuleCleanupProperties {
|
|||||||
private int batchSize = 500;
|
private int batchSize = 500;
|
||||||
// SHOP_DATA_CRAWL keeps one per-shop daily workbook in its task service
|
// SHOP_DATA_CRAWL keeps one per-shop daily workbook in its task service
|
||||||
// and must not be removed by the age-based sweep.
|
// and must not be removed by the age-based sweep.
|
||||||
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN", "WITHDRAW", "APPEARANCE_PATENT", "SIMILAR_ASIN", "COLLECT_DATA"));
|
/** 参与按天清理的模块:取自模块注册表(G6)。 */
|
||||||
|
private List<String> moduleTypes = new ArrayList<>(TaskModuleRegistry.ageCleanupModuleTypes());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站内通知(铃铛)扫描与探测配置:
|
||||||
|
* 任务失败扫描、下游服务健康探测、已读通知保留期。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ConfigurationProperties(prefix = "aiimage.notification")
|
||||||
|
public class NotificationProperties {
|
||||||
|
|
||||||
|
/** 扫描总开关:关闭后任务失败扫描与服务探测都不执行(应急降噪)。 */
|
||||||
|
private boolean scanEnabled = true;
|
||||||
|
|
||||||
|
/** 扫描间隔(毫秒),默认 5 分钟(任务失败聚合按小时去重,高频扫描不会重复提醒)。 */
|
||||||
|
private long scanIntervalMs = 5 * 60 * 1000L;
|
||||||
|
|
||||||
|
/** 首次执行延迟(毫秒),默认 2 分钟,避开启动阶段的数据库压力。 */
|
||||||
|
private long scanInitialDelayMs = 2 * 60 * 1000L;
|
||||||
|
|
||||||
|
/** 任务失败扫描开关。 */
|
||||||
|
private boolean taskScanEnabled = true;
|
||||||
|
|
||||||
|
/** 任务失败回看窗口(分钟):窗口内进入失败终态的任务参与聚合。 */
|
||||||
|
private int taskFailedWindowMinutes = 60;
|
||||||
|
|
||||||
|
/** 单轮任务扫描最多处理条数(超出下一轮继续,避免大表拖垮扫描)。 */
|
||||||
|
private int taskScanMaxRows = 1000;
|
||||||
|
|
||||||
|
/** 服务健康探测开关。 */
|
||||||
|
private boolean serviceProbeEnabled = true;
|
||||||
|
|
||||||
|
/** 品牌检测服务地址(主机A 15126,探测 /api/version);留空=跳过该项探测。 */
|
||||||
|
private String brandServiceUrl = "";
|
||||||
|
|
||||||
|
/** 跟价任务 API 地址(主机B 18960,探测根路径);留空=跳过该项探测。 */
|
||||||
|
private String priceTrackApiUrl = "";
|
||||||
|
|
||||||
|
/** jikip 代理接口探测开关(复用余额查询接口判活,使用 user-secret 的 jikip 配置)。 */
|
||||||
|
private boolean jikipProbeEnabled = true;
|
||||||
|
|
||||||
|
/** 麦象(18960 任务调度)异常扫描开关:任务停滞/失败/队列积压 → 管理员通知。 */
|
||||||
|
private boolean maixiangScanEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 麦象后台接口令牌(18960 console token)。留空=跳过麦象异常扫描——
|
||||||
|
* 该令牌与「跟价任务 API 地址」(priceTrackApiUrl) 一起构成后台只读接口的访问凭据。
|
||||||
|
*/
|
||||||
|
private String maixiangConsoleToken = "";
|
||||||
|
|
||||||
|
/** 麦象批量任务停滞阈值(分钟):status=0/1 且超过该时长无更新视为卡住。 */
|
||||||
|
private int maixiangStuckMinutes = 60;
|
||||||
|
|
||||||
|
/** 麦象单任务滞留阈值(分钟):创建超时仍未完成(status=0/1)视为滞留/无人消费。 */
|
||||||
|
private int maixiangSingleStuckMinutes = 30;
|
||||||
|
|
||||||
|
/** 麦象任务失败告警阈值(条):近 30 分钟窗口内失败数达到该值才告警。 */
|
||||||
|
private int maixiangFailMinCount = 1;
|
||||||
|
|
||||||
|
/** 麦象队列积压阈值(条):task:queue 待处理数达到该值告警。 */
|
||||||
|
private int maixiangQueuePendingThreshold = 300;
|
||||||
|
|
||||||
|
/** 麦象队列积压阈值(条):task:processing 处理中数达到该值告警。 */
|
||||||
|
private int maixiangQueueProcessingThreshold = 100;
|
||||||
|
|
||||||
|
/** 已读通知保留天数(超期自动清理),默认 90 天。 */
|
||||||
|
private int readRetentionDays = 90;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未读通知保留天数,默认 180 天(比已读长一倍)。
|
||||||
|
*
|
||||||
|
* <p>未读通知此前永不清理:不看铃铛的用户会无限累积。保留期给得更宽,
|
||||||
|
* 是因为未读意味着"用户可能还没看到",但也不能永远留着。
|
||||||
|
*/
|
||||||
|
private int unreadRetentionDays = 180;
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class})
|
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class, CapacityPlanProperties.class, UserSecretProperties.class, NotificationProperties.class, DeviceLogOssProperties.class})
|
||||||
public class PropertiesConfig {
|
public class PropertiesConfig {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,15 @@ public class SchedulingConfig {
|
|||||||
|
|
||||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调度线程池:承载全站 30+ 个 @Scheduled(含 imagevideo 1s 派发、5s 轮询、结果文件 worker 15s 等高频任务)。
|
||||||
|
*
|
||||||
|
* <p>此前默认 4 线程:任一慢任务(如结果文件组装被内联执行时)都会把兜底类任务
|
||||||
|
* (StaleTaskRepair 心跳判死、陈旧扫描、历史清理)顺延,而兜底任务被顺延会直接放大线上故障面。
|
||||||
|
* 提到 16 并保持可配(aiimage.scheduling.pool-size)。
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public TaskScheduler taskScheduler(@Value("${aiimage.scheduling.pool-size:4}") int poolSize) {
|
public TaskScheduler taskScheduler(@Value("${aiimage.scheduling.pool-size:16}") int poolSize) {
|
||||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||||
scheduler.setPoolSize(Math.max(1, poolSize));
|
scheduler.setPoolSize(Math.max(1, poolSize));
|
||||||
scheduler.setThreadNamePrefix("aiimage-scheduling-");
|
scheduler.setThreadNamePrefix("aiimage-scheduling-");
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ public class SimilarAsinProperties {
|
|||||||
private int staleTimeoutMinutes = 30;
|
private int staleTimeoutMinutes = 30;
|
||||||
private String staleFinalizeCron = "0 */2 * * * *";
|
private String staleFinalizeCron = "0 */2 * * * *";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二次判死线(心跳正常但连续 N 分钟无结果分片上报)阈值,分钟;<=0 关闭本线。
|
||||||
|
* 既有判定以 Redis heartbeat stale 为主信号,但心跳随 Python HTTP 心跳每分钟刷新——
|
||||||
|
* 主线程卡死时心跳线程照发,任务永远不 stale(与生产 28131 同型缺口)。
|
||||||
|
* 本线改看 biz_task_scope_state.last_chunk_at(仅分片上传时刷新)。
|
||||||
|
*/
|
||||||
|
private int noResultUploadTimeoutMinutes = 180;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 末尾零头 batch 的强制 flush 阈值(分钟):当不足 llmBatchSize 的零头 row
|
* 末尾零头 batch 的强制 flush 阈值(分钟):当不足 llmBatchSize 的零头 row
|
||||||
* 长时间挂着(Python 慢回传)时触发提交。
|
* 长时间挂着(Python 慢回传)时触发提交。
|
||||||
@@ -161,6 +169,11 @@ public class SimilarAsinProperties {
|
|||||||
private int llmMaxTokens = 64000;
|
private int llmMaxTokens = 64000;
|
||||||
private int llmConnectTimeoutMillis = 10000;
|
private int llmConnectTimeoutMillis = 10000;
|
||||||
private int llmReadTimeoutMillis = 180000;
|
private int llmReadTimeoutMillis = 180000;
|
||||||
|
/**
|
||||||
|
* 首次尝试读超时(毫秒):上游偶发单请求不应答,首查用更短预算快速失败后重试;
|
||||||
|
* 生产实测成功调用 p99≈34s、超 60s 仅 0.02%,默认 60s 兼顾慢响应与快速失败。
|
||||||
|
*/
|
||||||
|
private int llmFirstAttemptReadTimeoutMillis = 60000;
|
||||||
private int llmRetryTimes = 3;
|
private int llmRetryTimes = 3;
|
||||||
|
|
||||||
/** 批内行级并发上限:每行最多 2 次图片对比 + 1 次合规 + 3 次类目匹配。 */
|
/** 批内行级并发上限:每行最多 2 次图片对比 + 1 次合规 + 3 次类目匹配。 */
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import io.micrometer.core.instrument.MeterRegistry;
|
|||||||
import org.springframework.beans.factory.ObjectProvider;
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import com.nanri.aiimage.common.module.TaskModuleRegistry;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.task.TaskExecutor;
|
import org.springframework.core.task.TaskExecutor;
|
||||||
@@ -52,11 +53,11 @@ import java.util.concurrent.Semaphore;
|
|||||||
public class TaskFileJobConfig {
|
public class TaskFileJobConfig {
|
||||||
|
|
||||||
/** 结果文件 Job 支持的全部 moduleType(启动校验枚举源,见 ResultFileJobHandlerRegistry.validateCoverage) */
|
/** 结果文件 Job 支持的全部 moduleType(启动校验枚举源,见 ResultFileJobHandlerRegistry.validateCoverage) */
|
||||||
public static final Set<String> RESULT_FILE_JOB_MODULE_TYPES = Set.of(
|
/**
|
||||||
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE",
|
* 结果文件 Job 支持的 moduleType:取自模块注册表(G6),
|
||||||
"PUBLISH", "QUERY_ASIN", "SHOP_DATA_CRAWL", "WITHDRAW",
|
* 与 {@code ResultFileJobHandlerRegistry.validateCoverage} 的启动自检配合使用。
|
||||||
"PATROL_DELETE", "APPEARANCE_PATENT", "SIMILAR_ASIN",
|
*/
|
||||||
"DELETE_BRAND", "BRAND", "COLLECT_DATA");
|
public static final Set<String> RESULT_FILE_JOB_MODULE_TYPES = TaskModuleRegistry.resultFileJobModuleTypes();
|
||||||
|
|
||||||
@Bean("taskFileJobDispatchExecutor")
|
@Bean("taskFileJobDispatchExecutor")
|
||||||
public TaskExecutor taskFileJobDispatchExecutor(
|
public TaskExecutor taskFileJobDispatchExecutor(
|
||||||
|
|||||||
+26
-1
@@ -29,7 +29,14 @@ public class TransientStorageProperties {
|
|||||||
*/
|
*/
|
||||||
private long maxTotalConcurrentOperations = 0;
|
private long maxTotalConcurrentOperations = 0;
|
||||||
private long acquirePermitTimeoutMillis = 2000;
|
private long acquirePermitTimeoutMillis = 2000;
|
||||||
private long baseRetryDelayMillis = 500;
|
/**
|
||||||
|
* 首次重试前的基础退避。
|
||||||
|
*
|
||||||
|
* <p>线上高频的重试诱因是 `unexpected end of stream`——那是**立即失败**(连接被 RustFS
|
||||||
|
* 重置后 OkHttp 读响应即报错),不是等超时,所以 500ms 基本是白等:每天上千次累计十几分钟。
|
||||||
|
* 降到 200ms 保留退避语义(真遇到服务端过载仍会退让),又不至于让用户等太久。
|
||||||
|
*/
|
||||||
|
private long baseRetryDelayMillis = 200;
|
||||||
private long maxRetryDelayMillis = 5000;
|
private long maxRetryDelayMillis = 5000;
|
||||||
private long retryJitterMillis = 250;
|
private long retryJitterMillis = 250;
|
||||||
private long failureWindowSeconds = 60;
|
private long failureWindowSeconds = 60;
|
||||||
@@ -37,7 +44,19 @@ public class TransientStorageProperties {
|
|||||||
private long failureCooldownMillis = 10000;
|
private long failureCooldownMillis = 10000;
|
||||||
private int dispatcherMaxRequests = 56;
|
private int dispatcherMaxRequests = 56;
|
||||||
private int dispatcherMaxRequestsPerHost = 56;
|
private int dispatcherMaxRequestsPerHost = 56;
|
||||||
|
/**
|
||||||
|
* 空闲连接保留数。
|
||||||
|
*
|
||||||
|
* <p>2026-09-17 曾试过设 0(彻底不复用)来验证"unexpected end of stream 是复用死连接导致的"
|
||||||
|
* 这一假设——**实测照旧失败**(新容器起来后第一次请求就中招)。至此已排除公网链路、
|
||||||
|
* keepAlive 过长、连接复用三项;用 mc 并发压 200 个小对象也全部成功,说明服务端没问题。
|
||||||
|
* 剩余方向指向 MinIO Java SDK / OkHttp 与 RustFS 的协议细节,故恢复默认的连接复用。
|
||||||
|
*/
|
||||||
private int connectionPoolMaxIdle = 5;
|
private int connectionPoolMaxIdle = 5;
|
||||||
|
/**
|
||||||
|
* 空闲连接在池里的保留时长。曾由 300000 调到 30000 试图减少 unexpected end of stream,
|
||||||
|
* 实测无改善(该现象与连接复用无关,见 {@link #connectionPoolMaxIdle} 的排查记录),故恢复原值。
|
||||||
|
*/
|
||||||
private long connectionPoolKeepAliveMillis = 300000;
|
private long connectionPoolKeepAliveMillis = 300000;
|
||||||
private long warnPayloadBytes = 5L * 1024 * 1024;
|
private long warnPayloadBytes = 5L * 1024 * 1024;
|
||||||
private long maxPayloadBytes = 50L * 1024 * 1024;
|
private long maxPayloadBytes = 50L * 1024 * 1024;
|
||||||
@@ -48,6 +67,12 @@ public class TransientStorageProperties {
|
|||||||
*/
|
*/
|
||||||
private long maxDecompressedPayloadBytes = 100L * 1024 * 1024;
|
private long maxDecompressedPayloadBytes = 100L * 1024 * 1024;
|
||||||
private boolean fallbackToLocalOnOversize = true;
|
private boolean fallbackToLocalOnOversize = true;
|
||||||
|
/**
|
||||||
|
* 上传失败(已重试+熔断)时是否回落到本机磁盘。默认 false:
|
||||||
|
* 多实例/容器化部署下 local 指针只有写入它的那个实例能读,宁可让本次写入失败,
|
||||||
|
* 也不要把跨节点不可读的脏指针交给调用方;仅单机部署才应打开。
|
||||||
|
*/
|
||||||
|
private boolean fallbackToLocalOnError = false;
|
||||||
private boolean deleteRetryEnabled = true;
|
private boolean deleteRetryEnabled = true;
|
||||||
private String deleteRetryCron = "0 */5 * * * *";
|
private String deleteRetryCron = "0 */5 * * * *";
|
||||||
private int deleteRetryQueueCapacity = 10000;
|
private int deleteRetryQueueCapacity = 10000;
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ public class UserSecretProperties {
|
|||||||
/** 单轮巡检时间预算(分钟),超时中断本轮。 */
|
/** 单轮巡检时间预算(分钟),超时中断本轮。 */
|
||||||
private int checkBudgetMinutes = 20;
|
private int checkBudgetMinutes = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测请求使用的 LLM 模型:独立于业务任务模型(业务用 gemini-3.8-flash 等),
|
||||||
|
* 选便宜的可用模型,只验证密钥有效性与链路连通,降低每次检测与巡检的成本。
|
||||||
|
* 用 lite 而非 mini:mini 在中继分组下无可用渠道(503 model_not_found),实测 lite 可路由。
|
||||||
|
*/
|
||||||
|
private String checkModel = "doubao-seed-2-0-lite-260215";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检测出口代理提取链接(选配):配置后检测请求优先经该代理 IP 发出,
|
* 检测出口代理提取链接(选配):配置后检测请求优先经该代理 IP 发出,
|
||||||
* 代理不可用时自动回退直连;留空则全部直连。
|
* 代理不可用时自动回退直连;留空则全部直连。
|
||||||
@@ -36,4 +43,10 @@ public class UserSecretProperties {
|
|||||||
|
|
||||||
/** jikip 用户 ID(余量查询参数)。 */
|
/** jikip 用户 ID(余量查询参数)。 */
|
||||||
private String jikipUserId = "";
|
private String jikipUserId = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 巡检发现欠费/密钥失效时是否推送站内通知(桌面端用户 + 后台管理员);
|
||||||
|
* 关闭后巡检只更新检测状态、不发通知(应急降噪开关)。
|
||||||
|
*/
|
||||||
|
private boolean notifyEnabled = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -1,8 +1,8 @@
|
|||||||
package com.nanri.aiimage.modules.admin.controller;
|
package com.nanri.aiimage.modules.admin.controller;
|
||||||
|
|
||||||
import com.nanri.aiimage.common.api.ApiResponse;
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
import com.nanri.aiimage.common.security.AdminAuthSupport;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
|
||||||
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
|
import com.nanri.aiimage.modules.permission.model.vo.PermissionMenuItemVo;
|
||||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||||
import com.nanri.aiimage.modules.permission.service.support.AdminMenuTreeBuilder;
|
import com.nanri.aiimage.modules.permission.service.support.AdminMenuTreeBuilder;
|
||||||
@@ -76,8 +76,10 @@ public class AdminConsoleController {
|
|||||||
@Operation(summary = "当前登录管理员的可见后台菜单树")
|
@Operation(summary = "当前登录管理员的可见后台菜单树")
|
||||||
public ApiResponse<Map<String, Object>> currentUserMenus(HttpServletRequest request) {
|
public ApiResponse<Map<String, Object>> currentUserMenus(HttpServletRequest request) {
|
||||||
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
|
AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request);
|
||||||
|
// 补全祖先分组:部分授权用户(只授权了子页面)也要看到「一级分组 + 子页面」层级,
|
||||||
|
// 与超管的菜单组织顺序一致;分组节点无页面路由,不构成权限扩展。
|
||||||
List<PermissionMenuItemVo> menus = permissionMenuService.getUserColumnPermissions(
|
List<PermissionMenuItemVo> menus = permissionMenuService.getUserColumnPermissions(
|
||||||
operator, operator.getId(), PermissionMenuService.MENU_TYPE_ADMIN);
|
operator, operator.getId(), PermissionMenuService.MENU_TYPE_ADMIN, true);
|
||||||
menus = AdminMenuTreeBuilder.filterByMenuType(menus, PermissionMenuService.MENU_TYPE_ADMIN);
|
menus = AdminMenuTreeBuilder.filterByMenuType(menus, PermissionMenuService.MENU_TYPE_ADMIN);
|
||||||
List<Map<String, Object>> items = AdminMenuTreeBuilder.toMapList(AdminMenuTreeBuilder.build(menus));
|
List<Map<String, Object>> items = AdminMenuTreeBuilder.toMapList(AdminMenuTreeBuilder.build(menus));
|
||||||
return ApiResponse.success(Map.of("items", items));
|
return ApiResponse.success(Map.of("items", items));
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ import com.nanri.aiimage.modules.admin.model.dto.AdminUserCreateRequest;
|
|||||||
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
||||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||||
import com.nanri.aiimage.modules.admin.service.AdminUserService;
|
import com.nanri.aiimage.modules.admin.service.AdminUserService;
|
||||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
import com.nanri.aiimage.common.security.AdminAuthSupport;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|||||||
+15
-4
@@ -8,14 +8,16 @@ import com.nanri.aiimage.modules.admin.model.dto.AdminUserUpdateRequest;
|
|||||||
import com.nanri.aiimage.modules.admin.model.vo.AdminBriefVo;
|
import com.nanri.aiimage.modules.admin.model.vo.AdminBriefVo;
|
||||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserItemVo;
|
import com.nanri.aiimage.modules.admin.model.vo.AdminUserItemVo;
|
||||||
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
import com.nanri.aiimage.modules.admin.model.vo.AdminUserListVo;
|
||||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
import com.nanri.aiimage.common.security.AdminAuthSupport;
|
||||||
import com.nanri.aiimage.modules.admin.util.PinyinAbbrUtil;
|
import com.nanri.aiimage.modules.admin.util.PinyinAbbrUtil;
|
||||||
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
import com.nanri.aiimage.modules.auth.util.WerkzeugPasswordEncoder;
|
||||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
import com.nanri.aiimage.common.mapper.AdminUserMapper;
|
||||||
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
|
import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.common.model.entity.AdminUserEntity;
|
||||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||||
|
import com.nanri.aiimage.modules.admin.spi.UserSecretCleanupPort;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -32,6 +34,7 @@ import java.util.stream.Collectors;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class AdminUserService {
|
public class AdminUserService {
|
||||||
|
|
||||||
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
private static final DateTimeFormatter CREATED_AT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||||
@@ -40,6 +43,7 @@ public class AdminUserService {
|
|||||||
private final WerkzeugPasswordEncoder passwordEncoder;
|
private final WerkzeugPasswordEncoder passwordEncoder;
|
||||||
private final AdminAuthSupport adminAuthSupport;
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
private final PermissionMenuService permissionMenuService;
|
private final PermissionMenuService permissionMenuService;
|
||||||
|
private final UserSecretCleanupPort userSecretCleanupPort;
|
||||||
|
|
||||||
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
|
public AdminUserListVo listUsers(AdminUserEntity currentUser, Integer page, Integer pageSize,
|
||||||
String username, Long createdById, String roleFilter) {
|
String username, Long createdById, String roleFilter) {
|
||||||
@@ -258,6 +262,9 @@ public class AdminUserService {
|
|||||||
}
|
}
|
||||||
replaceDirectPermissionsByType(operator, userId, adminIds, PermissionMenuService.MENU_TYPE_ADMIN);
|
replaceDirectPermissionsByType(operator, userId, adminIds, PermissionMenuService.MENU_TYPE_ADMIN);
|
||||||
replaceDirectPermissionsByType(operator, userId, appIds, PermissionMenuService.MENU_TYPE_APP);
|
replaceDirectPermissionsByType(operator, userId, appIds, PermissionMenuService.MENU_TYPE_APP);
|
||||||
|
// 两类都落库后再统一级联一次:单类型落库时目标的有效集不完整,提前级联会把
|
||||||
|
// 员工另一类型的合法授权当越权删掉(生产表现:员工菜单权限“自己没掉”)。
|
||||||
|
permissionMenuService.cascadeSubordinateOverreach(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void replaceDirectPermissionsByType(AdminUserEntity operator, Long userId, List<Long> columnIds,
|
private void replaceDirectPermissionsByType(AdminUserEntity operator, Long userId, List<Long> columnIds,
|
||||||
@@ -265,9 +272,11 @@ public class AdminUserService {
|
|||||||
UserColumnPermissionUpdateRequest permissionRequest = new UserColumnPermissionUpdateRequest();
|
UserColumnPermissionUpdateRequest permissionRequest = new UserColumnPermissionUpdateRequest();
|
||||||
permissionRequest.setColumnIds(columnIds);
|
permissionRequest.setColumnIds(columnIds);
|
||||||
permissionMenuService.updateUserColumnPermissions(
|
permissionMenuService.updateUserColumnPermissions(
|
||||||
operator, userId, permissionRequest, menuType);
|
operator, userId, permissionRequest, menuType, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除用户并级联清理其服务端密钥;不清理会留下无主密钥行(biz_user_api_secret 按 uid 绑定,无外键)。 */
|
||||||
|
@Transactional
|
||||||
public void deleteUser(AdminUserEntity currentUser, Long uid) {
|
public void deleteUser(AdminUserEntity currentUser, Long uid) {
|
||||||
String role = adminAuthSupport.currentRole(currentUser);
|
String role = adminAuthSupport.currentRole(currentUser);
|
||||||
if (role == null) {
|
if (role == null) {
|
||||||
@@ -294,6 +303,8 @@ public class AdminUserService {
|
|||||||
if (affected == 0) {
|
if (affected == 0) {
|
||||||
throw new BusinessException("用户不存在");
|
throw new BusinessException("用户不存在");
|
||||||
}
|
}
|
||||||
|
int secretRows = userSecretCleanupPort.adminClearByUser(uid);
|
||||||
|
log.info("[admin-user] 用户已删除 uid={} 级联清理密钥行={}", uid, secretRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<Long, String> loadCreatorMap(List<AdminUserEntity> rows) {
|
private Map<Long, String> loadCreatorMap(List<AdminUserEntity> rows) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user