Compare commits

..

2 Commits

Author SHA1 Message Date
huangzd1997 52c59755ab 修复货源查询 404 行被过滤且状态列置空:Python 404 的 ASIN 保留导出并标记「ASIN未找到」
Build Backend JAR / build (push) Has been cancelled
线上现象:货源采集任务 26402(1212.xlsx)结果文件中存在 Python 端
404 Not Found 的行没有图,且状态列空白——这些行实际已完成采集但被后端过滤。

根因链:
1. Python 404 行 title/url/图全空 → isMissingRequired 必填校验拦截 →
   markRowsFailed(status=FAILED,error 保留 "404 Not Found");
2. isExportableResultRow 对「其它错误需带 title/url/图」的判断把这类 404 行
   过滤掉,即使存在也被 isSilentOutputFailure 把状态列置空。

修复:
- 新增 isAsinNotFound 判定(404 / not found / 商品未找到 等文案,大小写不敏感);
- resolveResultStatus 在图片判定后、rawStatus→失败 映射前插入
  「ASIN未找到」状态;isExportableResultRow 对 404 行直接放行;
- writeResultWorkbook 状态列 gate 排除 404 行(不再被置空);
- 新增 2 条单元测试(404/中文文案命中、普通技术失败不误判)。
2026-09-02 13:51:10 +08:00
huangzd1997 478233ba7a 修复采集数据结果明细 module_type 写读大小写不一致:写入端误用小写 collectdata 导致 task-50 之后所有采集任务明细行生成结果文件时读不到
线上现象:任务 26334 采集完成(去重 42/无效品牌 62/品牌拒绝 6/保留 85)
但结果文件「采集数据结果」sheet 只有表头 0 行,历史卡片「保留 0」。

根因链:
1. CollectDataResultItemBatchWriter(task-50 新增写入端)常量误写为小写
   "collectdata",与 CollectDataService.MODULE_TYPE(大写 COLLECT_DATA)不一致;
2. loadFinalRows 按大写查 biz_task_result_item 命中 0 行 → 组装空表 →
   stats.finalRowCount 被 rows.size()=0 覆盖,UI 显示保留 0;
3. 生产库 5386 行小写 collectdata(8/31 起约 15 个任务)全部读不到,
   大写 COLLECT_DATA 仅存 1067 行历史数据。

修复:
- 写入常量改回大写 COLLECT_DATA(与读取端一致),现有行查询 in(大写,小写)
  兼容存量,旧任务重试幂等命中不重复插入;
- loadFinalRows / deleteTask / deleteHistory 查询改 in(MODULE_TYPE, LEGACY_MODULE_TYPE),
  历史小写行可读出并正确清理(RustFS 对象不泄漏);
- 新增 2 条回归测试(写入 module_type 必须与读取端常量一致、小写存量行重试命中),
  collectdata 全包 168 测试全绿。
2026-09-02 13:51:10 +08:00
1518 changed files with 37010 additions and 130940 deletions
+2 -24
View File
@@ -14,35 +14,13 @@ jobs:
build:
runs-on: ubuntu-latest
container: maven:3.9-eclipse-temurin-21
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Test (Java: unit + contract + ArchUnit)
working-directory: backend-java
run: mvn test -q
- name: Package
working-directory: backend-java
- name: Build with Maven
run: mvn clean package -DskipTests -q
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: backend-jar
path: backend-java/target/*.jar
frontend-test:
runs-on: ubuntu-latest
container: node:24-alpine
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
working-directory: frontend-vue
run: npm ci
- name: Type check (vue-tsc)
working-directory: frontend-vue
run: npm run build
- name: Frontend contract tests (npm test → node --test)
working-directory: frontend-vue
run: npm test
path: target/*.jar
-10
View File
@@ -100,7 +100,6 @@ ipython_config.py
*.sqlite3
# ===== Misc =====
.playwright-cli/
desktop/
ERP-Demo/
xlsx/
@@ -124,12 +123,3 @@ backend-java/docs/*audit*.md
check_progress.py
progress.json
progress_*.json
# ===== 本地预览工具(不入库)=====
backend/_preview_mock.py
# ===== 战役辅助脚本与本地数据(不入库)=====
scripts/test_task_*.py
scripts/*_scan.py
scripts/*_migration.py
data/logs/
-6
View File
@@ -1,6 +0,0 @@
node_modules/
dist/
.env*
!.env.example
test-results/
vite-dev.log
-39
View File
@@ -1,39 +0,0 @@
# Admin Frontend Vue
独立的后台管理前端工程,与客户端 `frontend-vue` 完全分离。
## 本地开发
```powershell
npm install
$env:VITE_API_TARGET = "http://127.0.0.1:18080"
npm run dev
```
开发入口:`http://localhost:5174/admin-vue/`
## 构建
```powershell
npm run build
npm test
```
构建产物在 `dist/`,部署到 Nginx 的 `/admin-vue/` 根目录。该工程不写入客户端的 `new_web_source`,也不打进 Java JAR。
## 运行时契约
- 登录态:复用同源 Cookie/Session。
- 当前用户:`GET /api/admin/current-user`
- 菜单树:`GET /api/admin/current-user/menus`Java 返回树形菜单。
- 退出登录:`POST /api/admin/logout`
- 前端路由:History 模式,基准路径 `/admin-vue/`
- 权限粒度:菜单/页面级,不定义按钮级权限。
## 首批页面
- `/admin-vue/account/users`
- `/admin-vue/account/menus`
- `/admin-vue/account/groups`
当前首批页面已接入 Java 现有用户、菜单和数据权限分组 API;Java 菜单接口完成树形响应和 Flyway 菜单迁移后,路由权限守卫即可按最终菜单树工作。
@@ -1,5 +0,0 @@
# Flask 管理能力移除 / Nginx 入口切换(module 11
- /admin 由 Nginx 302 → /admin-vue/Java 静态 admin.html/login.html 副本随发布清理。
- Flask 管理页面/API 与版本蓝图在发布前已由 Java/Vue 覆盖(07/09 模块),此处由发布管线停用。
- 发布验收见 tests/live/task-200/220/240RELEASE_SMOKE=1 触发)。
@@ -1,29 +0,0 @@
# 仅作部署参考,实际 server/root/upstream 按环境调整。
location = /admin {
return 302 /admin-vue/;
}
location /admin-vue/assets/ {
alias /var/www/admin-vue/assets/;
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /admin-vue/ {
alias /var/www/admin-vue/;
try_files $uri $uri/ /admin-vue/index.html;
add_header Cache-Control "no-cache";
}
location /api/ {
proxy_pass http://java_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
@@ -1,20 +0,0 @@
# Admin Vue 发布目录结构(module 10
发布目标:
```text
/var/www/
├── admin-vue-<ts>/ # 新版本临时发布目录
│ ├── index.html
│ └── assets/...
├── admin-vue-prev/ # 上一版本(回滚保留)
└── admin-vue -> admin-vue-<ts> # Nginx 根目录符号链接(原子切换)
```
发布过程:
1. `npm ci && npm run build` 产出 `dist/`
2. 运行 `deploy/verify-dist.mjs`:校验 `index.html` 引用完整、assets 存在、base 为 `/admin-vue/`
3. 上传 `dist/``admin-vue-<ts>/`
4. `ln -sfn admin-vue-<ts> /var/www/admin-vue` 原子切换。
5. 旧版本目录保留为 `admin-vue-prev`,用于快速回滚。
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env node
// Admin Vue 构建产物清单校验(module 10):base=/admin-vue/ 且 index.html 引用的资源均存在。
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const root = resolve(process.cwd())
const dist = join(root, 'dist')
const index = join(dist, 'index.html')
function fail(message) {
console.error(`[verify-dist] ${message}`)
process.exit(1)
}
if (!existsSync(index)) fail('缺少 dist/index.html(请先 npm run build')
const html = readFileSync(index, 'utf8')
if (!html.includes('/admin-vue/')) fail('index.html 资源引用未包含 base=/admin-vue/')
const assetsDir = join(dist, 'assets')
const assets = existsSync(assetsDir) ? readdirSync(assetsDir) : []
const refs = [...html.matchAll(/(?:src|href)="\/admin-vue\/assets\/([^"]+)"/g)].map((m) => m[1])
for (const ref of refs) {
if (!assets.includes(ref)) fail(`index.html 引用的资源缺失: assets/${ref}`)
}
console.log(`[verify-dist] ok: base=/admin-vue/, assets=${assets.length}, refs=${refs.length}`)
-47
View File
@@ -1,47 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
// module 13 task 249:壳层换皮真实验收 —— 蓝白浅色变量生效、角色胶囊/次级退出、
// 代表页跳转无 pageerror。断言用 CSS 变量与 DOM 结构,避免仅截图无断言。
async function openAdmin(page: Page) {
await page.goto('/admin-vue/')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
}
test('test_task_249_shell_light_normal_primary_path', async ({ page }) => {
await openAdmin(page)
const vars = await page.evaluate(() => {
const s = getComputedStyle(document.documentElement)
return {
sidebar: s.getPropertyValue('--admin-sidebar').trim(),
primary: s.getPropertyValue('--admin-primary').trim(),
}
})
expect(vars.sidebar).toBe('#f8fbfe')
expect(vars.primary).toBe('#4f78a5')
await expect(page.locator('.admin-user-role')).toHaveText('超级管理员')
})
test('test_task_249_shell_light_normal_variant_input', async ({ page }) => {
await openAdmin(page)
await expect(page.locator('.admin-user-logout')).toBeVisible()
await expect(page.locator('.admin-kicker')).toHaveText('运营管理控制台')
})
test('test_task_249_shell_light_boundary_empty_input', async ({ page }) => {
// 未知路由仍进 404,不因换皮破坏。
await page.goto('/admin-vue/definitely/not/exists')
await expect(page.locator('.not-found')).toContainText('404')
})
test('test_task_249_shell_light_invalid_input_rejected', async ({ page }) => {
// 代表页跳转整段无未捕获脚本错误。
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openAdmin(page)
await page.goto('/admin-vue/account/menus')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
await page.goto('/admin-vue/account/groups')
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
expect(errors).toEqual([])
})
@@ -1,97 +0,0 @@
import { existsSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { expect, test } from '@playwright/test'
// 撞款控制台(DuplicateCheckPage)像素级复刻 reference asin-store-console 的真浏览器验收:
// mock 后端跑真实数据渲染,无 pageerror;并截图本页与 reference 离线页供人工比对。
const REFERENCE = 'D:\\todesk\\副业\\数富AI\\crawler-plugin\\reference\\asin-store-console\\asin-store-console\\index.html'
test('test_dup_console_render_dup_tab_normal_primary_path', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await page.goto('/admin-vue/shop-center/duplicate-check')
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
// KPI + 矩阵行 + 撞款卡片都渲染出真实 mock 数据
await expect(page.locator('.kpi')).toHaveCount(7)
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
await expect(page.locator('.dup-card').first()).toBeVisible()
// 分组撞款:切到「分组撞款」出现分组 chips
await page.getByRole('button', { name: /分组撞款/ }).click()
await expect(page.locator('.g-chip').first()).toBeVisible()
expect(errors).toEqual([])
await page.screenshot({ path: 'test-results/dup-console-admin-dup.jpg' })
})
test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await page.goto('/admin-vue/shop-center/duplicate-check')
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
// 台账 tab 下页面同时有「店铺上架分布」分页器(常显,见 tests/align-dup-console.test.ts)与台账分页器,
// 全页 .old-pagination 会命中 2 个元素触发 strict mode;限定到台账表格(.table-wrap)紧邻的分页器。
await expect(page.locator('.table-wrap + .old-pagination')).toBeVisible()
expect(errors).toEqual([])
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
})
test('test_dup_console_palette_matches_reference_tokens', async ({ page }) => {
// reference 设计令牌在页面落定:主蓝 #2E5BE6、红 #D63A4A、弱红底、单元底色等(按钮蓝白统一为 #4f78a5)。
await page.goto('/admin-vue/shop-center/duplicate-check')
await expect(page.locator('.kpi')).toHaveCount(7)
const got = await page.evaluate(() => {
const cs = (sel, prop) => {
const el = document.querySelector(sel)
return el ? getComputedStyle(el)[prop] : null
}
return {
btnPrimary: cs('.btn-primary', 'backgroundColor'),
btnPrimaryColor: cs('.btn-primary', 'color'),
kpiDanger: cs('.kpi.danger .k-value', 'color'),
kpiPrimary: cs('.kpi.primary .k-value', 'color'),
mCell: cs('.m-cell', 'backgroundColor'),
mCellText: cs('.m-cell', 'color'),
badgeDup: cs('.badge.dup', 'backgroundColor'),
badgeSingle: cs('.badge.single', 'backgroundColor'),
kSub: cs('.kpi .k-sub', 'color'),
selectBorder: cs('.f-group select', 'borderColor'),
kDotDanger: cs('.kpi.danger .k-dot', 'backgroundColor'),
}
})
expect(got.btnPrimary).toBe('rgb(79, 120, 165)') // 全站按钮主色统一 #4f78a5d385d999 起)
expect(got.btnPrimaryColor).toBe('rgb(255, 255, 255)')
expect(got.kpiDanger).toBe('rgb(214, 58, 74)')
expect(got.kpiPrimary).toBe('rgb(46, 91, 230)')
expect(got.mCell).toBe('rgb(234, 240, 255)')
expect(got.mCellText).toBe('rgb(46, 91, 230)')
expect(got.badgeDup).toBe('rgb(253, 235, 237)')
expect(got.badgeSingle).toBe('rgb(231, 247, 239)')
expect(got.kSub).toBe('rgb(152, 161, 177)')
expect(got.selectBorder).toBe('rgb(213, 219, 230)')
expect(got.kDotDanger).toBe('rgb(214, 58, 74)')
})
test('test_dup_console_structure_matches_reference', async ({ page }) => {
// 结构对齐:矩阵列=可见店铺数、台账列=reference 10 列、分组说明抽屉内 4 列。
await page.goto('/admin-vue/shop-center/duplicate-check')
await expect(page.locator('.kpi')).toHaveCount(7)
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
const matrixHeaders = await page.locator('table.matrix thead th').count()
expect(matrixHeaders).toBe(8) // ASIN + 品牌 + 6 家店
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
const ledgerHeaders = await page.locator('table.ledger thead th').count()
expect(ledgerHeaders).toBe(10)
})
test('test_dup_console_reference_screenshot', async ({ page }) => {
// reference 离线原型截图:仅供人工像素比对(不接入应用路由)。
test.skip(!existsSync(REFERENCE), 'reference 原型不在本机,跳过比对')
await page.goto(pathToFileURL(REFERENCE).href)
await expect(page.locator('.kpi').first()).toBeVisible({ timeout: 15000 })
await page.screenshot({ path: 'test-results/dup-console-reference.jpg' })
})
@@ -1,44 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
// 后台管理列表「创建时间 + 更新时间」两列验收(2026-09-19 需求):
// 8 个实体管理列表统一展示创建/更新时间,数据取自各列表 VO。
// mock 夹具统一给 createdAt=2026-03-01 09:15:00、updatedAt=2026-09-18 16:42:30
// 页面统一经 formatDateTime 归一为「YYYY-MM-DD HH:mm:ss」。
const CREATED = '2026-03-01 09:15:00'
const UPDATED = '2026-09-18 16:42:30'
const PAGES: Array<{ title: string; path: string; heading: string }> = [
{ title: '用户管理', path: '/admin-vue/account/users', heading: '用户列表' },
{ title: '菜单管理', path: '/admin-vue/account/menus', heading: '菜单' },
{ title: '密钥管理', path: '/admin-vue/account/user-secrets', heading: '用户密钥列表' },
{ title: '不符合ASIN数据', path: '/admin-vue/asin-center/invalid', heading: '品牌数据列表' },
{ title: '数据去重总数据', path: '/admin-vue/asin-center/registry', heading: '数据去重总数据' },
{ title: '查询ASIN', path: '/admin-vue/asin-center/query', heading: '查询' },
{ title: '最低价ASIN', path: '/admin-vue/asin-center/skip-price', heading: '最低价' },
{ title: '商品类目', path: '/admin-vue/asin-center/categories', heading: '商品类目' },
]
async function openPage(page: Page, path: string) {
await page.goto(path)
await expect(page.locator('.panel-box table tbody tr').first()).toBeVisible({ timeout: 15000 })
}
for (const spec of PAGES) {
test(`${spec.title}:表格展示创建时间与更新时间`, async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openPage(page, spec.path)
const table = page.locator('.panel-box table').first()
// 表头出现两列
await expect(table.locator('thead')).toContainText('创建时间')
await expect(table.locator('thead')).toContainText('更新时间')
// 数据行渲染出真实时间值(非空、非占位符)
await expect(table.locator('tbody')).toContainText(CREATED)
await expect(table.locator('tbody')).toContainText(UPDATED)
expect(errors).toEqual([])
})
}
@@ -1,122 +0,0 @@
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',
})
})
@@ -1,79 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
// 教程管理页验收(module 记录与版本):列表新增「版本号」列 + 上传时间列,
// 默认按上传时间降序;版本号/上传时间表头可点击切换升降序;空版本历史行沉底。
// 依赖 scripts/mock-admin-server.mjs 的教程包夹具(4 条,其中 1 条无版本号、1 条 09-01 历史行)。
async function openTutorial(page: Page) {
await page.goto('/admin-vue/records/tutorial')
await expect(page.locator('.admin-topbar h1')).toHaveText('教程管理')
await expect(page.locator('.panel-box tbody tr').first()).toBeVisible()
}
const versionTexts = (page: Page) =>
page.locator('.panel-box tbody tr td:nth-child(3)').allTextContents()
const fileTexts = (page: Page) =>
page.locator('.panel-box tbody tr td:nth-child(2) .file-name').allTextContents()
test('test_tutorial_list_default_desc_by_upload_time', async ({ page }) => {
await openTutorial(page)
// 表头:版本号、上传时间(均带排序标记)
await expect(page.locator('.sort-version')).toHaveText(/版本号/)
await expect(page.locator('.sort-time')).toHaveText(/上传时间/)
// 默认排序状态:上传时间降序(▼),版本号未激活(▲▼)
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
// 默认按上传时间降序:09-14 → 09-12 → 09-10 → 09-01;空版本行沉底
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
expect(await fileTexts(page)).toEqual([
'数富AI-教学客户端-v4.zip',
'数富AI-教学客户端-v3.zip',
'数富AI-教学客户端-v2.zip',
'数富AI-教学客户端.zip',
])
// 当前生效 = 最新上传(夹具里 09-14 那条),不随列表排序变化
await expect(page.locator('.tag-active')).toHaveCount(1)
await expect(page.locator('.tag-active').locator('xpath=..')).toHaveText(/数富AI-教学客户端-v4\.zip/)
})
test('test_tutorial_sort_toggle_by_version_and_time', async ({ page }) => {
await openTutorial(page)
// 点「版本号」:首次为降序(v3.10.0 按自然序大于 v3.2.0),空版本行仍在末尾
await page.locator('.sort-version').click()
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▼')
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
// 再点一次切升序
await page.locator('.sort-version').click()
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲')
expect(await versionTexts(page)).toEqual(['v3.1.0', 'v3.2.0', 'v3.10.0', '—'])
// 点回「上传时间」:切换字段时回到降序默认
await page.locator('.sort-time').click()
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
expect(await fileTexts(page)).toEqual([
'数富AI-教学客户端-v4.zip',
'数富AI-教学客户端-v3.zip',
'数富AI-教学客户端-v2.zip',
'数富AI-教学客户端.zip',
])
})
test('test_tutorial_upload_dialog_has_version_field', async ({ page }) => {
await openTutorial(page)
await page.getByRole('button', { name: '上传教程包' }).click()
const dialog = page.locator('.el-dialog')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.el-form-item').first()).toContainText('版本号')
await expect(dialog.locator('input').first()).toHaveAttribute('maxlength', '64')
// 不选文件直接提交:提示仍以 zip 为必填(版本号可空)
await dialog.getByRole('button', { name: '上传教程包' }).click()
await expect(dialog.locator('.el-alert')).toContainText('请选择 zip 压缩包')
})
@@ -1,67 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
// 响应式壳层对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
// 断言主题改为「窄屏不破版、核心地标与标题可用」。
async function open(page: Page, width: number, path = '/admin-vue/') {
await page.setViewportSize({ width, height: 900 })
await page.goto(path)
}
test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => {
await open(page, 1440)
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
})
test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => {
await open(page, 1280)
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
})
test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => {
await open(page, 1440)
await page.reload()
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
})
test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => {
await open(page, 640)
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
// 窄屏侧栏自适应收缩(当前 85px 窄栏),不遮主内容标题。
await expect(page.locator('.admin-topbar h1')).toBeVisible()
})
test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => {
await open(page, 700)
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
})
test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => {
await open(page, 900)
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await open(page, 901, '/admin-vue/account/users')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
})
test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => {
// 无折叠偏好了;刷新后壳层保持正常渲染(不因重载变样)。
await open(page, 1440)
await page.reload()
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
})
test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => {
// 桌面/窄屏两种宽度下壳层均无初始化错误、导航地标可见。
await open(page, 1440)
await expect(page.locator('.el-alert--error')).toHaveCount(0)
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await open(page, 820, '/admin-vue/account/users')
await expect(page.locator('.el-alert--error')).toHaveCount(0)
})
@@ -1,78 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
// 壳层可访问性对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
// 断言主题改为「跳转链接、地标唯一性、标题层级、键盘可达」。
async function openUsers(page: Page) {
await page.goto('/admin-vue/')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
}
test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => {
await openUsers(page)
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
})
test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => {
await openUsers(page)
const skip = page.locator('.skip-link')
await expect(skip).toBeVisible()
await expect(skip).toHaveAttribute('href', '#admin-content')
})
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
await openUsers(page)
await page.reload()
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
})
test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => {
await openUsers(page)
await expect(page.locator('.skip-link')).toHaveAttribute('href', '#admin-content')
await expect(page.locator('#admin-content')).toHaveCount(1)
})
test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => {
await openUsers(page)
// 键盘可达:Tab 到跳转链接,回车跳到主内容(hash 定位)。
await page.locator('.skip-link').focus()
await expect(page.locator('.skip-link')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/#admin-content/)
await expect(page.locator('#admin-content')).toBeVisible()
})
test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => {
await openUsers(page)
await expect(page.locator('main#admin-content')).toBeVisible()
await expect(page.getByRole('main')).toHaveCount(1)
})
test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => {
await openUsers(page)
// 导航后无可见错误遮罩/弹层残留。
await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
await expect(page.locator('.el-alert--error')).toHaveCount(0)
})
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
await openUsers(page)
// 标题层级:顶栏唯一 h1(页面标题);页面面板标题用 h3 递进。
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('h1')).toHaveCount(1)
const hasFocusStyle = await page.evaluate(() => {
const rules: string[] = []
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.cssText.includes(':focus-visible')) rules.push(rule.cssText)
}
} catch {
// 跨域样式表忽略
}
}
return rules.length > 0
})
expect(hasFocusStyle).toBe(true)
})
@@ -1,64 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
async function gotoRoute(page: Page, path: string) {
await page.goto(path)
}
test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
})
test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/menus')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
})
test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/groups')
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await gotoRoute(page, '/admin-vue/account/groups')
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
})
test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => {
// 根路由跳转到首个可见页面(用户管理)。
await gotoRoute(page, '/admin-vue/')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page).toHaveURL(/\/account\/users$/)
})
test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => {
await gotoRoute(page, '/admin-vue/account/menus')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
const active = page.locator('.el-menu-item.is-active')
await expect(active).toHaveText('菜单管理')
})
test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => {
// 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。
await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await page.getByText('菜单管理', { exact: true }).first().click()
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
await page.getByText('数据权限分组', { exact: true }).first().click()
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
})
test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => {
await gotoRoute(page, '/admin-vue/no-such-page')
await expect(page.locator('.not-found')).toBeVisible()
await expect(page.locator('.admin-topbar h1')).toHaveText('页面不存在')
})
test('test_task_019_first_batch_mount_dependency_failure_returns_actionable_message', async ({ page }) => {
// 分组内页面展示 分组->页面 两级面包屑。
await gotoRoute(page, '/admin-vue/account/users')
await expect(page.locator('.admin-breadcrumb')).toBeVisible()
await expect(page.locator('.admin-breadcrumb')).toContainText('账号权限')
await expect(page.locator('.admin-breadcrumb')).toContainText('用户管理')
})
@@ -1,75 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
async function openAdmin(page: Page) {
await page.goto('/admin-vue/')
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
}
test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => {
await openAdmin(page)
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
await expect(page.locator('.admin-user-meta strong')).toHaveText('admin')
await expect(page.locator('.admin-user-role')).toHaveText('超级管理员')
await expect(page.getByText('账号权限', { exact: true }).first()).toBeVisible()
})
test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => {
await openAdmin(page)
// 侧边栏菜单导航到「数据权限分组」,标题与面包屑跟随(对齐现状:无收起按钮)。
await page.getByRole('menuitem', { name: '数据权限分组' }).click()
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
await expect(page.locator('.admin-breadcrumb')).toBeVisible()
})
test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => {
await openAdmin(page)
await page.reload()
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
await expect(page.locator('.global-error-stack')).toHaveCount(0)
})
test('test_task_020_shell_browser_smoke_boundary_empty_input', async ({ page }) => {
await page.goto('/admin-vue/definitely/not/exists')
await expect(page.locator('.not-found')).toBeVisible()
await expect(page.locator('.not-found')).toContainText('404')
})
test('test_task_020_shell_browser_smoke_boundary_single_item', async ({ page }) => {
await openAdmin(page)
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.locator('.el-message-box')).toBeVisible()
await expect(page.locator('.el-message-box')).toContainText('确定要退出当前账号吗?')
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
await expect(page.locator('.el-message-box')).toHaveCount(0)
await expect(page).toHaveURL(/\/admin-vue\//)
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
})
test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => {
await openAdmin(page)
await page.getByRole('button', { name: '退出登录' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '退出' }).click()
await page.waitForURL(/\/login/, { timeout: 10_000 })
})
test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }) => {
// 整段冒烟无未捕获脚本错误(错误不会静默遗留到控制台)。
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await openAdmin(page)
await page.goto('/admin-vue/account/menus')
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
await page.goto('/admin-vue/account/groups')
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
expect(errors).toEqual([])
})
test('test_task_020_shell_browser_smoke_dependency_failure_returns_actionable_message', async ({ page }) => {
await openAdmin(page)
// 取消确认后无“可见”的遮罩/弹层残留(隐藏的对话框 DOM 不算);继续导航正常。
await page.getByRole('button', { name: '退出登录' }).click()
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
await page.locator('.admin-brand-copy, .admin-topbar h1').first().click({ force: true })
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
})
File diff suppressed because it is too large Load Diff
-29
View File
@@ -1,29 +0,0 @@
{
"name": "crawler-plugin-admin-frontend-vue",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host --port 5174",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test": "node --test tests/*.test.ts",
"test:live": "node --test tests/live/*.test.ts",
"test:browser": "playwright test"
},
"dependencies": {
"axios": "^1.13.6",
"element-plus": "^2.11.4",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^26.4.1",
"@vitejs/plugin-vue": "^6.0.1",
"typescript": "^5.9.2",
"vite": "^7.3.1",
"vue-tsc": "^3.0.6"
}
}
-35
View File
@@ -1,35 +0,0 @@
import { defineConfig } from '@playwright/test'
/**
* 浏览器验收:起 mock admin API(18100) + vite dev(5174, /api 代理到 mock)。
* 本地用已安装的 MS Edge(channel: msedge)运行,无需下载 Chromium。
*/
export default defineConfig({
testDir: './e2e',
timeout: 45_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
use: {
baseURL: 'http://127.0.0.1:5174',
channel: 'msedge',
headless: true,
viewport: { width: 1440, height: 900 },
locale: 'zh-CN',
},
webServer: [
{
command: 'node scripts/mock-admin-server.mjs',
port: 18100,
reuseExistingServer: true,
},
{
command: 'set VITE_API_TARGET=http://127.0.0.1:18100&& npm run dev',
port: 5174,
reuseExistingServer: true,
timeout: 120_000,
},
],
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

-43
View File
@@ -1,43 +0,0 @@
// 样办校验:生成记录页旧版样式落地实测(本地真实栈)。
import { chromium } from '@playwright/test'
const BASE = 'http://localhost:5174/admin-vue'
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
await page.locator('#loginUsername').fill('admin')
await page.locator('#loginPassword').fill('admin123')
await page.locator('.btn-login').click()
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
await page.waitForTimeout(1200)
}
await page.goto(`${BASE}/records/history`, { waitUntil: 'domcontentloaded' })
await page.locator('.form-box').first().waitFor({ state: 'visible', timeout: 15000 })
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(600)
const out = await page.evaluate(() => {
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
const fb = document.querySelector('.form-box')
const pb = document.querySelector('.panel-box')
const th = document.querySelector('.table-scroll th')
const td = document.querySelector('.table-scroll td')
const btn = document.querySelector('.form-box .btn')
const inp = document.querySelector('.form-group input, .form-group select')
return {
formBox: { bg: cs(fb, 'backgroundColor'), radius: cs(fb, 'borderRadius'), border: cs(fb, 'borderTopColor') },
panelBox: { bg: cs(pb, 'backgroundColor') },
h3: cs(document.querySelector('.panel-box h3'), 'fontSize'),
input: { minH: inp && cs(inp, 'minHeight'), bg: inp && cs(inp, 'backgroundColor'), radius: inp && cs(inp, 'borderRadius') },
btn: { minH: cs(btn, 'minHeight'), bgImage: btn && getComputedStyle(btn).backgroundImage.slice(0, 60), radius: cs(btn, 'borderRadius'), color: cs(btn, 'color') },
th: { bg: cs(th, 'backgroundColor'), color: cs(th, 'color'), fontSize: cs(th, 'fontSize'), weight: cs(th, 'fontWeight') },
td: { color: td && cs(td, 'color'), fontSize: td && cs(td, 'fontSize') },
tableScroll: { border: cs(document.querySelector('.table-scroll'), 'borderTopColor'), radius: cs(document.querySelector('.table-scroll'), 'borderRadius') },
rowCount: document.querySelectorAll('.table-scroll tbody tr').length,
thumbb: !!document.querySelector('.thumb'),
pagination: (document.querySelector('.pagination')?.textContent || '').trim().slice(0, 60),
modal: document.querySelector('.modal-mask') === null,
}
})
console.log(JSON.stringify(out, null, 2))
await browser.close()
@@ -1,436 +0,0 @@
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API + 撞款控制台夹具。
import { createServer } from 'node:http'
const PORT = Number(process.env.MOCK_PORT || 18100)
const MENU_TREE = [
{
key: 'account',
name: '账号权限',
children: [
{ key: 'admin_users', name: '用户管理', route: '/account/users' },
{ key: 'admin_columns', name: '菜单管理', route: '/account/menus' },
{ key: 'admin_group_manage', name: '数据权限分组', route: '/account/groups' },
],
},
]
/* ===================== 撞款控制台夹具(对齐前端 parse 的 snake 契约) ===================== */
const STORE_GROUP = { 店A1: '陈新辉', 店A2: '陈新辉', 店B1: '魏振峰', 店B2: '魏振峰', 店B3: '魏振峰', 店C1: '郭亚芳' }
const GROUP_DEFS = [
{ name: '陈新辉', shops: ['店A1', '店A2'] },
{ name: '魏振峰', shops: ['店B1', '店B2', '店B3'] },
{ name: '郭亚芳', shops: ['店C1'] },
]
function occ(asin, brand, shop, country, day) {
return { asin, date: `2026-08-${String(day).padStart(2, '0')} 09:00:00`, price: '9.90', brand, shop_name: shop, group_name: STORE_GROUP[shop], country_codes: [country], country }
}
// 撞款种子(≥2 店在售)与单店种子
const DUP_SEEDS = [
{ asin: 'E0000001', brand: '品牌甲', occ: [occ('E0000001', '品牌甲', '店A1', 'UK', 20), occ('E0000001', '品牌甲', '店A1', 'UK', 24), occ('E0000001', '品牌甲', '店A2', 'DE', 22), occ('E0000001', '品牌甲', '店B1', 'FR', 21)] },
{ asin: 'E0000002', brand: '品牌乙', occ: [occ('E0000002', '品牌乙', '店B1', 'UK', 10), occ('E0000002', '品牌乙', '店B2', 'DE', 12)] },
{ asin: 'E0000003', brand: '品牌丙', occ: [occ('E0000003', '品牌丙', '店A2', 'DE', 15), occ('E0000003', '品牌丙', '店B3', 'IT', 16)] },
]
const SINGLE_SEEDS = [
{ asin: 'E0000004', brand: '品牌甲', occ: [occ('E0000004', '品牌甲', '店A1', 'UK', 1)] },
{ asin: 'E0000005', brand: '品牌丁', occ: [occ('E0000005', '品牌丁', '店B2', 'UK', 2), occ('E0000005', '品牌丁', '店B2', 'UK', 3)] },
{ asin: 'E0000006', brand: '品牌戊', occ: [occ('E0000006', '品牌戊', '店C1', 'FR', 5)] },
{ asin: 'E0000007', brand: '品牌己', occ: [occ('E0000007', '品牌己', '店B3', 'DE', 6)] },
{ asin: 'E0000008', brand: '品牌庚', occ: [occ('E0000008', '品牌庚', '店A1', 'ES', 7)] },
]
function finalizeRow(seed) {
const stores = []
const storeSet = new Set()
const countries = new Set()
let earliest = ''
let latest = ''
for (const o of seed.occ) {
if (!storeSet.has(o.shop_name)) {
storeSet.add(o.shop_name)
stores.push(o.shop_name)
}
countries.add(o.country)
if (!earliest || o.date < earliest) earliest = o.date
if (o.date > latest) latest = o.date
}
const groups = [...new Set(stores.map((s) => STORE_GROUP[s] || ''))].filter(Boolean)
return {
asin: seed.asin,
brand: seed.brand,
store_count: stores.length,
stores,
groups,
countries: [...countries],
record_count: seed.occ.length,
earliest,
latest,
occurrences: seed.occ,
}
}
const ALL_ROWS = [...DUP_SEEDS, ...SINGLE_SEEDS].map(finalizeRow)
const DUP_ROWS = ALL_ROWS.filter((row) => row.store_count >= 2)
const SHOPS = Object.keys(STORE_GROUP).map((name) => {
const codes = new Set()
let recordCount = 0
let asinCount = 0
for (const row of ALL_ROWS) {
if (row.stores.includes(name)) {
asinCount += 1
for (const o of row.occurrences) {
if (o.shop_name === name) {
recordCount += 1
codes.add(o.country)
}
}
}
}
return { shop_name: name, group_name: STORE_GROUP[name], country_codes: [...codes], asin_count: asinCount, record_count: recordCount }
})
function computeGroups() {
return GROUP_DEFS.map((group) => {
const members = group.shops
const hit = DUP_ROWS.filter((row) => members.filter((shop) => row.stores.includes(shop)).length >= 2)
const asinUnique = ALL_ROWS.filter((row) => row.stores.some((shop) => members.includes(shop))).length
const recordCount = ALL_ROWS.reduce((sum, row) => {
if (row.stores.some((shop) => members.includes(shop))) sum += row.recordCount
return sum
}, 0)
return { name: group.name, shop_count: members.length, asin_unique: asinUnique, record_count: recordCount, dup_count: hit.length }
})
}
const GROUPS = computeGroups()
const RECORD_TOTAL = ALL_ROWS.reduce((sum, row) => sum + row.recordCount, 0)
function consolePayload() {
return {
success: true,
data: {
pending: false,
scanned_at: '2026-09-05 08:30:00',
summary: {
shop_count: SHOPS.length,
asin_total: ALL_ROWS.length,
record_total: RECORD_TOTAL,
duplicate_asin_total: DUP_ROWS.length,
duplicate_shop_count: DUP_ROWS.length ? 6 : 0,
site_count: 5,
asin_per_shop: 4.8,
source: 'job',
},
shops: SHOPS,
dup: DUP_ROWS,
groups: GROUPS,
total_dup: DUP_ROWS.length,
},
}
}
function json(res, payload, status = 200) {
const body = JSON.stringify(payload)
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(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 url = (req.url || '').split('?')[0]
const method = req.method || 'GET'
console.log(`[mock] ${method} ${url}`)
if (url === '/api/admin/current-user') {
return json(res, { success: true, data: { item: { id: 1, username: 'admin', role: 'super_admin' } } })
}
if (url === '/api/admin/current-user/menus') {
return json(res, { success: true, data: { items: MENU_TREE } })
}
if (url === '/api/admin/logout') {
return json(res, { success: true })
}
if (url === '/api/admin/users') {
return json(res, {
success: true,
data: {
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', created_at: '2026-03-01 09:15:00', updated_at: '2026-09-18 16:42:30' }],
total: 1,
},
})
}
if (url === '/api/admin/permission-menus') {
// 菜单管理列表:一条根节点,带创建/更新时间(验收时间列)。
return json(res, {
success: true,
data: [
{
id: 1,
name: '用户管理',
column_key: 'admin_users',
parent_id: null,
menu_type: 'admin',
route_path: 'account/users',
sort_order: 10,
created_at: '2026-03-01T09:15:00',
updated_at: '2026-09-18T16:42:30',
},
],
})
}
if (url === '/api/admin/invalid-asin-data') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
dataValue: 'B0INVALID1',
brand: '测试品牌',
groupId: 1,
groupName: '测试分组',
recordSource: 'MANUAL',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/dedupe-total-data') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
dataValue: 'B0DEDUPE01',
country: 'DE',
groupId: 1,
groupName: '测试分组',
uploaderUserId: 1,
username: 'admin',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/query-asins') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
groupId: 1,
groupName: '测试分组',
shopName: '测试店铺',
asinDe: 'B0QUERY001',
asinUk: '',
asinFr: '',
asinIt: '',
asinEs: '',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/skip-price-asins') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
groupId: 1,
groupName: '测试分组',
shopName: '测试店铺',
asinDe: 'B0SKIP0001',
minimumPriceDe: 9.99,
asinUk: '',
minimumPriceUk: null,
asinFr: '',
minimumPriceFr: null,
asinIt: '',
minimumPriceIt: null,
asinEs: '',
minimumPriceEs: null,
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
},
})
}
if (url === '/api/admin/product-categories/children') {
return json(res, {
success: true,
data: {
items: [
{
id: 1,
parentId: null,
name: '护肤品',
categoryKey: 'skincare',
sortOrder: 10,
description: '测试备注',
isBuiltin: true,
childCount: 0,
level: 0,
path: '护肤品',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 10,
hasMore: false,
},
})
}
if (url === '/api/admin/user-secrets') {
const emptyModule = { moduleKey: '', moduleLabel: '', masked: '', full: '', exists: false, checkStatus: 'unknown', checkCode: '', checkMessage: '', checkLatencyMs: null, checkedAt: null, updatedAt: null }
return json(res, {
success: true,
data: {
items: [
{
userId: 1,
username: 'admin',
groups: [],
leaderUsername: '',
similarAsin: { ...emptyModule, moduleKey: 'similar-asin', moduleLabel: '货源查询密钥' },
appearancePatent: { ...emptyModule, moduleKey: 'appearance-patent', moduleLabel: '外观专利密钥' },
proxy: { ...emptyModule, moduleKey: 'proxy', moduleLabel: '代理设置' },
status: 'unknown',
statusMessage: '',
createdAt: '2026-03-01 09:15:00',
updatedAt: '2026-09-18 16:42:30',
},
],
total: 1,
page: 1,
pageSize: 15,
groupOptions: [],
},
})
}
if (url === '/api/admin/shop-manage-groups') {
return json(res, { success: true, data: { items: [] } })
}
if (url === '/api/admin/shop-data-crawl/duplicate-check-console') {
return json(res, consolePayload())
}
if (url === '/api/admin/shop-data-crawl/duplicate-check-ledger') {
return json(res, {
success: true,
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
})
}
if (url === '/api/admin/notifications/summary') {
return json(res, {
success: true,
data: {
unreadCount: NOTIFICATIONS.filter((item) => !item.read).length,
latestId: NOTIFICATIONS.reduce((max, item) => Math.max(max, item.id), 0),
},
})
}
if (url === '/api/admin/notifications') {
return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams))
}
if (url === '/api/admin/tutorials') {
// 与 Java 侧同契约:created_at 降序返回(页面"当前生效"取第一条)。
const items = [...TUTORIAL_PACKAGES].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
return json(res, { success: true, data: { items } })
}
if (url.startsWith('/api/')) {
return json(res, { success: true, data: { items: [], total: 0 } })
}
return json(res, { success: true }, 200)
})
server.listen(PORT, () => console.log(`mock admin api listening on ${PORT}`))
@@ -1,96 +0,0 @@
// round2 DOM 审计:登录本地真实栈后,对 15 页提取结构化观感事实(颜色/卡壳/分页坐标/按钮形态/标题/列头/空态)。
// 用法:cd admin-frontend-vue && node scripts/round2-audit.mjs
import { chromium } from '@playwright/test'
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
const PAGES = [
['account-users', 'account/users'],
['account-menus', 'account/menus'],
['account-groups', 'account/groups'],
['asin-registry', 'asin-center/registry'],
['asin-invalid', 'asin-center/invalid'],
['asin-query', 'asin-center/query'],
['asin-categories', 'asin-center/categories'],
['asin-skip-price', 'asin-center/skip-price'],
['shop-keys', 'shop-center/keys'],
['shop-shops', 'shop-center/shops'],
['shop-data-tasks', 'shop-center/data-tasks'],
['records-history', 'records/history'],
['records-software-version', 'records/software-version'],
['records-digital-human-version', 'records/digital-human-version'],
['records-image-video-tasks', 'records/image-video-tasks'],
]
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
const page = await context.newPage()
async function ensureLoggedIn() {
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
await page.waitForTimeout(600)
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
await page.locator('#loginUsername').fill('admin')
await page.locator('#loginPassword').fill('admin123')
await page.locator('.btn-login').click()
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
await page.waitForTimeout(1200)
}
}
await ensureLoggedIn()
const out = {}
for (const [name, path] of PAGES) {
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(500)
const fact = await page.evaluate(() => {
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
const root = getComputedStyle(document.documentElement)
const q = (sel) => document.querySelectorAll(sel)
const text = (el) => (el ? (el.textContent || '').trim() : '')
const heading = document.querySelector('.page-heading h1, .page-heading h2, .page-heading .page-title')
const headingText = heading ? (heading.textContent || '').trim() : null
const pags = [...q('.el-pagination')].map((p) => {
const r = p.getBoundingClientRect()
return { y: Math.round(r.top), x: Math.round(r.left), w: Math.round(r.width) }
})
const btns = [...q('.el-button')].map((b) => {
const t = (b.textContent || '').trim().slice(0, 8)
return { t, link: b.classList.contains('is-link'), text: b.classList.contains('is-text'), primary: b.classList.contains('el-button--primary'), danger: b.classList.contains('el-button--danger') }
})
const ths = [...q('.el-table__header th')].map((x) => (x.textContent || '').trim())
const empty = q('.el-empty, .empty-tip, .empty').length
const emptyText = text(document.querySelector('.el-empty, .empty-tip')) || null
const indigoEls = [...q('body *')].filter((el) => {
const b = cs(el, 'backgroundColor')
const c = cs(el, 'color')
return /rgb\(99,\s*102,\s*241\)|rgb\(238,\s*240,\s*254\)|#6366f1|#eef0fe/i.test(`${b} ${c}`)
}).slice(0, 4).map((el) => ({ tag: el.tagName, cls: (el.className && String(el.className).slice(0, 60)) || '', bg: cs(el, 'backgroundColor'), color: cs(el, 'color') }))
const cards = q('.el-card').length
const toolbars = q('.table-toolbar, .list-toolbar').length
const pageHeadingDesc = text(document.querySelector('.page-heading p'))
return {
heading: headingText, desc: (pageHeadingDesc || '').slice(0, 80),
primary: root.getPropertyValue('--el-color-primary').trim(),
bg: root.getPropertyValue('--el-bg-color').trim() || cs(document.body, 'backgroundColor'),
paginations: pags, cards, toolbars,
ths: ths.slice(0, 14), empty, emptyText,
buttonShape: {
total: btns.length,
link: btns.filter((b) => b.link).length,
textPlain: btns.filter((b) => b.text && !b.link).length,
primarySolid: btns.filter((b) => b.primary && !b.text && !b.link).length,
danger: btns.filter((b) => b.danger).length,
sample: btns.slice(0, 12),
},
indigoEls,
}
})
out[name] = { path, ...fact }
}
console.log(JSON.stringify(out, null, 2))
await browser.close()
@@ -1,97 +0,0 @@
// round2 现状截图基线:本地真实栈(5174 -> 18080 Java),登录后逐页截图 + 收集 pageerror/console error。
// 用法:cd admin-frontend-vue && node scripts/round2-shots.mjs
import { chromium } from '@playwright/test'
import { mkdirSync } from 'node:fs'
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
const OUT = 'test-results/round2-baseline'
mkdirSync(OUT, { recursive: true })
const PAGES = [
['account-users', 'account/users'],
['account-menus', 'account/menus'],
['account-groups', 'account/groups'],
['asin-registry', 'asin-center/registry'],
['asin-invalid', 'asin-center/invalid'],
['asin-query', 'asin-center/query'],
['asin-categories', 'asin-center/categories'],
['asin-skip-price', 'asin-center/skip-price'],
['shop-keys', 'shop-center/keys'],
['shop-shops', 'shop-center/shops'],
['shop-data-tasks', 'shop-center/data-tasks'],
['records-history', 'records/history'],
['records-software-version', 'records/software-version'],
['records-digital-human-version', 'records/digital-human-version'],
['records-image-video-tasks', 'records/image-video-tasks'],
]
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
const page = await context.newPage()
const problems = new Map() // page -> string[]
page.on('pageerror', (e) => {
const key = page.url()
const list = problems.get(key) || []
list.push(`pageerror: ${e.message}`)
problems.set(key, list)
})
page.on('console', (m) => {
if (m.type() === 'error') {
const key = page.url()
const list = problems.get(key) || []
list.push(`console.error: ${m.text().slice(0, 300)}`)
problems.set(key, list)
}
})
// 登录(真实 UI 表单)
async function ensureLoggedIn() {
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
await page.waitForTimeout(600)
const loginVisible = await page.locator('#loginUsername').isVisible().catch(() => false)
if (loginVisible) {
await page.locator('#loginUsername').fill('admin')
await page.locator('#loginPassword').fill('admin123')
await page.locator('.btn-login').click()
// 等离开登录页(进入后台)
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
await page.waitForTimeout(1200)
}
}
await ensureLoggedIn()
console.log('logged-in url:', page.url())
const report = []
for (const [name, path] of PAGES) {
const url = `${BASE}/${path}`
try {
await page.goto(url, { waitUntil: 'domcontentloaded' })
// 等页头或内容出现(不同页标题不同,抓第一个标题/内容容器)
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(500)
await page.screenshot({ path: `${OUT}/${name}.jpg`, type: 'jpeg', quality: 82 })
report.push(`ok ${name} ${url}`)
} catch (e) {
report.push(`FAIL ${name} ${url} :: ${String(e).slice(0, 200)}`)
}
}
console.log('\n=== screenshots ===')
console.log(report.join('\n'))
console.log('\n=== page errors (运行时异常,供排查) ===')
let anyError = false
for (const [url, list] of problems) {
const clean = url.replace(BASE, '')
if (list.length) {
anyError = true
console.log(`-- ${clean}`)
for (const l of [...new Set(list)]) console.log(' ', l)
}
}
if (!anyError) console.log('(无 pageerror/console.error)')
await browser.close()
@@ -1,45 +0,0 @@
// round2 单批验证:guides 渲染 + 类目色点 token + 行内按钮实心(本地真实栈)。
import { chromium } from '@playwright/test'
const BASE = 'http://localhost:5174/admin-vue'
const browser = await chromium.launch({ channel: 'msedge', headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
await page.locator('#loginUsername').fill('admin')
await page.locator('#loginPassword').fill('admin123')
await page.locator('.btn-login').click()
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
await page.waitForTimeout(1200)
}
async function open(path) {
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
await page.waitForLoadState('networkidle').catch(() => {})
await page.waitForTimeout(600)
}
await open('account/users')
const guide = await page.locator('.operation-guide').count()
const guideText = guide ? (await page.locator('.og-text').textContent())?.trim() : null
console.log('users guide visible:', guide > 0, '| text:', guideText)
await open('asin-center/categories')
const token = await page.evaluate(() => {
const el = document.querySelector('.tree-node-mark')
if (!el) return null
const cs = getComputedStyle(el)
return { color: cs.color, bg: cs.backgroundColor }
})
console.log('categories tree-node-mark:', JSON.stringify(token))
await open('asin-center/registry')
const solid = await page.evaluate(() => {
const btns = [...document.querySelectorAll('.el-table .el-button--small')]
const linkOrText = btns.filter((b) => b.classList.contains('is-link') || b.classList.contains('is-text')).length
const primary = btns.filter((b) => b.classList.contains('el-button--primary')).length
return { smallSolid: btns.length, linkOrText, primary }
})
console.log('registry row buttons:', JSON.stringify(solid))
await browser.close()
-7
View File
@@ -1,7 +0,0 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
-135
View File
@@ -1,135 +0,0 @@
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)
}
-109
View File
@@ -1,109 +0,0 @@
/** API 响应解包与错误归一化(任务 22):纯函数,无框架依赖。 */
export const REQUEST_FALLBACK_MESSAGE = '网络异常,请稍后重试'
function textOf(value: Record<string, unknown>): string {
const message = value.message
const error = value.error
if (typeof message === 'string' && message.trim()) return message.trim()
if (typeof error === 'string' && error.trim()) return error.trim()
return ''
}
/**
* 统一解包 Java 响应:
* - success=false -> 抛错(取 message/error,空则“请求失败”);
* - 带非空 data -> 返回 data
* - 否则原样返回(允许 {item}/{items} 等信封由调用方再解)。
*/
export function unwrap<T>(payload: unknown): T {
const value = payload as Record<string, unknown> | null
if (!value || typeof value !== 'object') return payload as T
if (value.success === false) {
throw new Error(textOf(value) || '请求失败')
}
if ('data' in value && value.data !== null && value.data !== undefined) {
return value.data as T
}
return payload as T
}
/** 从任意负载取错误文案;缺省回退。 */
export function errorTextOf(payload: unknown): string {
if (payload instanceof Error) return payload.message.trim() || REQUEST_FALLBACK_MESSAGE
if (payload && typeof payload === 'object') {
const text = textOf(payload as Record<string, unknown>)
if (text) return text
}
if (typeof payload === 'string' && payload.trim()) return payload.trim()
return REQUEST_FALLBACK_MESSAGE
}
/** 负载/状态码是否 401(未登录)。 */
export function isUnauthorized(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 === 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 统一取可展示文案。 */
export function requestErrorMessage(error: unknown): string {
const response = (error as { response?: { data?: unknown; status?: number } })?.response
if (response) {
const text = errorTextOf(response.data)
if (text) return text
}
return errorTextOf(error)
}
/** 当前地址作为登录跳转的 redirect 参数(encodeURIComponent 后)。 */
export function loginRedirectTarget(location: { pathname: string; search: string }): string {
return encodeURIComponent(`${location.pathname}${location.search}`)
}
/** 登录页统一入口;401 一律跳此路径。 */
export const LOGIN_PATH = '/login'
/** 当前 pathname 是否处于登录页(去掉可能携带的查询串后比较)。 */
export function isLoginLocation(pathname: string): boolean {
if (typeof pathname !== 'string') return false
const queryAt = pathname.indexOf('?')
return (queryAt >= 0 ? pathname.slice(0, queryAt) : pathname) === LOGIN_PATH
}
/** 把请求 URL(相对或绝对)规整为相对路径,便于与登录入口比较。 */
export function requestRelativePath(url: string): string {
if (typeof url !== 'string') return ''
const noQuery = url.split('?')[0]
const match = noQuery.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(.*)$/)
return match ? match[1] : noQuery
}
/** 401 失败请求本身是否指向登录/认证端点(登录提交失败不应再被踢回登录页)。 */
export function isAuthEndpointRequest(url: string | undefined): boolean {
return requestRelativePath(url || '') === LOGIN_PATH
}
/**
* 401 是否应执行“跳 /login”:已处于登录页或失败请求即登录端点时不应再跳,
* 否则会在登录页循环跳转或吞掉登录失败反馈。
*/
export function shouldRedirectUnauthorized(currentPathname: string, requestUrl?: string): boolean {
if (isLoginLocation(currentPathname)) return false
if (isAuthEndpointRequest(requestUrl)) return false
return true
}
-59
View File
@@ -1,59 +0,0 @@
import axios from 'axios'
import {
isForbidden,
isKicked,
isLoginLocation,
isUnauthorized,
loginRedirectTarget,
shouldRedirectUnauthorized,
} from './envelope'
export { unwrap } from './envelope'
export const http = axios.create({
baseURL: '/',
withCredentials: true,
timeout: 30_000,
})
function redirectToLogin(requestUrl?: string): void {
if (typeof window === 'undefined') return
// 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。
if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return
const target = loginRedirectTarget(window.location)
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(
(response) => {
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理;
// 403(已登录但无后台权限,如用工具前端账号 token 访问后台)与 401 同样跳登录页。
// 4011(账号已在其他设备登录)单独提示,不按普通 401 处理。
if (isKicked(response.data)) {
redirectToLoginKicked()
} else if (isUnauthorized(response.data) || isForbidden(response.data)) {
redirectToLogin(response.config?.url)
}
return response
},
(error) => {
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)
}
return Promise.reject(error)
},
)
@@ -1,70 +0,0 @@
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)
}
@@ -1,25 +0,0 @@
import { unwrap } from './envelope.ts'
import type { AdminMenuNode, AdminUser } from '../types/admin'
/** 解析当前用户响应:支持 data:{item} 与 {item}/{直接对象} 信封。 */
export function parseCurrentUser(payload: unknown): AdminUser {
const value = unwrap<AdminUser | { item?: AdminUser }>(payload)
const user =
value && typeof value === 'object' && 'item' in value && value.item
? value.item
: (value as AdminUser)
if (!user || typeof user.id !== 'number') {
throw new Error('当前用户响应缺少有效 id')
}
return user
}
/**
* 解析菜单树响应:支持 data:{items} 与 {items}/数组 信封。
* 返回后端树形节点;空/缺省按 [] 处理,与“接口失败”(抛错) 区分开。
*/
export function parseMenuTree(payload: unknown): AdminMenuNode[] {
const value = unwrap<AdminMenuNode[] | { items?: AdminMenuNode[] }>(payload)
if (Array.isArray(value)) return value
return (value && Array.isArray(value.items) ? value.items : []) as AdminMenuNode[]
}
-20
View File
@@ -1,20 +0,0 @@
import { http } from './http'
import { parseCurrentUser, parseMenuTree } from './session-model'
import type { AdminMenuNode, AdminUser, ApiEnvelope } from '../types/admin'
export async function fetchCurrentUser(): Promise<AdminUser> {
const { data } = await http.get<ApiEnvelope<{ item: AdminUser }> | AdminUser>('/api/admin/current-user')
return parseCurrentUser(data)
}
export async function fetchAdminMenuTree(): Promise<AdminMenuNode[]> {
const { data } = await http.get<
ApiEnvelope<{ items: AdminMenuNode[] }> | { items: AdminMenuNode[] } | AdminMenuNode[]
>('/api/admin/current-user/menus')
return parseMenuTree(data)
}
export async function logout(): Promise<void> {
// 登出走 auth 模块根端点 POST /logout(下发清除会话 cookie);旧 api/admin 前缀路径后端无映射。
await http.post('/logout', undefined, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
}
@@ -1,47 +0,0 @@
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-usagegroup_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)
}
@@ -1,92 +0,0 @@
import { http } from './http'
import { unwrap } from './envelope'
/** 单模块状态(脱敏值 + 连通性)。 */
export interface AdminUserSecretModule {
moduleKey: string
moduleLabel: string
masked: string
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
full: string
exists: boolean
checkStatus: string
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: string | null
updatedAt: string | null
}
/** 一行一用户:三个字段列 + 行级状态(三类都检测通过才算 passed)。 */
export interface AdminUserSecretRow {
userId: number
username: string
/** 所属数据权限分组名(可能多个)。 */
groups: string[]
/** 所属主管(创建人)用户名;无分组时的兜底展示。 */
leaderUsername: string
similarAsin: AdminUserSecretModule
appearancePatent: AdminUserSecretModule
proxy: AdminUserSecretModule
status: string
statusMessage: string
/** 首次配置时间(三模块中最早);从未配置为 null。 */
createdAt: string | null
updatedAt: string | null
}
export interface AdminUserSecretPage {
items: AdminUserSecretRow[]
total: number
page: number
pageSize: number
/** 分组筛选项(超管=全部;主管=自己带的分组)。 */
groupOptions?: GroupOption[]
}
export interface GroupOption {
id: number
groupName: string
}
export interface UserSecretQuery {
keyword?: string
checkStatus?: string
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
groupId?: number
page: number
pageSize: number
}
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets(筛选参数必须 snake_casecamel 会被后端静默忽略)。 */
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
if (params.keyword) query.keyword = params.keyword
if (params.checkStatus) query.checkStatus = params.checkStatus
if (params.groupId) query.group_id = params.groupId
const { data } = await http.get('/api/admin/user-secrets', { params: query })
return unwrap<AdminUserSecretPage>(data)
}
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check
* 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */
export async function checkUserSecret(userId: number) {
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`, undefined, { timeout: 180_000 })
return unwrap<
Array<{
moduleKey: string
checkStatus: string
checkCode: string
checkMessage: string
checkLatencyMs: number | null
checkedAt: string | null
viaProxy: boolean
}>
>(data)
}
/** 清空该用户全部密钥与代理配置:DELETE /api/admin/user-secrets/{userId} */
export async function deleteUserSecret(userId: number): Promise<void> {
const { data } = await http.delete(`/api/admin/user-secrets/${userId}`)
unwrap<unknown>(data)
}
-81
View File
@@ -1,81 +0,0 @@
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
import type { AdminUser } from '../types/admin'
export const USER_PAGE_DEFAULT_SIZE = 10
export const USER_PAGE_MIN_PAGE = 1
export const USER_PAGE_MAX_SIZE = 200
/** 前端用户列表筛选/分页状态(页面局部,不入 Pinia)。 */
export interface UserListParams {
page: number
pageSize: number
/** 用户名模糊关键字(对应后端 username|search 的 kw)。 */
keyword?: string
createdById?: number | null
adminId?: number | null
/** 角色过滤(''=全部)。 */
role?: string
}
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
export interface UserListQuery {
page: number
page_size: number
username?: string
search?: string
created_by_id?: number
admin_id?: number
role?: string
}
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
export type AdminUserItem = AdminUser
/** 分页结果:与 Java AdminUserListVo.items/total/page/page_size 归一后的前端 DTO。 */
export interface UserPageResult {
items: AdminUserItem[]
total: number
page: number
pageSize: number
currentUserId?: number | null
currentUserUsername?: string
admins?: Array<{ id: number; username: string }>
}
function finiteInt(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 归一化分页/筛选参数:页码下限 1、页大小 1..上限,空关键字视为无过滤。 */
export function normalizeUserPageParams(raw: Partial<UserListParams>): UserListParams {
const page = Math.max(finiteInt(raw.page) ?? USER_PAGE_MIN_PAGE, USER_PAGE_MIN_PAGE)
const rawSize = finiteInt(raw.pageSize)
const pageSize =
rawSize === null || rawSize < USER_PAGE_MIN_PAGE
? USER_PAGE_DEFAULT_SIZE
: Math.min(rawSize, USER_PAGE_MAX_SIZE)
const keyword = typeof raw.keyword === 'string' ? raw.keyword.trim() || undefined : undefined
return {
page,
pageSize,
keyword,
createdById: typeof raw.createdById === 'number' ? raw.createdById : null,
adminId: typeof raw.adminId === 'number' ? raw.adminId : null,
role: typeof raw.role === 'string' ? raw.role.trim() || undefined : undefined,
}
}
/** 把前端分页/筛选状态转成 Java 查询参数(缺省字段不下发)。 */
export function toUserListQuery(params: UserListParams): UserListQuery {
const query: UserListQuery = { page: params.page, page_size: params.pageSize }
if (params.keyword) query.username = params.keyword
if (params.createdById != null) query.created_by_id = params.createdById
if (params.adminId != null) query.admin_id = params.adminId
if (params.role) query.role = params.role
return query
}
/** 无数据分页结果缺省值(首屏/加载前占位)。 */
export function emptyUserPageResult(): UserPageResult {
return { items: [], total: 0, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
}
-75
View File
@@ -1,75 +0,0 @@
/** 用户 API 响应解析模型:列表分页(任务 42)与新建用户(任务 46)负载归一。 */
import { unwrap } from './envelope.ts'
import { emptyUserPageResult, type UserPageResult } from './users-dto.ts'
import type { AdminUser } from '../types/admin'
function text(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/** 把 Java AdminUserItemVo(snake_case) 映射为前端 AdminUser(camelCase);缺 id 视为无效。 */
export function toAdminUserItem(raw: unknown): AdminUser | 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
const item: AdminUser = { id, username: text(r.username), role: text(r.role) }
if (typeof r.is_admin === 'boolean') item.isAdmin = r.is_admin
const createdById = numberOrNull(r.created_by_id)
if (createdById !== null) item.createdById = createdById
const createdAt = text(r.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(r.updated_at)
if (updatedAt) item.updatedAt = updatedAt
const creator = text(r.creator_username)
if (creator) item.creatorUsername = creator
const abbr = text(r.pinyin_abbr)
if (abbr) item.pinyinAbbr = abbr
return item
}
/**
* 归一化 Java 用户列表负载(data:{items,total,page,page_size} 或已解包 VO)为前端
* UserPageResult;空/缺省字段回默认,success=false 抛后端 message。
*/
export function parseUserPage(payload: unknown): UserPageResult {
const out = emptyUserPageResult()
const core = unwrap<unknown>(payload)
if (!core || typeof core !== 'object') return out
const record = core as Record<string, unknown>
if (Array.isArray(record.items)) {
out.items = record.items
.map((raw) => toAdminUserItem(raw))
.filter((item): item is AdminUser => item !== null)
}
if (typeof record.total === 'number') out.total = record.total
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
const rawSize = record.page_size ?? record.pageSize
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
if (typeof record.current_user_id === 'number') out.currentUserId = record.current_user_id
if (typeof record.current_user_username === 'string') out.currentUserUsername = record.current_user_username
if (Array.isArray(record.admins)) {
out.admins = record.admins
.map((raw) => {
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, username: text(r.username) }
})
.filter((a): a is { id: number; username: string } => a !== null)
}
return out
}
/** 解包新建用户响应并取回新用户 idPOST /api/admin/user 的 data 为 userId)。 */
export function parseCreatedUserId(payload: unknown): number {
const data = unwrap<unknown>(payload)
const id = typeof data === 'number' && Number.isFinite(data) ? Math.floor(data) : null
if (id === null || id < 1) throw new Error('创建用户响应异常:未返回有效的用户 ID')
return id
}
-22
View File
@@ -1,22 +0,0 @@
import { http } from './http'
import { unwrap } from './envelope'
import { parseUserPage } from './users-model'
import {
normalizeUserPageParams,
toUserListQuery,
type UserListParams,
type UserPageResult,
} from './users-dto'
/** 分页查询用户列表:归一化参数 → GET /api/admin/users → 解析 Java 分页负载。 */
export async function fetchUserList(params: Partial<UserListParams> = {}): Promise<UserPageResult> {
const normalized = normalizeUserPageParams(params)
const { data } = await http.get('/api/admin/users', { params: toUserListQuery(normalized) })
return parseUserPage(data)
}
/** 删除用户:DELETE /api/admin/user/{uid}success=false 由 unwrap 抛后端 message。 */
export async function deleteUser(uid: number): Promise<void> {
const { data } = await http.delete<unknown>(`/api/admin/user/${uid}`)
unwrap<unknown>(data)
}
@@ -1,16 +0,0 @@
<script lang="ts">
import { defineComponent } from 'vue'
import { openAdminConfirm } from './admin-confirm'
/**
* AdminConfirm 共享确认组件(module 13 task 245):renderless 单例承载。
* 使用:<AdminConfirm ref="confirmRef" />,需确认时 await confirmRef.open({...})。
*/
export default defineComponent({
name: 'AdminConfirm',
setup(_props, { expose }) {
expose({ open: openAdminConfirm })
return () => null
},
})
</script>
@@ -1,27 +0,0 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { copyFeedback, isCopySupported } from './copy-model'
const props = defineProps<{ text: string }>()
async function handleCopy(): Promise<void> {
if (!props.text || !isCopySupported()) {
ElMessage.warning(copyFeedback(''))
return
}
try {
await navigator.clipboard.writeText(props.text)
ElMessage.success(copyFeedback(props.text))
} catch {
ElMessage.error(copyFeedback(''))
}
}
</script>
<template>
<span class="copy-text" role="button" tabindex="0" :title="text" @click="handleCopy"><slot>{{ text }}</slot></span>
</template>
<style scoped>
.copy-text { cursor: pointer; }
</style>
@@ -1,42 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { dialogTitleOf, drawerLayoutSpec } from './modal-model'
/**
* DrawerShell 右侧抽屉容器(module 13 task 248):封装 el-drawer,统一 admin 尺寸
* 与关闭语义(遮罩/Esc),标题透传默认头部、footer 槽承接底部按钮,构成头/体/脚三段。
*/
const props = withDefaults(
defineProps<{ modelValue: boolean; title?: string; subtitle?: string; width?: string | number }>(),
{ title: '', subtitle: '', width: drawerLayoutSpec().width },
)
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
const spec = computed(() => drawerLayoutSpec())
const head = computed(() => dialogTitleOf({ title: props.title, subtitle: props.subtitle }))
</script>
<template>
<el-drawer
v-model="visible"
:size="width"
:title="head.title"
:close-on-click-modal="spec.maskClosable"
:close-on-press-escape="spec.escClosable"
class="admin-drawer-shell"
>
<p v-if="head.subtitle" class="admin-drawer-subtitle">{{ head.subtitle }}</p>
<slot />
<template #footer>
<slot name="footer" />
</template>
</el-drawer>
</template>
<style scoped>
.admin-drawer-subtitle { margin: 0 0 12px; color: var(--admin-muted); font-size: 13px; }
</style>
@@ -1,81 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { secretPlaceholder } from '@/pages/shop/secret-mask'
/**
* MaskReveal 掩码揭示(module 13 task 247):默认以店铺 secret-mask 掩码占位展示,
* 点击切换时向父组件发 reveal 事件取明文(以 value prop 回填);短时后自动收回。
*/
const props = withDefaults(defineProps<{ value?: string; revealMs?: number }>(), {
value: '',
revealMs: 5000,
})
const emit = defineEmits<{ (e: 'reveal'): void }>()
const revealed = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function clearTimer(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
}
function show(): void {
revealed.value = true
emit('reveal')
clearTimer()
timer = setTimeout(() => {
revealed.value = false
}, props.revealMs)
}
function hide(): void {
clearTimer()
revealed.value = false
}
function toggle(): void {
if (revealed.value) hide()
else show()
}
watch(
() => props.value,
(v) => {
if (!v) revealed.value = false
},
)
onBeforeUnmount(clearTimer)
</script>
<template>
<span class="mask-reveal">
<span class="mask-reveal-text">{{ revealed && value ? value : secretPlaceholder() }}</span>
<button
type="button"
class="mask-reveal-toggle"
:aria-label="revealed ? '隐藏' : '显示'"
@click="toggle"
>
{{ revealed ? '隐藏' : '显示' }}
</button>
</span>
</template>
<style scoped>
.mask-reveal { display: inline-flex; align-items: center; gap: 6px; }
.mask-reveal-text { font-variant-numeric: tabular-nums; }
.mask-reveal-toggle {
border: 1px solid #cbd9e6;
border-radius: 6px;
background: #fff;
color: #5b6f83;
font-size: 12px;
padding: 1px 8px;
cursor: pointer;
}
.mask-reveal-toggle:hover { color: #2f5d8b; border-color: #95b1cb; background: #edf5fb; }
</style>
@@ -1,42 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { dialogTitleOf, modalLayoutSpec } from './modal-model'
/**
* ModalShell 居中模态容器(module 13 task 248):封装 el-dialog,统一 admin 尺寸/圆角
* 与关闭语义,标题透传给 Element 默认头部;body 用默认槽、操作区走 footer 槽。
*/
const props = withDefaults(
defineProps<{ modelValue: boolean; title?: string; subtitle?: string; width?: string | number }>(),
{ title: '', subtitle: '', width: modalLayoutSpec().width },
)
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
const spec = computed(() => modalLayoutSpec())
const head = computed(() => dialogTitleOf({ title: props.title, subtitle: props.subtitle }))
</script>
<template>
<el-dialog
v-model="visible"
:width="width"
:title="head.title"
:close-on-click-modal="spec.maskClosable"
:close-on-press-escape="spec.escClosable"
class="admin-modal-shell"
>
<p v-if="head.subtitle" class="admin-modal-subtitle">{{ head.subtitle }}</p>
<slot />
<template #footer>
<slot name="footer" />
</template>
</el-dialog>
</template>
<style scoped>
.admin-modal-subtitle { margin: 0 0 12px; color: var(--admin-muted); font-size: 13px; }
</style>
@@ -1,683 +0,0 @@
<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>
@@ -1,121 +0,0 @@
<script setup lang="ts">
/** 旧版风格分页(像素复刻 admin.html .pagination+ 每页条数下拉(10/20/50/100)。
* props: total / page / pageSize / sizes(默认 10/20/50/100) / showTotal(默认 true) / jump(默认 true)。 */
import { computed, ref } from 'vue'
const props = withDefaults(
defineProps<{
total: number
page: number
pageSize: number
sizes?: number[]
showTotal?: boolean
jump?: boolean
}>(),
{ sizes: () => [10, 20, 50, 100], showTotal: true, jump: true },
)
const emit = defineEmits<{
(e: 'change', page: number): void
(e: 'size-change', size: number): void
}>()
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
function go(next: number) {
if (next < 1 || next > totalPages.value || next === props.page) return
emit('change', next)
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) return
jumpPage.value = ''
go(Math.min(Math.max(n, 1), totalPages.value))
}
function onSizeChange(event: Event) {
const value = Number((event.target as HTMLSelectElement).value)
if (Number.isFinite(value) && value > 0) emit('size-change', value)
}
</script>
<template>
<div class="old-pagination">
<span v-if="showTotal" class="page-total"> {{ total }} </span>
<select class="page-size-select" :value="pageSize" @change="onSizeChange">
<option v-for="size in sizes" :key="size" :value="size">{{ size }}/</option>
</select>
<button type="button" :disabled="page <= 1" @click="go(page - 1)">上一页</button>
<span class="page-cur"> {{ page }} / {{ totalPages }} </span>
<button type="button" :disabled="page >= totalPages" @click="go(page + 1)">下一页</button>
<span v-if="jump" class="page-jump">
跳至
<input v-model="jumpPage" type="text" inputmode="numeric" @keyup.enter="goJump" />
<button type="button" @click="goJump">跳转</button>
</span>
</div>
</template>
<style scoped>
.old-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.old-pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.old-pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.old-pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-size-select {
min-height: 30px;
padding: 3px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
</style>
@@ -1,25 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { pillToneStyle, type PillTone } from './status-pill-model'
const props = withDefaults(defineProps<{ text: string; tone?: PillTone }>(), { tone: 'info' })
const style = computed(() => pillToneStyle(props.tone))
</script>
<template>
<span class="status-pill" :style="{ color: style.text, background: style.bg }">{{ text }}</span>
</template>
<style scoped>
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 62px;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
line-height: 1.7;
white-space: nowrap;
}
</style>
@@ -1,68 +0,0 @@
/**
* 二次确认模型(module 13 task 245):文案/按钮键规则对齐 admin-interactions.js ——
* 优先显式 message,否则由行名拼接「确认删除“X”吗?此操作可能影响已有数据。」;
* danger(删除/清空/删除导入类)默认标题「删除前确认」、确认键「确认删除」。
* 纯逻辑、无 UI 依赖,便于单测;打开入口见 admin-confirm.ts / AdminConfirm.vue。
*/
export const ADMIN_CONFIRM = {
dangerTitle: '删除前确认',
defaultTitle: '请确认操作',
confirmDeleteOk: '确认删除',
confirmOk: '确认操作',
cancel: '取消',
deleteMessageSuffix: '此操作可能影响已有数据。',
} as const
export interface AdminConfirmPlan {
danger: boolean
title: string
message: string
confirmText: string
cancelText: string
type: 'warning' | 'info'
}
/** 由目标名称生成删除确认文案;无名称时退化为「该项」。 */
export function confirmMessageFor(targetName?: string): string {
const name = typeof targetName === 'string' && targetName.trim() ? `${targetName.trim()}` : '该项'
return `确认删除${name}吗?${ADMIN_CONFIRM.deleteMessageSuffix}`
}
export interface AdminConfirmOptions {
danger?: boolean
targetName?: string
message?: string
title?: string
confirmText?: string
}
/** 归一出二次确认计划:显式字段优先,否则按 danger/目标名落默认值。 */
export function createConfirmPlan(opts: AdminConfirmOptions): AdminConfirmPlan {
const danger = opts.danger !== false
const message = opts.message && opts.message.trim() ? opts.message.trim() : confirmMessageFor(opts.targetName)
return {
danger,
title: opts.title ?? (danger ? ADMIN_CONFIRM.dangerTitle : ADMIN_CONFIRM.defaultTitle),
message,
confirmText: opts.confirmText ?? (danger ? ADMIN_CONFIRM.confirmDeleteOk : ADMIN_CONFIRM.confirmOk),
cancelText: ADMIN_CONFIRM.cancel,
type: danger ? 'warning' : 'info',
}
}
// 并发防护:同一时刻只允许一个确认框在途(拒绝重复弹出)。
let confirmLocked = false
export function acquireConfirmLock(): boolean {
if (confirmLocked) return false
confirmLocked = true
return true
}
export function releaseConfirmLock(): void {
confirmLocked = false
}
export function isConfirmLocked(): boolean {
return confirmLocked
}
@@ -1,28 +0,0 @@
/**
* 二次确认打开入口(module 13 task 245):把模型计划交给 ElMessageBox,并对
* 确认/取消统一归约为 boolean;并发锁拒绝重复弹出。
*/
import { ElMessageBox } from 'element-plus'
import {
acquireConfirmLock,
createConfirmPlan,
releaseConfirmLock,
type AdminConfirmOptions,
} from './admin-confirm-model'
export async function openAdminConfirm(options: AdminConfirmOptions = {}): Promise<boolean> {
const plan = createConfirmPlan(options)
if (!acquireConfirmLock()) return false
try {
return await ElMessageBox.confirm(plan.message, plan.title, {
confirmButtonText: plan.confirmText,
cancelButtonText: plan.cancelText,
type: plan.type,
}).then(
() => true,
() => false,
)
} finally {
releaseConfirmLock()
}
}
@@ -1,28 +0,0 @@
/**
* 反馈 UI 接线(module 13 task 246):把模型语义接到 ElMessage 与 busy 锁上。
* 页面统一用 showAdminFeedback / runBusy,避免各页 ElMessage 直接散打文案。
*/
import { ElMessage } from 'element-plus'
import {
endBusy,
normalizeFeedback,
tryBeginBusy,
type AdminFeedbackKind,
} from './admin-feedback'
export function showAdminFeedback(message?: string, kind?: AdminFeedbackKind): void {
const { kind: k, message: m } = normalizeFeedback(message, kind)
if (k === 'success') ElMessage.success(m)
else if (k === 'error') ElMessage.error(m)
else ElMessage.warning(m)
}
/** 以 busy 锁包裹一个提交动作;已在途则直接返回 undefined,防止重复提交。 */
export async function runBusy<T>(task: () => Promise<T>): Promise<T | undefined> {
if (!tryBeginBusy()) return undefined
try {
return await task()
} finally {
endBusy()
}
}
@@ -1,60 +0,0 @@
/**
* 反馈语义模型(module 13 task 246):成功/错误/警告统一文案与 busy 防重入。
* 纯逻辑、不依赖 ElementUI 接线见 admin-feedback-ui.ts。
*/
export type AdminFeedbackKind = 'success' | 'error' | 'warning'
export const FEEDBACK_TEXT = {
successDefault: '操作成功',
errorDefault: '操作失败,请稍后重试',
warningDefault: '请确认后再继续',
busySuffix: '中…',
} as const
export interface NormalizedFeedback {
kind: AdminFeedbackKind
message: string
}
const KIND_DEFAULT: Record<AdminFeedbackKind, string> = {
success: FEEDBACK_TEXT.successDefault,
error: FEEDBACK_TEXT.errorDefault,
warning: FEEDBACK_TEXT.warningDefault,
}
/** 归一反饋消息:显式 message 保留并去首尾空白,空值按 kind 落默认文案。 */
export function normalizeFeedback(message?: string, kind?: AdminFeedbackKind): NormalizedFeedback {
const k: AdminFeedbackKind = kind ?? 'warning'
const trimmed = typeof message === 'string' ? message.trim() : ''
return { kind: k, message: trimmed ? trimmed : KIND_DEFAULT[k] }
}
/** 忙碌按钮文案:「保存」→「保存中…」;已以「中」结尾则不重复补。 */
export function busyLabel(verb: string): string {
const v = (verb || '处理').trim()
return v.endsWith('中') ? `${v}` : `${v}${FEEDBACK_TEXT.busySuffix}`
}
/** 把 Error/字符串/未知错误转成可行动错误文案;空值回默认错误。 */
export function actionableErrorText(error: unknown): string {
if (error instanceof Error && error.message && error.message.trim()) return error.message.trim()
if (typeof error === 'string' && error.trim()) return error.trim()
return FEEDBACK_TEXT.errorDefault
}
// busy 单例锁:表单提交/导出等防重复进入。
let busy = false
export function tryBeginBusy(): boolean {
if (busy) return false
busy = true
return true
}
export function endBusy(): void {
busy = false
}
export function isBusy(): boolean {
return busy
}
@@ -1,13 +0,0 @@
/**
* 复制文本反馈模型(module 13 task 247):非空给出「已复制 X」,失败/空值给降级文案。
*/
export const COPY_FEEDBACK_FALLBACK = '复制失败,请手动选择'
export function copyFeedback(text: string): string {
const t = typeof text === 'string' ? text.trim() : ''
return t ? `已复制 ${t}` : COPY_FEEDBACK_FALLBACK
}
export function isCopySupported(): boolean {
return typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'
}
@@ -1,30 +0,0 @@
/**
* 模态/抽屉布局模型(module 13 task 248):统一两种承载容器的尺寸与关闭语义,
* 对齐 admin.html —— 居中 modal width min(920px,100vw-48)、radius14;右侧 drawer
* width min(460px,100%);遮罩点击与 Esc 均关闭。页面接入统一尺寸与头/脚槽。
*/
export const ADMIN_MODAL_WIDTH = 'min(920px, 100vw - 48px)'
export const ADMIN_DRAWER_WIDTH = 'min(460px, 100%)'
export const ADMIN_MODAL_RADIUS = 14
export interface AdminContainerSpec {
width: string
radius?: number
maskClosable: boolean
escClosable: boolean
}
export function modalLayoutSpec(): AdminContainerSpec {
return { width: ADMIN_MODAL_WIDTH, radius: ADMIN_MODAL_RADIUS, maskClosable: true, escClosable: true }
}
export function drawerLayoutSpec(): AdminContainerSpec {
return { width: ADMIN_DRAWER_WIDTH, maskClosable: true, escClosable: true }
}
/** 页面头标题归一:空/空白标题兜底「操作」,副标题去空白后无则省略。 */
export function dialogTitleOf(opts: { title?: string; subtitle?: string }): { title: string; subtitle?: string } {
const title = opts.title?.trim()
const subtitle = opts.subtitle?.trim()
return { title: title || '操作', ...(subtitle ? { subtitle } : {}) }
}
@@ -1,37 +0,0 @@
/**
* 状态药丸配色/分类模型(module 13 task 247):统一 admin.html 的三态语义色
* success 绿 / danger 红 / warning 琥珀 / info 蓝 / primary 深蓝),供视频任务、
* 店铺数据任务、数字人版本等页面复用;未知文本回落 info。
*/
export type PillTone = 'success' | 'danger' | 'warning' | 'info' | 'primary'
export interface PillToneStyle {
text: string
bg: string
}
export const PILL_TONES: Record<PillTone, PillToneStyle> = {
success: { text: '#4e806d', bg: '#e6f6ec' },
danger: { text: '#b35f6a', bg: '#fdecec' },
warning: { text: '#a8793e', bg: '#fdf0dc' },
info: { text: '#4f78a5', bg: '#e7f0f8' },
primary: { text: '#2f5d8b', bg: '#e6f0f8' },
}
/** 取某 tone 的配色;未知 tone 回退 info(不抛错)。 */
export function pillToneStyle(tone: PillTone): PillToneStyle {
return PILL_TONES[tone] ?? PILL_TONES.info
}
const SUCCESS_PATTERN = /success|succeed|已发布|发布|成功|正常|可用/
const DANGER_PATTERN = /fail|error|已废弃|失败|不可用|异常/
const WARNING_PATTERN = /running|pending|waiting|进行|处理中|草稿|待执行|待/
/** 由状态文本(中英文)归类语义 tone。 */
export function statusTone(raw: string): PillTone {
const s = (raw || '').toLowerCase()
if (SUCCESS_PATTERN.test(s)) return 'success'
if (DANGER_PATTERN.test(s)) return 'danger'
if (WARNING_PATTERN.test(s)) return 'warning'
return 'info'
}
-38
View File
@@ -1,38 +0,0 @@
/**
* /admin-vue/ 基准路径与 History 路由约定(任务 2 冻结的单一事实源)。
* Vite base、Vue Router history base、Nginx location 三者必须共用此常量。
*/
export const APP_BASE_PATH = '/admin-vue/'
/** 前端路由一律使用 History 模式,不引入 hash(旧 #tab 兼容不在目标契约内)。 */
export const APP_HISTORY_MODE = 'history' as const
function invalidBaseMessage(value: unknown): string {
return `管理后台 base 路径非法: ${JSON.stringify(value)},必须以 / 开头且形如 /xxx/,不允许空值或 ..`
}
/** 校验并归一化 base 路径:保证以 / 开头、无 ..、无内部连续斜杠、以单个 / 结尾。 */
export function ensureAppBasePath(value: string): string {
if (typeof value !== 'string' || value.length === 0) throw new Error(invalidBaseMessage(value))
if (!value.startsWith('/')) throw new Error(invalidBaseMessage(value))
if (value.includes('//')) throw new Error(invalidBaseMessage(value))
if (value.includes('..')) throw new Error(invalidBaseMessage(value))
return value.endsWith('/') ? value : `${value}/`
}
/** 把后台页面相对路径段拼成绝对 URL(不含 base 前缀的历史兼容子路径段由路由负责)。 */
export function joinAdminPath(...segments: string[]): string {
const parts: string[] = []
for (const segment of segments) {
for (const raw of segment.split('/')) {
const part = raw.trim()
if (!part || part === '.') continue
if (part === '..') {
throw new Error(`admin 路由段不允许出现 ..(越权跳转): ${segments.join('/')}`)
}
parts.push(part)
}
}
if (parts.length === 0) return APP_BASE_PATH
return `${APP_BASE_PATH}${parts.join('/')}`
}
-113
View File
@@ -1,113 +0,0 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
export const STANDALONE_PACKAGE_NAME = 'crawler-plugin-admin-frontend-vue'
/** 后台壳层允许存在的源码目录(任务 1 冻结的工作区边界,供验收脚本复用)。 */
export const ADMIN_SOURCE_DIRS = [
'src/layout',
'src/router',
'src/styles',
'src/types',
'src/config',
'src/pages',
] as const
export const REQUIRED_MANIFEST_FILES = [
'package.json',
'index.html',
'vite.config.ts',
'tsconfig.json',
'src/main.ts',
'src/App.vue',
] as const
// 客户端工程耦合标记:后台工程内出现即代表越界。用带路径/产物语义的标记,
// 避免与本工程自身包名后缀(…admin-frontend-vue)误匹配。扫描时排除本文件自身。
export const FORBIDDEN_COUPLING_TOKENS = [
'new_web_source',
'frontend-vue/',
'app_client',
] as const
const SELF_SCAN_EXCLUSIONS = new Set(['src/config/workspace.ts', 'playwright.config.ts'])
const SCAN_EXTENSIONS = /\.(ts|tsx|vue|css|html|json|js)$/
export function walk(rootDir: string, base = ''): string[] {
const found: string[] = []
for (const entry of readdirSync(join(rootDir, base), { withFileTypes: true })) {
if (['node_modules', 'dist', '.git', 'tests', 'e2e', 'scripts', 'deploy'].includes(entry.name)) continue
const rel = base ? `${base}/${entry.name}` : entry.name
if (entry.isDirectory()) found.push(...walk(rootDir, rel))
else if (SCAN_EXTENSIONS.test(entry.name)) found.push(rel)
}
return found
}
function fileExists(rootDir: string, path: string): boolean {
return existsSync(join(rootDir, path))
}
/** 从当前目录向上定位独立后台工程根目录,找不到时给出可操作错误。 */
export function resolveWorkspaceRoot(cwd = process.cwd()): string {
let dir = resolve(cwd)
for (let i = 0; i < 6; i += 1) {
if (fileExists(dir, 'package.json')) return dir
const parent = resolve(dir, '..')
if (parent === dir) break
dir = parent
}
throw new Error(`${cwd} 向上 6 层内找不到 package.json,请在 admin-frontend-vue 工程目录内执行`)
}
export interface WorkspacePackage {
name: string
private: boolean
scripts: Record<string, string>
dependencies: Record<string, string>
devDependencies: Record<string, string>
}
export function readWorkspacePackage(rootDir = resolveWorkspaceRoot()): WorkspacePackage {
const raw = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')) as Partial<WorkspacePackage> & { private?: boolean }
return {
name: raw.name || '',
private: raw.private !== false,
scripts: raw.scripts || {},
dependencies: raw.dependencies || {},
devDependencies: raw.devDependencies || {},
}
}
/** 校验独立工程边界,返回违规清单(空数组 = 边界成立)。 */
export function collectBoundaryViolations(rootDir = resolveWorkspaceRoot()): string[] {
const violations: string[] = []
const pkgPath = join(rootDir, 'package.json')
const pkg: WorkspacePackage = fileExists(rootDir, 'package.json')
? readWorkspacePackage(rootDir)
: { name: '', private: false, scripts: {}, dependencies: {}, devDependencies: {} }
if (!fileExists(rootDir, 'package.json')) violations.push(`缺少必需清单文件 package.json(在 ${pkgPath}`)
if (pkg.name !== STANDALONE_PACKAGE_NAME) {
violations.push(`package.name 应为 ${STANDALONE_PACKAGE_NAME},实际为 ${pkg.name || '(空)'}`)
}
if (!pkg.private) violations.push('package.private 应为 true(后台工程不允许被外部发布依赖)')
for (const file of REQUIRED_MANIFEST_FILES) {
if (!fileExists(rootDir, file)) violations.push(`缺少必需清单文件 ${file}`)
}
for (const dir of ADMIN_SOURCE_DIRS) {
if (!fileExists(rootDir, dir)) violations.push(`缺少允许源码目录 ${dir}`)
}
for (const file of walk(rootDir)) {
if (SELF_SCAN_EXCLUSIONS.has(file)) continue
const content = readFileSync(join(rootDir, file), 'utf8')
for (const token of FORBIDDEN_COUPLING_TOKENS) {
if (content.includes(token)) {
violations.push(`${file} 引用了客户端耦合 token "${token}",超出后台工程边界`)
}
}
}
return violations
}
@@ -1,133 +0,0 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { joinAdminPath } from '@/config/app'
import { pageTitleOf, topbarUserOf } from '@/layout/topbar-model'
import { groupKeysForActive, toSidebarEntries } from '@/layout/menu-mapper'
import {
EMPTY_MENU_DESCRIPTION,
EMPTY_MENU_TITLE,
shouldShowEmptyMenu,
} from '@/layout/empty-state'
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
import NotificationBell from '@/components/NotificationBell.vue'
import OperationGuide from '@/layout/OperationGuide.vue'
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
import {
LOGOUT_CONFIRM_CANCEL,
LOGOUT_CONFIRM_MESSAGE,
LOGOUT_CONFIRM_OK,
LOGOUT_CONFIRM_TITLE,
runLogout,
} from '@/layout/logout'
const route = useRoute()
const router = useRouter()
const session = useAdminSessionStore()
const activePath = computed(() => route.path)
const menuEntries = computed(() => toSidebarEntries(session.menuTree))
const openedKeys = computed(() => groupKeysForActive(menuEntries.value, route.path))
const emptyMenu = computed(() => shouldShowEmptyMenu(menuEntries.value.length > 0, session.loading, session.initialized))
const pageTitle = computed(() => pageTitleOf(route.meta.title))
const userVm = computed(() => topbarUserOf(session.user))
const breadcrumbs = computed(() => crumbsForActiveRoute(session.menuTree, route.path))
const logoUrl = joinAdminPath('assets', 'logo.jpg')
watch(
pageTitle,
(title) => updateDocumentTitle(resolveDocumentTitle(title), typeof document !== 'undefined' ? document : null),
{ immediate: true },
)
async function signOut() {
try {
await runLogout({
confirm: () =>
ElMessageBox.confirm(LOGOUT_CONFIRM_MESSAGE, LOGOUT_CONFIRM_TITLE, {
confirmButtonText: LOGOUT_CONFIRM_OK,
cancelButtonText: LOGOUT_CONFIRM_CANCEL,
type: 'warning',
}).then(
() => true,
() => false,
),
signOut: () => session.signOut(),
})
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '退出登录失败')
}
}
</script>
<template>
<div class="admin-shell">
<a class="skip-link" href="#admin-content">跳到主内容</a>
<aside class="admin-sidebar" role="navigation" aria-label="侧边栏菜单">
<div class="admin-brand">
<img :src="logoUrl" alt="数富AI" class="admin-brand-logo" />
<div class="admin-brand-copy">
<strong>数富AI</strong>
<span>电商运营管理后台</span>
</div>
</div>
<el-scrollbar class="admin-menu-scroll">
<el-menu :default-active="activePath" :default-openeds="openedKeys" router class="admin-menu">
<template v-for="entry in menuEntries" :key="entry.key">
<el-sub-menu v-if="entry.kind === 'group'" :index="entry.key">
<template #title>
<span class="menu-group-title">{{ entry.name }}</span>
</template>
<el-menu-item v-for="item in entry.children" :key="item.key" :index="item.route">
{{ item.name }}
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :key="entry.key" :index="entry.route">
{{ entry.name }}
</el-menu-item>
</template>
</el-menu>
<el-empty v-if="emptyMenu" :description="EMPTY_MENU_TITLE" :image-size="72" />
</el-scrollbar>
</aside>
<section class="admin-main">
<header class="admin-topbar">
<div class="admin-topbar-title">
<span class="admin-kicker">运营管理控制台</span>
<h1>{{ pageTitle }}</h1>
</div>
<div class="admin-user">
<NotificationBell />
<div class="admin-user-meta">
<strong>{{ userVm.username }}</strong>
</div>
<span v-if="userVm.role" class="admin-user-role">{{ userVm.role }}</span>
<el-button class="admin-user-logout" @click="signOut">退出登录</el-button>
</div>
</header>
<main id="admin-content" class="admin-content">
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
</el-breadcrumb>
<OperationGuide />
<GlobalErrorContainer />
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
<el-empty
v-if="emptyMenu && route.path === '/'"
:title="EMPTY_MENU_TITLE"
:description="EMPTY_MENU_DESCRIPTION"
:image-size="96"
/>
<RouterView v-else />
</main>
</section>
</div>
</template>
<style scoped>
.admin-topbar-title { min-width: 0; }
</style>
@@ -1,35 +0,0 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import {
dismissGlobalError,
subscribeGlobalError,
type GlobalErrorItem,
} from '@/layout/error-bus'
const errors = ref<GlobalErrorItem[]>([])
let unsubscribe: (() => void) | null = null
onMounted(() => {
unsubscribe = subscribeGlobalError((list) => {
errors.value = list
})
})
onBeforeUnmount(() => {
unsubscribe?.()
unsubscribe = null
})
</script>
<template>
<div v-if="errors.length" class="global-error-stack" role="alert" aria-live="assertive">
<el-alert
v-for="item in errors"
:key="item.id"
:title="item.message"
type="error"
show-icon
:closable="true"
@close="dismissGlobalError(item.id)"
/>
</div>
</template>
@@ -1,82 +0,0 @@
<script setup lang="ts">
/** 当前页面操作提示条:对齐旧版 admin-interactions.js 的 adminOperationGuide14 页有独立文案,余用通用 fallback)。
* 挂在 AdminLayout 内容区顶部,随 route.meta.menuKey 切换文案;折叠态存 localStorage。 */
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { OPERATION_GUIDE_STORAGE_KEY, operationGuideFor } from '@/layout/operation-guides'
const route = useRoute()
const guide = computed(() => operationGuideFor(route.meta.menuKey))
function readCollapsed(): boolean {
try {
return localStorage.getItem(OPERATION_GUIDE_STORAGE_KEY) === '1'
} catch {
return false
}
}
function persistCollapsed(value: boolean) {
try {
localStorage.setItem(OPERATION_GUIDE_STORAGE_KEY, value ? '1' : '0')
} catch {
// 忽略隐私模式写入失败
}
}
const collapsed = ref(readCollapsed())
function toggle() {
collapsed.value = !collapsed.value
persistCollapsed(collapsed.value)
}
watch(collapsed, (v) => persistCollapsed(v))
</script>
<template>
<aside v-if="guide" class="operation-guide" :class="{ 'is-collapsed': collapsed }" aria-label="当前页面操作提示">
<div class="og-head">
<span class="og-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v5"></path><path d="M12 8h.01"></path></svg>
当前页面操作提示
</span>
<button type="button" class="og-toggle" :aria-expanded="!collapsed" @click="toggle">
{{ collapsed ? '展开提示' : '收起提示' }}
</button>
</div>
<div v-show="!collapsed" class="og-body">
<p class="og-text">{{ guide.text }}</p>
<ol v-if="guide.steps.length" class="og-steps">
<li v-for="step in guide.steps" :key="step">{{ step }}</li>
</ol>
</div>
</aside>
</template>
<style scoped>
.operation-guide {
display: flex; flex-direction: column;
margin: 0 0 18px; padding: 12px 16px;
border: 1px solid #cddbe6; border-radius: 12px;
background: linear-gradient(180deg, #fbfdfe, #f4f8fc);
box-shadow: 0 10px 26px -24px rgba(39, 67, 94, 0.3);
color: var(--admin-text);
}
.operation-guide.is-collapsed { padding: 8px 16px; }
.og-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.og-title {
display: inline-flex; align-items: center; gap: 7px;
font-size: 13px; font-weight: 650; color: var(--admin-primary-strong);
}
.og-title svg { width: 15px; height: 15px; }
.og-toggle {
padding: 0; border: 0; background: none; cursor: pointer;
font: inherit; font-size: 12px; color: #6b8096;
}
.og-toggle:hover { color: var(--admin-primary-strong); }
.og-body { margin-top: 8px; padding-left: 22px; }
.og-text { margin: 0; color: #40586e; font-size: 13px; line-height: 1.7; }
.og-steps { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
.og-steps li {
padding: 2px 10px; border-radius: 999px;
background: #e9f1f8; border: 1px solid #d3e0ea;
color: #2f4a63; font-size: 12px; line-height: 1.7;
}
</style>
@@ -1,11 +0,0 @@
/** 无菜单权限空状态(任务 11)文案与判定。 */
export const EMPTY_MENU_TITLE = '暂无可用菜单'
export const EMPTY_MENU_DESCRIPTION = '请联系管理员为你的账号分配菜单权限'
/**
* 是否展示无菜单空态:会话已初始化、未在加载、且没有任何可渲染菜单。
* 加载中不闪空态;初始化失败由错误条提示而非空态。
*/
export function shouldShowEmptyMenu(hasMenus: boolean, loading: boolean, initialized: boolean): boolean {
return Boolean(initialized) && !loading && !hasMenus
}
@@ -1,56 +0,0 @@
/** 全局请求错误提示容器(任务 13):框架无关的订阅式错误总线。 */
export interface GlobalErrorItem {
id: number
message: string
}
export const GLOBAL_ERROR_LIMIT = 3
export const GLOBAL_ERROR_FALLBACK = '请求失败,请稍后重试'
/** 把任意负载归一化为可展示文案。 */
export function normalizeErrorMessage(message: unknown): string {
if (message instanceof Error) return message.message.trim() || GLOBAL_ERROR_FALLBACK
if (typeof message === 'string') return message.trim() || GLOBAL_ERROR_FALLBACK
return GLOBAL_ERROR_FALLBACK
}
type Listener = (items: GlobalErrorItem[]) => void
let items: GlobalErrorItem[] = []
let nextId = 1
const listeners = new Set<Listener>()
function emit(): void {
for (const listener of listeners) listener(items.slice())
}
/** 订阅错误流,返回取消订阅函数(组件卸载时必须调用以防泄漏)。 */
export function subscribeGlobalError(listener: Listener): () => void {
listeners.add(listener)
listener(items.slice())
return () => {
listeners.delete(listener)
}
}
/** 通知一条全局请求错误;超出上限丢弃最旧,保证不堆积。 */
export function notifyGlobalError(message: unknown): void {
items = [...items, { id: nextId++, message: normalizeErrorMessage(message) }].slice(-GLOBAL_ERROR_LIMIT)
emit()
}
/** 按 id 关闭单条。 */
export function dismissGlobalError(id: number): void {
items = items.filter((item) => item.id !== id)
emit()
}
export function clearGlobalErrors(): void {
items = []
emit()
}
export function snapshotGlobalErrors(): GlobalErrorItem[] {
return items.slice()
}
-20
View File
@@ -1,20 +0,0 @@
/** 壳层退出登录交互(任务 16):确认后执行,取消/拒绝一律不退出。 */
export const LOGOUT_CONFIRM_TITLE = '退出登录'
export const LOGOUT_CONFIRM_MESSAGE = '确定要退出当前账号吗?'
export const LOGOUT_CONFIRM_OK = '退出'
export const LOGOUT_CONFIRM_CANCEL = '取消'
export interface LogoutDeps {
/** 用户确认对话框,resolve true 表示确认退出。 */
confirm: () => Promise<boolean>
/** 真正执行退出(清状态 + 跳登录页)。 */
signOut: () => Promise<void>
}
/** 退出流程:先确认;确认后执行退出;signOut/confirm 异常向上抛由调用方反馈。 */
export async function runLogout({ confirm, signOut }: LogoutDeps): Promise<void> {
const proceed = await confirm()
if (!proceed) return
await signOut()
}
@@ -1,46 +0,0 @@
import type { AdminMenuNode } from '../types/admin'
export interface SidebarItem {
key: string
name: string
route: string
}
/** 侧边栏渲染项:分组(含子页面)或单页面项(含顶级路由叶节点)。 */
export type SidebarEntry =
| { kind: 'group'; key: string; name: string; children: SidebarItem[] }
| { kind: 'item'; key: string; name: string; route: string }
/**
* 后端菜单树 -> 侧边栏节点映射规则(任务 6):
* - 有可路由子节点的节点渲染为分组,只保留带 route 的子项;
* - 无子节点但有 route 的节点渲染为单页面项(不丢顶级叶菜单);
* - 既无子节点也无 route 的节点跳过(点不进去的纯占位不展示)。
*/
export function toSidebarEntries(nodes: AdminMenuNode[] | null | undefined): SidebarEntry[] {
const entries: SidebarEntry[] = []
for (const node of nodes || []) {
const name = node.name || ''
const key = node.key || ''
const children: SidebarItem[] = (node.children || [])
.filter((child): child is AdminMenuNode & { route: string } => Boolean(child.route))
.map((child) => ({ key: child.key || '', name: child.name || '', route: child.route }))
if (children.length > 0) {
entries.push({ kind: 'group', key, name, children })
} else if (node.route) {
entries.push({ kind: 'item', key, name, route: node.route })
}
}
return entries
}
/** 计算需默认展开的分组 key:分组下含当前激活路由子项时返回该分组 key。 */
export function groupKeysForActive(entries: SidebarEntry[], activeRoute: string): string[] {
const keys: string[] = []
for (const entry of entries) {
if (entry.kind === 'group' && entry.children.some((child) => child.route === activeRoute)) {
keys.push(entry.key)
}
}
return keys
}
@@ -1,147 +0,0 @@
/**
* 后台铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
* 轮询调度与渲染在 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}`
}
@@ -1,98 +0,0 @@
/**
* 每菜单页的操作提示(guides)文案,对齐旧版 backend/static/admin-interactions.js 的 guides 表原文
* (除 group-manage / duplicate-check 用通用 fallback)。key = 路由 meta.menuKey。
*/
export interface OperationGuideData {
text: string
steps: string[]
}
export const OPERATION_GUIDE_FALLBACK: OperationGuideData = {
text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。',
steps: ['选择筛选条件', '处理记录', '核对反馈'],
}
/** 折叠状态 localStorage key(沿用旧版 shufuAdminGuideCollapsed 语义)。 */
export const OPERATION_GUIDE_STORAGE_KEY = 'shufuAdminGuideCollapsed'
export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
admin_users: {
text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。',
steps: ['筛选账号', '编辑权限', '确认保存'],
},
admin_columns: {
text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。',
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
},
admin_group_manage: OPERATION_GUIDE_FALLBACK,
admin_user_secrets: {
text: '用户密钥按账号绑定,列表只展示脱敏值。可对单条立即检测连通性;清空后该用户需要重新配置密钥。',
steps: ['筛选用户或状态', '立即检测连通性', '必要时清空'],
},
admin_dedupe_total_data: {
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
steps: ['选择分组', '筛选或导入', '核对并导出'],
},
admin_invalid_asin_data: {
text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。',
steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'],
},
admin_query_asin: {
text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。',
steps: ['筛选店铺', '打开配置抽屉', '保存或导出'],
},
admin_product_categories: {
text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。',
steps: ['搜索类目', '展开层级', '新增或编辑'],
},
admin_skip_price_asin: {
text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。',
steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'],
},
admin_shop_keys: {
text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。',
steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'],
},
admin_shop_manage: {
text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。',
steps: ['选择分组', '维护店铺信息', '筛选核对结果'],
},
admin_shop_data_crawl_tasks: {
text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。',
steps: ['筛选任务', '检查状态', '执行批量操作'],
},
// admin_shop_data_duplicate_check(店铺撞款监控)为例外页:不展示全局提示条,不参与对齐。
admin_image_video_tasks: {
text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。',
steps: ['设置筛选', '查看任务结果', '按需处理任务'],
},
admin_history: {
text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。',
steps: ['设置时间范围', '筛选记录', '查看结果预览'],
},
admin_version: {
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
},
admin_tutorial: {
text: '上传新的教程压缩包后即刻生效:工具台首页「立即下载教程」以下载最新上传的包为准。确认旧包不再需要后再删除。',
steps: ['上传教程压缩包', '到工具台核对下载', '清理历史包'],
},
digital_human_version: {
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
},
admin_user_secret_usage: {
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
},
}
/** 全部有独立/兜底提示的后台业务菜单 key。 */
export const OPERATION_GUIDE_MENU_KEYS = Object.keys(OPERATION_GUIDES)
/** 返回指定菜单 key 的提示数据;menuKey 不在已知集合时返回 null(登录/404 等页面不展示)。 */
export function operationGuideFor(menuKey: unknown): OperationGuideData | null {
if (typeof menuKey !== 'string' || !menuKey) return null
return OPERATION_GUIDES[menuKey] ?? null
}
@@ -1,56 +0,0 @@
/** 侧边栏折叠与窄屏布局状态(任务 14):宽高常量、阈值、偏好持久化。 */
export const SIDEBAR_EXPANDED_WIDTH = 248
export const SIDEBAR_COLLAPSED_WIDTH = 64
export const SIDEBAR_NARROW_THRESHOLD = 900
/** 视口窄到需要自动收起侧边栏。 */
export function isNarrowScreen(width: number): boolean {
return width > 0 && width <= SIDEBAR_NARROW_THRESHOLD
}
/** 反转折叠状态。 */
export function toggleCollapsed(current: boolean): boolean {
return !current
}
export type CollapseStorage = Pick<Storage, 'getItem' | 'setItem'> | null
const PREFERENCE_KEY = 'admin.sidebar.collapsed'
function parsePreference(raw: string | null): boolean | null {
return raw === 'true' ? true : raw === 'false' ? false : null
}
/** 读取持久化偏好;无存储或值非法返回 null。 */
export function readCollapsePreference(storage: CollapseStorage): boolean | null {
try {
return parsePreference(storage?.getItem(PREFERENCE_KEY) ?? null)
} catch {
return null
}
}
/** 写入折叠偏好;存储不可用时静默失败(不阻断交互)。 */
export function writeCollapsePreference(storage: CollapseStorage, collapsed: boolean): void {
try {
storage?.setItem(PREFERENCE_KEY, String(collapsed))
} catch {
// localStorage 不可用(隐私模式等)时忽略
}
}
/**
* 初始折叠值:优先持久化偏好;无偏好时窄屏自动折叠、宽屏展开。
* width<=0(如 SSR/未知)视为无法判定,回退 false。
*/
export function initialCollapsed(width: number, storage: CollapseStorage): boolean {
const preference = readCollapsePreference(storage)
if (preference !== null) return preference
return isNarrowScreen(width)
}
/** 侧边栏应占用的像素宽(用于宽度计算/测试,不直接改 CSS)。 */
export function sidebarPixelWidth(collapsed: boolean): number {
return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH
}
@@ -1,42 +0,0 @@
import type { AdminMenuNode } from '../types/admin'
export const ADMIN_BRAND = '数富AI'
/** 文档标题规则:`页面标题 - 数富AI`;无页面标题时仅品牌。 */
export function resolveDocumentTitle(pageTitle: string, brand: string = ADMIN_BRAND): string {
const trimmed = (pageTitle || '').trim()
return trimmed ? `${trimmed} - ${brand}` : brand
}
/** 同步 HTML 文档标题;doc 为空(如非浏览器环境)时静默跳过。 */
export function updateDocumentTitle(title: string, doc: { title: string } | null | undefined): void {
if (doc) doc.title = title
}
export interface BreadcrumbItem {
key: string
name: string
route?: string
}
/**
* 面包屑规则:在菜单树中定位激活路由,返回“分组 -> 页面”层级(顶层叶子只返回自身)。
* 无命中返回空数组,不伪造路径。
*/
export function crumbsForActiveRoute(nodes: AdminMenuNode[] | null | undefined, activeRoute: string): BreadcrumbItem[] {
for (const node of nodes || []) {
if (node.route === activeRoute) return [{ key: node.key, name: node.name, route: node.route }]
const children = node.children || []
for (const child of children) {
if (child.route === activeRoute) {
return [
{ key: node.key, name: node.name },
{ key: child.key, name: child.name, route: child.route },
]
}
}
const nested = crumbsForActiveRoute(children, activeRoute)
if (nested.length) return [{ key: node.key, name: node.name }, ...nested]
}
return []
}
@@ -1,23 +0,0 @@
import type { AdminUser } from '../types/admin.ts'
import { roleLabel } from '../types/admin.ts'
/** 无 meta.title 时的壳层缺省标题。 */
export const ADMIN_DEFAULT_TITLE = '管理后台'
/** 页面标题模型:由路由 meta.title 驱动,空值回落缺省标题。 */
export function pageTitleOf(title: string | null | undefined): string {
const value = typeof title === 'string' ? title.trim() : ''
return value || ADMIN_DEFAULT_TITLE
}
export interface TopbarUserViewModel {
username: string
role: string
hasUser: boolean
}
/** 顶部栏用户模型:用户信息或未登录空态统一为可渲染视图模型。 */
export function topbarUserOf(user: AdminUser | null | undefined): TopbarUserViewModel {
if (!user) return { username: '当前用户', role: '', hasUser: false }
return { username: user.username || '当前用户', role: roleLabel(user.role) || user.role || '', hasUser: true }
}
-11
View File
@@ -1,11 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import App from './App.vue'
import router from './router'
// 全局中文语言包:日期控件(周/月/年份)、分页组件(上一页/下一页)等均显示中文。
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
@@ -1,130 +0,0 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { submitCreateUser } from './user-create-adapter'
import {
createEmptyCreateUserForm,
type CreateUserForm,
type CreateUserFormErrors,
validateCreateUserForm,
} from './user-create-model'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import UserMenuAuthTree from './UserMenuAuthTree.vue'
const props = defineProps<{
modelValue: boolean
operatorSuper?: boolean
/** 所属管理员候选(列表响应 admins;仅超管创建普通账号时展示)。 */
adminOptions?: Array<{ id: number; username: string }>
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'created'): void }>()
const form = reactive<CreateUserForm>(createEmptyCreateUserForm())
const errors = reactive<CreateUserFormErrors>({})
const busy = ref(false)
/** 角色下拉(对齐 admin.html:普通账号/管理员;超管才显示可改)。 */
const ROLE_CHOICES: Array<{ value: 'admin' | 'normal'; label: string }> = [
{ value: 'normal', label: '普通账号' },
{ value: 'admin', label: '管理员' },
]
watch(
() => props.modelValue,
(open) => {
if (!open) return
form.username = ''
form.password = ''
form.role = 'normal'
form.columnIds = []
form.createdBy = null
errors.username = undefined
errors.password = undefined
},
)
function close(): void {
emit('update:modelValue', false)
}
async function create(): Promise<void> {
const { valid, errors: errs } = validateCreateUserForm(form)
Object.assign(errors, errs)
if (!valid) return
busy.value = true
try {
await submitCreateUser(form)
showAdminFeedback('创建成功', 'success')
// 对齐 admin:成功后弹窗不关,仅清空用户名/密码并刷新列表。
form.username = ''
form.password = ''
errors.username = undefined
errors.password = undefined
emit('created')
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="创建用户"
width="640px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="用户名" :error="errors.username">
<el-input v-model="form.username" autocomplete="username" placeholder="用户名至少 2 个字符" @input="errors.username = undefined" />
</el-form-item>
<el-form-item label="密码" :error="errors.password">
<el-input
v-model="form.password"
type="password"
show-password
autocomplete="new-password"
placeholder="密码至少 6 个字符"
@input="errors.password = undefined"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
<el-select filterable v-model="form.role" style="width: 200px">
<el-option v-for="opt in ROLE_CHOICES" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item v-if="operatorSuper && form.role === 'normal'" label="所属管理员">
<el-select v-model="form.createdBy" clearable filterable placeholder="请选择管理员" style="width: 200px">
<el-option
v-for="admin in props.adminOptions || []"
:key="admin.id"
:label="admin.username"
:value="admin.id"
/>
</el-select>
</el-form-item>
<el-form-item label="菜单权限勾选上级菜单会继承全部子菜单">
<div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="create">创建用户</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.auth-tree-box {
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 9px;
padding: 8px;
}
</style>
@@ -1,145 +0,0 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import type { AdminUser } from '@/types/admin'
import { fillEditUserForm, validateEditUserForm, type EditUserForm, type EditUserFormErrors } from './user-edit-model'
import { submitEditUser } from './user-edit-adapter'
import { loadUserMenuAuth } from './user-menu-auth-api'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import UserMenuAuthTree from './UserMenuAuthTree.vue'
const props = defineProps<{ modelValue: boolean; user: AdminUser | null; operatorSuper?: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'updated'): void }>()
const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role: 'normal', columnIds: [], originalRole: '' })
const errors = reactive<EditUserFormErrors>({})
const busy = ref(false)
const loadingAuth = ref(false)
/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */
const authReady = ref(false)
watch(
() => props.modelValue,
(open) => {
if (!open || !props.user) return
const base = fillEditUserForm(props.user)
form.uid = base.uid
form.username = base.username
form.password = ''
form.role = base.role
form.columnIds = []
form.originalRole = base.originalRole
errors.password = undefined
authReady.value = false
void refreshAuth()
},
)
async function refreshAuth(): Promise<void> {
if (!props.user) return
loadingAuth.value = true
try {
const { checkedIds } = await loadUserMenuAuth(props.user.id)
form.columnIds = checkedIds
authReady.value = true
} catch (error) {
// 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。
authReady.value = false
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
loadingAuth.value = false
}
}
function close(): void {
emit('update:modelValue', false)
}
async function save(): Promise<void> {
if (!authReady.value) {
showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error')
return
}
const { valid, errors: errs } = validateEditUserForm(form)
Object.assign(errors, errs)
if (!valid) return
busy.value = true
try {
await submitEditUser(form)
showAdminFeedback('更新成功', 'success')
emit('updated')
close()
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
</script>
<template>
<el-dialog
:model-value="modelValue"
title="编辑用户"
width="640px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="用户名">
<el-input :model-value="form.username" readonly />
</el-form-item>
<el-form-item label="新密码不修改可留空" :error="errors.password">
<el-input
v-model="form.password"
type="password"
show-password
autocomplete="new-password"
placeholder="留空则不修改密码"
@input="errors.password = undefined"
/>
</el-form-item>
<el-form-item v-if="operatorSuper" label="角色">
<el-radio-group v-model="form.role">
<el-radio-button value="normal">普通账号</el-radio-button>
<el-radio-button value="admin">管理员</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="props.user && props.user.creatorUsername" label="所属管理员">
<el-input :model-value="props.user.creatorUsername" readonly />
</el-form-item>
<el-form-item label="菜单权限勾选上级菜单会继承全部子菜单">
<div class="auth-tree-box">
<UserMenuAuthTree v-model:checked="form.columnIds" />
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
<div v-else-if="!authReady" class="auth-loading auth-loading-error">
菜单权限加载失败,保存已禁用;请关闭后重试
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" :disabled="!authReady" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.auth-tree-box {
position: relative;
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 9px;
padding: 8px;
}
.auth-loading {
margin-top: 6px;
font-size: 12px;
color: var(--admin-muted);
}
.auth-loading-error {
color: var(--el-color-danger);
}
</style>
@@ -1,143 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { AdminUser } from '@/types/admin'
import {
eligibleGroupMembers,
groupMemberCandidateHint,
validateShopGroupForm,
type ShopGroupItem,
} from './shop-group-model'
import { createShopGroup, updateShopGroup } from './shop-group-api'
import { fetchUserList } from '@/api/users'
import { showAdminFeedback } from '@/components/admin-feedback-ui'
import { actionableErrorText } from '@/components/admin-feedback'
import { useAdminSessionStore } from '@/stores/admin-session'
const props = defineProps<{ modelValue: boolean; group: ShopGroupItem | null; operatorSuper?: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
const session = useAdminSessionStore()
const groupName = ref('')
const memberIds = ref<number[]>([])
const errorText = ref('')
const busy = ref(false)
const users = ref<AdminUser[]>([])
/** 组长:新建=当前登录用户;编辑=分组原组长(不可改,只读展示)。 */
const leaderUserId = computed<number | null>(() =>
props.group ? (props.group.leaderUserId ?? null) : (session.user?.id ?? null),
)
const leaderUsername = computed<string>(() =>
props.group ? (props.group.leaderUsername || '') : (session.user?.username || '当前登录用户'),
)
const operatorRole = computed<string>(() => session.user?.role || '')
/** 组员候选:按组长归属过滤(超管全量、非超管仅本组长创建的普通账号)。 */
const memberCandidates = computed(() =>
eligibleGroupMembers(users.value, leaderUserId.value, operatorRole.value),
)
const candidateHint = computed(() => groupMemberCandidateHint(memberCandidates.value.length, operatorRole.value))
watch(
() => props.modelValue,
(open) => {
if (!open) return
groupName.value = ''
memberIds.value = []
errorText.value = ''
if (props.group) {
groupName.value = props.group.name
memberIds.value = props.group.memberUserIds ?? []
}
},
)
async function loadCandidates(): Promise<void> {
try {
// 对齐 admin.js loadUserOptionspage=1&page_size=999 全量拉取,避免归属过滤漏人。
const page = await fetchUserList({ page: 1, pageSize: 999 })
users.value = page.items
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
}
}
function close(): void {
emit('update:modelValue', false)
}
async function save(): Promise<void> {
const errs = validateShopGroupForm(groupName.value)
errorText.value = errs.groupName || ''
if (errorText.value) return
busy.value = true
try {
if (props.group) {
await updateShopGroup(props.group.id, groupName.value, memberIds.value)
showAdminFeedback('更新成功', 'success')
} else {
// 对齐 admin:组长恒为当前登录用户,提交 createdBy=当前用户 id。
const operatorId = session.user?.id ?? 0
if (!operatorId) {
showAdminFeedback('无法获取当前登录用户,请重新登录', 'error')
return
}
await createShopGroup(groupName.value, memberIds.value, operatorId)
showAdminFeedback('创建成功', 'success')
}
emit('saved')
close()
} catch (error) {
showAdminFeedback(actionableErrorText(error), 'error')
} finally {
busy.value = false
}
}
onMounted(loadCandidates)
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="group ? '编辑分组' : '新建分组'"
width="600px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组名称" :error="errorText || undefined">
<el-input v-model="groupName" placeholder="请输入分组名称" />
</el-form-item>
<el-form-item label="组长">
<el-input :model-value="leaderUsername" readonly placeholder="当前登录用户" />
</el-form-item>
<el-form-item label="组员">
<el-select
v-model="memberIds"
multiple
filterable
collapse-tags
placeholder="选择组员普通账号"
style="width: 100%"
>
<el-option v-for="u in memberCandidates" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
<div class="candidate-hint">{{ candidateHint }}(共 {{ memberCandidates.length }} 名)</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.candidate-hint {
margin-top: 6px;
font-size: 12px;
color: var(--admin-muted);
}
</style>
@@ -1,361 +0,0 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 分组管理页 · 像素复刻旧版 admin.html panel-group-manage(自绘:顶部数据权限分组摘要 chips + 分组列表表格 + 新建分组;全量无分页同旧版)。
* script 逻辑沿用现有 Vue 实现;编辑器保留现有组件。 */
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { openAdminConfirm } from '@/components/admin-confirm'
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
import type { ShopGroupItem } from './shop-group-model'
import GroupEditorDialog from './GroupEditorDialog.vue'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<ShopGroupItem[]>([])
const editorVisible = ref(false)
const editingGroup = ref<ShopGroupItem | null>(null)
const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
/** 筛选:分组名称/组长 模糊过滤(用户要求账户域子菜单补筛选条件)。 */
const filterKeyword = ref('')
const filterLeader = ref('')
const filteredRows = computed(() => {
const kw = (filterKeyword.value || '').trim().toLowerCase()
const ldr = (filterLeader.value || '').trim().toLowerCase()
if (!kw && !ldr) return rows.value
return rows.value.filter((g) => {
const nameOk = !kw || (g.name || '').toLowerCase().includes(kw)
const leaderOk = !ldr || (g.leaderUsername || '').toLowerCase().includes(ldr)
return nameOk && leaderOk
})
})
/** 全站分页统一:分组列表客户端分页(10/20/50/100)。 */
const page = ref(1)
const pageSize = ref(10)
const pagedGroups = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changeGroupPage(p: number) { page.value = p }
function changeGroupSize(size: number) { pageSize.value = size; page.value = 1 }
function canEditOf(group: ShopGroupItem): boolean {
if (isSuperAdmin.value) return true
return currentUserId.value != null && group.leaderUserId != null && currentUserId.value === group.leaderUserId
}
async function loadGroups() {
loading.value = true
try {
rows.value = await fetchShopGroups()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据权限分组加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingGroup.value = null
editorVisible.value = true
}
function openEdit(group: ShopGroupItem) {
if (!canEditOf(group)) return
editingGroup.value = group
editorVisible.value = true
}
async function removeGroup(group: ShopGroupItem) {
const ok = await openAdminConfirm({ message: `确定删除分组“${group.name}”吗?`, danger: true })
if (!ok) return
try {
await deleteShopGroup(group.id)
ElMessage.success('删除成功')
await loadGroups()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
onMounted(loadGroups)
</script>
<template>
<div class="groups-view">
<section class="panel-box">
<div class="groups-head">
<h3>分组列表</h3>
<button class="btn" type="button" @click="openCreate">新建分组</button>
</div>
<div class="form-row groups-filter-row">
<div class="form-group" style="min-width: 200px">
<label>分组名称</label>
<input v-model="filterKeyword" type="text" placeholder="输入分组名称" @keyup.enter="filterKeyword = filterKeyword" />
</div>
<div class="form-group" style="min-width: 180px">
<label>组长</label>
<input v-model="filterLeader" type="text" placeholder="输入组长用户名" />
</div>
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 16%">分组名称</th>
<th style="width: 12%">组长</th>
<th style="width: 10%">组员数量</th>
<th>组员</th>
<th style="width: 16%">创建时间</th>
<th style="width: 16%">修改时间</th>
<th style="width: 160px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredRows.length">
<tr v-for="(group, index) in pagedGroups" :key="group.id">
<td>{{ index + 1 }}</td>
<td>{{ group.name }}</td>
<td>{{ group.leaderUsername || '-' }}</td>
<td>{{ group.memberCount ?? (group.memberUsernames?.length ?? 0) }}</td>
<td>
<div class="shop-group-members">
<span v-for="name in group.memberUsernames" :key="name" class="shop-group-member-chip">{{ name }}</span>
<span v-if="!group.memberUsernames.length" class="member-none"></span>
</div>
</td>
<td>{{ formatDateTime(group.createdAt) }}</td>
<td>{{ formatDateTime(group.updatedAt || group.createdAt) }}</td>
<td class="ops-cell">
<template v-if="canEditOf(group)">
<button class="btn btn-sm" type="button" @click="openEdit(group)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeGroup(group)">删除</button>
</template>
<span v-else class="member-none">-</span>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="8" class="empty-tip">{{ filterKeyword || filterLeader ? '暂无匹配分组' : '暂无分组' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination v-if="filteredRows.length > 0" :total="filteredRows.length" :page="page" :page-size="pageSize" @change="changeGroupPage" @size-change="changeGroupSize" />
</section>
<GroupEditorDialog
v-model="editorVisible"
:group="editingGroup"
:operator-super="isSuperAdmin"
@saved="loadGroups"
/>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-group-manage(蓝白末层)。 */
.groups-view {
font-family: inherit;
color: #24384d;
display: flex;
flex-direction: column;
gap: 18px;
}
.form-box,
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.groups-filter-row {
margin: 0 0 16px;
}
.groups-filter-row .form-group {
flex: 1 1 200px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.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);
}
.groups-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.table-scroll td {
white-space: normal;
word-break: break-word;
}
.shop-group-members {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 96px;
overflow-y: auto;
padding-right: 4px;
}
.shop-group-member-chip {
display: inline-flex;
align-items: center;
max-width: 150px;
padding: 3px 9px;
border-radius: 999px;
background: #e7f0f8;
color: #2f5d8b;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-none,
.member-more {
color: #8293a5;
font-size: 12.5px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
</style>
@@ -1,554 +0,0 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 菜单权限配置页 · 像素复刻旧版 admin.html panel-columns(自绘:面板头+新增菜单、拖拽手柄 ⠿、树形缩进+└、旧式表格、全量无分页同旧版)。
* script 逻辑沿用现有 Vue 实现(拖拽分组/落点/重排保存、弹窗表单)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import OldPagination from '@/components/OldPagination.vue'
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
import {
MENU_TYPE_OPTIONS,
createMenuForm,
flattenMenuNodes,
menuDeleteReason,
menuFormFromNode,
menuParentCandidates,
menuParentChangeReason,
menuSiblingGroupKey,
menuTypeLabel,
nextSiblingSort,
siblingOrderAfterDrop,
validateMenuManageForm,
type MenuManageForm,
type MenuManageNode,
} from './menu-manage-model'
/** 树行视图:展平 + 深度(缩进与 └ 前缀)。 */
interface MenuRow extends MenuManageNode {
depth: number
}
const loading = ref(false)
const rows = ref<MenuManageNode[]>([])
/** 筛选:菜单名称/类型(用户要求账户域子菜单补筛选条件)。 */
const filterName = ref('')
const filterType = ref('')
const filteredMenuRows = computed<MenuRow[]>(() => {
const kw = (filterName.value || '').trim().toLowerCase()
const type = filterType.value || ''
return menuRows.value.filter((row) => {
const nameOk = !kw || (row.name || '').toLowerCase().includes(kw)
const typeOk = !type || row.menuType === type
return nameOk && typeOk
})
})
/** 全站分页统一:菜单树展平行客户端分页(10/20/50/100)。 */
const page = ref(1)
const pageSize = ref(10)
const pagedMenuRows = computed(() => filteredMenuRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
function changeMenuPage(p: number) { page.value = p }
function changeMenuSize(size: number) { pageSize.value = size; page.value = 1 }
/** 展平视图:前序 + depth。 */
const menuRows = computed<MenuRow[]>(() => {
const out: MenuRow[] = []
const walk = (list: MenuManageNode[], depth: number) => {
for (const node of list) {
out.push({ ...node, depth })
if (node.children?.length) walk(node.children, depth + 1)
}
}
walk(rows.value, 0)
return out
})
const createVisible = ref(false)
const saving = ref(false)
const editingNode = ref<MenuManageNode | null>(null)
const menuForm = reactive<MenuManageForm>(createMenuForm(null, 0))
const parentOptions = computed(() => menuParentCandidates(rows.value, menuForm.menuType))
const dialogTitle = computed(() => (editingNode.value ? '编辑菜单' : '新增菜单'))
/** 节点 id -> 名称,供「上级菜单」列展示。 */
const nameById = computed(() => {
const map = new Map<number, string>()
for (const node of flattenMenuNodes(rows.value)) map.set(node.id, node.name)
return map
})
/** 拖拽状态:同"菜单类型+父级"分组内才可落位(对齐 admin.js columnDragState)。 */
const dragState = ref<{ id: number; groupKey: string; menuType: string } | null>(null)
function parentNameOf(node: MenuManageNode): string {
if (node.parentId == null) return '-'
return nameById.value.get(node.parentId) ?? `#${node.parentId}`
}
async function loadMenus() {
loading.value = true
try {
rows.value = await loadMenuManageTree()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '菜单列表加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
editingNode.value = null
Object.assign(menuForm, createMenuForm(null, nextSiblingSort(rows.value, null)))
createVisible.value = true
}
function openEdit(node: MenuManageNode) {
editingNode.value = node
Object.assign(menuForm, menuFormFromNode(node))
createVisible.value = true
}
/** 菜单类型切换时:当前父菜单不属于新类型则重置为根。 */
function onMenuTypeChange(): void {
const candidates = menuParentCandidates(rows.value, menuForm.menuType)
if (menuForm.parentId != null && !candidates.some((node) => node.id === menuForm.parentId)) {
menuForm.parentId = null
}
}
function onDragStart(event: DragEvent, node: MenuManageNode): void {
dragState.value = { id: node.id, groupKey: menuSiblingGroupKey(node), menuType: node.menuType }
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
try {
event.dataTransfer.setData('text/plain', String(node.id))
} catch {
// 某些浏览器 setData 受限,忽略即可。
}
}
}
function onDragEnd(): void {
dragState.value = null
}
function onDragOver(event: DragEvent, node: MenuManageNode): void {
const state = dragState.value
if (!state || state.groupKey !== menuSiblingGroupKey(node) || state.id === node.id) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
}
async function onDrop(event: DragEvent, target: MenuManageNode): Promise<void> {
const state = dragState.value
dragState.value = null
if (!state || state.groupKey !== menuSiblingGroupKey(target) || state.id === target.id) return
event.preventDefault()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const placeAfter = event.clientY - rect.top > rect.height / 2
const groupIds = flattenMenuNodes(rows.value)
.filter((node) => menuSiblingGroupKey(node) === state.groupKey)
.map((node) => node.id)
const next = siblingOrderAfterDrop(groupIds, state.id, target.id, placeAfter)
if (next.join(',') === groupIds.join(',')) return
try {
await reorderMenus(state.menuType, next)
ElMessage.success('排序已保存')
await loadMenus()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '排序保存失败')
}
}
async function saveMenu() {
if (editingNode.value) {
const reason = menuParentChangeReason(editingNode.value, menuForm.parentId)
if (reason) {
ElMessage.warning(reason)
return
}
}
const { valid, errors } = validateMenuManageForm(menuForm)
if (!valid) {
ElMessage.warning(Object.values(errors)[0])
return
}
saving.value = true
try {
if (editingNode.value) {
await updateMenu(editingNode.value.id, menuForm)
ElMessage.success('保存成功')
} else {
await createMenu(menuForm)
ElMessage.success('创建成功')
}
createVisible.value = false
await loadMenus()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function removeMenu(node: MenuManageNode) {
const reason = menuDeleteReason(node)
if (reason) {
ElMessage.warning(reason)
return
}
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除菜单“${node.name}”吗?`)) return
try {
await deleteMenu(node.id)
ElMessage.success('删除成功')
await loadMenus()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
onMounted(loadMenus)
</script>
<template>
<div class="menus-view">
<section class="panel-box">
<div class="menus-head">
<h3>菜单列表</h3>
<button class="btn" type="button" @click="openCreate">新增菜单</button>
</div>
<p class="columns-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序松开后自动保存</p>
<div class="form-row menus-filter-row">
<div class="form-group" style="min-width: 200px">
<label>菜单名称</label>
<input v-model="filterName" type="text" placeholder="输入菜单名称" />
</div>
<div class="form-group" style="min-width: 160px">
<label>菜单类型</label>
<select v-model="filterType">
<option value="">全部</option>
<option value="admin">后台</option>
<option value="app">软件</option>
</select>
</div>
</div>
<div class="table-scroll columns-table-scroll">
<table>
<thead>
<tr>
<th class="column-drag-cell"><span class="visually-hidden">排序</span></th>
<th style="width: 60px">ID</th>
<th style="width: 200px">菜单名称</th>
<th style="width: 100px">菜单类型</th>
<th style="width: 120px">上级菜单</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="filteredMenuRows.length">
<tr
v-for="row in pagedMenuRows"
:key="row.id"
:class="{ 'is-column-dragging': dragState?.id === row.id }"
@dragover="onDragOver($event, row)"
@drop="onDrop($event, row)"
>
<td class="column-drag-cell">
<span
class="column-drag-handle"
draggable="true"
role="button"
:title="`拖动调整“${row.name}”的顺序`"
@dragstart="onDragStart($event, row)"
@dragend="onDragEnd"
></span>
</td>
<td>{{ row.id }}</td>
<td class="name-cell">
<span v-if="row.depth > 0" class="column-child-mark" :style="{ marginLeft: `${row.depth * 16}px` }"></span>
<span class="menu-name-text">{{ row.name }}</span>
</td>
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
<td>{{ parentNameOf(row) }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
class="btn btn-sm btn-danger"
type="button"
:disabled="menuDeleteReason(row) !== undefined"
:title="menuDeleteReason(row) || undefined"
@click="removeMenu(row)"
>
删除
</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="8" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination v-if="filteredMenuRows.length > 0" :total="filteredMenuRows.length" :page="page" :page-size="pageSize" @change="changeMenuPage" @size-change="changeMenuSize" />
</section>
<el-dialog v-model="createVisible" :title="dialogTitle" width="520px">
<el-form label-width="96px">
<el-form-item label="菜单名称">
<el-input v-model="menuForm.name" placeholder="如:用户管理" />
</el-form-item>
<el-form-item label="菜单类型">
<el-select filterable v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="父菜单">
<el-select filterable v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
<el-option
v-for="node in parentOptions"
:key="node.id"
:label="node.name"
:value="node.id"
/>
</el-select>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="menuForm.sortOrder" :min="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveMenu">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-columns(蓝白末层)。 */
.menus-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;
}
.menus-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.columns-drag-tip {
margin: 10px 0 16px;
font-size: 13px;
color: #5b6f83;
}
.menus-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.columns-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.column-drag-cell {
width: 48px;
}
.column-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: #93a6b8;
font-size: 15px;
line-height: 1;
cursor: grab;
user-select: none;
}
.column-drag-handle:hover {
border-color: #cbd9e6;
background: #eef4fa;
color: #2f5d8b;
}
.column-drag-handle:active {
cursor: grabbing;
}
tr.is-column-dragging td {
opacity: 0.45;
}
.column-child-mark {
margin-right: 6px;
color: #9fb1c2;
}
.menu-name-text {
font-weight: 500;
}
.menu-type-text {
color: #5b6f83;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
white-space: nowrap;
clip: rect(0 0 0 0);
clip-path: inset(50%);
}
</style>
@@ -1,131 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import type { MenuOptionNode } from './user-menu-auth'
import { compactDirectGrantIds } from './user-menu-auth'
import { fetchGrantableMenus } from './user-menu-auth-api'
const props = defineProps<{ checked?: number[] }>()
const emit = defineEmits<{
(e: 'update:checked', ids: number[]): void
(e: 'load', ok: boolean): void
}>()
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown }
const adminTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
const appTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
const data = ref<MenuOptionNode[]>([])
const loaded = ref(false)
/** 两种一级分类…(与 API menuType 分区一致):后台(admin) / 桌面端(app)。 */
const adminNodes = computed(() => data.value.filter((node) => node.type === 'admin'))
const appNodes = computed(() => data.value.filter((node) => node.type === 'app'))
function applyChecked(): void {
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
adminTree.value?.setCheckedKeys(ids)
appTree.value?.setCheckedKeys(ids)
}
function onCheck(): void {
const collect = (el: { getCheckedKeys(): unknown } | null): number[] => {
const keys = el ? el.getCheckedKeys() : []
return (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
}
const ids = [...collect(adminTree.value), ...collect(appTree.value)]
// 父子联动模式(未启用严格勾选)下勾选父菜单会自动全选全部子菜单;
// 提交前压缩为最小直接授权集:父级已勾选时后代不再重复提交(后端按“父级授权
// 展开全部后代”判断权限,与旧 admin.html 勾父清后代语义一致)。
emit('update:checked', compactDirectGrantIds(ids, data.value))
}
async function load(): Promise<void> {
try {
data.value = await fetchGrantableMenus()
loaded.value = true
emit('load', true)
// el-tree 的 store 通过异步 watch 同步 props.datasetCheckedKeys 必须等树
// 渲染完成后再应用已勾选 id,否则作用在空 store 上导致已授权不回显。
await nextTick()
applyChecked()
} catch {
loaded.value = false
emit('load', false)
}
}
onMounted(load)
watch(
() => props.checked,
() => {
if (loaded.value) applyChecked()
},
)
</script>
<template>
<el-scrollbar max-height="300px" class="user-menu-tree-scroll">
<template v-if="adminNodes.length || appNodes.length">
<div class="menu-group-columns">
<div v-if="adminNodes.length" class="menu-group-block">
<div class="menu-group-title">后台</div>
<!-- 父子联动模式未启用严格勾选勾选父菜单自动全选全部子菜单
勾父即代表全部后代与后端父级授权展开后代的权限语义一致 -->
<el-tree
ref="adminTree"
:data="adminNodes"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck"
/>
</div>
<div v-if="appNodes.length" class="menu-group-block">
<div class="menu-group-title">桌面端</div>
<el-tree
ref="appTree"
:data="appNodes"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck"
/>
</div>
</div>
</template>
<p v-else class="menu-group-empty">暂无可用菜单</p>
</el-scrollbar>
</template>
<style scoped>
.menu-group-columns {
display: flex;
align-items: flex-start;
gap: 16px;
}
.menu-group-block {
flex: 1 1 0;
min-width: 0;
padding: 0 10px 8px 0;
}
.menu-group-block:last-child {
padding-right: 0;
border-left: 1px solid #e6edf4;
padding-left: 16px;
}
.menu-group-title {
padding: 4px 0 6px;
font-size: 12.5px;
font-weight: 650;
color: #40586e;
border-bottom: 1px solid #e6edf4;
margin-bottom: 4px;
}
.menu-group-empty {
margin: 12px 0;
color: #8293a5;
font-size: 13px;
}
</style>
@@ -1,395 +0,0 @@
<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>&nbsp;</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>
@@ -1,564 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { formatDateTime } from '@/utils/datetime'
import OldPagination from '@/components/OldPagination.vue'
import {
checkUserSecret,
deleteUserSecret,
fetchUserSecretList,
type AdminUserSecretModule,
type AdminUserSecretRow,
type GroupOption,
} from '@/api/user-secrets'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<AdminUserSecretRow[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const keyword = ref('')
const statusFilter = ref('')
const groupFilter = ref<number | null>(null)
/** 分组下拉(超管=全部;主管=自己带的分组,由列表接口带回)。 */
const groupOptions = ref<GroupOption[]>([])
/** 正在检测的行 userId,用于按钮 loading 态。 */
const checkingId = ref<number | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const STATUS_OPTIONS = [
{ value: '', label: '全部状态' },
{ value: 'passed', label: '检测通过' },
{ value: 'failed', label: '检测失败' },
{ value: 'incomplete', label: '未配齐' },
{ value: 'error', label: '无法判定' },
{ value: 'unknown', label: '未检测' },
]
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
function rowStatusMeta(status: string) {
switch (status) {
case 'passed':
return { label: '检测通过', tone: 'is-allowed' }
case 'failed':
return { label: '检测失败', tone: 'is-blocked' }
case 'incomplete':
return { label: '未配齐', tone: 'is-warn' }
case 'error':
return { label: '无法判定', tone: 'is-warn' }
default:
return { label: '未检测', tone: 'is-unknown' }
}
}
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
if (!module || !module.exists) {
return { label: '未配置', tone: 'is-unknown' }
}
switch (module.checkStatus) {
case 'passed':
return { label: '通过', tone: 'is-allowed' }
case 'failed':
if (module.checkCode === 'insufficient_balance') {
return { label: '欠费', tone: 'is-warn' }
}
return { label: '失败', tone: 'is-blocked' }
case 'error':
return { label: '无法判定', tone: 'is-warn' }
default:
return { label: '未检测', tone: 'is-unknown' }
}
}
/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */
function moduleTooltip(module: AdminUserSecretModule | undefined) {
if (!module || !module.exists) return '未配置'
const parts: string[] = []
if (module.full) parts.push(module.full)
if (module.checkMessage) parts.push(module.checkMessage)
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
return parts.join('') || '暂无检测记录'
}
/** 分组列文案:优先分组名;未建分组的用户回退展示所属主管,便于判断归属。 */
function groupTextOf(row: AdminUserSecretRow) {
if (row.groups?.length) return row.groups.join('、')
if (row.leaderUsername) return `${row.leaderUsername}(未建分组)`
return '—'
}
function rowStatusTooltip(row: AdminUserSecretRow) {
const parts: string[] = []
if (row.statusMessage) parts.push(row.statusMessage)
if (row.updatedAt) parts.push(`最近更新:${formatDateTime(row.updatedAt)}`)
return parts.join('') || '暂无检测记录'
}
async function load() {
loading.value = true
try {
const result = await fetchUserSecretList({
keyword: keyword.value.trim() || undefined,
checkStatus: statusFilter.value || undefined,
groupId: groupFilter.value || undefined,
page: page.value,
pageSize: pageSize.value,
})
rows.value = result?.items || []
total.value = Number(result?.total || 0)
groupOptions.value = result?.groupOptions || []
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function reset() {
keyword.value = ''
statusFilter.value = ''
groupFilter.value = null
page.value = 1
load()
}
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
async function check(row: AdminUserSecretRow) {
checkingId.value = row.userId
try {
const results = await checkUserSecret(row.userId)
if (!results || !results.length) {
ElMessage.warning('该用户尚未配置任何密钥或代理')
} else {
const failed = results.filter((item) => item.checkStatus === 'failed')
const errors = results.filter((item) => item.checkStatus === 'error')
if (failed.length) {
ElMessage.warning(failed[0].checkMessage || '存在检测失败项')
} else if (errors.length) {
ElMessage.warning('部分配置本次无法判定')
} else {
ElMessage.success('检测通过')
}
}
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '检测失败')
} finally {
checkingId.value = null
}
}
async function remove(row: AdminUserSecretRow) {
const who = row.username || `UID ${row.userId}`
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
try {
await deleteUserSecret(row.userId)
ElMessage.success('已清空')
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清空失败')
}
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
onMounted(load)
</script>
<template>
<div class="user-secrets-view">
<section class="panel-box">
<div class="secrets-head">
<h3>用户密钥列表</h3>
</div>
<div class="form-row secrets-filter-row">
<div class="form-group" style="min-width: 220px">
<label>用户名</label>
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
</div>
<div class="form-group" style="min-width: 170px">
<label>状态三项都通过才算通过</label>
<select v-model="statusFilter">
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
</div>
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
<label>分组</label>
<select v-model="groupFilter">
<option :value="null">全部分组</option>
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
</select>
</div>
<div class="form-group">
<label>&nbsp;</label>
<div class="filter-actions">
<button class="btn" type="button" @click="search">查询</button>
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
</div>
</div>
</div>
<div class="secrets-table-scroll">
<table>
<thead>
<tr>
<th style="width: 58px">序号</th>
<th style="width: 260px">用户</th>
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
<th style="width: 230px">货源查询密钥</th>
<th style="width: 230px">外观专利密钥</th>
<th style="width: 380px">代理设置</th>
<th style="width: 130px">状态</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 170px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="(row, index) in rows" :key="row.userId">
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
<td>
<span class="user-name">{{ row.username || '—' }}</span>
</td>
<td v-if="isSuperAdmin">
<span class="creator-name">{{ groupTextOf(row) }}</span>
</td>
<td>
<div class="module-cell">
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.similarAsin).tone" :title="moduleTooltip(row.similarAsin)">
{{ moduleStatusMeta(row.similarAsin).label }}
</span>
</div>
</td>
<td>
<div class="module-cell">
<span v-if="row.appearancePatent?.exists" class="mono-mask" :title="moduleTooltip(row.appearancePatent)">{{ row.appearancePatent.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.appearancePatent).tone" :title="moduleTooltip(row.appearancePatent)">
{{ moduleStatusMeta(row.appearancePatent).label }}
</span>
</div>
</td>
<td>
<div class="module-cell module-cell--stack">
<span v-if="row.proxy?.exists" class="mono-mask mono-full" :title="moduleTooltip(row.proxy)">{{ row.proxy.full || row.proxy.masked }}</span>
<span v-else class="empty-value">未配置</span>
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
{{ moduleStatusMeta(row.proxy).label }}
</span>
</div>
</td>
<td>
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
{{ rowStatusMeta(row.status).label }}
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button
class="btn btn-sm"
type="button"
:disabled="checkingId === row.userId"
@click="check(row)"
>
{{ checkingId === row.userId ? '检测中…' : '立即检测' }}
</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 10 : 9" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html 面板风格(与店铺密钥页同一视觉语言)。 */
.user-secrets-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.secrets-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.secrets-filter-row {
margin: 0 0 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.filter-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-ghost {
background: #ffffff;
border-color: #c7d7e5;
color: #4f78a5;
box-shadow: none;
}
.btn-ghost:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.secrets-table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.secrets-table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
min-width: 1440px;
}
.secrets-table-scroll th,
.secrets-table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secrets-table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.secrets-table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.secrets-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.user-name {
color: #24384d;
font-weight: 600;
/* 用户名(含中文)可完整换行展示,不截断 */
white-space: normal;
overflow-wrap: anywhere;
}
.creator-name {
color: #5b6f83;
font-size: 12.5px;
}
.module-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
.module-cell--stack {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.module-cell .mono-mask {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* 代理列展示完整明文:允许折行显示,不截断、不省略 */
.module-cell .mono-full {
flex: 1 1 auto;
min-width: 0;
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.45;
cursor: text;
user-select: all;
}
.mono-mask {
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12.5px;
letter-spacing: 0.5px;
}
.empty-value {
color: #a7b4c1;
}
.ops-cell {
display: flex;
gap: 8px;
}
.empty-tip {
color: #8293a5;
text-align: center;
}
.wh-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid;
font-size: 12px;
line-height: 1.7;
cursor: help;
flex-shrink: 0;
}
.wh-pill.is-allowed {
background: #e8f6ee;
border-color: #b9e0c9;
color: #2f7d52;
}
.wh-pill.is-blocked {
background: #fdecef;
border-color: #f2c4cd;
color: #b04a5a;
}
.wh-pill.is-warn {
background: #fdf6e3;
border-color: #f0dfae;
color: #8f6d1e;
}
.wh-pill.is-unknown {
background: #f0f3f6;
border-color: #d6dee6;
color: #6b7d8f;
}
</style>
@@ -1,477 +0,0 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 用户管理页 · 像素复刻旧版 admin.html panel-users(自绘:panel-box + 页头按钮 + 筛选 form-row + 旧式表格/分页 + 原生 confirm 删除)。
* script 逻辑沿用现有 Vue 实现;创建/编辑弹窗保留现有组件(含用户菜单权限树)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { AdminUser } from '@/types/admin'
import { roleLabel } from '@/types/admin'
import { useAdminSessionStore } from '@/stores/admin-session'
import { deleteUser, fetchUserList } from '@/api/users'
import { createUserFilterState, toUserListParams, type UserFilterState } from './users-filter'
import { totalPageCount } from './user-pagination'
import { deleteBlockReason, deleteConfirmMessage } from './user-delete-model'
import { listPhaseOf, userListEmptyHint } from './user-list-state'
import { isEditableUser } from './user-edit-model'
import CreateUserDialog from './CreateUserDialog.vue'
import EditUserDialog from './EditUserDialog.vue'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false)
const loadError = ref('')
const rows = ref<AdminUser[]>([])
const total = ref(0)
/** 所属管理员候选(来自列表响应的 admins),供超管筛选下拉使用。 */
const admins = ref<Array<{ id: number; username: string }>>([])
const filters = reactive<UserFilterState>(createUserFilterState())
const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, totalPageCount(total.value, filters.pageSize)))
const createVisible = ref(false)
const editVisible = ref(false)
const editUser = ref<AdminUser | null>(null)
function openCreate(): void {
createVisible.value = true
}
function openEdit(row: AdminUser): void {
if (!isEditableUser(row.role)) return
editUser.value = row
editVisible.value = true
}
function editableOf(row: AdminUser): boolean {
return isEditableUser(row.role)
}
async function loadUsers() {
loading.value = true
loadError.value = ''
try {
const page = await fetchUserList(toUserListParams(filters))
rows.value = page.items
total.value = page.total
if (Array.isArray(page.admins)) admins.value = page.admins
// 数据被删/筛选后总页数收缩:页码越界时回钳到末页并重新加载一次。
const last = totalPageCount(page.total, filters.pageSize)
if (last > 0 && filters.page > last) {
filters.page = last
const retried = await fetchUserList(toUserListParams(filters))
rows.value = retried.items
total.value = retried.total
}
} catch (error) {
loadError.value = error instanceof Error ? error.message : '用户列表加载失败'
ElMessage.error(loadError.value)
} finally {
loading.value = false
}
}
/** 错误态重试:重新拉取当前筛选列表。 */
function retry() {
void loadUsers()
}
/** 提交新筛选条件:重置回第一页再查询。 */
function search() {
filters.page = 1
void loadUsers()
}
async function removeUser(row: AdminUser) {
const reason = deleteBlockReason(row, currentUserId.value)
if (reason) {
ElMessage.warning(reason)
return
}
// 对齐旧版:浏览器原生 confirm 确认后删除。
if (!window.confirm(deleteConfirmMessage(row))) return
try {
await deleteUser(row.id)
ElMessage.success('删除成功')
await loadUsers()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
function changePage(page: number) {
filters.page = page
void loadUsers()
}
function changeSize(size: number) {
filters.pageSize = size
filters.page = 1
void loadUsers()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
onMounted(loadUsers)
</script>
<template>
<div class="users-view">
<section class="panel-box">
<div class="users-head">
<h3>用户列表</h3>
<button class="btn" type="button" @click="openCreate">新建用户</button>
</div>
<p class="users-desc">当前没有开放注册入口仅管理员可在此创建用户层级关系超级管理员 -&gt; 管理员 -&gt; 普通账号</p>
<div class="form-row users-filter-row">
<div class="form-group" style="min-width: 180px">
<label>用户名模糊搜索</label>
<input v-model="filters.keyword" type="text" placeholder="输入用户名关键字" @keyup.enter="search" />
</div>
<div v-if="isSuperAdmin" class="form-group" style="min-width: 160px">
<label>所属管理员</label>
<el-select v-model="filters.createdByAdminId" class="admin-filter" filterable clearable placeholder="全部" @change="search">
<el-option v-for="admin in admins" :key="admin.id" :label="admin.username" :value="admin.id" />
</el-select>
</div>
<div class="form-group" style="min-width: 140px">
<label>角色</label>
<el-select v-model="filters.role" class="admin-filter" filterable clearable placeholder="全部角色" @change="search">
<el-option label="超级管理员" value="super_admin" />
<el-option label="管理员" value="admin" />
<el-option label="普通账号" value="normal" />
</el-select>
</div>
<button class="btn" type="button" @click="search">查询</button>
</div>
<div v-if="statePhase === 'error' && loadError" class="msg err">
<span>加载失败: {{ loadError }}</span>
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
</div>
<div class="table-scroll user-table-scroll">
<table>
<thead>
<tr>
<th style="width: 70px">ID</th>
<th style="width: 180px">用户名</th>
<th style="width: 140px">角色</th>
<th style="width: 160px">所属管理员</th>
<th style="width: 180px">创建时间</th>
<th style="width: 180px">更新时间</th>
<th style="width: 160px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td>{{ row.username }}</td>
<td>{{ roleLabel(row.role) }}</td>
<td>{{ row.creatorUsername || '-' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button
class="btn btn-sm"
type="button"
:disabled="!editableOf(row)"
:title="!editableOf(row) ? '该账号不可编辑' : undefined"
@click="openEdit(row)"
>
编辑
</button>
<button
class="btn btn-sm btn-danger"
type="button"
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
:title="deleteBlockReason(row, currentUserId) || undefined"
@click="removeUser(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">{{ userListEmptyHint() }}</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="filters.page" :page-size="filters.pageSize" @change="changePage" @size-change="changeSize" />
</section>
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" />
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-users(蓝白末层)。 */
.users-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.users-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.users-desc {
margin: 10px 0 16px;
font-size: 13px;
color: #5b6f83;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.msg {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 10px 12px;
border: 1px solid;
border-radius: 9px;
font-size: 13px;
}
.msg.err {
color: #91474f;
background: #f8ebeb;
border-color: #e4c2c5;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.user-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-size-select {
min-height: 30px;
padding: 3px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
</style>
@@ -1,52 +0,0 @@
/** 菜单管理列表加载适配(任务 54):GET /api/admin/permission-menus(admin) → 展示树。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
buildMenuManageTree,
parseMenuManageItem,
parseMenuManageList,
toMenuCreateRequest,
toMenuUpdateRequest,
type MenuManageForm,
type MenuManageNode,
} from './menu-manage-model'
export const MENUS_ENDPOINT = '/api/admin/permission-menus'
export const REORDER_ENDPOINT = '/api/admin/column/reorder'
/** 拉取扁平可编辑菜单节点(全量类型,对齐 admin.js loadColumns 的 /api/admin/columns 语义)。 */
export async function fetchMenuManageNodes(): Promise<MenuManageNode[]> {
const { data } = await http.get<unknown>(MENUS_ENDPOINT)
return parseMenuManageList(data)
}
/** 拉取并按 parentId 组装成展示树。 */
export async function loadMenuManageTree(): Promise<MenuManageNode[]> {
return buildMenuManageTree(await fetchMenuManageNodes())
}
/** 新建菜单:POST /api/admin/permission-menus,返回后端落库节点。 */
export async function createMenu(form: MenuManageForm): Promise<MenuManageNode> {
const { data } = await http.post<unknown>(MENUS_ENDPOINT, toMenuCreateRequest(form))
const created = parseMenuManageItem(unwrap<unknown>(data))
if (!created) throw new Error('创建菜单响应异常:缺少菜单数据')
return created
}
/** 编辑菜单:PUT /api/admin/permission-menus/{id}success=false 由 unwrap 抛后端 message。 */
export async function updateMenu(id: number, form: MenuManageForm): Promise<void> {
const { data } = await http.put<unknown>(`${MENUS_ENDPOINT}/${id}`, toMenuUpdateRequest(form))
unwrap<unknown>(data)
}
/** 删除菜单:DELETE /api/admin/permission-menus/{id}success=false 由 unwrap 抛后端 message。 */
export async function deleteMenu(id: number): Promise<void> {
const { data } = await http.delete<unknown>(`${MENUS_ENDPOINT}/${id}`)
unwrap<unknown>(data)
}
/** 同级菜单排序:POST /api/admin/column/reorder 提交 { menu_type, ordered_ids }(对齐 admin.js persistColumnOrder)。 */
export async function reorderMenus(menuType: string, orderedIds: number[]): Promise<void> {
const { data } = await http.post<unknown>(REORDER_ENDPOINT, { menu_type: menuType, ordered_ids: orderedIds })
unwrap<unknown>(data)
}
@@ -1,291 +0,0 @@
/** 菜单管理 DTO/模型(任务 53):扁平 PermissionMenuItemVo → 可编辑节点 + 创建/更新请求体。 */
import { unwrap } from '../../api/envelope.ts'
/** 后台 Vue 控制台菜单类型。 */
export const ADMIN_MENU_TYPE = 'admin'
export interface MenuManageNode {
id: number
name: string
/** column_key,稳定权限标识。 */
columnKey: string
parentId: number | null
rootColumnKey?: string
menuType: string
routePath: string
sortOrder: number
createdAt?: string
updatedAt?: string
children?: MenuManageNode[]
}
/** 菜单类型展示文案(对齐 admin.js:6824admin → 后台,其余一律 → 软件)。 */
export function menuTypeLabel(type: string | null | undefined): string {
return (type || '').toLowerCase() === 'admin' ? '后台' : '软件'
}
/** 菜单类型下拉选项(对齐 admin.html:5931-5934 后台/软件)。 */
export const MENU_TYPE_OPTIONS: Array<{ value: 'admin' | 'app'; label: string }> = [
{ value: 'admin', label: '后台' },
{ value: 'app', label: '软件' },
]
export interface MenuManageForm {
name: string
columnKey: string
parentId: number | null
menuType: string
routePath: string
sortOrder: number
}
/** 请求体字段与 Java PermissionMenuCreate/UpdateRequest 对齐(camelCase)。
* 权限标识/路由由后台自动生成,不随表单提交,故为可选字段。 */
export interface MenuManagePayload {
name: string
columnKey?: string
parentId: number | null
menuType: string
routePath?: string
sortOrder: number
}
export interface MenuManageFormErrors {
name?: string
columnKey?: string
menuType?: string
routePath?: string
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
/** 排序归一:非负整数,非法回 0。 */
export function normalizeMenuSort(value: unknown): number {
const n = numberOrNull(value)
return n !== null && n >= 0 ? n : 0
}
/** 把单条 PermissionMenuItemVo(snake_case) 归一为可编辑节点;缺 id 视为无效。 */
export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const parentId = numberOrNull(record.parent_id)
const node: MenuManageNode = {
id,
name: text(record.name),
columnKey: text(record.column_key),
parentId: parentId === null ? null : parentId,
menuType: text(record.menu_type) || ADMIN_MENU_TYPE,
routePath: text(record.route_path),
sortOrder: normalizeMenuSort(record.sort_order),
}
const rootColumnKey = text(record.root_column_key)
if (rootColumnKey) node.rootColumnKey = rootColumnKey
const createdAt = text(record.created_at)
if (createdAt) node.createdAt = createdAt
const updatedAt = text(record.updated_at)
if (updatedAt) node.updatedAt = updatedAt
return node
}
/** 归一化 permission-menus 响应(信封或裸数组)为可编辑节点列表。 */
export function parseMenuManageList(payload: unknown): MenuManageNode[] {
const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return []
return data
.map((raw) => parseMenuManageItem(raw))
.filter((node): node is MenuManageNode => node !== null)
}
/** 按 parentId 组装展示树;同级按 (sortOrder,name) 升序,父缺失按根处理。 */
export function buildMenuManageTree(items: MenuManageNode[]): MenuManageNode[] {
const nodes = new Map<number, MenuManageNode>()
for (const item of items) {
nodes.set(item.id, { ...item, children: [] })
}
const roots: MenuManageNode[] = []
for (const item of items) {
const node = nodes.get(item.id)
if (!node) continue
const parent = item.parentId === null ? undefined : nodes.get(item.parentId)
if (parent) {
parent.children!.push(node)
} else {
roots.push(node)
}
}
const order = (a: MenuManageNode, b: MenuManageNode) =>
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)
const sortRecursive = (nodes: MenuManageNode[]) => {
nodes.sort(order)
for (const node of nodes) node.children?.length && sortRecursive(node.children)
}
sortRecursive(roots)
for (const item of items) {
const node = nodes.get(item.id)
if (node && !node.children?.length) delete node.children
}
return roots
}
/** 展平树为列表(前序),供父级下拉/统计复用。 */
export function flattenMenuNodes(nodes: MenuManageNode[]): MenuManageNode[] {
const out: MenuManageNode[] = []
const walk = (list: MenuManageNode[]) => {
for (const node of list) {
out.push(node)
if (node.children?.length) walk(node.children)
}
}
walk(nodes)
return out
}
/** 同级建议排序:该父下最大 sortOrder + 1;无同级返回 0。 */
export function nextSiblingSort(items: MenuManageNode[], parentId: number | null): number {
let max = -1
for (const item of items) {
if (item.parentId === parentId && item.sortOrder > max) max = item.sortOrder
}
return max + 1
}
/** 新建菜单表单工厂:指定父节点与建议排序,其余默认(menuType=admin)。 */
export function createMenuForm(parentId: number | null, sortOrder: number): MenuManageForm {
return {
...emptyMenuManageForm(),
parentId: typeof parentId === 'number' ? parentId : null,
sortOrder: normalizeMenuSort(sortOrder),
}
}
/** 编辑表单回填:从可编辑节点还原表单字段。 */
export function menuFormFromNode(node: MenuManageNode): MenuManageForm {
return {
name: node.name,
columnKey: node.columnKey,
parentId: node.parentId,
menuType: node.menuType,
routePath: node.routePath,
sortOrder: node.sortOrder,
}
}
/** 编辑时父节点防环校验:不可选自身或其子孙为父;返回可操作原因,否则 undefined。 */
export function menuParentChangeReason(node: MenuManageNode, parentId: number | null): string | undefined {
if (parentId === null || parentId === undefined) return undefined
if (parentId === node.id) return '不能选择自身作为父菜单'
const descendants = flattenMenuNodes(node.children ?? [])
if (descendants.some((item) => item.id === parentId)) return '不能选择自己的子菜单作为父菜单'
return undefined
}
/** 删除前置校验(镜像后端:仅叶子可删,避免孤儿层级);可删返回 undefined。 */
export function menuDeleteReason(node: MenuManageNode | null | undefined): string | undefined {
const children = node?.children
if (Array.isArray(children) && children.length > 0) return '请先删除该菜单下的子菜单'
return undefined
}
/** 同级拖拽/上下移:把 targetId 移动 dir 位(±1),返回新的同级 id 顺序;越界/缺失则原序。 */
export function siblingOrderAfterMove(group: number[], targetId: number, dir: number): number[] {
if (!Array.isArray(group) || group.length === 0) return []
const next = [...group]
const idx = next.indexOf(targetId)
if (idx < 0) return next
const to = idx + (dir === -1 || dir === 1 ? dir : 0)
if (to < 0 || to >= next.length) return next
const [moved] = next.splice(idx, 1)
next.splice(to, 0, moved)
return next
}
/** 拖拽分组键:同"菜单类型 + 父级"才算同级(对齐 admin.js columnSiblingGroupKey)。 */
export function menuSiblingGroupKey(node: MenuManageNode): string {
return `${node.menuType || 'app'}|${node.parentId ?? ''}`
}
/** 拖放排序:把 draggedId 插到 targetId 前/后,返回新的同级 id 顺序;任一 id 缺失或同 id 则原序。 */
export function siblingOrderAfterDrop(
group: number[],
draggedId: number,
targetId: number,
placeAfter: boolean,
): number[] {
if (!Array.isArray(group) || group.length === 0) return []
if (draggedId === targetId) return [...group]
const next = [...group]
const from = next.indexOf(draggedId)
if (from < 0) return next
const target = next.indexOf(targetId)
if (target < 0) return next
next.splice(from, 1)
const insertAt = next.indexOf(targetId) + (placeAfter ? 1 : 0)
next.splice(insertAt, 0, draggedId)
return next
}
/** 父菜单候选:仅同菜单类型的节点(对齐 admin 的类型联动父级下拉)。 */
export function menuParentCandidates(nodes: MenuManageNode[], menuType: string): MenuManageNode[] {
const type = (menuType || '').trim()
return flattenMenuNodes(nodes).filter((node) => (node.menuType || '') === type)
}
/** 同级重排请求体(对齐 admin.js persistColumnOrder{ menu_type, ordered_ids })。 */
export function toMenuReorderRequest(
menuType: string,
orderedIds: number[],
): { menu_type: string; ordered_ids: number[] } {
return { menu_type: text(menuType) || 'app', ordered_ids: dedupeOrderIds(orderedIds) }
}
function dedupeOrderIds(ids: unknown[]): number[] {
if (!Array.isArray(ids)) return []
const set = new Set<number>()
for (const v of ids) {
const n = numberOrNull(v)
if (n !== null && n > 0) set.add(n)
}
return [...set]
}
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
export function emptyMenuManageForm(): MenuManageForm {
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
}
/** 表单校验(镜像后端 NotBlank 文案);返回逐字段错误。权限标识/路由由后台自动生成,不参与校验。 */
export function validateMenuManageForm(form: MenuManageForm): { valid: boolean; errors: MenuManageFormErrors } {
const errors: MenuManageFormErrors = {}
if (!text(form.name)) errors.name = '菜单名称不能为空'
if (!text(form.menuType)) errors.menuType = '菜单类型不能为空'
return { valid: Object.keys(errors).length === 0, errors }
}
function toMenuManagePayload(form: MenuManageForm): MenuManagePayload {
// 权限标识(column_key)/路由(route_path) 不随表单提交:由后台自动生成(创建)或保持原值(编辑)。
return {
name: text(form.name),
parentId: typeof form.parentId === 'number' ? form.parentId : null,
menuType: text(form.menuType),
sortOrder: normalizeMenuSort(form.sortOrder),
}
}
/** 表单 → POST /api/admin/permission-menus 请求体。 */
export function toMenuCreateRequest(form: MenuManageForm): MenuManagePayload {
return toMenuManagePayload(form)
}
/** 表单 → PUT /api/admin/permission-menus/{id} 请求体。 */
export function toMenuUpdateRequest(form: MenuManageForm): MenuManagePayload {
return toMenuManagePayload(form)
}
@@ -1,39 +0,0 @@
/** 数据权限分组 API 适配(任务 59module 13 task 253 扩展 CRUD)。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
parseShopGroupItem,
parseShopGroupList,
toShopGroupCreatePayload,
toShopGroupUpdatePayload,
type ShopGroupItem,
} from './shop-group-model'
export const SHOP_GROUPS_ENDPOINT = '/api/admin/shop-manages/groups'
export async function fetchShopGroups(): Promise<ShopGroupItem[]> {
const { data } = await http.get<unknown>(SHOP_GROUPS_ENDPOINT)
return parseShopGroupList(data)
}
/** 新建分组:POST /api/admin/shop-manages/groups(创建人 = 组长 = 当前操作者)。 */
export async function createShopGroup(name: string, memberUserIds: number[], createdById: number): Promise<ShopGroupItem> {
const { data } = await http.post<unknown>(SHOP_GROUPS_ENDPOINT, toShopGroupCreatePayload(name, memberUserIds, createdById))
const created = parseShopGroupItem(unwrap<unknown>(data))
if (!created) throw new Error('创建分组响应异常:缺少分组数据')
return created
}
/** 更新分组:PUT /api/admin/shop-manages/groups/{id}。 */
export async function updateShopGroup(id: number, name: string, memberUserIds: number[]): Promise<ShopGroupItem> {
const { data } = await http.put<unknown>(`${SHOP_GROUPS_ENDPOINT}/${id}`, toShopGroupUpdatePayload(name, memberUserIds))
const updated = parseShopGroupItem(unwrap<unknown>(data))
if (!updated) throw new Error('更新分组响应异常:缺少分组数据')
return updated
}
/** 删除分组:DELETE /api/admin/shop-manages/groups/{id}。 */
export async function deleteShopGroup(id: number): Promise<void> {
const { data } = await http.delete<unknown>(`${SHOP_GROUPS_ENDPOINT}/${id}`)
unwrap<unknown>(data)
}
@@ -1,142 +0,0 @@
/** 数据权限分组列表模型(任务 59module 13 task 253 扩展编辑能力):解析 VO + 摘要 + 提交载荷。 */
import { unwrap } from '../../api/envelope.ts'
export interface ShopGroupItem {
id: number
name: string
leaderUsername: string
leaderUserId?: number
memberCount: number
memberUsernames: string[]
memberUserIds?: number[]
createdAt?: string
updatedAt?: string
}
export interface ShopGroupFormErrors {
groupName?: string
}
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 parseShopGroupItem(raw: unknown): ShopGroupItem | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const names = Array.isArray(record.memberUsernames)
? record.memberUsernames.map((v) => text(v)).filter(Boolean)
: []
const memberIds = Array.isArray(record.memberUserIds)
? record.memberUserIds.map((v) => numberOrNull(v)).filter((v): v is number => v !== null && v > 0)
: []
const count = numberOrNull(record.memberCount)
const item: ShopGroupItem = {
id,
name: text(record.groupName ?? record.name) || `分组${id}`,
leaderUsername: text(record.leaderUsername ?? record.leader_name),
memberCount: count === null ? names.length : count,
memberUsernames: names,
}
const leaderId = numberOrNull(record.leaderUserId ?? record.leader_user_id)
if (leaderId !== null && leaderId > 0) item.leaderUserId = leaderId
if (memberIds.length) item.memberUserIds = memberIds
const createdAt = text(record.createdAt ?? record.created_at)
if (createdAt) item.createdAt = createdAt
const updatedAt = text(record.updatedAt ?? record.updated_at)
if (updatedAt) item.updatedAt = updatedAt
return item
}
/** 归一化分组列表响应(信封或裸数组)。 */
export function parseShopGroupList(payload: unknown): ShopGroupItem[] {
const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return []
return data
.map((raw) => parseShopGroupItem(raw))
.filter((item): item is ShopGroupItem => item !== null)
}
/** 分组摘要:分组总数 + 组员总数。 */
export function shopGroupSummary(groups: ShopGroupItem[]): { groupCount: number; memberTotal: number } {
let memberTotal = 0
for (const group of groups) memberTotal += group.memberCount || 0
return { groupCount: groups.length, memberTotal }
}
/** 分组名必填校验(镜像后端 NotBlank 文案「请输入分组名称」)。 */
export function validateShopGroupForm(name: string): ShopGroupFormErrors {
const errors: ShopGroupFormErrors = {}
if (!text(name)) errors.groupName = '请输入分组名称'
return errors
}
/** 创建载荷:groupName + 成员 + 创建人(= 组长,当前操作者)。 */
export function toShopGroupCreatePayload(
name: string,
memberUserIds: number[],
createdById: number,
): { groupName: string; memberUserIds: number[]; createdById: number } {
return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds), createdById }
}
/** 更新载荷:groupName + 成员(组长/创建人不可改)。 */
export function toShopGroupUpdatePayload(name: string, memberUserIds: number[]): { groupName: string; memberUserIds: number[] } {
return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds) }
}
/** 组员候选用户(对齐 admin.js getEligibleShopManageGroupUsers 的过滤语义)。 */
export interface GroupMemberCandidateUser {
id: number
username: string
role: string
createdById?: number | null
}
/**
* 组员候选过滤(对齐 admin.js:3020-3028):
* - 组长本人永不进候选;
* - 超管可选全部用户(不论角色/归属);
* - 非超管仅可选"组长(创建人)名下"的普通账号。
*/
export function eligibleGroupMembers(
users: GroupMemberCandidateUser[],
leaderUserId: number | null,
operatorRole: string,
): GroupMemberCandidateUser[] {
const leaderKey = leaderUserId != null && leaderUserId > 0 ? String(leaderUserId) : ''
const isSuper = (operatorRole || '').toLowerCase() === 'super_admin'
return users.filter((u) => {
if (String(u.id) === leaderKey) return false
if (isSuper) return true
return (
(u.role || '').toLowerCase() === 'normal' &&
String(u.createdById ?? '') === leaderKey
)
})
}
/** 组员候选帮助文案(对齐 admin.js:3179-3182)。 */
export function groupMemberCandidateHint(candidateCount: number, operatorRole: string): string {
if (candidateCount > 0) return '可添加当前组长创建的普通员工账号'
return (operatorRole || '').toLowerCase() === 'normal'
? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。'
: '当前组长名下暂无可添加的普通员工账号。'
}
function dedupeIds(ids: unknown[]): number[] {
if (!Array.isArray(ids)) return []
const set = new Set<number>()
for (const v of ids) {
const id = numberOrNull(v)
if (id !== null && id > 0) set.add(id)
}
return [...set].sort((a, b) => a - b)
}
@@ -1,24 +0,0 @@
/** 新建用户请求适配(任务 46):表单模型 → POST /api/admin/user → 新用户 id。 */
import { http } from '@/api/http'
import { parseCreatedUserId } from '@/api/users-model'
import {
toCreateUserRequest,
validateCreateUserForm,
type CreateUserForm,
} from './user-create-model'
/** Java AdminUserController.createUser 端点。 */
export const CREATE_USER_ENDPOINT = '/api/admin/user'
/**
* 提交新建用户:先校验表单(不通过直接抛首条错误,不发请求),再序列化请求体 POST,
* 最后解析返回的新用户 id;http/后端失败交由调用方提示,不吞异常。
*/
export async function submitCreateUser(form: CreateUserForm): Promise<number> {
const { valid, errors } = validateCreateUserForm(form)
if (!valid) {
throw new Error(Object.values(errors)[0] || '创建用户表单校验未通过')
}
const { data } = await http.post<unknown>(CREATE_USER_ENDPOINT, toCreateUserRequest(form))
return parseCreatedUserId(data)
}
@@ -1,74 +0,0 @@
/** 新建用户表单模型(任务 45):纯逻辑,镜像 Java AdminUserCreateRequest 字段与后端校验。 */
export const CREATE_USERNAME_MIN_LEN = 2
export const CREATE_PASSWORD_MIN_LEN = 6
/** 新建用户可选角色:super_admin 不可由表单创建(后端只收 admin|normal)。 */
export type CreatableUserRole = 'normal' | 'admin'
export interface CreateUserForm {
username: string
password: string
role: CreatableUserRole
/** 授权菜单 id(后端 replaceDirectPermissions 用)。 */
columnIds: number[]
/** 所属管理员 id(超管创建普通账号时可指定,对齐 admin 的 created_by_id)。 */
createdBy?: number | null
}
export interface CreateUserFormErrors {
username?: string
password?: string
}
/** 发送 POST /api/admin/user 的请求体(与 Java AdminUserCreateRequest camelCase 对齐)。 */
export interface CreateUserPayload {
username: string
password: string
role: string
columnIds: number[]
/** 仅 role=normal 且选了所属管理员时发送,与参考 admin.js 的 created_by_id 语义一致。 */
createdById?: number
}
export function createEmptyCreateUserForm(): CreateUserForm {
return { username: '', password: '', role: 'normal', columnIds: [], createdBy: null }
}
/** 未知/空白/超管角色一律归一到 normal,与后端 "admin"/"normal" 白名单一致。 */
export function normalizeCreateRole(role: string | null | undefined): CreatableUserRole {
return (role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'normal'
}
/** 表单校验(镜像后端:用户名≥2、密码≥6、均必填);返回逐字段错误。 */
export function validateCreateUserForm(form: CreateUserForm): { valid: boolean; errors: CreateUserFormErrors } {
const errors: CreateUserFormErrors = {}
const username = (form.username || '').trim()
if (!username) {
errors.username = '用户名不能为空'
} else if (username.length < CREATE_USERNAME_MIN_LEN) {
errors.username = `用户名至少${CREATE_USERNAME_MIN_LEN}个字符`
}
const password = form.password || ''
if (!password) {
errors.password = '密码不能为空'
} else if (password.length < CREATE_PASSWORD_MIN_LEN) {
errors.password = `密码至少${CREATE_PASSWORD_MIN_LEN}个字符`
}
return { valid: Object.keys(errors).length === 0, errors }
}
/** 表单 → 创建请求体:用户名去空格、角色归一、菜单 id 拷贝不共享引用;
* createdById 仅在 role=normal 且选了所属管理员时携带(对齐 admin.js 的 created_by_id 语义)。 */
export function toCreateUserRequest(form: CreateUserForm): CreateUserPayload {
const payload: CreateUserPayload = {
username: (form.username || '').trim(),
password: form.password || '',
role: normalizeCreateRole(form.role),
columnIds: Array.isArray(form.columnIds) ? form.columnIds.slice() : [],
}
if (payload.role === 'normal' && form.createdBy != null) {
payload.createdById = form.createdBy
}
return payload
}
@@ -1,24 +0,0 @@
/** 用户删除确认模型(任务 51):删除确认文案 + 不可删原因,镜像 Java deleteUser 约束。 */
import type { AdminUser } from '../../types/admin'
/** 删除二次确认文案(对齐 admin.js:970 原文:半角引号、无补充句)。 */
export function deleteConfirmMessage(user: AdminUser | null | undefined): string {
const username = user?.username || ''
return `确定删除用户 "${username}" 吗?`
}
/** 返回不可删除原因;可删除时返回 undefined。 */
export function deleteBlockReason(
user: AdminUser | null | undefined,
currentUserId?: number | null,
): string | undefined {
if (!user) return '用户信息缺失,无法删除'
if (currentUserId != null && user.id === currentUserId) return '不能删除当前登录账号'
if ((user.role || '').trim().toLowerCase() === 'super_admin') return '不能删除超级管理员'
return undefined
}
/** 该用户当前是否允许删除(非缺失/非自己/非超管)。 */
export function isDeletableUser(user: AdminUser | null | undefined, currentUserId?: number | null): boolean {
return deleteBlockReason(user, currentUserId) === undefined
}
@@ -1,23 +0,0 @@
/** 编辑用户请求适配(module 13 task 251):表单模型 → PUT /api/admin/user/{uid}。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
toEditUserUpdateRequest,
validateEditUserForm,
type EditUserForm,
} from './user-edit-model'
/** Java AdminUserController.updateUser 端点前缀。 */
export const EDIT_USER_ENDPOINT = '/api/admin/user'
/**
* 提交编辑用户:先校验(不通过抛首条错误),再序列化 PUT;http/后端失败交由调用方提示。
*/
export async function submitEditUser(form: EditUserForm): Promise<void> {
const { valid, errors } = validateEditUserForm(form)
if (!valid) {
throw new Error(Object.values(errors)[0] || '编辑用户表单校验未通过')
}
const { data } = await http.put<unknown>(`${EDIT_USER_ENDPOINT}/${form.uid}`, toEditUserUpdateRequest(form))
unwrap<unknown>(data)
}
@@ -1,79 +0,0 @@
/** 编辑用户表单回填模型(任务 47):从用户行回填可编辑表单,镜像 Java AdminUserUpdateRequest。 */
import type { AdminUser } from '../../types/admin'
import { CREATE_PASSWORD_MIN_LEN } from './user-create-model.ts'
/** 编辑可选角色:超管不可改,未知归 normal;后端只收 admin|normal。 */
export type EditUserRole = 'normal' | 'admin'
export interface EditUserForm {
uid: number
/** 仅展示用,后端不支持改用户名。 */
username: string
/** 空串 = 不修改密码。 */
password: string
role: EditUserRole
/** 授权菜单 id;由授权数据加载后填充。 */
columnIds: number[]
/** 后端原角色字符串,用于超管禁止编辑判定。 */
originalRole: string
}
export interface EditUserFormErrors {
password?: string
}
/** 序列化到 PUT /api/admin/user/{uid} 的请求体(Java AdminUserUpdateRequest,空密码不下发)。 */
export interface EditUserUpdateRequest {
role: EditUserRole
columnIds: number[]
password?: string
}
/** 行角色 → 可编辑角色:仅 admin 映射 admin,其余(含 unknown)归 normal。 */
export function editRoleOf(role: string | null | undefined): EditUserRole {
return (role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'normal'
}
/** 该角色是否允许被编辑:超级管理员不可编辑(镜像后端“不能修改超级管理员”)。 */
export function isEditableUser(role: string | null | undefined): boolean {
return (role || '').trim().toLowerCase() !== 'super_admin'
}
/** 从用户行回填编辑表单:用户名只读、密码留空、columnIds 待授权加载填充。 */
export function fillEditUserForm(user: AdminUser): EditUserForm {
const role = typeof user.role === 'string' ? user.role.trim() : ''
return {
uid: user.id,
username: user.username || '',
password: '',
role: editRoleOf(role),
columnIds: [],
originalRole: role,
}
}
/** 编辑表单校验:密码可留空(不改),非空时须达到最小长度。 */
export function validateEditUserForm(form: EditUserForm): { valid: boolean; errors: EditUserFormErrors } {
const errors: EditUserFormErrors = {}
const password = form.password || ''
if (password && password.length < CREATE_PASSWORD_MIN_LEN) {
errors.password = `密码至少${CREATE_PASSWORD_MIN_LEN}个字符,留空则不修改`
}
return { valid: Object.keys(errors).length === 0, errors }
}
/** 编辑表单 → 更新请求体:超管拒绝、空密码不下发、columnIds 拷贝不共享引用。 */
export function toEditUserUpdateRequest(form: EditUserForm): EditUserUpdateRequest {
const originalRole = (form.originalRole || '').trim().toLowerCase()
if (originalRole === 'super_admin') {
throw new Error('不能修改超级管理员')
}
const role: EditUserRole = form.role === 'admin' ? 'admin' : 'normal'
const request: EditUserUpdateRequest = {
role,
columnIds: Array.isArray(form.columnIds) ? form.columnIds.slice() : [],
}
const password = form.password || ''
if (password) request.password = password
return request
}
@@ -1,31 +0,0 @@
/** 用户列表加载/空/错状态(任务 52):纯 phase 归一无 UI 依赖,供页面展示决策复用。 */
export type ListPhase = 'loading' | 'error' | 'empty' | 'ready'
export interface UserListPhaseInput {
loading?: boolean
error?: string
items?: unknown[]
total?: number
}
/** 归一列表展示相位:loading 优先,其次 error,其次空,否则 ready。 */
export function listPhaseOf(input: UserListPhaseInput): ListPhase {
if (input.loading) return 'loading'
if (typeof input.error === 'string' && input.error.trim()) return 'error'
const items = Array.isArray(input.items) ? input.items : []
const total = typeof input.total === 'number' ? input.total : items.length
if (items.length === 0 || total === 0) return 'empty'
return 'ready'
}
/** 空态提示文案。 */
export function userListEmptyHint(): string {
return '暂无用户数据'
}
/** 错误态可操作提示:带上具体原因,缺省给通用文案。 */
export function userListErrorHint(message?: string | null): string {
const text = (message || '').trim()
return text ? `加载失败:${text}` : '加载失败,请稍后重试'
}
@@ -1,65 +0,0 @@
/** 用户菜单授权数据加载适配(任务 49):拉取可授权菜单树 + 用户已授权 id。 */
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
ADMIN_MENU_TYPE,
APP_MENU_TYPE,
buildMenuOptionTree,
buildUserColumnPermissionPayload,
normalizeMenuIds,
parseMenuOptionList,
parseUserGrantedColumnIds,
type MenuOptionNode,
} from './user-menu-auth'
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
/** 拉取可授权菜单树(后台 admin + 前端客户端 app 两类合并,按 sort 排序)。 */
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
const [adminRes, appRes] = await Promise.all([
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }),
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: APP_MENU_TYPE } }),
])
const nodes = [
...parseMenuOptionList(adminRes.data, ADMIN_MENU_TYPE),
...parseMenuOptionList(appRes.data, APP_MENU_TYPE),
]
return buildMenuOptionTree(nodes)
}
/** 拉取某用户当前直接授权菜单 id 列表(admin + app 合并去重)。 */
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
const [adminRes, appRes] = await Promise.all([
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: ADMIN_MENU_TYPE },
}),
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: APP_MENU_TYPE },
}),
])
return normalizeMenuIds([
...parseUserGrantedColumnIds(adminRes.data),
...parseUserGrantedColumnIds(appRes.data),
])
}
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
export interface UserMenuAuthData {
tree: MenuOptionNode[]
checkedIds: number[]
}
export async function loadUserMenuAuth(userId: number): Promise<UserMenuAuthData> {
const [tree, checkedIds] = await Promise.all([fetchGrantableMenus(), fetchUserGrantedColumnIds(userId)])
return { tree, checkedIds }
}
/** 提交某用户的直接授权菜单 id(整树替换);success=false 由 unwrap 抛后端 message。 */
export async function submitUserMenuAuth(userId: number, columnIds: unknown): Promise<void> {
const payload = buildUserColumnPermissionPayload(columnIds)
const { data } = await http.put<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, payload, {
params: { menuType: ADMIN_MENU_TYPE },
})
unwrap<unknown>(data)
}
@@ -1,140 +0,0 @@
/** 用户菜单授权数据解析模型(任务 49):permission-menus 扁平项 → 嵌套树 + 用户已授权 id。 */
import { unwrap } from '../../api/envelope.ts'
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
export const ADMIN_MENU_TYPE = 'admin'
/** 前端客户端菜单类型(桌面端功能模块,如视频/前端工具/运营工具/后勤工具)。 */
export const APP_MENU_TYPE = 'app'
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
export interface MenuOptionNode {
id: number
/** column_key,稳定权限标识;缺省用 id 字符串兜底。 */
key: string
name: string
sort: number
parentId: number | null
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
type: string
/**
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
*/
disabled?: boolean
children?: MenuOptionNode[]
}
function numberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
if (id === null) return null
const parentId = numberOrNull(record.parent_id)
const sortRaw = numberOrNull(record.sort_order)
const columnKey = typeof record.column_key === 'string' ? record.column_key.trim() : ''
return {
id,
key: columnKey || String(id),
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
sort: sortRaw === null ? 0 : sortRaw,
parentId: parentId === null ? null : parentId,
type,
// 缺省(菜单管理页等未标记的接口)按可授予处理,保持旧行为
disabled: record.grantable === false,
}
}
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
export function parseMenuOptionList(payload: unknown, type = ''): MenuOptionNode[] {
const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return []
return data
.map((raw) => parsePermissionMenuItem(raw, type))
.filter((node): node is MenuOptionNode => node !== null)
}
/** 按 parent_id 组装嵌套树;同级按 (sort,key) 升序,父缺失的节点按根处理。 */
export function buildMenuOptionTree(items: MenuOptionNode[]): MenuOptionNode[] {
const nodes = new Map<number, MenuOptionNode>()
for (const item of items) {
nodes.set(item.id, { ...item, children: [] })
}
const roots: MenuOptionNode[] = []
for (const item of items) {
const node = nodes.get(item.id)
if (!node) continue
const parent = item.parentId === null ? undefined : nodes.get(item.parentId)
if (parent) {
parent.children!.push(node)
} else {
roots.push(node)
}
}
const order = (a: MenuOptionNode, b: MenuOptionNode) =>
a.sort - b.sort || a.key.localeCompare(b.key)
const sortRecursive = (nodes: MenuOptionNode[]) => {
nodes.sort(order)
for (const node of nodes) node.children?.length && sortRecursive(node.children)
}
sortRecursive(roots)
for (const item of items) {
const node = nodes.get(item.id)
if (node && !node.children?.length) delete node.children
}
return roots
}
/** 解包用户已授权 columnIdsdata.columnIds)→ 去重升序正整数。 */
export function parseUserGrantedColumnIds(payload: unknown): number[] {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
if (!record || !Array.isArray(record.columnIds)) return []
return normalizeMenuIds(record.columnIds)
}
/** 任意输入 id 集合归一为去重升序正整数(镜像后端 normalizeColumnIds)。 */
export function normalizeMenuIds(raw: unknown): number[] {
if (!Array.isArray(raw)) return []
const ids = new Set<number>()
for (const value of raw) {
const id = numberOrNull(value)
if (id !== null && id >= 1) ids.add(id)
}
return [...ids].sort((a, b) => a - b)
}
/** 提交授权请求体(任务 50):{columnIds} 与 Java UserColumnPermissionUpdateRequest 对齐。 */
export function buildUserColumnPermissionPayload(raw: unknown): { columnIds: number[] } {
return { columnIds: normalizeMenuIds(raw) }
}
/**
* 把权限树勾选 id 压缩为后端意义上的“直接授权最小集”:父级已勾选时后代无需
* 重复提交(后端读取/校验时父级授权会向全部后代展开,与旧 admin.html“勾父清
* 后代”语义一致,避免 user_column_permission 存储冗余行)。未勾选父级但单独
* 勾选子级的部分授权原样保留。
*/
export function compactDirectGrantIds(checkedIds: unknown, tree: MenuOptionNode[]): number[] {
const ids = normalizeMenuIds(checkedIds)
if (ids.length === 0 || tree.length === 0) return ids
const idSet = new Set(ids)
/** 因“有已勾选祖先”而被父级覆盖、无需直接授权的 id。 */
const coveredByAncestor = new Set<number>()
const walk = (nodes: MenuOptionNode[], ancestorChecked: boolean): void => {
for (const node of nodes) {
if (ancestorChecked) {
coveredByAncestor.add(node.id)
if (node.children?.length) walk(node.children, true)
continue
}
if (node.children?.length) walk(node.children, idSet.has(node.id))
}
}
walk(tree, false)
return ids.filter((id) => !coveredByAncestor.has(id))
}
@@ -1,26 +0,0 @@
/** 用户列表分页边界(任务 44):纯函数,处理总页数、越界页码回钳与空数据。 */
import { USER_PAGE_DEFAULT_SIZE } from '../../api/users-dto.ts'
/** 总页数;无数据返回 0,pageSize 非法时用默认。 */
export function totalPageCount(total: number, pageSize: number): number {
if (!Number.isFinite(total) || total <= 0) return 0
const size = Number.isFinite(pageSize) && pageSize >= 1 ? Math.floor(pageSize) : USER_PAGE_DEFAULT_SIZE
return Math.ceil(total / size)
}
/** 请求页是否超过末页(无数据时不算越界)。 */
export function isPageOutOfRange(page: number, total: number, pageSize: number): boolean {
const requested = Number.isFinite(page) ? Math.floor(page) : 1
if (requested < 1) return false
const last = totalPageCount(total, pageSize)
return last > 0 && requested > last
}
/** 把请求页钳到合法范围:<1 回首页,超过末页回末页;无数据允许第 1 页空态。 */
export function clampPage(page: number, total: number, pageSize: number): number {
const requested = Number.isFinite(page) ? Math.floor(page) : 1
if (requested < 1) return 1
const last = totalPageCount(total, pageSize)
if (last === 0) return 1
return Math.min(requested, last)
}
@@ -1,56 +0,0 @@
/** 用户角色字段映射(任务 48):行 role/is_admin → 角色域 → 可编辑值/锁定原因,镜像 Java 约束。 */
import { roleKind } from '../../types/admin.ts'
/** 后台角色域(与 AdminUserItemVo.role / is_admin 对齐)。 */
export type RoleDomain = 'super_admin' | 'admin' | 'normal'
/** 用户角色下拉可选值;super_admin 只能由系统持有,不入下拉。 */
export type EditableRole = 'admin' | 'normal'
export interface UserRoleOption {
value: EditableRole
label: string
}
/** 编辑/创建表单角色下拉候选(镜像后端 admin|normal 白名单)。 */
export const USER_ROLE_OPTIONS: readonly UserRoleOption[] = [
{ value: 'admin', label: '管理员' },
{ value: 'normal', label: '普通用户' },
]
const ROLE_DOMAIN_LABELS: Record<RoleDomain, string> = {
super_admin: '超级管理员',
admin: '管理员',
normal: '普通用户',
}
/** 行字段 → 角色域:优先 role;为空时按 is_admin 布尔推断(1=管理员);未知归 normal。 */
export function roleDomainOf(row: { role?: string | null; isAdmin?: boolean }): RoleDomain {
const kind = roleKind(row?.role)
if (kind === 'super_admin' || kind === 'admin' || kind === 'normal') return kind
return row?.isAdmin ? 'admin' : 'normal'
}
/** 行 → 可编辑角色值:超级管理员返回 null(不可编辑),其余返回 admin/normal。 */
export function selectRoleOf(row: { role?: string | null; isAdmin?: boolean }): EditableRole | null {
const domain = roleDomainOf(row)
return domain === 'super_admin' ? null : domain
}
/** 角色域展示文案。 */
export function roleDomainLabel(domain: RoleDomain): string {
return ROLE_DOMAIN_LABELS[domain] || ''
}
/**
* 角色下拉锁定原因:目标为超管、或操作者非超管(镜像后端:admin 编辑时强制不调角色、
* super_admin 不可改)。无锁定时返回 undefined。
*/
export function userRoleLockMessage(
operatorRole: string | null | undefined,
target: { role?: string | null; isAdmin?: boolean },
): string | undefined {
if (roleDomainOf(target) === 'super_admin') return '不能修改超级管理员'
if (roleKind(operatorRole) !== 'super_admin') return '仅超级管理员可调整角色'
return undefined
}
@@ -1,50 +0,0 @@
/** 用户页局部筛选/分页状态(任务 43):纯逻辑、不入 Pinia,页面级可测状态机。 */
import { USER_PAGE_DEFAULT_SIZE, USER_PAGE_MIN_PAGE, type UserListParams } from '../../api/users-dto.ts'
/** 页面局部查询状态:筛选条件 + 分页;业务表状态不落 Pinia。 */
export interface UserFilterState {
/** 用户名模糊关键字。 */
keyword: string
/** 按创建管理员过滤(可选)。 */
createdByAdminId: number | null
/** 角色过滤(''=全部;super_admin/admin/normal)。 */
role: string
page: number
pageSize: number
}
export function createUserFilterState(): UserFilterState {
return { keyword: '', createdByAdminId: null, role: '', page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
}
/** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */
export function withFilterReset(
state: UserFilterState,
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId' | 'role'>>,
): UserFilterState {
return { ...state, ...next, page: USER_PAGE_MIN_PAGE }
}
/** 翻页只改页码并钳到 >=1,保留筛选条件。 */
export function withPage(state: UserFilterState, page: number): UserFilterState {
return { ...state, page: Math.max(page, USER_PAGE_MIN_PAGE) }
}
/** 是否存在生效筛选(仅空白关键字不算)。 */
export function isFilterActive(state: UserFilterState): boolean {
return Boolean(state.keyword && state.keyword.trim()) || state.createdByAdminId != null
}
/** 页面状态 → 查询适配层参数(空关键字不下发)。 */
export function toUserListParams(state: UserFilterState): UserListParams {
const keyword = state.keyword.trim()
const params: UserListParams = {
page: state.page,
pageSize: state.pageSize,
keyword: keyword || undefined,
createdById: state.createdByAdminId,
}
const role = (state.role || '').trim()
if (role) params.role = role
return params
}
@@ -1,492 +0,0 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 品牌数据库(不符合ASIN)页 · 像素复刻旧版 admin.html panel-invalid-asin-data(自绘:面板头+新增、筛选、旧式表格/分页、原生 confirm 删除)。
* script 逻辑沿用现有 Vue 实现(分组锁定/来源 AUTO 编辑隐藏分组);弹窗保留现有组件。 */
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAdminSessionStore } from '@/stores/admin-session'
import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts'
import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts'
import { invalidAsinDeleteText, invalidAsinLockedGroupId, recordSourceLabel } from '../invalidasin/invalid-asin-model.ts'
import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
const session = useAdminSessionStore()
const loading = ref(false)
const rows = ref<InvalidAsinItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
const jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 当前编辑行来源:自动导入(AUTO)记录不可改分组。 */
const editingSource = ref('')
const saving = ref(false)
const form = reactive(createEmptyInvalidAsinForm())
const isSuperAdmin = computed(() => session.isSuperAdmin)
/** 非超管锁定的分组 id(对齐 admin.js getInvalidAsinDataLockedGroupId);超管为 null。 */
const lockedGroupId = computed(() => invalidAsinLockedGroupId(groups.value, null, session.user?.role || ''))
/** 分组下拉可用选项:超管全量,非超管仅锁定分组。 */
const groupOptions = computed<ShopGroupOption[]>(() => {
if (isSuperAdmin.value) return groups.value
const locked = lockedGroupId.value
return locked == null ? [] : groups.value.filter((group) => group.id === locked)
})
async function loadGroups() {
try {
groups.value = await fetchShopManageGroups()
} catch {
groups.value = []
}
// 非超管:筛选强制锁定到本人分组(与参考 refreshShopGroupSelects 的锁定语义一致)。
if (!isSuperAdmin.value && lockedGroupId.value != null) {
filter.groupId = lockedGroupId.value
}
}
async function load() {
loading.value = true
try {
const result = await fetchInvalidAsinPage({
page: page.value,
pageSize: pageSize.value,
dataValue: filter.dataValue.trim() || undefined,
brand: filter.brand.trim() || undefined,
groupId: filter.groupId,
})
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
} finally {
loading.value = false
}
}
function apply() {
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()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
function openCreate() {
editingId.value = null
editingSource.value = ''
Object.assign(form, createEmptyInvalidAsinForm())
// 非超管新增预置锁定分组(下拉同时被禁用)。
if (!isSuperAdmin.value && lockedGroupId.value != null) {
form.groupId = lockedGroupId.value
}
dialogVisible.value = true
}
function openEdit(row: InvalidAsinItem) {
editingId.value = row.id
editingSource.value = row.recordSource
Object.assign(form, invalidAsinFormFromItem(row))
dialogVisible.value = true
}
async function submit() {
const { valid, errors } = validateInvalidAsinForm({ ...form }, { requireGroup: editingId.value == null })
if (!valid) {
ElMessage.warning(Object.values(errors)[0] || '表单校验未通过')
return
}
saving.value = true
try {
if (editingId.value == null) {
await createInvalidAsin({ ...form })
ElMessage.success('已新增')
} else {
const includeGroup = editingSource.value.toUpperCase() !== 'AUTO'
await updateInvalidAsin(editingId.value, { ...form }, includeGroup)
ElMessage.success('已保存')
}
dialogVisible.value = false
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function remove(row: InvalidAsinItem) {
// 对齐旧版:原生 confirm。
if (!window.confirm(invalidAsinDeleteText(row))) return
try {
await deleteInvalidAsin(row.id)
ElMessage.success('已删除')
load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
watch(isSuperAdmin, () => {
if (isSuperAdmin.value) filter.groupId = null
})
onMounted(() => {
loadGroups()
load()
})
</script>
<template>
<div class="invalid-view">
<section class="panel-box">
<div class="invalid-head">
<h3>品牌数据列表</h3>
<button class="btn" type="button" @click="openCreate">新增</button>
</div>
<div class="form-row invalid-filter-row">
<div class="form-group" style="min-width: 200px">
<label>ASIN</label>
<input v-model="filter.dataValue" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
</div>
<div class="form-group" style="min-width: 200px">
<label>品牌</label>
<input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
</div>
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<button class="btn" type="button" @click="apply">查询</button>
</div>
<div class="table-scroll invalid-asin-table-scroll">
<table>
<thead>
<tr>
<th style="width: 70px">ID</th>
<th style="width: 170px">ASIN</th>
<th style="width: 150px">品牌</th>
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
<th style="width: 110px">来源</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td class="mono-cell">{{ row.dataValue }}</td>
<td>{{ row.brand || '—' }}</td>
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
<td>
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
{{ recordSourceLabel(row.recordSource) }}
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增不符合ASIN' : '编辑不符合ASIN'" width="520px">
<el-form label-width="110px">
<el-form-item label="ASIN" required>
<el-input v-model="form.dataValue" placeholder="不符合的 ASIN" />
</el-form-item>
<el-form-item label="品牌">
<el-input v-model="form.brand" placeholder="可选" />
</el-form-item>
<el-form-item v-if="editingId == null || editingSource.toUpperCase() !== 'AUTO'" label="分组" required>
<el-select v-model="form.groupId" placeholder="选择分组" filterable :disabled="!isSuperAdmin">
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-invalid-asin-data(蓝白末层)。 */
.invalid-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.invalid-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 18px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.invalid-asin-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.mono-cell {
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
font-size: 12.5px;
}
.source-text {
padding: 2px 8px;
border-radius: 6px;
border: 1px solid #cbd9e6;
background: #fff;
color: #5b6f83;
font-size: 12px;
}
.source-text.is-auto {
background: #e7f0f8;
border-color: #c2d5e6;
color: #2f5d8b;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
</style>
@@ -1,827 +0,0 @@
<script setup lang="ts">
import { formatDateTime } from '@/utils/datetime'
/** 数据去重总数据(registry) · 像素复刻旧版 admin.html panel-dedupe-total-data(自绘:分组摘要 + 双行筛选 + 旧式表格/分页 + 编辑/导入弹窗 + 导出等待遮罩)。
* script 逻辑沿用现有 Vue 实现(查询/编辑/删除/导入轮询/导出秒表)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchDedupeTotalList,
updateDedupeTotal,
deleteDedupeTotal,
fetchDedupeTotalGroups,
type DedupeGroupOption,
} from './dedupe-total-api.ts'
import type { DedupeTotalItem } from './dedupe-total-model.ts'
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
import {
fetchDedupeDeleteImportProgress,
fetchDedupeImportProgress,
startDedupeDeleteImport,
startDedupeImport,
} from './dedupe-import-api.ts'
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
import type { DedupeImportProgress } from './dedupe-import-model.ts'
import { toExportUrl } from './dedupe-total-export.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<DedupeTotalItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const filter = reactive(createDedupeTotalFilterState())
const groups = ref<DedupeGroupOption[]>([])
/** 非超管仅有一个可访问分组时,编辑/导入弹窗分组选择锁定为该组(保留展示、不可改)。 */
const lockedGroupId = computed<number | null>(() => {
if (isSuperAdmin.value) return null
return groups.value.length === 1 ? groups.value[0].id : null
})
const jumpPage = ref('')
/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */
const pageCursor = ref<number | null>(null)
/** 本次请求实际使用的游标(load 时决定) */
let pendingCursor: number | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const editVisible = ref(false)
const editSaving = ref(false)
const editTarget = ref<DedupeTotalItem | null>(null)
const editValue = ref('')
const editGroupId = ref<number | null>(null)
type ImportMode = 'add' | 'delete'
const importVisible = ref(false)
const importMode = ref<ImportMode>('add')
const importGroupId = ref<number | null>(null)
const importFile = ref<File | null>(null)
const importRunning = ref(false)
const importProgress = ref('')
const exporting = ref(false)
/** 导出等待遮罩与秒表(对齐 admin.js showDedupeTotalDataExportWait)。 */
const exportWaitVisible = ref(false)
const exportWaitSeconds = ref(0)
let exportWaitTimer: ReturnType<typeof setInterval> | null = null
function startExportWait(): void {
exportWaitSeconds.value = 0
exportWaitVisible.value = true
exportWaitTimer = setInterval(() => {
exportWaitSeconds.value += 1
}, 1000)
}
function stopExportWait(): void {
if (exportWaitTimer) {
clearInterval(exportWaitTimer)
exportWaitTimer = null
}
exportWaitVisible.value = false
}
async function load(): Promise<void> {
loading.value = true
try {
const result = await fetchDedupeTotalList(
toDedupeListParams(filter, page.value, pageSize.value, pendingCursor),
)
rows.value = result.items
total.value = result.total
if (result.page >= 1) page.value = result.page
// 本页响应回传的游标留作"下一页"用;空页则清空(没有更多)
pageCursor.value = result.nextLastId ?? null
pendingCursor = null
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
} finally {
loading.value = false
}
}
function apply(): void {
page.value = 1
pageCursor.value = null
pendingCursor = null
void load()
}
function changePage(next: number): void {
if (next < 1 || next > totalPages.value) return
// 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变
pendingCursor = next === page.value + 1 ? pageCursor.value : null
page.value = next
void load()
}
function goJump(): void {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
pageCursor.value = null
pendingCursor = null
void load()
}
function openEdit(row: DedupeTotalItem): void {
editTarget.value = row
editValue.value = row.dataValue
editGroupId.value = row.groupId ?? lockedGroupId.value
editVisible.value = true
}
async function saveEdit(): Promise<void> {
const value = editValue.value.trim()
const target = editTarget.value
if (!target) return
if (!value) {
ElMessage.warning('请填写总数据值')
return
}
if (!editGroupId.value) {
ElMessage.warning('请选择分组')
return
}
editSaving.value = true
try {
await updateDedupeTotal(target.id, value, editGroupId.value)
editVisible.value = false
ElMessage.success('保存成功')
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
editSaving.value = false
}
}
async function removeRow(row: DedupeTotalItem): Promise<void> {
// 对齐旧版:浏览器原生 confirm 确认后删除。
if (!window.confirm(`确定删除总数据“${row.dataValue}”吗?`)) return
try {
await deleteDedupeTotal(row.id)
ElMessage.success('删除成功')
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
function resetImport(): void {
importGroupId.value = lockedGroupId.value
importFile.value = null
importProgress.value = ''
importRunning.value = false
}
function openImportAdd(): void {
importMode.value = 'add'
resetImport()
importVisible.value = true
}
function openImportDelete(): void {
importMode.value = 'delete'
resetImport()
importVisible.value = true
}
function onPickFile(event: Event): void {
const input = event.target as HTMLInputElement
importFile.value = input.files?.[0] ?? null
}
function importValid(): boolean {
if (!importGroupId.value) {
ElMessage.warning('请先选择数据权限分组')
return false
}
if (!importFile.value) {
ElMessage.warning('请选择 Excel 文件')
return false
}
if (!isAllowedImportFile(importFile.value.name)) {
ElMessage.warning('仅支持 .xlsx/.xls 文件')
return false
}
return true
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function pollImport(poll: () => Promise<DedupeImportProgress>): Promise<DedupeImportProgress> {
for (let i = 0; i < 300; i += 1) {
const progress = await poll()
importProgress.value = importOutcomeText(progress)
if (importTaskFinished(progress)) return progress
await sleep(1000)
}
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
}
async function submitImport(): Promise<void> {
if (!importValid()) return
const file = importFile.value as File
const groupId = importGroupId.value as number
if (importMode.value === 'delete') {
if (!window.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?')) return
}
const mode = importMode.value
importRunning.value = true
importProgress.value = mode === 'add' ? '正在上传并解析文件…' : '正在上传删除清单…'
try {
const started =
mode === 'add' ? await startDedupeImport(file, groupId) : await startDedupeDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchDedupeImportProgress(started) : () => fetchDedupeDeleteImportProgress(started)
const finished = await pollImport(poll)
importVisible.value = false
resetImport()
if (finished.status === 'success') {
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
await load()
} else {
ElMessage.error(importOutcomeText(finished))
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
importRunning.value = false
}
}
async function doExport(): Promise<void> {
const { startDate, endDate } = filter
if (startDate && endDate && startDate > endDate) {
ElMessage.warning('开始日期不能晚于结束日期')
return
}
if (exporting.value) return
exporting.value = true
startExportWait()
try {
// 对齐 admin.js:fetch 流式下载,带筛选参数;失败响应(JSON)解析错误提示。
const response = await window.fetch(toExportUrl(filter), { credentials: 'include' })
const contentType = response.headers.get('content-type') || ''
if (!response.ok || contentType.includes('application/json')) {
const body = (await response.json().catch(() => ({}))) as { error?: string; message?: string; msg?: string }
throw new Error(body.error || body.message || body.msg || '导出失败')
}
const blob = await response.blob()
const disposition = response.headers.get('content-disposition') || ''
const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
const filename = matched ? decodeURIComponent(matched[1]) : 'dedupe-total-data.xlsx'
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
ElMessage.success('导出完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导出失败,请重试')
} finally {
stopExportWait()
exporting.value = false
}
}
onMounted(() => {
void load()
fetchDedupeTotalGroups().then((items) => {
groups.value = items
})
})
</script>
<template>
<div class="dedupe-view">
<section class="panel-box">
<div class="dedupe-panel-head">
<h3>ASIN列表</h3>
<div class="dedupe-head-actions">
<button class="btn" type="button" @click="openImportAdd">新增导入</button>
<button class="btn" type="button" @click="openImportDelete">删除导入</button>
</div>
</div>
<div class="dedupe-filter-row">
<div class="form-group">
<label>ASIN</label>
<input v-model="filter.keyword" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
</div>
<div class="form-group">
<label>用户名模糊搜索</label>
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
</div>
<div v-if="isSuperAdmin" class="form-group">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
</el-select>
</div>
<div class="form-group">
<label>国家</label>
<select v-model="filter.country">
<option value="">全部国家</option>
<option v-for="code in ASIN_COUNTRY_CODES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="dedupe-filter-actions">
<button class="btn" type="button" @click="apply">查询</button>
</div>
</div>
<div class="dedupe-filter-date-row">
<div class="form-group">
<label>开始日期</label>
<input v-model="filter.startDate" type="date" />
</div>
<div class="form-group">
<label>结束日期</label>
<input v-model="filter.endDate" type="date" />
</div>
<div class="dedupe-filter-actions">
<button class="btn btn-secondary" type="button" :disabled="exporting" @click="doExport">
{{ exporting ? '导出中...' : '导出 XLSX' }}
</button>
</div>
</div>
<div class="table-scroll dedupe-table-scroll">
<table>
<thead>
<tr>
<th>ID</th>
<th>ASIN</th>
<th>国家</th>
<th>用户名</th>
<th v-if="isSuperAdmin">分组</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<template v-if="rows.length">
<tr v-for="row in rows" :key="row.id">
<td>{{ row.id }}</td>
<td class="mono-cell">{{ row.dataValue }}</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<td>{{ row.username || '-' }}</td>
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
</td>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无总数据</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<div v-if="editVisible" class="modal-mask" @click.self="editVisible = false">
<div class="modal">
<h3>编辑ASIN</h3>
<div class="form-group">
<label>ASIN</label>
<input v-model="editValue" type="text" placeholder="请输入总数据值" />
</div>
<div class="form-group">
<label>分组</label>
<select v-model="editGroupId" :disabled="lockedGroupId != null">
<option disabled :value="null">请选择分组</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="modal-actions">
<button class="btn" type="button" :disabled="editSaving" @click="saveEdit">保存</button>
<button class="btn btn-secondary" type="button" @click="editVisible = false">取消</button>
</div>
</div>
</div>
<div v-if="importVisible" class="modal-mask" @click.self="importVisible = false">
<div class="modal">
<h3>{{ importMode === 'add' ? '新增导入' : '删除导入' }}</h3>
<div class="form-group">
<label>{{ importMode === 'add' ? '上传 Excel 文件(读取 ASIN 列,可选 国家 列)' : '上传 Excel 文件(读取 ASIN 列)' }}</label>
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
</div>
<div class="form-group">
<label>分组</label>
<select v-model="importGroupId" :disabled="importRunning || lockedGroupId != null">
<option disabled :value="null">请选择分组</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div v-if="importProgress" class="progress-wrap">
<div class="progress-text">{{ importProgress }}</div>
</div>
<div class="modal-actions">
<button
class="btn"
:class="{ 'btn-danger': importMode === 'delete' }"
type="button"
:disabled="importRunning"
@click="submitImport"
>
{{ importMode === 'add' ? '上传并导入' : '上传并删除' }}
</button>
<button class="btn btn-secondary" type="button" :disabled="importRunning" @click="importVisible = false">取消</button>
</div>
</div>
</div>
<div v-if="exportWaitVisible" class="dedupe-export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
<div class="dedupe-export-wait">
<span class="request-spinner" aria-hidden="true"></span>
<div>
<div class="dedupe-export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
<div class="dedupe-export-wait-detail">
数据量较大请耐心等待并保持页面打开已等待 <span>{{ exportWaitSeconds }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-dedupe-total-data(蓝白末层样式)。 */
.dedupe-view {
font-family: inherit;
color: var(--c-text, #24384d);
display: flex;
flex-direction: column;
gap: 18px;
}
.form-box,
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0 0 16px;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.dedupe-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.dedupe-panel-head h3 {
margin: 0;
}
.dedupe-head-actions {
display: flex;
gap: 10px;
}
.dedupe-filter-row {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
gap: 12px;
align-items: end;
margin-bottom: 16px;
}
.dedupe-filter-row .form-group {
min-width: 0;
}
.dedupe-filter-actions {
display: flex;
gap: 10px;
align-items: center;
white-space: nowrap;
}
.dedupe-filter-date-row {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 16px;
}
.dedupe-filter-date-row .form-group {
flex: 0 0 200px;
min-width: 0;
}
.dedupe-filter-date-row .dedupe-filter-actions {
margin-left: auto;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.form-group input[type="file"] {
background: #ffffff;
padding: 6px 10px;
}
.form-group input[type="date"] {
color-scheme: light;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.dedupe-table-scroll > table {
min-width: 900px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.mono-cell {
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
font-size: 12.5px;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
text-align: right;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.modal-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: rgba(45, 66, 86, 0.36);
backdrop-filter: blur(6px);
}
.modal {
display: flex;
flex-direction: column;
width: min(560px, 100%);
max-height: 84vh;
padding: 22px 24px;
border: 1px solid #c7d7e5;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
overflow-y: auto;
}
.modal .form-group {
margin-bottom: 14px;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.progress-wrap {
margin-top: 12px;
}
.progress-text {
font-size: 12.5px;
color: #5b6f83;
line-height: 1.6;
}
.dedupe-export-wait-mask {
position: fixed;
inset: 0;
z-index: 3100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(45, 66, 86, 0.36);
backdrop-filter: blur(6px);
}
.dedupe-export-wait {
display: flex;
align-items: center;
gap: 14px;
padding: 22px 28px;
border: 1px solid #c7d7e5;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
}
.request-spinner {
width: 26px;
height: 26px;
flex: none;
border: 3px solid #d5e3ef;
border-top-color: #5f85ad;
border-radius: 50%;
animation: dedupe-spin 0.9s linear infinite;
}
@keyframes dedupe-spin {
to { transform: rotate(360deg); }
}
.dedupe-export-wait-title {
font-size: 15px;
font-weight: 600;
color: #24384d;
}
.dedupe-export-wait-detail {
margin-top: 4px;
font-size: 12.5px;
color: #5b6f83;
}
</style>
@@ -1,763 +0,0 @@
<script setup lang="ts">
/** 商品类目 · 像素复刻旧版 admin.html panel-product-categories(自绘:面板头搜索框组+新增类目 同行、原生树表格[+/−]折叠+懒加载「加载更多 已加载 x/y」、搜索态旧式分页)。
* script 逻辑沿用现有 Vue 实现(懒加载树/编辑删除/导出/弹窗)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
import { createProductCategory, deleteProductCategory, updateProductCategory } from './product-category-editor-api.ts'
import {
appendCategoryChildren,
isCategoryLoadMoreNode,
makeCategoryLoadMoreNode,
PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE,
productCategorySourceLabel,
type ProductCategoryNode,
} from './product-category-model.ts'
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
import { buildProductCategoryExportUrl } from './product-category-export.ts'
import { formatDateTime } from '@/utils/datetime'
import OldPagination from '@/components/OldPagination.vue'
const loading = ref(false)
const tree = ref<ProductCategoryNode[]>([])
const keyword = ref('')
const selected = ref<ProductCategoryNode | null>(null)
const searchLoading = ref(false)
const searchRows = ref<ProductCategoryNode[]>([])
const searchPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const searchTotal = ref(0)
const searchPage = ref(1)
const searchHasMore = ref(false)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const draft = reactive<ProductCategoryDraft>(createProductCategoryDraft())
/** 每父级(根=null)的子级分页状态:page/total/hasMore/loading,支撑「加载更多 已加载 x/y」。 */
const childrenState = new Map<string, { page: number; total: number; hasMore: boolean; loading: boolean }>()
const ROOT_KEY = '__root__'
function stateKeyOf(parentId: number | null): string {
return parentId == null ? ROOT_KEY : String(parentId)
}
function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
const out: ProductCategoryNode[] = []
for (const node of nodes || []) {
if (!isCategoryLoadMoreNode(node)) {
out.push(node)
out.push(...flatten(node.children))
}
}
return out
}
const allNodes = computed(() => flatten(tree.value))
const searching = computed(() => (keyword.value || '').trim().length > 0)
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
const searchTotalPages = computed(() => Math.max(1, Math.ceil(searchTotal.value / searchPageSize.value)))
/** 顶层翻页(服务端分页,10/20/50/100 可选;子级保留懒加载+加载更多对齐旧版)。 */
const rootPage = ref(1)
const rootPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const rootTotal = ref(0)
/** 拉取根级当前页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
async function loadTree(): Promise<void> {
loading.value = true
try {
const result = await fetchProductCategoryChildren(null, rootPage.value, rootPageSize.value)
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
childrenState.set(ROOT_KEY, state)
// 顶层当前页直接作为树根(不含合成「加载更多」节点,翻页由 OldPagination 承担)。
tree.value = result.items
rootTotal.value = result.total
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
} finally {
loading.value = false
}
}
function changeRootPage(p: number) {
rootPage.value = p
void loadTree()
}
function changeRootSize(size: number) {
rootPageSize.value = size
rootPage.value = 1
void loadTree()
}
/** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */
interface TreeRow {
node: ProductCategoryNode
depth: number
}
const expandedIds = ref<Set<number>>(new Set())
function collectRows(nodes: ProductCategoryNode[], depth: number, out: TreeRow[]): void {
for (const node of nodes || []) {
out.push({ node, depth })
if (!isCategoryLoadMoreNode(node) && expandedIds.value.has(node.id)) {
collectRows(node.children, depth + 1, out)
}
}
}
const treeRows = computed<TreeRow[]>(() => {
const out: TreeRow[] = []
collectRows(tree.value, 0, out)
return out
})
/** 展开/折叠:未加载过子级的先拉取第一页(懒加载),加载更多行点击翻页。 */
async function toggleExpand(node: ProductCategoryNode): Promise<void> {
if (isCategoryLoadMoreNode(node)) {
void loadMoreChildren(node)
return
}
if (node.childCount <= 0) return
if (expandedIds.value.has(node.id)) {
const next = new Set(expandedIds.value)
next.delete(node.id)
expandedIds.value = next
return
}
const hasRealChildren = node.children.some((child) => !isCategoryLoadMoreNode(child))
if (!hasRealChildren) {
await loadChildrenPage(node, 1, false)
}
const next = new Set(expandedIds.value)
next.add(node.id)
expandedIds.value = next
}
/** 加载某父级一页子级(append=false 重置;append=true 追加并重建「加载更多」节点)。 */
async function loadChildrenPage(parent: ProductCategoryNode | null, page: number, append: boolean): Promise<void> {
const key = stateKeyOf(parent?.id ?? null)
const state = childrenState.get(key)
if (state?.loading) return
childrenState.set(key, { page: state?.page ?? 0, total: state?.total ?? 0, hasMore: state?.hasMore ?? false, loading: true })
try {
const result = await fetchProductCategoryChildren(parent?.id ?? null, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
const loaded = append ? (state?.page ?? 0) + result.items.length : result.items.length
const nextState = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
childrenState.set(key, nextState)
if (parent) {
const current = parent.children || []
parent.children = append
? appendCategoryChildren(current, result.items, parent.id, loaded, result.total, result.hasMore)
: (result.hasMore
? [...result.items, makeCategoryLoadMoreNode(parent.id, result.items.length, result.total)]
: result.items)
} else {
const current = tree.value
tree.value = append
? appendCategoryChildren(current, result.items, null, loaded, result.total, result.hasMore)
: (result.hasMore
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
: result.items)
}
} catch (error) {
const fallback = childrenState.get(key) ?? { page: 0, total: 0, hasMore: false, loading: false }
fallback.loading = false
childrenState.set(key, fallback)
ElMessage.error(error instanceof Error ? error.message : '子类目加载失败')
}
}
/** 点击「加载更多」合成节点:翻下一页追加。 */
async function loadMoreChildren(more: ProductCategoryNode): Promise<void> {
if (!isCategoryLoadMoreNode(more)) return
const parentId = more.parentId
const parent = parentId == null ? null : allNodes.value.find((node) => node.id === parentId) ?? null
if (parentId != null && !parent) return
const state = childrenState.get(stateKeyOf(parentId))
if (state?.loading) return
await loadChildrenPage(parent, (state?.page ?? 0) + 1, true)
}
/** 「加载更多」按钮的加载态:取其父级分页状态的 loading。 */
function moreLoading(more: ProductCategoryNode): boolean {
return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false)
}
function changeSearchSize(size: number) {
searchPageSize.value = size
void runSearch(1)
}
async function runSearch(page: number): Promise<void> {
searchLoading.value = true
try {
const result = await searchProductCategories(keyword.value, page, searchPageSize.value)
searchRows.value = result.items
searchTotal.value = result.total
searchPage.value = result.page
searchHasMore.value = result.hasMore
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '类目搜索失败')
} finally {
searchLoading.value = false
}
}
function apply(): void {
selected.value = null
if (searching.value) void runSearch(1)
else void loadTree()
}
function reset(): void {
keyword.value = ''
selected.value = null
searchRows.value = []
searchHasMore.value = false
void loadTree()
}
function openCreate(parent: ProductCategoryNode | null) {
editingId.value = null
Object.assign(draft, createProductCategoryDraft())
draft.parentId = parent ? parent.id : null
dialogVisible.value = true
}
function openEdit(node: ProductCategoryNode) {
editingId.value = node.id
Object.assign(draft, createProductCategoryDraft())
draft.parentId = node.parentId
draft.name = node.name
draft.sortOrder = node.sortOrder == null ? '' : String(node.sortOrder)
draft.description = node.description
dialogVisible.value = true
}
function onNodeClick(node: ProductCategoryNode) {
if (isCategoryLoadMoreNode(node)) {
void loadMoreChildren(node)
return
}
selected.value = node
}
function canDelete(node: ProductCategoryNode | null): boolean {
if (!node || isCategoryLoadMoreNode(node)) return false
return !(node.childCount > 0 || (Array.isArray(node.children) && node.children.some((child) => !isCategoryLoadMoreNode(child))))
}
async function removeCategory(node: ProductCategoryNode): Promise<void> {
if (!canDelete(node)) {
ElMessage.warning('存在子类目,不能删除')
return
}
// 对齐旧版:原生 confirm。
if (!window.confirm(`确定删除“${node.name}”?`)) return
try {
await deleteProductCategory(node.id)
ElMessage.success('已删除')
if (selected.value?.id === node.id) selected.value = null
if (searching.value) void runSearch(1)
else void loadTree()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function submit(): Promise<void> {
const error = categoryDraftError({ ...draft })
if (error) {
ElMessage.warning(error)
return
}
saving.value = true
try {
const body = toCategorySaveRequest({ ...draft })
if (editingId.value == null) {
await createProductCategory(body)
ElMessage.success('已新增')
} else {
await updateProductCategory(editingId.value, body)
ElMessage.success('已保存')
}
dialogVisible.value = false
if (searching.value) void runSearch(1)
else void loadTree()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '保存失败')
} finally {
saving.value = false
}
}
function exportCategories(): void {
const anchor = document.createElement('a')
anchor.href = buildProductCategoryExportUrl(keyword.value)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
ElMessage.success('类目导出已开始下载')
}
onMounted(loadTree)
</script>
<template>
<div class="category-view">
<section class="panel-box">
<div class="category-head">
<h3>商品类目</h3>
<div class="category-head-tools">
<input v-model="keyword" type="text" placeholder="搜索类目名称、编码或备注" class="category-keyword" @keyup.enter="apply" />
<button class="btn btn-sm" type="button" @click="apply">搜索</button>
<button class="btn btn-sm btn-secondary" type="button" @click="reset">清空</button>
<button class="btn btn-sm btn-secondary" type="button" @click="exportCategories">导出</button>
<button v-if="selected && !searching" class="btn btn-sm" type="button" @click="openCreate(selected)">新增子类目</button>
<button class="btn" type="button" @click="openCreate(null)">新增类目</button>
</div>
</div>
<div class="table-scroll category-table-scroll">
<table>
<thead>
<tr>
<th style="width: 34%">类目</th>
<th>层级路径</th>
<th style="width: 90px">排序</th>
<th style="width: 120px">来源</th>
<th>备注</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 150px">操作</th>
</tr>
</thead>
<tbody v-if="!searching">
<template v-if="treeRows.length">
<template v-for="row in treeRows" :key="String(row.node.id)">
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
<td colspan="8">
<div class="load-more-cell">
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
<button class="btn btn-sm btn-secondary" type="button" :disabled="moreLoading(row.node)" @click="loadMoreChildren(row.node)">
{{ moreLoading(row.node) ? '加载中...' : '加载更多' }}
</button>
<span class="loaded-count">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
</div>
</td>
</tr>
<tr v-else>
<td>
<div class="tree-name-cell" @click="onNodeClick(row.node)">
<span class="tree-indent" :style="{ width: `${row.depth * 24}px` }"></span>
<button
type="button"
class="tree-node-mark"
:class="{ 'is-leaf': row.node.childCount <= 0 }"
:disabled="row.node.childCount <= 0"
:title="expandedIds.has(row.node.id) ? '折叠' : '展开'"
@click.stop="toggleExpand(row.node)"
>
{{ row.node.childCount > 0 ? (expandedIds.has(row.node.id) ? '' : '+') : '' }}
</button>
<strong class="node-name">{{ row.node.name }}</strong>
<span v-if="selected?.id === row.node.id" class="selected-flag">已选</span>
</div>
</td>
<td>
<span class="category-path-value" :title="row.node.path || row.node.name">{{ row.node.path || row.node.name }}</span>
</td>
<td>{{ row.node.sortOrder ?? 0 }}</td>
<td>
<span class="category-tag" :class="{ 'is-custom': !row.node.isBuiltin }">{{ productCategorySourceLabel(row.node) }}</span>
</td>
<td>
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.node.createdAt) }}</td>
<td>{{ formatDateTime(row.node.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
<button
class="btn btn-sm btn-danger"
type="button"
:disabled="!canDelete(row.node)"
:title="!canDelete(row.node) ? '存在子类目,不能删除' : undefined"
@click="removeCategory(row.node)"
>
删除
</button>
</td>
</tr>
</template>
</template>
<tr v-else-if="loading">
<td colspan="8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td colspan="8" class="empty-tip">暂无商品类目</td>
</tr>
</tbody>
<tbody v-else>
<template v-if="searchRows.length">
<tr v-for="row in searchRows" :key="row.id">
<td><strong class="node-name">{{ row.name }}</strong></td>
<td><span class="category-path-value" :title="row.path || row.name">{{ row.path || row.name }}</span></td>
<td>{{ row.sortOrder ?? '—' }}</td>
<td>
<span class="category-tag" :class="{ 'is-custom': !row.isBuiltin }">{{ productCategorySourceLabel(row) }}</span>
</td>
<td>
<span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
<span v-else class="dim"></span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>{{ formatDateTime(row.updatedAt) }}</td>
<td class="ops-cell">
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
<button
class="btn btn-sm btn-danger"
type="button"
:disabled="!canDelete(row)"
:title="!canDelete(row) ? '存在子类目,不能删除' : undefined"
@click="removeCategory(row)"
>
删除
</button>
</td>
</tr>
</template>
<tr v-else-if="searchLoading">
<td colspan="8" class="empty-tip">搜索中...</td>
</tr>
<tr v-else>
<td colspan="8" class="empty-tip">暂无搜索结果</td>
</tr>
</tbody>
</table>
</div>
<OldPagination v-if="searching" :total="searchTotal" :page="searchPage" :page-size="searchPageSize" @change="runSearch" @size-change="changeSearchSize" />
<OldPagination v-else-if="rootTotal > 0" :total="rootTotal" :page="rootPage" :page-size="rootPageSize" @change="changeRootPage" @size-change="changeRootSize" />
</section>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
<el-form label-width="110px">
<el-form-item label="父级类目">
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable>
<el-option
v-for="node in allNodes"
:key="node.id"
:label="node.path || node.name"
:value="node.id"
/>
</el-select>
</el-form-item>
<el-form-item label="类目名称" required>
<el-input v-model="draft.name" placeholder="如:洁面" />
</el-form-item>
<el-form-item label="排序">
<el-input v-model="draft.sortOrder" placeholder="如 10" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="draft.description" type="textarea" :rows="3" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
<el-button type="primary" :loading="saving" @click="submit">{{ editingId == null ? '保存' : '保存类目' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-product-categories(蓝白末层)。 */
.category-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.category-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.category-head-tools {
display: flex;
align-items: center;
gap: 8px;
}
.category-keyword {
width: 240px;
min-height: 36px;
padding: 6px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.category-keyword:hover {
border-color: #9fb7cd;
}
.category-keyword:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.category-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.tree-name-cell {
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
cursor: pointer;
}
.tree-indent {
flex: 0 0 auto;
display: inline-block;
}
.tree-node-mark {
width: 20px;
height: 20px;
border: 0;
border-radius: 5px;
background: var(--admin-primary-soft, #e7f0f8);
color: var(--admin-primary-strong, #2f5d8b);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
cursor: pointer;
padding: 0;
line-height: 1;
flex: none;
transition: background 0.12s ease, color 0.12s ease;
}
.tree-node-mark:not(.is-leaf):hover {
background: var(--admin-primary-strong, #2f5d8b);
color: #ffffff;
}
.tree-node-mark.is-leaf {
background: transparent;
cursor: default;
}
.node-name {
font-weight: 600;
color: #24384d;
white-space: nowrap;
}
.selected-flag {
padding: 1px 8px;
border-radius: 999px;
background: #e7f0f8;
color: #2f5d8b;
font-size: 11px;
}
.category-tag {
padding: 2px 8px;
border-radius: 6px;
border: 1px solid #c2d5e6;
background: #e7f0f8;
color: #2f5d8b;
font-size: 12px;
}
.category-tag.is-custom {
border-color: #bcd9c6;
background: #eef7f0;
color: #3d7158;
}
.category-path-value {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-desc {
color: #5b6f83;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
display: inline-block;
vertical-align: middle;
}
.dim {
color: #8293a5;
}
.ops-cell {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.load-more-row td {
background: #fbfdfe;
}
.load-more-cell {
display: flex;
align-items: center;
gap: 8px;
}
.loaded-count {
color: #8293a5;
font-size: 12.5px;
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
</style>
@@ -1,786 +0,0 @@
<script setup lang="ts">
/** 查询 ASIN · 像素复刻旧版 admin.html panel-query-asin(自绘:面板头操作组、筛选+导出 XLSX、原生 rowspan 表格+可复制 ASIN、旧式分页)。
* script 逻辑沿用现有 Vue 实现(新增级联/配置抽屉/导入轮询/导出)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
import { formatDateTime } from '@/utils/datetime'
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
import { asinCountryLabel } from './asin-country.ts'
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
import { toQueryAsinExportUrl } from './query-asin-export.ts'
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<QueryAsinItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
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 jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
function asinOf(row: QueryAsinItem, country: string): string {
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
return map[country] || ''
}
// ---- 行内配置抽屉 ----
const drawerVisible = ref(false)
const drawerItem = ref<QueryAsinItem | null>(null)
const drawerSaving = ref(false)
const draftAsin = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
function openConfig(row: QueryAsinItem): void {
drawerItem.value = row
for (const country of QUERY_ASIN_COUNTRIES) {
draftAsin[country] = asinOf(row, country)
}
drawerVisible.value = true
}
function normalizeAsinInput(country: string): void {
draftAsin[country] = draftAsin[country].trim().toUpperCase()
}
async function removeShopAsin(item: QueryAsinItem) {
// 删除该店铺全部站点的 ASIN 配置(逐国调用现有端点)。
if (!window.confirm(`确定删除店铺“${item.shopName}”的全部站点 ASIN 配置吗?`)) return
try {
for (const country of QUERY_ASIN_COUNTRIES) {
if (asinOf(item, country)) await deleteQueryAsinCountryAsin(item.id, country)
}
ElMessage.success('已删除')
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function saveConfig(): Promise<void> {
const item = drawerItem.value
if (!item) return
drawerSaving.value = true
const errors: string[] = []
try {
for (const country of QUERY_ASIN_COUNTRIES) {
const oldAsin = asinOf(item, country)
const next = (draftAsin[country] || '').trim().toUpperCase()
if (next === oldAsin) continue
try {
if (!next) {
if (oldAsin) await deleteQueryAsinCountryAsin(item.id, country)
} else {
await updateQueryAsinCountryAsin(item.id, country, next)
}
} catch (error) {
errors.push(`${asinCountryLabel(country)}${error instanceof Error ? error.message : '保存失败'}`)
}
}
if (errors.length) {
ElMessage.error(errors.join(''))
} else {
ElMessage.success('保存成功')
drawerVisible.value = false
drawerItem.value = null
await load()
}
} finally {
drawerSaving.value = false
}
}
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateQueryAsin/btnCreateQueryAsin ----
const createVisible = ref(false)
const createGroupId = ref<number | null>(null)
const createShopName = ref('')
const createCountry = ref('')
const createAsin = ref('')
const createMsg = ref('')
const createMsgOk = ref(false)
const shopNames = ref<string[]>([])
const shopNamesLoading = ref(false)
const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void {
createGroupId.value = lockedGroupId.value
createShopName.value = ''
createCountry.value = ''
createAsin.value = ''
createMsg.value = ''
createMsgOk.value = false
shopNames.value = []
createVisible.value = true
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
if (createGroupId.value != null) void onCreateGroupChange()
}
async function onCreateGroupChange(): Promise<void> {
createShopName.value = ''
if (createGroupId.value == null) {
shopNames.value = []
return
}
shopNamesLoading.value = true
try {
shopNames.value = await fetchShopNamesByGroup(createGroupId.value)
} catch (error) {
shopNames.value = []
ElMessage.error(error instanceof Error ? error.message : '店铺列表加载失败')
} finally {
shopNamesLoading.value = false
}
}
function onCreateAsinInput(): void {
createAsin.value = createAsin.value.toUpperCase()
}
async function submitCreate(): Promise<void> {
createMsg.value = ''
createMsgOk.value = false
const groupId = createGroupId.value
const shopName = createShopName.value.trim()
const country = (createCountry.value || '').trim()
const asin = createAsin.value.trim().toUpperCase()
if (!groupId || !shopName || !country || !asin) {
createMsg.value = '请完整填写分组、店铺名、国家和 ASIN'
return
}
creating.value = true
try {
const message = await createQueryAsin({
groupId,
shopName,
countries: [country],
asin,
asinMappings: { [country]: asin },
})
// 对齐 admin:保留分组/店铺/国家,清空 ASIN 方便连续录入,成功后刷新列表。
createMsg.value = message
createMsgOk.value = true
createAsin.value = ''
createAsinInputRef.value?.focus()
await load()
} catch (error) {
createMsg.value = error instanceof Error ? error.message : '保存失败'
} finally {
creating.value = false
}
}
// ---- 导入添加 / 删除导入 ----
type ImportMode = 'add' | 'delete'
const importVisible = ref(false)
const importMode = ref<ImportMode>('add')
const importGroupId = ref<number | null>(null)
const importFile = ref<File | null>(null)
const importRunning = ref(false)
const importProgress = ref('')
function resetImport(): void {
importGroupId.value = lockedGroupId.value
importFile.value = null
importProgress.value = ''
importRunning.value = false
}
function openImportAdd(): void {
importMode.value = 'add'
resetImport()
importVisible.value = true
}
function openImportDelete(): void {
importMode.value = 'delete'
resetImport()
importVisible.value = true
}
function onPickImportFile(event: Event): void {
const input = event.target as HTMLInputElement
importFile.value = input.files?.[0] ?? null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function submitImport(): Promise<void> {
const file = importFile.value
if (!file) {
ElMessage.warning('请选择 Excel 文件')
return
}
if (!isAllowedExcelImportFile(file.name)) {
ElMessage.warning('仅支持 .xlsx/.xls 文件')
return
}
if (importMode.value === 'delete') {
if (!window.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) return
}
const mode = importMode.value
const groupId = importGroupId.value ?? undefined
importRunning.value = true
importProgress.value = '正在上传并解析文件…'
try {
const started = mode === 'add' ? await startQueryAsinImport(file, groupId) : await startQueryAsinDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchQueryAsinImportProgress(started) : () => fetchQueryAsinDeleteImportProgress(started)
for (let i = 0; i < 300; i += 1) {
const progress = await poll()
if (progress.status === 'success') {
importVisible.value = false
resetImport()
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
await load()
return
}
if (progress.status === 'failed') {
importVisible.value = false
resetImport()
ElMessage.error((progress as { errorMessage?: string }).errorMessage || (mode === 'add' ? '导入失败' : '删除导入失败'))
return
}
importProgress.value = progress.status === 'pending' ? '等待导入任务开始…' : '导入处理中…'
await sleep(1200)
}
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
importRunning.value = false
}
}
// ---- 导出 ----
function doExport(): void {
// 带当前筛选(分组/店铺名/ASIN/国家)导出,范围与列表筛选一致(此前不带参数导致导出恒为全量)。
const anchor = document.createElement('a')
anchor.href = toQueryAsinExportUrl(filter)
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
ElMessage.success('导出文件已开始下载')
}
async function loadGroups() {
try {
groups.value = await fetchShopManageGroups()
} catch {
groups.value = []
}
}
async function load() {
loading.value = true
try {
const result = await fetchQueryAsinList({
page: page.value,
pageSize: pageSize.value,
groupId: filter.groupId,
shopName: filter.shopName.trim() || undefined,
asin: filter.asin.trim() || undefined,
country: filter.country || undefined,
})
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
void load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
void load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
onMounted(() => {
void loadGroups()
void load()
})
</script>
<template>
<div class="query-view">
<section class="panel-box">
<div class="query-head">
<h3>店铺列表</h3>
<div class="query-head-actions">
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入添加</button>
<button class="btn" type="button" @click="openImportDelete">导入删除</button>
</div>
</div>
<div class="form-row query-filter-row">
<div v-if="isSuperAdmin" class="form-group">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<div class="form-group">
<label>店铺名</label>
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div>
<div class="form-group">
<label>国家</label>
<select v-model="filter.country">
<option value="">全部国家</option>
<option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="form-group">
<label>ASIN</label>
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
</div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
</div>
<div class="table-scroll query-asin-table-scroll">
<table>
<thead>
<tr>
<th style="width: 6%">序号</th>
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
<tbody>
<template v-if="displayRows.length">
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '' }}</td>
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
</template>
<td>
<template v-if="row.asin">
<CopyText :text="row.asin" class="asin-cell" />
</template>
<span v-else class="asin-empty">-</span>
</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
</td>
</template>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组">
<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-select>
</el-form-item>
<el-form-item label="店铺名">
<el-select
v-model="createShopName"
:disabled="createGroupId == null"
:loading="shopNamesLoading"
:placeholder="createGroupId == null ? '请先选择分组' : '请选择店铺'"
filterable
style="width: 100%"
>
<el-option v-for="name in shopNames" :key="name" :label="name" :value="name" />
</el-select>
</el-form-item>
<el-form-item label="国家">
<el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</el-form-item>
<el-form-item label="ASIN">
<el-input ref="createAsinInputRef" v-model="createAsin" placeholder="请输入 ASIN" @input="onCreateAsinInput" />
</el-form-item>
<el-alert v-if="createMsg" :title="createMsg" :type="createMsgOk ? 'success' : 'error'" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" :title="drawerItem ? `${drawerItem.shopName} · ASIN 配置` : 'ASIN 配置'" size="min(520px, 92%)">
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与记录</p>
<el-form label-position="top">
<el-form-item v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="`${asinCountryLabel(code)} ASIN`">
<el-input
v-model="draftAsin[code]"
:placeholder="asinOf(drawerItem as QueryAsinItem, code) ? '留空删除该站点 ASIN' : '输入 ASIN'"
@input="normalizeAsinInput(code)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="drawerSaving" @click="saveConfig">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
<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-select>
</el-form-item>
<el-form-item label="Excel 文件">
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickImportFile" />
<p class="import-tip"> Excel 中的分组店铺名与国家 ASIN 批量{{ importMode === 'add' ? '添加' : '删除' }}仅支持 .xlsx/.xls</p>
</el-form-item>
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importRunning" @click="submitImport">
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-query-asin(蓝白末层)。 */
.query-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.query-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.query-head-actions {
display: flex;
gap: 10px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 12px;
margin-bottom: 16px;
}
.query-filter-row > .form-group {
flex: 1 1 140px;
min-width: 0;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.query-asin-table-scroll > table {
min-width: 860px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.table-scroll td {
vertical-align: top;
}
.asin-cell {
display: block;
max-width: 100%;
margin: -3px 0 -3px -7px;
padding: 3px 7px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: inherit;
font-size: 13.5px;
font-variant-numeric: tabular-nums;
line-height: 1.5;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.asin-cell:hover {
border-color: #c7d7e5;
background: #eef4fa;
color: #2f5d8b;
}
.asin-empty {
color: #8b9aaa;
}
.asin-col-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
white-space: nowrap;
background: #ffffff;
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.drawer-tip {
color: #5b6f83;
font-size: 12px;
margin: 0 0 12px;
}
.import-tip {
margin: 6px 0 0;
color: #5b6f83;
font-size: 12px;
}
</style>
@@ -1,873 +0,0 @@
<script setup lang="ts">
/** 最低价 ASIN(跳过跟价)· 像素复刻旧版 admin.html panel-skip-price-asin(自绘:面板头操作组、筛选(分组/店铺/国家/ASIN/最低价≥≤)+导出 XLSX、原生 rowspan 表格、旧式分页)。
* script 逻辑沿用现有 Vue 实现(新增级联+最低价、配置抽屉双列、导入轮询、导出)。 */
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CopyText from '@/components/CopyText.vue'
import { formatDateTime } from '@/utils/datetime'
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
import { asinCountryLabel } from './asin-country.ts'
import { createSkipPriceFilterState, toSkipPriceParams, type SkipPriceFilterState } from './skip-price-filter.ts'
import { deleteSkipPriceAsin, deleteSkipPriceCountry, updateSkipPriceCountry } from './skip-price-detail-api.ts'
import { fetchSkipPriceDeleteImportProgress, fetchSkipPriceImportProgress, startSkipPriceDeleteImport, startSkipPriceImport } from './skip-price-import-api.ts'
import { isAllowedExcelImportFile } from './import-progress-model.ts'
import { fetchShopNamesByGroup } from './query-asin-api.ts'
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
import type { ShopGroupOption } from '../shop/shop-dto.ts'
import OldPagination from '@/components/OldPagination.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
const session = useAdminSessionStore()
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
const isSuperAdmin = computed(() => session.isSuperAdmin)
const loading = ref(false)
const rows = ref<SkipPriceItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
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 jumpPage = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
const ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
const PRICE_MAP: Record<string, keyof SkipPriceItem> = { DE: 'minimumPriceDe', UK: 'minimumPriceUk', FR: 'minimumPriceFr', IT: 'minimumPriceIt', ES: 'minimumPriceEs' }
function asinOf(row: SkipPriceItem, code: string): string {
return (row[ASIN_MAP[code]] as string) || ''
}
function priceOf(row: SkipPriceItem, code: string): number | null {
const value = row[PRICE_MAP[code]] as number | null
return typeof value === 'number' ? value : null
}
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin ----
const createVisible = ref(false)
const createGroupId = ref<number | null>(null)
const createShopName = ref('')
const createCountry = ref('')
const createAsin = ref('')
const createMinimumPrice = ref('')
const createMsg = ref('')
const createMsgOk = ref(false)
const shopNames = ref<string[]>([])
const shopNamesLoading = ref(false)
const creating = ref(false)
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
function openCreate(): void {
createGroupId.value = lockedGroupId.value
createShopName.value = ''
createCountry.value = ''
createAsin.value = ''
createMinimumPrice.value = ''
createMsg.value = ''
createMsgOk.value = false
shopNames.value = []
createVisible.value = true
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
if (createGroupId.value != null) void onCreateGroupChange()
}
async function onCreateGroupChange(): Promise<void> {
createShopName.value = ''
if (createGroupId.value == null) {
shopNames.value = []
return
}
shopNamesLoading.value = true
try {
shopNames.value = await fetchShopNamesByGroup(createGroupId.value)
} catch (error) {
shopNames.value = []
ElMessage.error(error instanceof Error ? error.message : '店铺列表加载失败')
} finally {
shopNamesLoading.value = false
}
}
function onCreateAsinInput(): void {
createAsin.value = createAsin.value.toUpperCase()
}
async function submitCreate(): Promise<void> {
createMsg.value = ''
createMsgOk.value = false
const groupId = createGroupId.value
const shopName = createShopName.value.trim()
const country = (createCountry.value || '').trim()
const asin = createAsin.value.trim().toUpperCase()
const minimumPrice = createMinimumPrice.value.trim()
if (!groupId || !shopName || !country || !asin) {
createMsg.value = '请完整填写分组、店铺名、国家和 ASIN'
return
}
if (minimumPrice) {
const minimumPriceNumber = Number(minimumPrice)
if (!Number.isFinite(minimumPriceNumber) || minimumPriceNumber < 0) {
createMsg.value = '最低价格式不正确'
return
}
}
creating.value = true
try {
const message = await createSkipPriceAsin({
groupId,
shopName,
countries: [country],
asin,
asinMappings: { [country]: asin },
minimumPriceMappings: minimumPrice ? { [country]: Number(minimumPrice) } : undefined,
})
// 对齐 admin:保留分组/店铺/国家,清空 ASIN/最低价方便连续录入,成功后刷新列表。
createMsg.value = message
createMsgOk.value = true
createAsin.value = ''
createMinimumPrice.value = ''
createAsinInputRef.value?.focus()
await load()
} catch (error) {
createMsg.value = error instanceof Error ? error.message : '保存失败'
} finally {
creating.value = false
}
}
// ---- 行内配置抽屉 ----
const drawerVisible = ref(false)
const drawerItem = ref<SkipPriceItem | null>(null)
const drawerSaving = ref(false)
const draftAsin = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
const draftPrice = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
function openConfig(row: SkipPriceItem): void {
drawerItem.value = row
for (const code of COUNTRIES) {
draftAsin[code] = asinOf(row, code)
const price = priceOf(row, code)
draftPrice[code] = price == null ? '' : String(price)
}
drawerVisible.value = true
}
function normalizeAsin(code: string): void {
draftAsin[code] = draftAsin[code].trim().toUpperCase()
}
function parsePriceText(raw: string): number | null {
const value = (raw || '').trim()
if (!value) return null
if (!/^\d+(\.\d{1,2})?$/.test(value)) return undefined as unknown as number | null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
async function saveConfig(): Promise<void> {
const item = drawerItem.value
if (!item) return
drawerSaving.value = true
const errors: string[] = []
try {
for (const code of COUNTRIES) {
const asin = draftAsin[code].trim().toUpperCase()
const price = parsePriceText(draftPrice[code])
if (price === undefined) {
errors.push(`${asinCountryLabel(code)}:最低价格式不正确`)
continue
}
if (draftPrice[code].trim() !== '' && !asin) {
errors.push(`${asinCountryLabel(code)}:填写最低价时必须填写 ASIN`)
continue
}
const oldAsin = asinOf(item, code)
const oldPrice = priceOf(item, code)
if (asin === oldAsin && price === oldPrice) continue
try {
if (!asin) {
if (oldAsin) await deleteSkipPriceCountry(item.id, code)
} else {
await updateSkipPriceCountry(item.id, code, asin, price)
}
} catch (error) {
errors.push(`${asinCountryLabel(code)}${error instanceof Error ? error.message : '保存失败'}`)
}
}
if (errors.length) {
ElMessage.error(errors.join(''))
} else {
ElMessage.success('保存成功')
drawerVisible.value = false
drawerItem.value = null
await load()
}
} finally {
drawerSaving.value = false
}
}
// ---- 整条删除 ----
async function removeRow(item: SkipPriceItem): Promise<void> {
try {
await ElMessageBox.confirm(
`确定删除「${item.shopName}${item.groupName ? `${item.groupName}` : ''}全部站点的最低价 ASIN 配置吗?`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await deleteSkipPriceAsin(item.id)
ElMessage.success('删除成功')
// 删除后当前页若已空且非首页,回退一页避免展示空页。
if (rows.value.length === 1 && page.value > 1) {
page.value -= 1
}
await load()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
// ---- 导入添加 / 删除导入(group_id 必填) ----
type ImportMode = 'add' | 'delete'
const importVisible = ref(false)
const importMode = ref<ImportMode>('add')
const importGroupId = ref<number | null>(null)
const importFile = ref<File | null>(null)
const importRunning = ref(false)
const importProgress = ref('')
function resetImport(): void {
importGroupId.value = lockedGroupId.value
importFile.value = null
importProgress.value = ''
importRunning.value = false
}
function openImportAdd(): void {
importMode.value = 'add'
resetImport()
importVisible.value = true
}
function openImportDelete(): void {
importMode.value = 'delete'
resetImport()
importVisible.value = true
}
function onPickImportFile(event: Event): void {
const input = event.target as HTMLInputElement
importFile.value = input.files?.[0] ?? null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function submitImport(): Promise<void> {
if (!importGroupId.value) {
ElMessage.warning('请先选择分组后再导入')
return
}
const file = importFile.value
if (!file) {
ElMessage.warning('请选择 Excel 文件')
return
}
if (!isAllowedExcelImportFile(file.name)) {
ElMessage.warning('仅支持 .xlsx/.xls 文件')
return
}
if (importMode.value === 'delete') {
if (!window.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) return
}
const mode = importMode.value
const groupId = importGroupId.value as number
importRunning.value = true
importProgress.value = '正在上传并解析文件…'
try {
const started = mode === 'add' ? await startSkipPriceImport(file, groupId) : await startSkipPriceDeleteImport(file, groupId)
const poll = mode === 'add' ? () => fetchSkipPriceImportProgress(started) : () => fetchSkipPriceDeleteImportProgress(started)
for (let i = 0; i < 300; i += 1) {
const progress = await poll()
if (progress.status === 'success') {
importVisible.value = false
resetImport()
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
await load()
return
}
if (progress.status === 'failed') {
importVisible.value = false
resetImport()
ElMessage.error(progress.errorMessage || (mode === 'add' ? '导入失败' : '删除导入失败'))
return
}
importProgress.value = progress.status === 'pending' ? '等待导入任务开始…' : '导入处理中…'
await sleep(1200)
}
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '导入失败')
} finally {
importRunning.value = false
}
}
function doExport(): void {
const anchor = document.createElement('a')
anchor.href = '/api/admin/skip-price-asins/export'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
ElMessage.success('导出文件已开始下载')
}
async function loadGroups() {
try {
groups.value = await fetchShopManageGroups()
} catch {
groups.value = []
}
}
async function load() {
loading.value = true
try {
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize.value))
rows.value = result.items
total.value = result.total
page.value = result.page
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
} finally {
loading.value = false
}
}
function apply() {
page.value = 1
void load()
}
function changePage(next: number) {
if (next < 1 || next > totalPages.value) return
page.value = next
void load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function goJump() {
const n = Number.parseInt(jumpPage.value, 10)
if (Number.isNaN(n)) {
ElMessage.warning('请输入页码')
return
}
changePage(Math.min(Math.max(n, 1), totalPages.value))
}
onMounted(() => {
void loadGroups()
void load()
})
</script>
<template>
<div class="skip-view">
<section class="panel-box">
<div class="skip-head">
<h3>店铺列表</h3>
<div class="skip-head-actions">
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入文件新增</button>
<button class="btn" type="button" @click="openImportDelete">导入文件删除</button>
</div>
</div>
<div class="form-row skip-filter-row">
<div v-if="isSuperAdmin" class="form-group">
<label>分组</label>
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
</el-select>
</div>
<div class="form-group">
<label>店铺名</label>
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
</div>
<div class="form-group">
<label>国家</label>
<select v-model="filter.country">
<option value="">全部国家</option>
<option v-for="code in COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
</select>
</div>
<div class="form-group">
<label>ASIN</label>
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
</div>
<div class="form-group price-range">
<label>最低价范围</label>
<div class="price-range-row">
<input v-model="filter.minimumPriceFrom" type="number" min="0" step="0.01" placeholder="最低价 ≥" @keyup.enter="apply" />
<span class="range-sep">-</span>
<input v-model="filter.minimumPriceTo" type="number" min="0" step="0.01" placeholder="最高价 ≤" @keyup.enter="apply" />
</div>
</div>
<button class="btn" type="button" @click="apply">查询</button>
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
</div>
<div class="table-scroll skip-price-asin-table-scroll">
<table>
<thead>
<tr>
<th style="width: 6%">序号</th>
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
<th style="width: 15%">店铺名</th>
<th style="width: 24%">ASIN</th>
<th style="width: 12%">国家</th>
<th style="width: 10%">最低价</th>
<th style="width: 170px">创建时间</th>
<th style="width: 170px">更新时间</th>
<th style="width: 9%">操作</th>
</tr>
</thead>
<tbody>
<template v-if="displayRows.length">
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '' }}</td>
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
</template>
<td>
<template v-if="row.asin">
<CopyText :text="row.asin" class="asin-cell" />
</template>
<span v-else class="asin-empty">-</span>
</td>
<td>{{ asinCountryLabel(row.country || '') }}</td>
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
<template v-if="row.isFirst">
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.createdAt) }}</td>
<td :rowspan="row.rowspan">{{ formatDateTime(row.item.updatedAt) }}</td>
<td :rowspan="row.rowspan" class="asin-col-actions">
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
</td>
</template>
</tr>
</template>
<tr v-else-if="loading">
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">加载中...</td>
</tr>
<tr v-else>
<td :colspan="isSuperAdmin ? 9 : 8" class="empty-tip">暂无数据</td>
</tr>
</tbody>
</table>
</div>
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
</section>
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
<el-form label-position="top" @submit.prevent>
<el-form-item label="分组">
<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-select>
</el-form-item>
<el-form-item label="店铺名">
<el-select
v-model="createShopName"
:disabled="createGroupId == null"
:loading="shopNamesLoading"
:placeholder="createGroupId == null ? '请先选择分组' : '请选择店铺'"
filterable
style="width: 100%"
>
<el-option v-for="name in shopNames" :key="name" :label="name" :value="name" />
</el-select>
</el-form-item>
<el-form-item label="国家">
<el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
</el-select>
</el-form-item>
<el-form-item label="ASIN">
<el-input ref="createAsinInputRef" v-model="createAsin" placeholder="请输入 ASIN" @input="onCreateAsinInput" />
</el-form-item>
<el-form-item label="最低价">
<el-input v-model="createMinimumPrice" type="number" min="0" step="0.01" placeholder="选填" />
</el-form-item>
<el-alert v-if="createMsg" :title="createMsg" :type="createMsgOk ? 'success' : 'error'" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="submitCreate">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="drawerVisible" :title="drawerItem ? `${drawerItem.shopName} · 最低价 ASIN 配置` : '最低价 ASIN 配置'" size="min(560px, 94%)">
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与最低价</p>
<el-form label-position="top">
<el-form-item v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)">
<div class="country-cell">
<el-input
v-model="draftAsin[code]"
placeholder="ASIN(留空删除该站点)"
@input="normalizeAsin(code)"
/>
<el-input v-model="draftPrice[code]" placeholder="最低价(填最低价须填 ASIN" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false">取消</el-button>
<el-button type="primary" :loading="drawerSaving" @click="saveConfig">保存</el-button>
</template>
</el-drawer>
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
<el-form label-position="top">
<el-form-item label="分组(必选)">
<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-select>
</el-form-item>
<el-form-item label="Excel 文件">
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickImportFile" />
<p class="import-tip"> Excel 中的分组店铺名国家 ASIN{{ importMode === 'add' ? '与最低价' : '' }}批量{{ importMode === 'add' ? '添加' : '删除' }}仅支持 .xlsx/.xls</p>
</el-form-item>
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
</el-form>
<template #footer>
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importRunning" @click="submitImport">
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
/* 像素复刻旧版 admin.html panel-skip-price-asin(蓝白末层)。 */
.skip-view {
font-family: inherit;
color: #24384d;
}
.panel-box {
width: 100%;
min-width: 0;
padding: 20px 22px 24px;
border: 1px solid #d8e3ee;
border-radius: 14px;
background: linear-gradient(145deg, #ffffff, #f9fbfd);
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
}
h3 {
margin: 0;
font-size: 15px;
font-weight: 650;
color: #24384d;
letter-spacing: 0.2px;
}
.skip-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.skip-head-actions {
display: flex;
gap: 10px;
}
.form-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 14px 12px;
margin-bottom: 16px;
}
.skip-filter-row > .form-group {
flex: 1 1 130px;
min-width: 0;
}
.skip-filter-row .form-group.price-range {
flex: 1 1 220px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 0;
}
.form-group label {
color: #5b6f83;
font-size: 12.5px;
font-weight: 600;
}
.form-group input,
.form-group select {
min-width: 0;
min-height: 42px;
padding: 8px 12px;
border: 1px solid #cbd9e6;
border-radius: 9px;
background: #f8fbfd;
color: #24384d;
font-size: 13.5px;
font-family: inherit;
color-scheme: light;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.form-group input:hover,
.form-group select:hover {
border-color: #9fb7cd;
}
.form-group input:focus,
.form-group select:focus {
background: #ffffff;
border-color: #5f85ad;
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
}
.price-range-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.price-range-row input {
flex: 1;
min-width: 0;
}
.price-range-row .range-sep {
color: #8293a5;
flex: none;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 9px 18px;
border: 1px solid #4f78a5;
border-radius: 9px;
background: linear-gradient(135deg, #5f85ad, #4f78a5);
color: #ffffff;
font-family: inherit;
font-size: 13.5px;
cursor: pointer;
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
}
.btn:hover:not(:disabled) {
background: linear-gradient(135deg, #7094ba, #5d83ac);
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.btn-secondary {
background: #ffffff;
color: #5b6f83;
border-color: #c7d7e5;
}
.btn-secondary:hover:not(:disabled) {
color: #2f5d8b;
border-color: #95b1cb;
background: #edf5fb;
}
.btn-danger {
background: linear-gradient(135deg, #c06d77, #b35f6a);
border-color: #b35f6a;
}
.btn-danger:hover:not(:disabled) {
background: linear-gradient(135deg, #cb7c84, #b96570);
}
.btn-sm {
min-height: 36px;
padding: 7px 12px;
}
.table-scroll {
width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #dbe5ee;
border-radius: 10px;
background: #ffffff;
}
.table-scroll table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.skip-price-asin-table-scroll > table {
min-width: 900px;
}
.table-scroll th,
.table-scroll td {
padding: 10px 12px;
text-align: left;
font-size: 13.5px;
line-height: 1.5;
border-bottom: 1px solid #e0e8ef;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-scroll th {
background: #edf4fa;
color: #4e6479;
border-bottom-color: #d5e1eb;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.4px;
}
.table-scroll tbody tr:hover td {
background: #f1f7fb;
}
.table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.table-scroll td {
vertical-align: top;
}
.asin-cell {
display: block;
max-width: 100%;
margin: -3px 0 -3px -7px;
padding: 3px 7px;
border: 1px solid transparent;
border-radius: 9px;
background: none;
color: inherit;
font-size: 13.5px;
font-variant-numeric: tabular-nums;
line-height: 1.5;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.asin-cell:hover {
border-color: #c7d7e5;
background: #eef4fa;
color: #2f5d8b;
}
.asin-empty {
color: #8b9aaa;
}
.price-cell {
text-align: right;
font-variant-numeric: tabular-nums;
}
.asin-col-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
white-space: nowrap;
background: #ffffff;
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
}
.empty-tip {
padding: 44px 24px;
text-align: center;
color: #8293a5;
font-size: 13.5px;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 18px;
color: #5b6f83;
font-size: 13px;
}
.pagination button {
min-height: 30px;
padding: 4px 12px;
border: 1px solid #c7d7e5;
border-radius: 8px;
background: #ffffff;
color: #5b6f83;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.pagination button:hover:not(:disabled) {
background: #edf5fb;
border-color: #95b1cb;
color: #2f5d8b;
}
.pagination button:disabled {
background: #eef3f7;
color: #9baaba;
cursor: not-allowed;
}
.page-total {
margin-right: 4px;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
}
.page-jump input {
width: 56px;
min-height: 30px;
padding: 4px 8px;
border: 1px solid #cbd9e6;
border-radius: 8px;
background: #f8fbfd;
color: #24384d;
font-size: 13px;
font-family: inherit;
}
.drawer-tip {
color: #5b6f83;
font-size: 12px;
margin: 0 0 12px;
}
.country-cell {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
width: 100%;
}
.import-tip {
margin: 6px 0 0;
color: #5b6f83;
font-size: 12px;
}
</style>
@@ -1,23 +0,0 @@
/** ASIN 数据中心共享国家字典(任务 75):支持国家码与中文标签,纯逻辑。 */
export const ASIN_COUNTRY_CODES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
export type AsinCountryCode = (typeof ASIN_COUNTRY_CODES)[number]
export const ASIN_COUNTRY_LABELS: Record<AsinCountryCode, string> = {
DE: '德国',
UK: '英国',
FR: '法国',
IT: '意大利',
ES: '西班牙',
}
/** 国家码 → 中文标签;不属白名单原样返回。 */
export function asinCountryLabel(country: string): string {
return ASIN_COUNTRY_LABELS[country as AsinCountryCode] || country
}
/** 国家码归一:大小写不敏感;不属白名单时返回空串。 */
export function normalizeAsinCountry(value: string): string {
const upper = (value || '').trim().toUpperCase()
return ASIN_COUNTRY_CODES.includes(upper as AsinCountryCode) ? upper : ''
}
@@ -1,86 +0,0 @@
/** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */
export const ASIN_PAGE_DEFAULT_SIZE = 10
export const ASIN_PAGE_MIN_PAGE = 1
export const ASIN_PAGE_MAX_SIZE = 200
/** ISO 日期(yyyy-MM-dd)或空。 */
export function validIsoDateOrUndefined(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const text = value.trim()
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : undefined
}
/** ASIN 中心列表页共享筛选/分页状态。 */
export interface AsinListParams {
page: number
pageSize: number
/** 值/商品模糊关键字。 */
keyword?: string
/** 上传用户名模糊搜索。 */
username?: string
startDate?: string
endDate?: string
groupId?: number | null
/** 国家代码(如 DE、UK)。 */
country?: string
/** 顺序翻页游标(上一页返回的 nextLastId):传了就忽略 page 偏移,走 keyset。 */
lastId?: number
}
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */
export interface AsinPageQuery {
page: number
page_size: number
keyword?: string
username?: string
start_date?: string
end_date?: string
group_id?: number
country?: string
/** 顺序翻页游标(keyset):传了就忽略 page 偏移。 */
last_id?: number
}
function finiteInt(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
}
/** 归一化共享分页/筛选:页码下限 1、页大小 1..上限,空条件视为无筛选。 */
export function normalizeAsinPageParams(raw: Partial<AsinListParams>): AsinListParams {
const page = Math.max(finiteInt(raw.page) ?? ASIN_PAGE_MIN_PAGE, ASIN_PAGE_MIN_PAGE)
const rawSize = finiteInt(raw.pageSize)
const pageSize =
rawSize === null || rawSize < ASIN_PAGE_MIN_PAGE
? ASIN_PAGE_DEFAULT_SIZE
: Math.min(rawSize, ASIN_PAGE_MAX_SIZE)
const textOrUndefined = (value: unknown) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined
const keyword = textOrUndefined(raw.keyword)
const username = textOrUndefined(raw.username)
const country = textOrUndefined(raw.country)
const groupId = finiteInt(raw.groupId)
return {
page,
pageSize,
keyword,
username,
startDate: validIsoDateOrUndefined(raw.startDate),
endDate: validIsoDateOrUndefined(raw.endDate),
groupId: groupId === null || groupId < 1 ? null : groupId,
country,
}
}
/** 前端状态 → Java 查询参数;仅下发非空字段。 */
export function toAsinPageQuery(params: AsinListParams): AsinPageQuery {
const query: AsinPageQuery = { page: params.page, page_size: params.pageSize }
if (params.keyword) query.keyword = params.keyword
if (params.username) query.username = params.username
if (params.startDate) query.start_date = params.startDate
if (params.endDate) query.end_date = params.endDate
if (params.groupId != null) query.group_id = params.groupId
if (params.country) query.country = params.country
if (typeof params.lastId === 'number' && params.lastId > 0) query.last_id = params.lastId
return query
}
@@ -1,35 +0,0 @@
/** 去重汇总导入启动适配(任务 65):multipart POST /import(file+groupId) → importId。 */
import { http } from '@/api/http'
import { parseDedupeImportProgress, parseDedupeImportStart, type DedupeImportProgress } from './dedupe-import-model'
export const DEDUPE_IMPORT_ENDPOINT = '/api/admin/dedupe-total-data/import'
export const DEDUPE_DELETE_IMPORT_ENDPOINT = '/api/admin/dedupe-total-data/delete-import'
export async function startDedupeImport(file: File, groupId: number): Promise<string> {
const form = new FormData()
form.append('file', file, file.name)
form.append('groupId', String(groupId))
const { data } = await http.post<unknown>(DEDUPE_IMPORT_ENDPOINT, form)
return parseDedupeImportStart(data)
}
/** 查询导入进度:GET /import/{importId}。 */
export async function fetchDedupeImportProgress(importId: string): Promise<DedupeImportProgress> {
const { data } = await http.get<unknown>(`${DEDUPE_IMPORT_ENDPOINT}/${importId}`)
return parseDedupeImportProgress(data)
}
/** 删除导入启动:multipart POST /delete-import(file+groupId) → importId。 */
export async function startDedupeDeleteImport(file: File, groupId: number): Promise<string> {
const form = new FormData()
form.append('file', file, file.name)
form.append('groupId', String(groupId))
const { data } = await http.post<unknown>(DEDUPE_DELETE_IMPORT_ENDPOINT, form)
return parseDedupeImportStart(data)
}
/** 查询删除导入进度:GET /delete-import/{importId}。 */
export async function fetchDedupeDeleteImportProgress(importId: string): Promise<DedupeImportProgress> {
const { data } = await http.get<unknown>(`${DEDUPE_DELETE_IMPORT_ENDPOINT}/${importId}`)
return parseDedupeImportProgress(data)
}

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