Compare commits
230 Commits
40224d8e56
...
4c88964473
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c88964473 | |||
| 6bd935dc6b | |||
| b7029835d8 | |||
| ab787ceff7 | |||
| 3dd39bba44 | |||
| 648935e757 | |||
| 6d9b4a13ee | |||
| 3b5adff2e3 | |||
| 6e2e4bf8e7 | |||
| c3dc2a3f2f | |||
| 0315a3a93d | |||
| 8add3ceaa4 | |||
| 5e5658b00d | |||
| 9ac4eda054 | |||
| c381527528 | |||
| efada31764 | |||
| d30e8f3e8f | |||
| 596933eea2 | |||
| a2bfb08122 | |||
| d3bbb1a296 | |||
| c915847d92 | |||
| 553aca34f7 | |||
| 207f48f7d8 | |||
| 0387dc4918 | |||
| 603822a6c1 | |||
| a8d08c4d39 | |||
| 1848a34a27 | |||
| 2f4bb834cf | |||
| 32ef9a2ebb | |||
| 1692fa4a69 | |||
| 63339b401c | |||
| 359f3ce1c9 | |||
| 6329695fc2 | |||
| 1d99423da5 | |||
| ad5c7c02f9 | |||
| af49cbda64 | |||
| 5e4a2a9d11 | |||
| a7cdb0031a | |||
| a6b18b78e6 | |||
| 6bd44e6884 | |||
| 8120ba48f6 | |||
| eebdb7af2d | |||
| cabbb4be0b | |||
| 6a0eb8cf63 | |||
| 0248c8737d | |||
| 4c6884223d | |||
| d745271685 | |||
| 3902acafb9 | |||
| 85f565bd82 | |||
| 178ff96be4 | |||
| 88f2ec8bb0 | |||
| f765b3ce67 | |||
| 8b4955d9b5 | |||
| 07fda4cf22 | |||
| 41cb4ce174 | |||
| e0ec2d864e | |||
| cea7662fc3 | |||
| 4e86506f3e | |||
| f7ad99deda | |||
| 8b0bf3d563 | |||
| 39f43469c2 | |||
| 4aa9b587ba | |||
| 3ba84de784 | |||
| 50ba594ad1 | |||
| c924ffa16d | |||
| 79ee16a374 | |||
| b4657a50fb | |||
| fe429c7a05 | |||
| cd9c5effb7 | |||
| 916d13c3ce | |||
| 9422ad529c | |||
| 79878c8532 | |||
| be486699fd | |||
| bf9ede7049 | |||
| 3a5b2f5101 | |||
| 125a9ff8d6 | |||
| de03a41734 | |||
| 67390c54a9 | |||
| 9e675d63a3 | |||
| 3a68489ee6 | |||
| 9684a184e1 | |||
| 0bf6026083 | |||
| 02a0a6cbf1 | |||
| 26f472413e | |||
| 0ff9024fe4 | |||
| c7708ca57c | |||
| a36a60f151 | |||
| e45ab1da9e | |||
| 05d19626a1 | |||
| cf750a26d4 | |||
| 656dc7ce2f | |||
| 37d3dc8a4c | |||
| 74691acdf9 | |||
| 46f1d27478 | |||
| ea9b263af3 | |||
| 95af9667f6 | |||
| 1e8e173236 | |||
| d3b7ea85eb | |||
| 4e7eb49e0d | |||
| 21e56d40e2 | |||
| 5e274878fe | |||
| 9a20b38bbd | |||
| 9aceedb2e2 | |||
| 23cb828162 | |||
| 3fc8746218 | |||
| b134db134e | |||
| e88bf5322b | |||
| e461ceec2c | |||
| 3056608e04 | |||
| 4cdc7205e9 | |||
| 6701c12178 | |||
| ee73d7d683 | |||
| 6dcdbec63d | |||
| 84cc592517 | |||
| 51b88e4331 | |||
| 16c17c5481 | |||
| f2d3b08e2f | |||
| e3cb97fb49 | |||
| d23fde1757 | |||
| 9b8423bb9b | |||
| f41d8b127f | |||
| cea4db2c45 | |||
| b791fa94e7 | |||
| 320600584c | |||
| ef19a5c00c | |||
| 8668a6e439 | |||
| 4dd9ca7a22 | |||
| 89c06b58c3 | |||
| a6bc6b80b3 | |||
| 423931eb1d | |||
| c4262bd704 | |||
| 94954648d2 | |||
| 21e4bfc221 | |||
| 883625a072 | |||
| 5708025baa | |||
| 3917d678d8 | |||
| 68f6ffe84e | |||
| e063aedb8f | |||
| 5eb3e865b2 | |||
| 91f429f1a0 | |||
| 095a79de94 | |||
| b60bf7bc13 | |||
| 154ec6e301 | |||
| 1d98315877 | |||
| 4731c9a23c | |||
| d8950e93da | |||
| df8255c41e | |||
| 103cb28a1f | |||
| 467c72efb4 | |||
| 818eed0cb5 | |||
| 1acb93ff1c | |||
| 9b21e1f22e | |||
| 479cc449d6 | |||
| b5a1a51403 | |||
| 61b2d7152d | |||
| c919780875 | |||
| 69f6965c93 | |||
| 40463772c5 | |||
| 137af44ae8 | |||
| cb1490ccc2 | |||
| 88d3bfb8b2 | |||
| 77d049990c | |||
| a1e466030b | |||
| d2ef1ce4d8 | |||
| 64f9ac54cf | |||
| a816bfa899 | |||
| cb8e092cc6 | |||
| 841430cd6c | |||
| 602c2118d3 | |||
| a877488f8f | |||
| bf0cecef88 | |||
| 25ebd667f1 | |||
| 085f352320 | |||
| 6368ee376f | |||
| 29641e20a7 | |||
| 0a1e778ef6 | |||
| 7fa9b5ccf3 | |||
| 813ab5db7b | |||
| 58ea911d66 | |||
| 9293cd0976 | |||
| 6ccd5ce1af | |||
| 42722d0cc6 | |||
| bef09cb5b5 | |||
| 9060464afd | |||
| be6a17d4bd | |||
| 72a8f75dc5 | |||
| 119095a57b | |||
| 8c6088bdc9 | |||
| 93033e94db | |||
| 7ccabf859f | |||
| 3828c90b4c | |||
| a46c35f01d | |||
| c1d6ce6d20 | |||
| d9b1b980ca | |||
| 43c7c03aee | |||
| ad6658e490 | |||
| a9b4a56986 | |||
| 5058baffe0 | |||
| 9d25f13fe9 | |||
| 4c78c64569 | |||
| 62bf4f005d | |||
| 43463e12ca | |||
| 27afc4470a | |||
| ce27161bbb | |||
| abc8ed72d4 | |||
| 23037c669d | |||
| fb420c2988 | |||
| 09b3d9f013 | |||
| 53b4cb52f3 | |||
| 8e5fc00945 | |||
| eb526fef5d | |||
| aeb226a0e6 | |||
| 50a6cf12e7 | |||
| 45a8161bb2 | |||
| aaa943a86d | |||
| ffb16bea9a | |||
| 5d9019a40d | |||
| 916f179fca | |||
| 47d1c6bf80 | |||
| 2077f871f8 | |||
| 0fcee02189 | |||
| fba8e35118 | |||
| 311df66231 | |||
| 0e8c5c7223 | |||
| 2f71b4be3c | |||
| 1063e5188c | |||
| dad9cda4ac | |||
| 1ae4bb2a36 | |||
| a8c5d357e6 | |||
| 289f4488f9 |
@@ -126,3 +126,9 @@ progress_*.json
|
||||
|
||||
# ===== 本地预览工具(不入库)=====
|
||||
backend/_preview_mock.py
|
||||
|
||||
# ===== 战役辅助脚本与本地数据(不入库)=====
|
||||
scripts/test_task_*.py
|
||||
scripts/*_scan.py
|
||||
scripts/*_migration.py
|
||||
data/logs/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env*
|
||||
!.env.example
|
||||
test-results/
|
||||
vite-dev.log
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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 菜单迁移后,路由权限守卫即可按最终菜单树工作。
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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/240(RELEASE_SMOKE=1 触发)。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 仅作部署参考,实际 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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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`,用于快速回滚。
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/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}`)
|
||||
@@ -0,0 +1,47 @@
|
||||
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('main h2')).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('main h2')).toHaveText('菜单管理')
|
||||
await page.goto('/admin-vue/account/groups')
|
||||
await expect(page.locator('main h2')).toHaveText('数据权限分组')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -9,7 +9,7 @@ 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-meta span')).toHaveText('super_admin')
|
||||
await expect(page.locator('.admin-user-role')).toHaveText('超级管理员')
|
||||
await expect(page.getByText('账号权限', { exact: true }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -66,10 +66,10 @@ test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }
|
||||
|
||||
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')).toHaveCount(0)
|
||||
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('用户管理')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="数富AI后台管理系统" />
|
||||
<title>数富AI - 后台管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -14,7 +14,7 @@ function redirectToLogin(requestUrl?: string): void {
|
||||
// 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。
|
||||
if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return
|
||||
const target = loginRedirectTarget(window.location)
|
||||
window.location.assign(`/login?redirect=${target}`)
|
||||
window.location.assign('/admin-vue/login?redirect=' + target)
|
||||
}
|
||||
|
||||
http.interceptors.response.use(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
|
||||
import type { AdminUser } from '../types/admin'
|
||||
|
||||
export const USER_PAGE_DEFAULT_SIZE = 15
|
||||
export const USER_PAGE_DEFAULT_SIZE = 10
|
||||
export const USER_PAGE_MIN_PAGE = 1
|
||||
export const USER_PAGE_MAX_SIZE = 200
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 用户列表查询适配(任务 42):纯解析模块,无 axios 依赖,与 Java AdminUserListVo 对齐。 */
|
||||
/** 用户 API 响应解析模型:列表分页(任务 42)与新建用户(任务 46)负载归一。 */
|
||||
import { unwrap } from './envelope.ts'
|
||||
import { emptyUserPageResult, type UserPageResult } from './users-dto.ts'
|
||||
import type { AdminUser } from '../types/admin'
|
||||
@@ -50,5 +50,24 @@ export function parseUserPage(payload: unknown): UserPageResult {
|
||||
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
|
||||
}
|
||||
|
||||
/** 解包新建用户响应并取回新用户 id(POST /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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
import { parseUserPage } from './users-model'
|
||||
import {
|
||||
normalizeUserPageParams,
|
||||
@@ -13,3 +14,9 @@ export async function fetchUserList(params: Partial<UserListParams> = {}): Promi
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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>
|
||||
@@ -0,0 +1,81 @@
|
||||
<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>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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>
|
||||
@@ -0,0 +1,25 @@
|
||||
<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>
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 二次确认模型(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
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 二次确认打开入口(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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 反馈 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 反馈语义模型(module 13 task 246):成功/错误/警告统一文案与 busy 防重入。
|
||||
* 纯逻辑、不依赖 Element;UI 接线见 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
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 复制文本反馈模型(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'
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 模态/抽屉布局模型(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 } : {}) }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 状态药丸配色/分类模型(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'
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
shouldShowEmptyMenu,
|
||||
} from '@/layout/empty-state'
|
||||
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||
import { initialCollapsed, toggleCollapsed, writeCollapsePreference } from '@/layout/sidebar-collapse'
|
||||
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
||||
import {
|
||||
LOGOUT_CONFIRM_CANCEL,
|
||||
@@ -25,22 +24,6 @@ import {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useAdminSessionStore()
|
||||
const collapsed = ref(readInitialCollapse())
|
||||
|
||||
function readInitialCollapse(): boolean {
|
||||
const storage = typeof window !== 'undefined' ? window.localStorage : null
|
||||
const width = typeof window !== 'undefined' ? window.innerWidth : 0
|
||||
return initialCollapsed(width, storage)
|
||||
}
|
||||
|
||||
function setCollapsed(value: boolean): void {
|
||||
collapsed.value = value
|
||||
if (typeof window !== 'undefined') writeCollapsePreference(window.localStorage, value)
|
||||
}
|
||||
|
||||
function toggleSidebar(): void {
|
||||
setCollapsed(toggleCollapsed(collapsed.value))
|
||||
}
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
const menuEntries = computed(() => toSidebarEntries(session.menuTree))
|
||||
@@ -78,19 +61,19 @@ async function signOut() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-shell" :class="{ 'is-collapsed': collapsed }">
|
||||
<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 v-if="!collapsed" class="admin-brand-copy">
|
||||
<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" :collapse="collapsed" router class="admin-menu">
|
||||
<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>
|
||||
@@ -107,30 +90,20 @@ async function signOut() {
|
||||
</el-menu>
|
||||
<el-empty v-if="emptyMenu" :description="EMPTY_MENU_TITLE" :image-size="72" />
|
||||
</el-scrollbar>
|
||||
|
||||
<button
|
||||
class="sidebar-collapse"
|
||||
type="button"
|
||||
:aria-expanded="!collapsed"
|
||||
aria-label="切换侧边栏"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
{{ collapsed ? '展开菜单' : '收起菜单' }}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section class="admin-main">
|
||||
<header class="admin-topbar">
|
||||
<div>
|
||||
<div class="admin-kicker">运营管理控制台</div>
|
||||
<div class="admin-topbar-title">
|
||||
<span class="admin-kicker">运营管理控制台</span>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
</div>
|
||||
<div class="admin-user">
|
||||
<div class="admin-user-meta">
|
||||
<strong>{{ userVm.username }}</strong>
|
||||
<span v-if="userVm.role">{{ userVm.role }}</span>
|
||||
</div>
|
||||
<el-button text type="danger" @click="signOut">退出登录</el-button>
|
||||
<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">
|
||||
@@ -150,3 +123,7 @@ async function signOut() {
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-topbar-title { min-width: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AdminUser } from '../types/admin'
|
||||
import type { AdminUser } from '../types/admin.ts'
|
||||
import { roleLabel } from '../types/admin.ts'
|
||||
|
||||
/** 无 meta.title 时的壳层缺省标题。 */
|
||||
export const ADMIN_DEFAULT_TITLE = '管理后台'
|
||||
@@ -18,5 +19,5 @@ export interface TopbarUserViewModel {
|
||||
/** 顶部栏用户模型:用户信息或未登录空态统一为可渲染视图模型。 */
|
||||
export function topbarUserOf(user: AdminUser | null | undefined): TopbarUserViewModel {
|
||||
if (!user) return { username: '当前用户', role: '', hasUser: false }
|
||||
return { username: user.username || '当前用户', role: user.role || '', hasUser: true }
|
||||
return { username: user.username || '当前用户', role: roleLabel(user.role) || user.role || '', hasUser: true }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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')
|
||||
@@ -0,0 +1,129 @@
|
||||
<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 个字符)" />
|
||||
</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 个字符)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="operatorSuper" label="角色">
|
||||
<el-select 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>
|
||||
@@ -0,0 +1,128 @@
|
||||
<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)
|
||||
|
||||
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
|
||||
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
|
||||
} catch (error) {
|
||||
showAdminFeedback(actionableErrorText(error), 'error')
|
||||
} finally {
|
||||
loadingAuth.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
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="留空则不修改密码"
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
.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);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<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 loadUserOptions:page=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>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { openAdminConfirm } from '@/components/admin-confirm'
|
||||
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
|
||||
import { shopGroupSummary, type ShopGroupItem } from './shop-group-model'
|
||||
import GroupEditorDialog from './GroupEditorDialog.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const rows = ref<ShopGroupItem[]>([])
|
||||
const editorVisible = ref(false)
|
||||
const editingGroup = ref<ShopGroupItem | null>(null)
|
||||
const summary = computed(() => shopGroupSummary(rows.value))
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
const currentUserId = computed(() => session.user?.id ?? null)
|
||||
// 客户端分页:全量拉取后按页切片展示。
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = computed(() => rows.value.length)
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
function changePage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
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="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>数据权限分组</h2>
|
||||
<p>管理店铺数据访问分组和组员范围。组长不可更改,组员为普通账号。</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-row :gutter="12" style="margin-bottom: 12px">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-block">
|
||||
<span class="stat-label">分组总数</span>
|
||||
<span class="stat-value">{{ summary.groupCount }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-block">
|
||||
<span class="stat-label">组员总数</span>
|
||||
<span class="stat-value">{{ summary.memberTotal }}</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新建分组</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="pagedRows" stripe>
|
||||
<el-table-column prop="name" label="分组名称" min-width="180" />
|
||||
<el-table-column prop="leaderUsername" label="组长" min-width="170" />
|
||||
<el-table-column prop="memberCount" label="组员数量" min-width="100" />
|
||||
<el-table-column label="组员" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.memberUsernames.length">
|
||||
<el-tag v-for="name in row.memberUsernames" :key="name" size="small" class="member-tag">{{ name }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="member-none">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="修改时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updatedAt || row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!canEditOf(row)" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="!canEditOf(row)" @click="removeGroup(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="changePage" @size-change="changeSize" />
|
||||
</div>
|
||||
</el-card>
|
||||
<GroupEditorDialog
|
||||
v-model="editorVisible"
|
||||
:group="editingGroup"
|
||||
:operator-super="isSuperAdmin"
|
||||
@saved="loadGroups"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.stat-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.member-tag {
|
||||
margin: 2px 4px 2px 0;
|
||||
}
|
||||
.member-more {
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.member-none {
|
||||
color: var(--admin-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<MenuManageNode[]>([])
|
||||
// 客户端分页:列表展示统一带分页组件。
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = computed(() => rows.value.length)
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
function changePage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
}
|
||||
const 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
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除菜单“${node.name}”吗?`, '删除确认', { type: 'warning' })
|
||||
await deleteMenu(node.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadMenus()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMenus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>菜单管理</h2>
|
||||
<p>维护后台菜单树和页面路由。权限粒度仅到菜单/页面。</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增菜单</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="pagedRows" row-key="id" default-expand-all stripe>
|
||||
<el-table-column label="排序" min-width="64">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="menu-drag-handle"
|
||||
draggable="true"
|
||||
role="button"
|
||||
:title="`拖动调整“${(row as MenuManageNode).name}”的顺序`"
|
||||
@dragstart="onDragStart($event, row as MenuManageNode)"
|
||||
@dragend="onDragEnd"
|
||||
@dragover="onDragOver($event, row as MenuManageNode)"
|
||||
@drop="onDrop($event, row as MenuManageNode)"
|
||||
>⠿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="菜单名称" min-width="180" />
|
||||
<el-table-column label="菜单类型" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="(row as MenuManageNode).menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel((row as MenuManageNode).menuType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上级菜单" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ parentNameOf(row as MenuManageNode) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="150">
|
||||
<template #default="{ row }">{{ formatDateTime((row as MenuManageNode).createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row as MenuManageNode)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="menuDeleteReason(row as MenuManageNode) !== undefined" @click="removeMenu(row as MenuManageNode)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="changePage"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<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 v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
||||
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="父菜单">
|
||||
<el-select v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
||||
<el-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>
|
||||
.menu-drag-tip {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.menu-drag-handle {
|
||||
display: inline-block;
|
||||
cursor: grab;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.menu-drag-handle:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.menu-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import type { MenuOptionNode } 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 tree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
||||
const data = ref<MenuOptionNode[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
function applyChecked(): void {
|
||||
const el: TreeRef | null = tree.value
|
||||
if (!el) return
|
||||
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
|
||||
el.setCheckedKeys(ids)
|
||||
}
|
||||
|
||||
function onCheck(): void {
|
||||
const el: TreeRef | null = tree.value
|
||||
if (!el) return
|
||||
const keys = el.getCheckedKeys()
|
||||
const ids = (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
|
||||
emit('update:checked', ids)
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
data.value = await fetchGrantableMenus()
|
||||
loaded.value = true
|
||||
applyChecked()
|
||||
emit('load', true)
|
||||
} 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">
|
||||
<el-tree
|
||||
ref="tree"
|
||||
:data="data"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
check-strictly
|
||||
default-expand-all
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
@@ -1,32 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http } from '@/api/http'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
import { fetchUserList } from '@/api/users'
|
||||
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'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const rows = ref<AdminUser[]>([])
|
||||
const total = ref(0)
|
||||
const form = reactive({ username: '', page: 1, pageSize: 15 })
|
||||
/** 所属管理员候选(来自列表响应的 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 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({ page: form.page, pageSize: form.pageSize, keyword: form.username || undefined })
|
||||
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) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '用户列表加载失败')
|
||||
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
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除用户“${row.username}”吗?`, '删除确认', { type: 'warning' })
|
||||
await http.delete(`/api/admin/user/${row.id}`)
|
||||
await ElMessageBox.confirm(deleteConfirmMessage(row), '删除用户', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
@@ -35,7 +100,13 @@ async function removeUser(row: AdminUser) {
|
||||
}
|
||||
|
||||
function changePage(page: number) {
|
||||
form.page = page
|
||||
filters.page = page
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
filters.pageSize = size
|
||||
filters.page = 1
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
@@ -47,30 +118,74 @@ onMounted(loadUsers)
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>用户管理</h2>
|
||||
<p>管理后台账号、角色和创建关系。</p>
|
||||
<p>当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</p>
|
||||
</div>
|
||||
<el-button type="primary">新建用户</el-button>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<el-form inline @submit.prevent="loadUsers">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" clearable placeholder="模糊搜索" @keyup.enter="loadUsers" />
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form inline @submit.prevent="search">
|
||||
<el-form-item label="用户名(模糊搜索)">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="输入用户名关键字" @keyup.enter="search" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isSuperAdmin" label="所属管理员">
|
||||
<el-select
|
||||
v-model="filters.createdByAdminId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部"
|
||||
style="width: 200px"
|
||||
@change="search"
|
||||
>
|
||||
<el-option
|
||||
v-for="admin in admins"
|
||||
:key="admin.id"
|
||||
:label="admin.username"
|
||||
:value="admin.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadUsers">查询</el-button>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="rows" stripe>
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-alert
|
||||
v-if="statePhase === 'error' && loadError"
|
||||
:title="loadError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<template #default>
|
||||
<el-button link type="primary" @click="retry">重试</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新建用户</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe :empty-text="userListEmptyHint()">
|
||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||
<el-table-column prop="role" label="角色" width="140" />
|
||||
<el-table-column prop="creatorUsername" label="所属管理员" width="160" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="角色" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeUser(row)">删除</el-button>
|
||||
<span>{{ roleLabel(row.role) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creatorUsername" label="所属管理员" min-width="160" />
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :disabled="!editableOf(row)" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
|
||||
@click="removeUser(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -78,15 +193,23 @@ onMounted(loadUsers)
|
||||
<span>共 {{ total }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:page-size="form.pageSize"
|
||||
:current-page="form.page"
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:page-size="filters.pageSize"
|
||||
:current-page="filters.page"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="changePage"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" />
|
||||
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.filter-card :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** 菜单管理列表加载适配(任务 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)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/** 菜单管理 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
|
||||
children?: MenuManageNode[]
|
||||
}
|
||||
|
||||
/** 菜单类型展示文案(对齐 admin.js:6824:admin → 后台,其余一律 → 软件)。 */
|
||||
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
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** 数据权限分组 API 适配(任务 59;module 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)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/** 数据权限分组列表模型(任务 59;module 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)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 新建用户请求适配(任务 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)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** 新建用户表单模型(任务 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
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 用户删除确认模型(任务 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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 编辑用户请求适配(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)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/** 编辑用户表单回填模型(任务 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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** 用户列表加载/空/错状态(任务 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}` : '加载失败,请稍后重试'
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** 用户菜单授权数据加载适配(任务 49):拉取可授权菜单树 + 用户已授权 id。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import {
|
||||
ADMIN_MENU_TYPE,
|
||||
buildMenuOptionTree,
|
||||
buildUserColumnPermissionPayload,
|
||||
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'
|
||||
|
||||
/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */
|
||||
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
|
||||
const { data } = await http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
|
||||
return buildMenuOptionTree(parseMenuOptionList(data))
|
||||
}
|
||||
|
||||
/** 拉取某用户当前直接授权菜单 id 列表。 */
|
||||
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
|
||||
const { data } = await http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: ADMIN_MENU_TYPE },
|
||||
})
|
||||
return parseUserGrantedColumnIds(data)
|
||||
}
|
||||
|
||||
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 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)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/** 用户菜单授权数据解析模型(任务 49):permission-menus 扁平项 → 嵌套树 + 用户已授权 id。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
|
||||
export const ADMIN_MENU_TYPE = 'admin'
|
||||
|
||||
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
|
||||
export interface MenuOptionNode {
|
||||
id: number
|
||||
/** column_key,稳定权限标识;缺省用 id 字符串兜底。 */
|
||||
key: string
|
||||
name: string
|
||||
sort: number
|
||||
parentId: number | null
|
||||
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): 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,
|
||||
}
|
||||
}
|
||||
|
||||
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
|
||||
export function parseMenuOptionList(payload: unknown): MenuOptionNode[] {
|
||||
const data = unwrap<unknown>(payload)
|
||||
if (!Array.isArray(data)) return []
|
||||
return data
|
||||
.map((raw) => parsePermissionMenuItem(raw))
|
||||
.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
|
||||
}
|
||||
|
||||
/** 解包用户已授权 columnIds(data.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) }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 用户列表分页边界(任务 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)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 用户角色字段映射(任务 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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 用户页局部筛选/分页状态(任务 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
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function createUserFilterState(): UserFilterState {
|
||||
return { keyword: '', createdByAdminId: null, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
|
||||
/** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */
|
||||
export function withFilterReset(
|
||||
state: UserFilterState,
|
||||
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId'>>,
|
||||
): 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()
|
||||
return {
|
||||
page: state.page,
|
||||
pageSize: state.pageSize,
|
||||
keyword: keyword || undefined,
|
||||
createdById: state.createdByAdminId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 不符合 ASIN(品牌数据库):分页 + 手动新增/编辑/删除。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } 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'
|
||||
|
||||
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 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 reset() {
|
||||
filter.dataValue = ''
|
||||
filter.brand = ''
|
||||
// 非超管重置不解除分组锁定。
|
||||
filter.groupId = isSuperAdmin.value ? null : lockedGroupId.value
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
await ElMessageBox.confirm(invalidAsinDeleteText(row), '删除', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteInvalidAsin(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>不符合 ASIN 数据</h2>
|
||||
<p>管理命中"不符合 ASIN"的品牌数据库记录。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>ASIN</label>
|
||||
<el-input v-model="filter.dataValue" placeholder="精确/模糊搜索ASIN" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>品牌</label>
|
||||
<el-input v-model="filter.brand" placeholder="品牌模糊" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable :disabled="!isSuperAdmin">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column prop="dataValue" label="ASIN" min-width="160" />
|
||||
<el-table-column prop="brand" label="品牌" min-width="140">
|
||||
<template #default="{ row }">{{ (row as InvalidAsinItem).brand || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="groupName" label="分组" min-width="130" />
|
||||
<el-table-column label="来源" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="light" :type="(row as InvalidAsinItem).recordSource !== 'MANUAL' ? 'info' : undefined">
|
||||
{{ recordSourceLabel((row as InvalidAsinItem).recordSource) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openEdit(row as InvalidAsinItem)">编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="remove(row as InvalidAsinItem)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="() => { page = 1; load() }" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<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>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,501 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 数据去重总数据(registry):对齐 admin panel-dedupe-total-data —— 顶部数据权限分组汇总卡、扩充筛选(用户名/日期/分组)、
|
||||
* 行编辑/删除、新增/删除导入(轮询)、导出(等待遮罩+已等待秒数)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import {
|
||||
fetchDedupeTotalList,
|
||||
updateDedupeTotal,
|
||||
deleteDedupeTotal,
|
||||
fetchDedupeTotalGroups,
|
||||
type DedupeGroupOption,
|
||||
} from './dedupe-total-api.ts'
|
||||
import { dedupeGroupSummaryOf, 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'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
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[]>([])
|
||||
|
||||
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
|
||||
const groupSummary = computed(() =>
|
||||
dedupeGroupSummaryOf(groups.value, session.user?.id ?? null, session.user?.role || ''),
|
||||
)
|
||||
|
||||
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))
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
if (result.page >= 1) page.value = result.page
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
Object.assign(filter, createDedupeTotalFilterState())
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function openEdit(row: DedupeTotalItem): void {
|
||||
editTarget.value = row
|
||||
editValue.value = row.dataValue
|
||||
editGroupId.value = row.groupId
|
||||
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> {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除总数据“${row.dataValue}”吗?`, '删除总数据', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await deleteDedupeTotal(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetImport(): void {
|
||||
importGroupId.value = null
|
||||
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') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?', '删除导入', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
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="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>数据去重总数据</h2>
|
||||
<p>去重总数据的 ASIN 值台账,可按 ASIN/用户名/分组/国家/日期筛选,支持 Excel 导入与导出。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="group-summary-card">
|
||||
<div class="group-summary">
|
||||
<span class="group-summary-title">数据权限分组</span>
|
||||
<div class="group-summary-body">
|
||||
<template v-if="groupSummary.kind === 'all'">
|
||||
<span class="summary-chip">全部分组 · {{ groupSummary.count }} 个</span>
|
||||
</template>
|
||||
<span v-else-if="groupSummary.kind === 'none'" class="dim">未加入分组</span>
|
||||
<template v-else>
|
||||
<span v-for="chip in groupSummary.chips" :key="chip.name" class="summary-chip" :title="chip.name">
|
||||
{{ chip.name }} · {{ chip.relation }}
|
||||
</span>
|
||||
<span v-if="groupSummary.extra > 0" class="dim">另 {{ groupSummary.extra }} 个</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>ASIN / 关键字</label>
|
||||
<el-input v-model="filter.keyword" placeholder="模糊搜索值" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>用户名(上传人)</label>
|
||||
<el-input v-model="filter.username" placeholder="输入用户名关键字" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable>
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>国家</label>
|
||||
<el-select v-model="filter.country" placeholder="全部" clearable>
|
||||
<el-option v-for="code in ASIN_COUNTRY_CODES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>开始日期</label>
|
||||
<el-date-picker v-model="filter.startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" style="width: 100%" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>结束日期</label>
|
||||
<el-date-picker v-model="filter.endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" style="width: 100%" />
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
|
||||
<el-button @click="openImportDelete">删除导入</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column prop="dataValue" label="ASIN" min-width="170" />
|
||||
<el-table-column label="国家" min-width="100" align="center">
|
||||
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column label="分组" min-width="150">
|
||||
<template #default="{ row }">{{ (row as DedupeTotalItem).groupName || '未分组' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row as DedupeTotalItem)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeRow(row as DedupeTotalItem)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑总数据" width="480px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="总数据值(ASIN)">
|
||||
<el-input v-model="editValue" placeholder="请输入总数据值" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="editGroupId" placeholder="请选择分组" style="width: 100%">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入去重数据' : '删除导入(按 Excel 批量删除)'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="数据权限分组">
|
||||
<el-select v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Excel 文件">
|
||||
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
|
||||
<p class="import-tip">{{ importMode === 'add' ? '上传 Excel 按 ASIN 新增到所选分组,仅支持 .xlsx/.xls。' : 'Excel 第一列 ASIN 将按分组批量删除匹配总数据,仅支持 .xlsx/.xls。' }}</p>
|
||||
</el-form-item>
|
||||
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importRunning" @click="submitImport">
|
||||
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div v-if="exportWaitVisible" class="export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
|
||||
<div class="export-wait">
|
||||
<span class="wait-spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<div class="export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
|
||||
<div class="export-wait-detail">数据量较大,请耐心等待并保持页面打开。已等待 {{ exportWaitSeconds }} 秒</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.heading-actions { display: flex; gap: 8px; flex: none; }
|
||||
.group-summary-card { margin-bottom: 12px; }
|
||||
.group-summary { display: flex; align-items: flex-start; gap: 14px; }
|
||||
.group-summary-title { flex: none; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 24px; }
|
||||
.group-summary-body { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.summary-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12.5px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.6; }
|
||||
.export-wait-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(24, 29, 43, 0.45);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.export-wait {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 14px;
|
||||
padding: 22px 28px;
|
||||
box-shadow: 0 12px 40px -12px rgba(39, 67, 94, 0.35);
|
||||
}
|
||||
.wait-spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: none;
|
||||
border: 3px solid var(--el-color-primary-light-7);
|
||||
border-top-color: var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
animation: dedupe-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes dedupe-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.export-wait-title { font-size: 15px; font-weight: 600; }
|
||||
.export-wait-detail { margin-top: 4px; color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
</style>
|
||||
@@ -0,0 +1,485 @@
|
||||
<script setup lang="ts">
|
||||
/** 商品类目:对齐 admin panel-product-categories —— 树节点带 来源/排序/备注 + 行内编辑删除(有子禁用)、
|
||||
* 按需懒加载子级(「加载更多 已加载 x/y」)、搜索态独立分页/加载更多、导出、新增/编辑弹窗语义。 */
|
||||
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'
|
||||
|
||||
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 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 ? '新增商品类目' : '编辑商品类目'))
|
||||
|
||||
/** 拉取根级第一页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
|
||||
async function loadTree(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchProductCategoryChildren(null, 1, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
|
||||
childrenState.set(ROOT_KEY, state)
|
||||
tree.value = result.hasMore
|
||||
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
|
||||
: result.items
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 树形表格(对齐旧版 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)
|
||||
}
|
||||
|
||||
async function runSearch(page: number): Promise<void> {
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const result = await searchProductCategories(keyword.value, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
// 分页换页模式:每页独立展示当前页结果(对齐列表统一分页交互)。
|
||||
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 searchChangePage(p: number): void {
|
||||
void runSearch(p)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除“${node.name}”?`, '删除类目', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await deleteProductCategory(node.id)
|
||||
ElMessage.success('已删除')
|
||||
if (selected.value?.id === node.id) selected.value = null
|
||||
if (searching.value) void runSearch(1)
|
||||
else void loadTree()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>商品类目</h2>
|
||||
<p>维护商品类目树与类目名称/说明;有子类目节点不可删除。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="openCreate(null)">新增类目</el-button>
|
||||
<el-button @click="exportCategories">导出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
<div class="filter-row">
|
||||
<el-input v-model="keyword" placeholder="搜索类目名称、编码或备注" clearable style="width: 280px" @keyup.enter="apply" />
|
||||
<el-button type="primary" @click="apply">搜索</el-button>
|
||||
<el-button @click="reset">清空</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="!searching" shadow="never">
|
||||
<el-table v-loading="loading" :data="treeRows" row-key="node.id" :class="{ 'tree-table': true }">
|
||||
<el-table-column label="类目名称" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<template v-if="isCategoryLoadMoreNode(row.node)">
|
||||
<div class="tree-name-cell">
|
||||
<span class="tree-indent" :style="{ '--indent': `${(row.depth + 1) * 24}px` }"></span>
|
||||
<el-button link size="small" type="primary" :loading="moreLoading(row.node)" @click="loadMoreChildren(row.node)">加载更多</el-button>
|
||||
<span class="dim">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="tree-name-cell" @click="onNodeClick(row.node as ProductCategoryNode)">
|
||||
<span class="tree-indent" :style="{ '--indent': `${row.depth * 24}px` }"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="tree-node-mark"
|
||||
:class="{ 'is-leaf': (row.node as ProductCategoryNode).childCount <= 0 }"
|
||||
:disabled="(row.node as ProductCategoryNode).childCount <= 0"
|
||||
:title="expandedIds.has((row.node as ProductCategoryNode).id) ? '折叠' : '展开'"
|
||||
@click.stop="toggleExpand(row.node as ProductCategoryNode)"
|
||||
>
|
||||
{{ (row.node as ProductCategoryNode).childCount > 0 ? (expandedIds.has((row.node as ProductCategoryNode).id) ? '−' : '+') : '' }}
|
||||
</button>
|
||||
<strong class="node-name">{{ (row.node as ProductCategoryNode).name }}</strong>
|
||||
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
|
||||
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="层级路径" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="dim" :title="(row.node as ProductCategoryNode).path">{{ (row.node as ProductCategoryNode).path || (row.node as ProductCategoryNode).name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" min-width="80">
|
||||
<template #default="{ row }">{{ (row.node as ProductCategoryNode).sortOrder ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain" :type="(row.node as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
|
||||
{{ productCategorySourceLabel(row.node as ProductCategoryNode) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="(row.node as ProductCategoryNode).description" class="node-desc" :title="(row.node as ProductCategoryNode).description">{{ (row.node as ProductCategoryNode).description }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="!isCategoryLoadMoreNode(row.node)">
|
||||
<el-button link size="small" type="primary" @click="openEdit(row.node as ProductCategoryNode)">编辑</el-button>
|
||||
<el-button
|
||||
link
|
||||
size="small"
|
||||
type="danger"
|
||||
:disabled="!canDelete(row.node as ProductCategoryNode)"
|
||||
@click="removeCategory(row.node as ProductCategoryNode)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="tree-actions">
|
||||
<span class="dim">{{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }}</span>
|
||||
<span class="btn-group">
|
||||
<el-button size="small" :disabled="!selected" @click="openCreate(selected)">新增子类目</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else shadow="never">
|
||||
<div class="search-head">
|
||||
<span>搜索结果:共 {{ searchTotal.toLocaleString() }} 条</span>
|
||||
<span class="dim">{{ keyword }}</span>
|
||||
</div>
|
||||
<el-table v-loading="searchLoading" :data="searchRows" stripe border>
|
||||
<el-table-column prop="name" label="类目名称" min-width="180" />
|
||||
<el-table-column label="层级路径" min-width="240">
|
||||
<template #default="{ row }">{{ (row as ProductCategoryNode).path || (row as ProductCategoryNode).name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" min-width="90">
|
||||
<template #default="{ row }">{{ (row as ProductCategoryNode).sortOrder ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain" :type="(row as ProductCategoryNode).isBuiltin ? 'info' : 'success'">
|
||||
{{ productCategorySourceLabel(row as ProductCategoryNode) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="180">
|
||||
<template #default="{ row }">{{ (row as ProductCategoryNode).description || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row as ProductCategoryNode)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="!canDelete(row as ProductCategoryNode)" @click="removeCategory(row as ProductCategoryNode)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="searchTotal" :page-size="PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE" :current-page="searchPage" @current-change="searchChangePage" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<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="非负整数,可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="draft.description" type="textarea" :rows="2" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">保存类目</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; }
|
||||
.actions { display: flex; align-items: center; gap: 10px; }
|
||||
.filter-row { display: flex; gap: 10px; align-items: center; }
|
||||
.tree-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.btn-group { display: inline-flex; gap: 6px; }
|
||||
.search-head { display: flex; justify-content: space-between; margin-bottom: 12px; }
|
||||
.load-more-row { text-align: center; margin-top: 12px; }
|
||||
/* 树形表格(对齐旧版 panel-product-categories):缩进 + [+/−] 折叠标记 + 粗体名称。 */
|
||||
.tree-name-cell { display: flex; align-items: center; gap: 8px; min-height: 24px; }
|
||||
.tree-indent { flex: 0 0 auto; width: var(--indent, 0px); }
|
||||
.tree-node-mark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: #eef0fe;
|
||||
color: #6366f1;
|
||||
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: #6366f1; color: #fff; }
|
||||
.tree-node-mark.is-leaf { background: transparent; cursor: default; }
|
||||
.node-name { font-weight: 600; color: var(--el-text-color-primary); white-space: nowrap; }
|
||||
.node-desc { color: var(--el-text-color-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; display: inline-block; vertical-align: middle; }
|
||||
.tree-table .el-table__row { cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,491 @@
|
||||
<script setup lang="ts">
|
||||
/** 查询 ASIN:对齐 admin panel-query-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国 ASIN 行可点击复制) +
|
||||
* 新增 ASIN 弹窗(分组→店铺联动→国家→ASIN) + 行内「配置」抽屉 + 导入添加/删除导入/导出。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import CopyText from '@/components/CopyText.vue'
|
||||
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 { 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'
|
||||
|
||||
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 filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||
|
||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
||||
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||
|
||||
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/操作4):分组(0)/店铺(1)/操作(4) 仅首行占位,其余合并。 */
|
||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 4) {
|
||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||
}
|
||||
return [1, 1]
|
||||
}
|
||||
|
||||
function asinOf(row: QueryAsinItem, country: string): string {
|
||||
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
|
||||
return map[country] || ''
|
||||
}
|
||||
|
||||
// ---- 行内配置抽屉 ----
|
||||
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 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 = null
|
||||
createShopName.value = ''
|
||||
createCountry.value = ''
|
||||
createAsin.value = ''
|
||||
createMsg.value = ''
|
||||
createMsgOk.value = false
|
||||
shopNames.value = []
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
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 = null
|
||||
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') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?', '删除导入', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
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 {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = '/api/admin/query-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 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 reset() {
|
||||
Object.assign(filter, createQueryAsinFilterState())
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadGroups()
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>查询 ASIN</h2>
|
||||
<p>每店铺在 5 个站点查询到的 ASIN 清单;行内可逐站配置 ASIN。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>店铺名</label>
|
||||
<el-input v-model="filter.shopName" placeholder="请输入店铺名" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>ASIN</label>
|
||||
<el-input v-model="filter.asin" placeholder="请输入 ASIN" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>国家</label>
|
||||
<el-select v-model="filter.country" placeholder="全部国家" clearable>
|
||||
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
||||
<el-button @click="openImportAdd">导入添加</el-button>
|
||||
<el-button @click="openImportDelete">导入删除</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
|
||||
<el-table-column label="分组" min-width="130">
|
||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="店铺" min-width="150">
|
||||
<template #default="{ row }">{{ row.item.shopName }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ASIN" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="国家" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as QueryAsinItem)">配置</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<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%" @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 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 v-model="importGroupId" placeholder="可不选" clearable style="width: 100%">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.heading-actions { display: flex; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 200px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
|
||||
.dim { color: var(--el-text-color-placeholder); }
|
||||
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
|
||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,555 @@
|
||||
<script setup lang="ts">
|
||||
/** 最低价 ASIN(跳过跟价):对齐 admin panel-skip-price-asin —— 纵向 rowspan 展示(序号/分组/店铺合并 + 逐国
|
||||
* ASIN 可点击复制 + 最低价两位小数)、新增 ASIN 弹窗(分组→店铺联动→国家→ASIN+最低价)、价格范围筛选、
|
||||
* 行内「配置」抽屉(逐国 ASIN+最低价,留空=删除该站点、低价必带 ASIN)、导入添加/删除导入/导出。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import CopyText from '@/components/CopyText.vue'
|
||||
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 { 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'
|
||||
|
||||
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 filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
||||
|
||||
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))
|
||||
|
||||
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/最低价4/操作5):分组(0)/店铺(1)/操作(5) 仅首行占位,其余合并。 */
|
||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 5) {
|
||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||
}
|
||||
return [1, 1]
|
||||
}
|
||||
|
||||
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin) ----
|
||||
const createVisible = ref(false)
|
||||
const createGroupId = ref<number | null>(null)
|
||||
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 = null
|
||||
createShopName.value = ''
|
||||
createCountry.value = ''
|
||||
createAsin.value = ''
|
||||
createMinimumPrice.value = ''
|
||||
createMsg.value = ''
|
||||
createMsgOk.value = false
|
||||
shopNames.value = []
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 导入添加 / 删除导入(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 = null
|
||||
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') {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?', '删除导入', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
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 reset() {
|
||||
Object.assign(filter, createSkipPriceFilterState())
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadGroups()
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>最低价 ASIN / 跳过跟价</h2>
|
||||
<p>每店铺在 5 个站点设定的最低价 ASIN 清单;行内可逐站配置 ASIN 与最低价。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" placeholder="全部分组" clearable filterable>
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>店铺</label>
|
||||
<el-input v-model="filter.shopName" placeholder="店铺名模糊" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>ASIN</label>
|
||||
<el-input v-model="filter.asin" placeholder="ASIN 搜索" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>国家</label>
|
||||
<el-select v-model="filter.country" placeholder="全部" clearable>
|
||||
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item price-range">
|
||||
<label>最低价</label>
|
||||
<div class="price-range-row">
|
||||
<el-input v-model="filter.minimumPriceFrom" placeholder="≥ 最低值" clearable @keyup.enter="apply" />
|
||||
<span class="range-sep">至</span>
|
||||
<el-input v-model="filter.minimumPriceTo" placeholder="≤ 最高值" clearable @keyup.enter="apply" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
||||
<el-button @click="openImportAdd">导入添加</el-button>
|
||||
<el-button @click="openImportDelete">导入删除</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
|
||||
<el-table-column label="分组" min-width="130">
|
||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="店铺" min-width="150">
|
||||
<template #default="{ row }">{{ row.item.shopName }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ASIN" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<CopyText v-if="row.asin" :text="row.asin" class="asin-cell" />
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="国家" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.country">{{ asinCountryLabel(row.country) }}</span>
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最低价" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.minimumPrice !== ''">{{ row.minimumPrice }}</span>
|
||||
<span v-else class="dim">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.isFirst" link type="primary" @click="openConfig(row.item as SkipPriceItem)">配置</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<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%" @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 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 v-model="importGroupId" placeholder="请选择分组" style="width: 100%">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.heading-actions { display: flex; gap: 8px; flex: none; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
/* 最低价范围筛选:≥ 与 ≤ 并排同一行。 */
|
||||
.f-item.price-range { width: 300px; }
|
||||
.price-range-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||
.price-range-row .el-input { flex: 1; }
|
||||
.range-sep { color: var(--el-text-color-secondary); font-size: 12px; flex: none; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.asin-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
|
||||
.dim { color: var(--el-text-color-placeholder); }
|
||||
.drawer-tip { color: var(--el-text-color-secondary); font-size: 12px; margin: 0 0 12px; }
|
||||
.country-cell { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; width: 100%; }
|
||||
.import-tip { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 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 : ''
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */
|
||||
|
||||
export const ASIN_PAGE_DEFAULT_SIZE = 15
|
||||
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
|
||||
}
|
||||
|
||||
/** 序列化到 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
|
||||
}
|
||||
|
||||
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
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** 去重汇总导入启动适配(任务 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)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/** 去重汇总导入模型(任务 65):启动结果解析 + xlsx 文件校验,纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
export const DEDUPE_IMPORT_ALLOWED_EXT = /\.(xlsx|xls)$/i
|
||||
|
||||
/** 是否为允许导入的 Excel 文件。 */
|
||||
export function isAllowedImportFile(fileName: unknown): boolean {
|
||||
return typeof fileName === 'string' && DEDUPE_IMPORT_ALLOWED_EXT.test(fileName.trim())
|
||||
}
|
||||
|
||||
/** 解包导入启动结果并取回 importId;缺省抛可读错误。 */
|
||||
export function parseDedupeImportStart(payload: unknown): string {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
const importId = typeof record?.importId === 'string' ? record.importId.trim() : ''
|
||||
if (!importId) throw new Error('导入任务响应异常:缺少任务 ID')
|
||||
return importId
|
||||
}
|
||||
|
||||
export type DedupeImportStatus = 'pending' | 'running' | 'success' | 'failed'
|
||||
|
||||
export interface DedupeImportProgress {
|
||||
status: DedupeImportStatus
|
||||
totalRows: number
|
||||
processedRows: number
|
||||
asinCount: number
|
||||
insertedCount: number
|
||||
skippedCount: number
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
function intOrZero(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
|
||||
}
|
||||
|
||||
/** 归一化导入进度负载;缺省按未开始(pending)处理,未知状态不改。 */
|
||||
export function parseDedupeImportProgress(payload: unknown): DedupeImportProgress {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
const rawStatus = typeof record?.status === 'string' ? record.status.trim().toLowerCase() : ''
|
||||
const status: DedupeImportStatus =
|
||||
rawStatus === 'running' || rawStatus === 'success' || rawStatus === 'failed' || rawStatus === 'pending'
|
||||
? rawStatus
|
||||
: 'pending'
|
||||
const progress: DedupeImportProgress = {
|
||||
status,
|
||||
totalRows: intOrZero(record?.totalRows),
|
||||
processedRows: intOrZero(record?.processedRows),
|
||||
asinCount: intOrZero(record?.asinCount),
|
||||
insertedCount: intOrZero(record?.insertedCount),
|
||||
skippedCount: intOrZero(record?.skippedCount),
|
||||
}
|
||||
const errorMessage =
|
||||
typeof record?.errorMessage === 'string' && record.errorMessage.trim()
|
||||
? record.errorMessage.trim()
|
||||
: undefined
|
||||
if (errorMessage) progress.errorMessage = errorMessage
|
||||
return progress
|
||||
}
|
||||
|
||||
/** 是否到达终态(success/failed)。 */
|
||||
export function importTaskFinished(progress: Pick<DedupeImportProgress, 'status'>): boolean {
|
||||
return progress.status === 'success' || progress.status === 'failed'
|
||||
}
|
||||
|
||||
/** 是否成功完成。 */
|
||||
export function importTaskSucceeded(progress: Pick<DedupeImportProgress, 'status'>): boolean {
|
||||
return progress.status === 'success'
|
||||
}
|
||||
|
||||
/** 结果文案:pending/running/success/failed 分别给等待、进度、完成/失败可读行。 */
|
||||
export function importOutcomeText(progress: DedupeImportProgress): string {
|
||||
switch (progress.status) {
|
||||
case 'running':
|
||||
return `导入处理中:已处理 ${progress.processedRows}/${progress.totalRows} 行`
|
||||
case 'success':
|
||||
return `导入完成:处理 ${progress.insertedCount} 条,跳过 ${progress.skippedCount} 条`
|
||||
case 'failed':
|
||||
return progress.errorMessage ? `导入失败:${progress.errorMessage}` : '导入失败,请重试'
|
||||
case 'pending':
|
||||
default:
|
||||
return '等待导入任务开始…'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** 去重汇总列表查询适配(任务 62):GET /api/admin/dedupe-total-data + 共享筛选归一。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import { normalizeAsinPageParams, toAsinPageQuery, type AsinListParams } from './asin-filter'
|
||||
import { parseDedupeTotalPage, parseDedupeGroupOptionList, type DedupeGroupOption, type DedupeTotalPageResult } from './dedupe-total-model'
|
||||
|
||||
export const DEDUPE_TOTAL_ENDPOINT = '/api/admin/dedupe-total-data'
|
||||
/** 数据权限分组选项源(与店铺/去重共用同一分组集)。 */
|
||||
export const DEDUPE_GROUPS_ENDPOINT = '/api/admin/shop-manages/groups'
|
||||
|
||||
export async function fetchDedupeTotalList(params: Partial<AsinListParams> = {}): Promise<DedupeTotalPageResult> {
|
||||
const normalized = normalizeAsinPageParams(params)
|
||||
const { data } = await http.get<unknown>(DEDUPE_TOTAL_ENDPOINT, { params: toAsinPageQuery(normalized) })
|
||||
return parseDedupeTotalPage(data)
|
||||
}
|
||||
|
||||
export { type DedupeGroupOption }
|
||||
|
||||
/** 编辑单行去重总数据(ASIN 值 + 分组):PUT /dedupe-total-data/{id}。 */
|
||||
export async function updateDedupeTotal(id: number, dataValue: string, groupId: number): Promise<void> {
|
||||
const { data } = await http.put<unknown>(`${DEDUPE_TOTAL_ENDPOINT}/${id}`, {
|
||||
data_value: dataValue,
|
||||
group_id: groupId,
|
||||
})
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 删除单行去重总数据:DELETE /dedupe-total-data/{id}。 */
|
||||
export async function deleteDedupeTotal(id: number): Promise<void> {
|
||||
const { data } = await http.delete<unknown>(`${DEDUPE_TOTAL_ENDPOINT}/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 加载数据权限分组选项:GET /shop-manages/groups → {id,name,leaderUserId}[]。 */
|
||||
export async function fetchDedupeTotalGroups(): Promise<DedupeGroupOption[]> {
|
||||
const { data } = await http.get<unknown>(DEDUPE_GROUPS_ENDPOINT)
|
||||
return parseDedupeGroupOptionList(data)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** 去重汇总导出等待模型(任务 64):导出 URL 构建 + 运行等待态提示,纯逻辑。 */
|
||||
import { validIsoDateOrUndefined } from './asin-filter.ts'
|
||||
import type { DedupeTotalFilterState } from './dedupe-total-filter.ts'
|
||||
|
||||
export const DEDUPE_TOTAL_EXPORT_ENDPOINT = '/api/admin/dedupe-total-data/export'
|
||||
|
||||
export type DedupeExportStage = 'idle' | 'running' | 'done' | 'error'
|
||||
|
||||
/** 运行中/完成等待提示;idle 无提示。 */
|
||||
export function exportStageHint(stage: DedupeExportStage): string | undefined {
|
||||
switch (stage) {
|
||||
case 'running':
|
||||
return '正在生成并下载导出文件,请稍候…'
|
||||
case 'done':
|
||||
return '导出完成'
|
||||
case 'error':
|
||||
return '导出失败,请重试'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 按导出可用筛选(用户名/日期区间/分组/国家)构建查询串;空白/非法条件不下发。 */
|
||||
export function buildDedupeExportQuery(state: DedupeTotalFilterState): string {
|
||||
const params = new URLSearchParams()
|
||||
const username = (state.username || '').trim()
|
||||
const country = (state.country || '').trim()
|
||||
if (username) params.set('username', username)
|
||||
if (country) params.set('country', country)
|
||||
const startDate = validIsoDateOrUndefined(state.startDate)
|
||||
const endDate = validIsoDateOrUndefined(state.endDate)
|
||||
if (startDate) params.set('start_date', startDate)
|
||||
if (endDate) params.set('end_date', endDate)
|
||||
if (typeof state.groupId === 'number' && state.groupId >= 1) params.set('group_id', String(state.groupId))
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
/** 导出下载地址:浏览器直接访问该 URL 触发流式下载。 */
|
||||
export function toExportUrl(state: DedupeTotalFilterState): string {
|
||||
const query = buildDedupeExportQuery(state)
|
||||
return query ? `${DEDUPE_TOTAL_EXPORT_ENDPOINT}?${query}` : DEDUPE_TOTAL_EXPORT_ENDPOINT
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** 去重汇总页面局部筛选状态(任务 63):日期区间/国家/分组/关键字,纯逻辑复用共享筛选类型。 */
|
||||
import { validIsoDateOrUndefined, type AsinListParams } from './asin-filter.ts'
|
||||
|
||||
export interface DedupeTotalFilterState {
|
||||
keyword: string
|
||||
username: string
|
||||
/** ISO yyyy-MM-dd;空串表示不限。 */
|
||||
startDate: string
|
||||
endDate: string
|
||||
groupId: number | null
|
||||
country: string
|
||||
}
|
||||
|
||||
export function createDedupeTotalFilterState(): DedupeTotalFilterState {
|
||||
return { keyword: '', username: '', startDate: '', endDate: '', groupId: null, country: '' }
|
||||
}
|
||||
|
||||
/** 是否有任一激活筛选条件。 */
|
||||
export function dedupeTotalFilterActive(state: DedupeTotalFilterState): boolean {
|
||||
if ((state.keyword || '').trim()) return true
|
||||
if ((state.username || '').trim()) return true
|
||||
if ((state.country || '').trim()) return true
|
||||
if (typeof state.groupId === 'number' && state.groupId >= 1) return true
|
||||
if (validIsoDateOrUndefined(state.startDate) || validIsoDateOrUndefined(state.endDate)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** 过滤态 + 页码 → 列表查询参数;空白/非法日期不下发。 */
|
||||
export function toDedupeListParams(
|
||||
state: DedupeTotalFilterState,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): AsinListParams {
|
||||
const params: AsinListParams = { page, pageSize }
|
||||
const keyword = (state.keyword || '').trim()
|
||||
const username = (state.username || '').trim()
|
||||
const country = (state.country || '').trim()
|
||||
if (keyword) params.keyword = keyword
|
||||
if (username) params.username = username
|
||||
if (country) params.country = country
|
||||
const startDate = validIsoDateOrUndefined(state.startDate)
|
||||
const endDate = validIsoDateOrUndefined(state.endDate)
|
||||
if (startDate) params.startDate = startDate
|
||||
if (endDate) params.endDate = endDate
|
||||
if (typeof state.groupId === 'number' && state.groupId >= 1) params.groupId = state.groupId
|
||||
return params
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/** 去重汇总列表查询模型(任务 62):解析 DedupeTotalDataPageVo(camel),纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
import { ASIN_PAGE_DEFAULT_SIZE, type AsinListParams } from './asin-filter.ts'
|
||||
|
||||
export interface DedupeTotalItem {
|
||||
id: number
|
||||
dataValue: string
|
||||
country: string
|
||||
groupId: number | null
|
||||
groupName: string
|
||||
uploaderUserId: number | null
|
||||
username: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface DedupeTotalPageResult {
|
||||
items: DedupeTotalItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function emptyDedupeTotalPage(): DedupeTotalPageResult {
|
||||
return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
|
||||
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 toDedupeTotalItem(raw: unknown): DedupeTotalItem | 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 item: DedupeTotalItem = {
|
||||
id,
|
||||
dataValue: text(record.dataValue ?? record.data_value),
|
||||
country: text(record.country),
|
||||
groupId: numberOrNull(record.groupId ?? record.group_id),
|
||||
groupName: text(record.groupName ?? record.group_name),
|
||||
uploaderUserId: numberOrNull(record.uploaderUserId ?? record.uploader_user_id),
|
||||
username: text(record.username),
|
||||
}
|
||||
const createdAt = text(record.createdAt ?? record.created_at)
|
||||
if (createdAt) item.createdAt = createdAt
|
||||
return item
|
||||
}
|
||||
|
||||
/** 归一化分页负载(信封或已解包 VO)为前端结果;缺省字段回默认。 */
|
||||
export function parseDedupeTotalPage(payload: unknown): DedupeTotalPageResult {
|
||||
const out = emptyDedupeTotalPage()
|
||||
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) => toDedupeTotalItem(raw))
|
||||
.filter((item): item is DedupeTotalItem => item !== null)
|
||||
}
|
||||
if (typeof record.total === 'number') out.total = Math.floor(record.total)
|
||||
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
||||
const rawSize = record.pageSize ?? record.page_size
|
||||
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 构造缺省分页查询参数(供初始加载使用)。 */
|
||||
export function emptyDedupePageParams(): Partial<AsinListParams> {
|
||||
return { page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
|
||||
/** 数据权限分组选项(GET /api/admin/shop-manages/groups items)。 */
|
||||
export interface DedupeGroupOption {
|
||||
id: number
|
||||
name: string
|
||||
leaderUserId?: number | null
|
||||
}
|
||||
|
||||
/** 归一化分组选项负载(信封 items 或裸数组),缺 id 行丢弃;解析组长 id 供汇总卡判定组长/组员。 */
|
||||
export function parseDedupeGroupOptionList(payload: unknown): DedupeGroupOption[] {
|
||||
const core = unwrap<unknown>(payload)
|
||||
const rawList = Array.isArray(core)
|
||||
? core
|
||||
: core && typeof core === 'object'
|
||||
? (core as Record<string, unknown>).items
|
||||
: []
|
||||
if (!Array.isArray(rawList)) return []
|
||||
const options: DedupeGroupOption[] = []
|
||||
for (const raw of rawList) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = typeof record.id === 'number' ? Math.floor(record.id) : Number(record.group_id)
|
||||
if (!Number.isFinite(id) || id <= 0) continue
|
||||
const nameRaw = record.group_name ?? record.name
|
||||
const name = typeof nameRaw === 'string' ? nameRaw.trim() : ''
|
||||
const option: DedupeGroupOption = { id, name: name || `分组${id}` }
|
||||
const leaderRaw = record.leader_user_id ?? record.leaderUserId
|
||||
const leaderUserId = typeof leaderRaw === 'number' ? Math.floor(leaderRaw) : Number(leaderRaw)
|
||||
if (Number.isFinite(leaderUserId) && leaderUserId > 0) option.leaderUserId = leaderUserId
|
||||
options.push(option)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/** 页顶数据权限分组汇总(对齐 admin.js renderDedupeGroupSummary)。 */
|
||||
export type DedupeGroupSummary =
|
||||
| { kind: 'all'; count: number }
|
||||
| { kind: 'none' }
|
||||
| { kind: 'chips'; chips: Array<{ name: string; relation: string }>; extra: number }
|
||||
|
||||
export function dedupeGroupSummaryOf(
|
||||
groups: DedupeGroupOption[],
|
||||
currentUserId: number | null,
|
||||
operatorRole: string,
|
||||
): DedupeGroupSummary {
|
||||
if ((operatorRole || '').toLowerCase() === 'super_admin') {
|
||||
return { kind: 'all', count: groups.length }
|
||||
}
|
||||
if (!groups.length) return { kind: 'none' }
|
||||
const visible = groups.slice(0, 4)
|
||||
const currentKey = currentUserId != null ? String(currentUserId) : ''
|
||||
const chips = visible.map((group) => ({
|
||||
name: group.name,
|
||||
relation: String(group.leaderUserId ?? '') === currentKey ? '组长' : '组员',
|
||||
}))
|
||||
return { kind: 'chips', chips, extra: Math.max(groups.length - visible.length, 0) }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/** ASIN 中心导入/轮询通用模型(任务 76):启动结果、Excel 校验与导入进度归一(含 deletedCount),纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
export const EXCEL_IMPORT_ALLOWED_EXT = /\.(xlsx|xls)$/i
|
||||
|
||||
/** 是否为允许导入的 Excel 文件。 */
|
||||
export function isAllowedExcelImportFile(fileName: unknown): boolean {
|
||||
return typeof fileName === 'string' && EXCEL_IMPORT_ALLOWED_EXT.test(fileName.trim())
|
||||
}
|
||||
|
||||
/** 解包导入启动结果并取回 importId;缺省抛可读错误。 */
|
||||
export function parseImportStart(payload: unknown): string {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
const importId = typeof record?.importId === 'string' ? record.importId.trim() : ''
|
||||
if (!importId) throw new Error('导入任务响应异常:缺少任务 ID')
|
||||
return importId
|
||||
}
|
||||
|
||||
export type ImportStatus = 'pending' | 'running' | 'success' | 'failed'
|
||||
|
||||
export interface ImportProgress {
|
||||
status: ImportStatus
|
||||
totalRows: number
|
||||
processedRows: number
|
||||
asinCount: number
|
||||
insertedCount: number
|
||||
deletedCount: number
|
||||
skippedCount: number
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
function intOrZero(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
|
||||
}
|
||||
|
||||
/** 归一化导入进度负载;缺省按未开始(pending)处理,未知状态不改。 */
|
||||
export function parseImportProgress(payload: unknown): ImportProgress {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
const rawStatus = typeof record?.status === 'string' ? record.status.trim().toLowerCase() : ''
|
||||
const status: ImportStatus =
|
||||
rawStatus === 'running' || rawStatus === 'success' || rawStatus === 'failed' || rawStatus === 'pending'
|
||||
? rawStatus
|
||||
: 'pending'
|
||||
const progress: ImportProgress = {
|
||||
status,
|
||||
totalRows: intOrZero(record?.totalRows),
|
||||
processedRows: intOrZero(record?.processedRows),
|
||||
asinCount: intOrZero(record?.asinCount),
|
||||
insertedCount: intOrZero(record?.insertedCount),
|
||||
deletedCount: intOrZero(record?.deletedCount),
|
||||
skippedCount: intOrZero(record?.skippedCount),
|
||||
}
|
||||
const errorMessage =
|
||||
typeof record?.errorMessage === 'string' && record.errorMessage.trim()
|
||||
? record.errorMessage.trim()
|
||||
: undefined
|
||||
if (errorMessage) progress.errorMessage = errorMessage
|
||||
return progress
|
||||
}
|
||||
|
||||
/** 是否到达终态(success/failed)。 */
|
||||
export function importFinished(progress: Pick<ImportProgress, 'status'>): boolean {
|
||||
return progress.status === 'success' || progress.status === 'failed'
|
||||
}
|
||||
|
||||
/** 是否成功完成。 */
|
||||
export function importSucceeded(progress: Pick<ImportProgress, 'status'>): boolean {
|
||||
return progress.status === 'success'
|
||||
}
|
||||
|
||||
/** 导入添加结果文案。 */
|
||||
export function importOutcomeText(progress: ImportProgress): string {
|
||||
switch (progress.status) {
|
||||
case 'running':
|
||||
return `导入处理中:已处理 ${progress.processedRows}/${progress.totalRows} 行`
|
||||
case 'success':
|
||||
return `导入完成:处理 ${progress.insertedCount} 条,跳过 ${progress.skippedCount} 条`
|
||||
case 'failed':
|
||||
return progress.errorMessage ? `导入失败:${progress.errorMessage}` : '导入失败,请重试'
|
||||
case 'pending':
|
||||
default:
|
||||
return '等待导入任务开始…'
|
||||
}
|
||||
}
|
||||
|
||||
/** 导入删除结果文案。 */
|
||||
export function deleteOutcomeText(progress: ImportProgress): string {
|
||||
switch (progress.status) {
|
||||
case 'running':
|
||||
return `删除处理中:已处理 ${progress.processedRows}/${progress.totalRows} 行`
|
||||
case 'success':
|
||||
return `删除完成:删除 ${progress.deletedCount} 条,跳过 ${progress.skippedCount} 条`
|
||||
case 'failed':
|
||||
return progress.errorMessage ? `删除失败:${progress.errorMessage}` : '删除失败,请重试'
|
||||
case 'pending':
|
||||
default:
|
||||
return '等待删除任务开始…'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/** 商品类目树/搜索适配(任务 78):GET /api/admin/product-categories(+keyword) 与 /product-categories/search。 */
|
||||
import { http } from '@/api/http'
|
||||
import {
|
||||
parseProductCategoryList,
|
||||
parseProductCategorySearchPage,
|
||||
type ProductCategoryListResult,
|
||||
type ProductCategoryNode,
|
||||
type ProductCategorySearchPage,
|
||||
} from './product-category-model'
|
||||
|
||||
export const PRODUCT_CATEGORY_ENDPOINT = '/api/admin/product-categories'
|
||||
|
||||
/** 拉取类目树(keyword 非空时由服务端过滤并保留父路径)。 */
|
||||
export async function fetchProductCategoryTree(keyword = ''): Promise<ProductCategoryListResult> {
|
||||
const trimmed = keyword.trim()
|
||||
const { data } = await http.get<unknown>(PRODUCT_CATEGORY_ENDPOINT, {
|
||||
params: trimmed ? { keyword: trimmed } : {},
|
||||
})
|
||||
return parseProductCategoryList(data)
|
||||
}
|
||||
|
||||
/** 分页搜索类目(keyword 空时返回空页)。 */
|
||||
export async function searchProductCategories(
|
||||
keyword: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<ProductCategorySearchPage> {
|
||||
const params: Record<string, string | number> = { page, pageSize }
|
||||
const trimmed = keyword.trim()
|
||||
if (trimmed) params.keyword = trimmed
|
||||
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/search`, { params })
|
||||
return parseProductCategorySearchPage(data)
|
||||
}
|
||||
|
||||
/** 子级类目分页页(对齐 admin.js loadProductCategoryChildren 的 /children 端点语义)。 */
|
||||
export interface ProductCategoryChildrenPage {
|
||||
items: ProductCategoryNode[]
|
||||
total: number
|
||||
page: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
/** 懒加载某父级的子类目页(parentId 为空表示根级),解析 page/total/hasMore。 */
|
||||
export async function fetchProductCategoryChildren(
|
||||
parentId: number | null,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<ProductCategoryChildrenPage> {
|
||||
const params: Record<string, string | number> = { page, page_size: pageSize }
|
||||
if (parentId != null) params.parent_id = parentId
|
||||
const { data } = await http.get<unknown>(`${PRODUCT_CATEGORY_ENDPOINT}/children`, { params })
|
||||
const parsed = parseProductCategorySearchPage(data)
|
||||
return { items: parsed.items, total: parsed.total, page: parsed.page, hasMore: parsed.hasMore }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 商品类目新增/编辑适配(任务 79):POST /api/admin/product-category + PUT /api/admin/product-category/{id}。 */
|
||||
import { http } from '@/api/http'
|
||||
import type { ProductCategorySaveRequest } from './product-category-editor-model'
|
||||
|
||||
export const PRODUCT_CATEGORY_ITEM_ENDPOINT = '/api/admin/product-category'
|
||||
|
||||
/** 新增类目。 */
|
||||
export async function createProductCategory(body: ProductCategorySaveRequest): Promise<void> {
|
||||
await http.post<unknown>(PRODUCT_CATEGORY_ITEM_ENDPOINT, body)
|
||||
}
|
||||
|
||||
/** 更新类目。 */
|
||||
export async function updateProductCategory(id: number, body: ProductCategorySaveRequest): Promise<void> {
|
||||
await http.put<unknown>(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`, body)
|
||||
}
|
||||
|
||||
/** 删除类目:DELETE /api/admin/product-category/{id}。 */
|
||||
export async function deleteProductCategory(id: number): Promise<void> {
|
||||
await http.delete<unknown>(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/** 商品类目新增/编辑表单模型(任务 79):保存草稿校验与序列化(名称必填、排序可选非负整数),纯逻辑。 */
|
||||
|
||||
export interface ProductCategoryDraft {
|
||||
parentId: number | null
|
||||
name: string
|
||||
sortOrder: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function createProductCategoryDraft(): ProductCategoryDraft {
|
||||
return { parentId: null, name: '', sortOrder: '', description: '' }
|
||||
}
|
||||
|
||||
export interface ProductCategorySaveRequest {
|
||||
parentId?: number
|
||||
name: string
|
||||
sortOrder?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
function parseSortOrder(text: string): number | null {
|
||||
const trimmed = (text || '').trim()
|
||||
if (!trimmed) return null
|
||||
if (!/^\d+$/.test(trimmed)) return null
|
||||
const num = Number(trimmed)
|
||||
return Number.isInteger(num) ? num : null
|
||||
}
|
||||
|
||||
/** 校验保存草稿;合法返回 null,否则返回可读错误。 */
|
||||
export function categoryDraftError(draft: ProductCategoryDraft): string | null {
|
||||
if (!draft || typeof draft !== 'object') return '类目表单数据无效'
|
||||
const name = typeof draft.name === 'string' ? draft.name.trim() : ''
|
||||
if (!name) return '类目名称不能为空'
|
||||
const rawSort = typeof draft.sortOrder === 'string' ? draft.sortOrder : ''
|
||||
if (rawSort.trim() !== '' && parseSortOrder(rawSort) === null) {
|
||||
return '排序需为非负整数'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 草稿 → 保存请求;仅提交非空/合法字段,名称 trim。 */
|
||||
export function toCategorySaveRequest(draft: ProductCategoryDraft): ProductCategorySaveRequest {
|
||||
const error = categoryDraftError(draft)
|
||||
if (error) throw new Error(error)
|
||||
const request: ProductCategorySaveRequest = { name: draft.name.trim() }
|
||||
if (typeof draft.parentId === 'number' && Number.isFinite(draft.parentId) && draft.parentId >= 1) {
|
||||
request.parentId = Math.floor(draft.parentId)
|
||||
}
|
||||
const sortOrder = parseSortOrder(draft.sortOrder)
|
||||
if (sortOrder !== null) request.sortOrder = sortOrder
|
||||
const description = typeof draft.description === 'string' ? draft.description.trim() : ''
|
||||
if (description) request.description = description
|
||||
return request
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** 商品类目导出 URL 构建(任务 79):keyword 非空时带查询参数,纯逻辑。 */
|
||||
|
||||
export const PRODUCT_CATEGORY_EXPORT_ENDPOINT = '/api/admin/product-categories/export'
|
||||
|
||||
/** 导出下载地址:浏览器直接访问该 URL 触发流式下载。 */
|
||||
export function buildProductCategoryExportUrl(keyword: string): string {
|
||||
const trimmed = (keyword || '').trim()
|
||||
if (!trimmed) return PRODUCT_CATEGORY_EXPORT_ENDPOINT
|
||||
return `${PRODUCT_CATEGORY_EXPORT_ENDPOINT}?keyword=${encodeURIComponent(trimmed)}`
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/** 商品类目树/搜索模型(任务 78):解析 ProductCategoryListVo(嵌套 tree/path/level/childCount),纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
export const PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
export interface ProductCategoryNode {
|
||||
id: number
|
||||
parentId: number | null
|
||||
name: string
|
||||
categoryKey: string
|
||||
sortOrder: number | null
|
||||
description: string
|
||||
isBuiltin: boolean
|
||||
childCount: number
|
||||
level: number | null
|
||||
path: string
|
||||
children: ProductCategoryNode[]
|
||||
}
|
||||
|
||||
export interface ProductCategoryListResult {
|
||||
tree: ProductCategoryNode[]
|
||||
items: ProductCategoryNode[]
|
||||
}
|
||||
|
||||
export interface ProductCategorySearchPage {
|
||||
items: ProductCategoryNode[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function int(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.floor(value)
|
||||
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
|
||||
return Math.floor(Number(value))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') return value !== 0
|
||||
if (typeof value === 'string') {
|
||||
const v = value.trim().toLowerCase()
|
||||
return v === 'true' || v === '1'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 解析单条类目节点(递归 children);缺 id 视为无效。 */
|
||||
export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = int(record.id)
|
||||
if (id === null) return null
|
||||
const childCount = int(record.childCount ?? record.child_count) ?? 0
|
||||
const node: ProductCategoryNode = {
|
||||
id,
|
||||
parentId: int(record.parentId ?? record.parent_id),
|
||||
name: text(record.name),
|
||||
categoryKey: text(record.categoryKey ?? record.category_key),
|
||||
sortOrder: int(record.sortOrder ?? record.sort_order),
|
||||
description: text(record.description),
|
||||
isBuiltin: asBoolean(record.isBuiltin ?? record.builtin),
|
||||
childCount,
|
||||
level: int(record.level),
|
||||
path: text(record.path),
|
||||
children: [],
|
||||
}
|
||||
if (Array.isArray(record.children)) {
|
||||
node.children = record.children
|
||||
.map((child) => toProductCategoryNode(child))
|
||||
.filter((child): child is ProductCategoryNode => child !== null)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function nodesOf(value: unknown): ProductCategoryNode[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.map((raw) => toProductCategoryNode(raw))
|
||||
.filter((node): node is ProductCategoryNode => node !== null)
|
||||
}
|
||||
|
||||
/** 解析类目 VO:树加载(list) 取嵌套 tree 与扁平 items。 */
|
||||
export function parseProductCategoryList(payload: unknown): ProductCategoryListResult {
|
||||
const core = unwrap<unknown>(payload)
|
||||
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : null
|
||||
return {
|
||||
tree: nodesOf(record?.tree),
|
||||
items: nodesOf(record?.items),
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析类目搜索分页 VO;缺省字段回默认。 */
|
||||
export function parseProductCategorySearchPage(payload: unknown): ProductCategorySearchPage {
|
||||
const core = unwrap<unknown>(payload)
|
||||
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : null
|
||||
const items = nodesOf(record?.items)
|
||||
const total = int(record?.total) ?? 0
|
||||
const page = int(record?.page) ?? 1
|
||||
const pageSize = int(record?.pageSize ?? record?.page_size) ?? PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE
|
||||
const hasMore = typeof record?.hasMore === 'boolean' ? record.hasMore : page * pageSize < total
|
||||
return { items, total, page, pageSize, hasMore }
|
||||
}
|
||||
|
||||
/** 来源展示标签(对齐 admin.js:6004 内置/自定义 tag)。 */
|
||||
export function productCategorySourceLabel(node: Pick<ProductCategoryNode, 'isBuiltin'> | null | undefined): string {
|
||||
return node?.isBuiltin ? '内置' : '自定义'
|
||||
}
|
||||
|
||||
/** 节点元信息文案:来源 + 排序(对齐参考 来源列/排序列)。 */
|
||||
export function productCategoryNodeMeta(node: Pick<ProductCategoryNode, 'isBuiltin' | 'sortOrder'>): string {
|
||||
return `${productCategorySourceLabel(node)} · 排序 ${node.sortOrder ?? 0}`
|
||||
}
|
||||
|
||||
/** 合成"加载更多"节点:挂在同级 children 末尾,点击翻下一页(对齐 admin.js 的 load-more 行)。 */
|
||||
export interface CategoryLoadMoreNode extends ProductCategoryNode {
|
||||
isLoadMore: true
|
||||
loadedCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export function isCategoryLoadMoreNode(node: ProductCategoryNode | null | undefined): node is CategoryLoadMoreNode {
|
||||
return !!node && (node as Partial<CategoryLoadMoreNode>).isLoadMore === true
|
||||
}
|
||||
|
||||
export function makeCategoryLoadMoreNode(
|
||||
parentId: number | null,
|
||||
loadedCount: number,
|
||||
totalCount: number,
|
||||
): CategoryLoadMoreNode {
|
||||
// 合成 id 独立负数域(-2_000_000 以下):父级 id=1 时 -parentId=-1 会与根级(-1)撞 key,
|
||||
// el-tree node-key 重复会致节点状态错位(子级挂错父级)。placeholder 域(-1_000_000)与此错开。
|
||||
const syntheticId = -(parentId ?? 0) - 2_000_000
|
||||
return {
|
||||
id: syntheticId,
|
||||
parentId,
|
||||
name: '',
|
||||
categoryKey: '',
|
||||
sortOrder: null,
|
||||
description: '',
|
||||
isBuiltin: false,
|
||||
childCount: 0,
|
||||
level: null,
|
||||
path: '',
|
||||
children: [],
|
||||
isLoadMore: true,
|
||||
loadedCount,
|
||||
totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
/** 把新一页并入同级 children:去掉旧"加载更多"节点,追加新条目;仍有下一页时补新"加载更多"节点。 */
|
||||
export function appendCategoryChildren(
|
||||
current: ProductCategoryNode[],
|
||||
nextItems: ProductCategoryNode[],
|
||||
parentId: number | null,
|
||||
loadedCount: number,
|
||||
totalCount: number,
|
||||
hasMore: boolean,
|
||||
): ProductCategoryNode[] {
|
||||
const base = current.filter((node) => !isCategoryLoadMoreNode(node))
|
||||
const merged = [...base, ...nextItems]
|
||||
if (hasMore) merged.push(makeCategoryLoadMoreNode(parentId, loadedCount, totalCount))
|
||||
return merged
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/** 查询 ASIN 列表查询适配(任务 71):GET /api/admin/query-asins + 分页/筛选归一与 camel 序列化。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import { fetchShopManageList } from '../shop/shop-manage-api.ts'
|
||||
import { parseQueryAsinPage, type QueryAsinPageResult } from './query-asin-model'
|
||||
import {
|
||||
normalizeQueryAsinParams,
|
||||
toQueryAsinQuery,
|
||||
type QueryAsinListParams,
|
||||
} from './query-asin-filter'
|
||||
|
||||
export const QUERY_ASIN_ENDPOINT = '/api/admin/query-asins'
|
||||
|
||||
export async function fetchQueryAsinList(params: Partial<QueryAsinListParams> = {}): Promise<QueryAsinPageResult> {
|
||||
const normalized = normalizeQueryAsinParams(params)
|
||||
const { data } = await http.get<unknown>(QUERY_ASIN_ENDPOINT, { params: toQueryAsinQuery(normalized) })
|
||||
return parseQueryAsinPage(data)
|
||||
}
|
||||
|
||||
/** 新增/覆盖查询 ASIN 载荷(对齐 Java QueryAsinCreateRequest camelCase)。 */
|
||||
export interface QueryAsinCreatePayload {
|
||||
groupId: number
|
||||
shopName: string
|
||||
countries: string[]
|
||||
asin: string
|
||||
asinMappings: Record<string, string>
|
||||
}
|
||||
|
||||
/** 新增或覆盖查询 ASIN:POST /api/admin/query-asins,返回后端成功消息。 */
|
||||
export async function createQueryAsin(payload: QueryAsinCreatePayload): Promise<string> {
|
||||
const { data } = await http.post<unknown>(QUERY_ASIN_ENDPOINT, payload)
|
||||
const record = unwrap<unknown>(data) as Record<string, unknown> | null | undefined
|
||||
const message = record && typeof record.message === 'string' ? record.message : ''
|
||||
return message || '保存成功'
|
||||
}
|
||||
|
||||
/** 按分组加载店铺名列表(对齐 admin.js loadAsinShopNameOptions:100/页翻页聚合去重,上限 20 页)。 */
|
||||
export async function fetchShopNamesByGroup(groupId: number): Promise<string[]> {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
const result = await fetchShopManageList({ page, pageSize: 100, groupId })
|
||||
for (const shop of result.items) {
|
||||
const name = (shop.shopName || '').trim()
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name)
|
||||
names.push(name)
|
||||
}
|
||||
}
|
||||
if (page * result.pageSize >= result.total) break
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 查询 ASIN 导入删除适配(任务 73):multipart POST /delete-import(file[+groupId]) → importId,及进度轮询。 */
|
||||
import { http } from '@/api/http'
|
||||
import {
|
||||
parseQueryAsinImportProgress,
|
||||
parseQueryAsinImportStart,
|
||||
type QueryAsinImportProgress,
|
||||
} from './query-asin-import-model'
|
||||
|
||||
export const QUERY_ASIN_DELETE_IMPORT_ENDPOINT = '/api/admin/query-asins/delete-import'
|
||||
|
||||
/** 启动导入删除;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */
|
||||
export async function startQueryAsinDeleteImport(file: File, groupId?: number): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
|
||||
form.append('groupId', String(Math.floor(groupId)))
|
||||
}
|
||||
const { data } = await http.post<unknown>(QUERY_ASIN_DELETE_IMPORT_ENDPOINT, form)
|
||||
return parseQueryAsinImportStart(data)
|
||||
}
|
||||
|
||||
/** 查询导入删除进度:GET /delete-import/{importId}。 */
|
||||
export async function fetchQueryAsinDeleteImportProgress(importId: string): Promise<QueryAsinImportProgress> {
|
||||
const { data } = await http.get<unknown>(`${QUERY_ASIN_DELETE_IMPORT_ENDPOINT}/${importId}`)
|
||||
return parseQueryAsinImportProgress(data)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 查询 ASIN 详情抽屉保存适配(任务 74):PUT/DELETE /api/admin/query-asins/{id}/countries/{country}。 */
|
||||
import { http } from '@/api/http'
|
||||
|
||||
export const QUERY_ASIN_DETAIL_ENDPOINT = '/api/admin/query-asins'
|
||||
|
||||
function countryUrl(id: number, country: string): string {
|
||||
return `${QUERY_ASIN_DETAIL_ENDPOINT}/${id}/countries/${country}`
|
||||
}
|
||||
|
||||
/** 保存某国家 ASIN(body { asin });返回后由列表刷新回读最新行。 */
|
||||
export async function updateQueryAsinCountryAsin(id: number, country: string, asin: string): Promise<void> {
|
||||
await http.put<unknown>(countryUrl(id, country), { asin })
|
||||
}
|
||||
|
||||
/** 删除某国家 ASIN(清空该站点 ASIN)。 */
|
||||
export async function deleteQueryAsinCountryAsin(id: number, country: string): Promise<void> {
|
||||
await http.delete<unknown>(countryUrl(id, country))
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 查询 ASIN 详情抽屉模型(任务 74):副标题、5 国家草稿与变更差异(空→删除),纯逻辑。 */
|
||||
import { QUERY_ASIN_COUNTRIES, asinForCountry, type QueryAsinCountry, type QueryAsinItem } from './query-asin-model.ts'
|
||||
|
||||
export const QUERY_ASIN_COUNTRY_LABELS: Record<QueryAsinCountry, string> = {
|
||||
DE: '德国',
|
||||
UK: '英国',
|
||||
FR: '法国',
|
||||
IT: '意大利',
|
||||
ES: '西班牙',
|
||||
}
|
||||
|
||||
/** 国家码 → 中文标签;不属白名单原样返回。 */
|
||||
export function queryAsinCountryLabel(country: string): string {
|
||||
return QUERY_ASIN_COUNTRY_LABELS[country as QueryAsinCountry] || country
|
||||
}
|
||||
|
||||
/** 抽屉副标题:`分组 / 店铺名`;无分组时仅店铺名。 */
|
||||
export function queryAsinDetailSubtitle(item: Pick<QueryAsinItem, 'groupId' | 'groupName' | 'shopName'>): string {
|
||||
const group = (item.groupName || '').trim()
|
||||
const shop = (item.shopName || '').trim()
|
||||
return group ? `${group} / ${shop}` : shop
|
||||
}
|
||||
|
||||
/** 抽屉单国家输入草稿(编辑前的初值)。 */
|
||||
export interface QueryAsinCountryDraft {
|
||||
code: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 由列表行生成 5 国家草稿(当前值 trimmed,保持支持国家序)。 */
|
||||
export function queryAsinCountryDraftsOf(item: QueryAsinItem): QueryAsinCountryDraft[] {
|
||||
return QUERY_ASIN_COUNTRIES.map((code) => ({
|
||||
code,
|
||||
value: asinForCountry(item, code).trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
export type QueryAsinCountryChangeKind = 'update' | 'delete'
|
||||
|
||||
export interface QueryAsinCountryChange {
|
||||
code: string
|
||||
label: string
|
||||
kind: QueryAsinCountryChangeKind
|
||||
asin?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 与当前行比较草稿产出变更:
|
||||
* - 归一(trim + 大写)后与当前相同 → 跳过;
|
||||
* - 草稿留空(当前有值) → delete;
|
||||
* - 新值非空 → update。
|
||||
* 未知国家码或缺失草稿不产生变更,避免误删;结果按支持国家序稳定输出。
|
||||
*/
|
||||
export function diffQueryAsinCountryChanges(
|
||||
item: QueryAsinItem,
|
||||
drafts: QueryAsinCountryDraft[],
|
||||
): QueryAsinCountryChange[] {
|
||||
const byCode = new Map<string, string>()
|
||||
for (const draft of drafts || []) {
|
||||
if (!draft || typeof draft.code !== 'string') continue
|
||||
byCode.set(draft.code.trim().toUpperCase(), typeof draft.value === 'string' ? draft.value : '')
|
||||
}
|
||||
const changes: QueryAsinCountryChange[] = []
|
||||
for (const code of QUERY_ASIN_COUNTRIES) {
|
||||
if (!byCode.has(code)) continue
|
||||
const next = (byCode.get(code) || '').trim().toUpperCase()
|
||||
const current = asinForCountry(item, code).trim().toUpperCase()
|
||||
if (next === current) continue
|
||||
if (!next) {
|
||||
changes.push({ code, label: queryAsinCountryLabel(code), kind: 'delete' })
|
||||
} else {
|
||||
changes.push({ code, label: queryAsinCountryLabel(code), kind: 'update', asin: next })
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** 查询 ASIN 页面筛选/分页状态(任务 71):分组/店铺名/ASIN/国家,仅下发非空且受支持的条件;Java 参数名 camelCase。 */
|
||||
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
|
||||
import { QUERY_ASIN_COUNTRIES } from './query-asin-model.ts'
|
||||
|
||||
/** 后端 QueryAsinService 对 pageSize 的上限;超发会被服务端静默截断,前端先行对齐。 */
|
||||
export const QUERY_ASIN_MAX_PAGE_SIZE = 100
|
||||
|
||||
export interface QueryAsinFilterState {
|
||||
groupId: number | null
|
||||
shopName: string
|
||||
asin: string
|
||||
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
|
||||
country: string
|
||||
}
|
||||
|
||||
export interface QueryAsinListParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
groupId?: number | null
|
||||
shopName?: string
|
||||
asin?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface QueryAsinQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
groupId?: number
|
||||
shopName?: string
|
||||
asin?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export function createQueryAsinFilterState(): QueryAsinFilterState {
|
||||
return { groupId: null, shopName: '', asin: '', country: '' }
|
||||
}
|
||||
|
||||
/** 国家码归一:大小写不敏感;不属 DE/UK/FR/IT/ES 白名单时返回空串(不发起后端不支持的筛选)。 */
|
||||
export function normalizeQueryAsinCountry(value: string): string {
|
||||
const upper = (value || '').trim().toUpperCase()
|
||||
return QUERY_ASIN_COUNTRIES.includes(upper as (typeof QUERY_ASIN_COUNTRIES)[number]) ? upper : ''
|
||||
}
|
||||
|
||||
/** 是否有任一激活筛选条件。 */
|
||||
export function queryAsinFilterActive(state: QueryAsinFilterState): boolean {
|
||||
if (typeof state.groupId === 'number' && state.groupId >= 1) return true
|
||||
if ((state.shopName || '').trim()) return true
|
||||
if ((state.asin || '').trim()) return true
|
||||
return normalizeQueryAsinCountry(state.country) !== ''
|
||||
}
|
||||
|
||||
/** 归一化列表查询参数:页码下限 1、页大小 1..100、空白与非法条件不下发。 */
|
||||
export function normalizeQueryAsinParams(raw: Partial<QueryAsinListParams>): QueryAsinListParams {
|
||||
const page = typeof raw.page === 'number' && Number.isFinite(raw.page)
|
||||
? Math.max(Math.floor(raw.page), 1)
|
||||
: 1
|
||||
const rawSize = typeof raw.pageSize === 'number' && Number.isFinite(raw.pageSize)
|
||||
? Math.floor(raw.pageSize)
|
||||
: NaN
|
||||
const pageSize =
|
||||
Number.isNaN(rawSize) || rawSize < 1
|
||||
? ASIN_PAGE_DEFAULT_SIZE
|
||||
: Math.min(rawSize, QUERY_ASIN_MAX_PAGE_SIZE)
|
||||
const groupId =
|
||||
typeof raw.groupId === 'number' && Number.isFinite(raw.groupId) && raw.groupId >= 1
|
||||
? Math.floor(raw.groupId)
|
||||
: null
|
||||
const shopName = typeof raw.shopName === 'string' ? raw.shopName.trim() : ''
|
||||
const asin = typeof raw.asin === 'string' ? raw.asin.trim() : ''
|
||||
const country = normalizeQueryAsinCountry(typeof raw.country === 'string' ? raw.country : '')
|
||||
const params: QueryAsinListParams = { page, pageSize }
|
||||
if (groupId !== null) params.groupId = groupId
|
||||
if (shopName) params.shopName = shopName
|
||||
if (asin) params.asin = asin
|
||||
if (country) params.country = country
|
||||
return params
|
||||
}
|
||||
|
||||
/** 筛选态 + 页码 → 列表查询参数(已归一,可幂等重入)。 */
|
||||
export function toQueryAsinParams(
|
||||
state: QueryAsinFilterState,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): QueryAsinListParams {
|
||||
return normalizeQueryAsinParams({
|
||||
page,
|
||||
pageSize,
|
||||
groupId: state.groupId,
|
||||
shopName: state.shopName,
|
||||
asin: state.asin,
|
||||
country: state.country,
|
||||
})
|
||||
}
|
||||
|
||||
/** 前端查询参数 → Java 查询参数;与 QueryAsinController @RequestParam(camel) 对齐,仅下发非空字段。 */
|
||||
export function toQueryAsinQuery(params: QueryAsinListParams): QueryAsinQuery {
|
||||
const query: QueryAsinQuery = { page: params.page, pageSize: params.pageSize }
|
||||
if (typeof params.groupId === 'number' && params.groupId >= 1) query.groupId = params.groupId
|
||||
if (params.shopName) query.shopName = params.shopName
|
||||
if (params.asin) query.asin = params.asin
|
||||
if (params.country) query.country = params.country
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 查询 ASIN 导入添加适配(任务 72):multipart POST /import(file[+groupId]) → importId,及进度轮询。 */
|
||||
import { http } from '@/api/http'
|
||||
import {
|
||||
parseQueryAsinImportProgress,
|
||||
parseQueryAsinImportStart,
|
||||
type QueryAsinImportProgress,
|
||||
} from './query-asin-import-model'
|
||||
|
||||
export const QUERY_ASIN_IMPORT_ENDPOINT = '/api/admin/query-asins/import'
|
||||
|
||||
/** 启动导入添加;groupId 为 Excel 未提供分组时的兜底,可选(>=1 才下发)。 */
|
||||
export async function startQueryAsinImport(file: File, groupId?: number): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
if (typeof groupId === 'number' && Number.isFinite(groupId) && groupId >= 1) {
|
||||
form.append('groupId', String(Math.floor(groupId)))
|
||||
}
|
||||
const { data } = await http.post<unknown>(QUERY_ASIN_IMPORT_ENDPOINT, form)
|
||||
return parseQueryAsinImportStart(data)
|
||||
}
|
||||
|
||||
/** 查询导入添加进度:GET /import/{importId}。 */
|
||||
export async function fetchQueryAsinImportProgress(importId: string): Promise<QueryAsinImportProgress> {
|
||||
const { data } = await http.get<unknown>(`${QUERY_ASIN_IMPORT_ENDPOINT}/${importId}`)
|
||||
return parseQueryAsinImportProgress(data)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/** 查询 ASIN 导入模型(任务 72):启动结果解析 + xlsx 校验 + 进度归一(含 deletedCount),纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
export const QUERY_ASIN_IMPORT_ALLOWED_EXT = /\.(xlsx|xls)$/i
|
||||
|
||||
/** 是否为允许导入的 Excel 文件。 */
|
||||
export function isAllowedQueryAsinImportFile(fileName: unknown): boolean {
|
||||
return typeof fileName === 'string' && QUERY_ASIN_IMPORT_ALLOWED_EXT.test(fileName.trim())
|
||||
}
|
||||
|
||||
/** 解包导入启动结果并取回 importId;缺省抛可读错误。 */
|
||||
export function parseQueryAsinImportStart(payload: unknown): string {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
const importId = typeof record?.importId === 'string' ? record.importId.trim() : ''
|
||||
if (!importId) throw new Error('导入任务响应异常:缺少任务 ID')
|
||||
return importId
|
||||
}
|
||||
|
||||
export type QueryAsinImportStatus = 'pending' | 'running' | 'success' | 'failed'
|
||||
|
||||
export interface QueryAsinImportProgress {
|
||||
status: QueryAsinImportStatus
|
||||
totalRows: number
|
||||
processedRows: number
|
||||
asinCount: number
|
||||
insertedCount: number
|
||||
deletedCount: number
|
||||
skippedCount: number
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
function intOrZero(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
|
||||
}
|
||||
|
||||
/** 归一化导入进度负载;缺省按未开始(pending)处理,未知状态不改。 */
|
||||
export function parseQueryAsinImportProgress(payload: unknown): QueryAsinImportProgress {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
const rawStatus = typeof record?.status === 'string' ? record.status.trim().toLowerCase() : ''
|
||||
const status: QueryAsinImportStatus =
|
||||
rawStatus === 'running' || rawStatus === 'success' || rawStatus === 'failed' || rawStatus === 'pending'
|
||||
? rawStatus
|
||||
: 'pending'
|
||||
const progress: QueryAsinImportProgress = {
|
||||
status,
|
||||
totalRows: intOrZero(record?.totalRows),
|
||||
processedRows: intOrZero(record?.processedRows),
|
||||
asinCount: intOrZero(record?.asinCount),
|
||||
insertedCount: intOrZero(record?.insertedCount),
|
||||
deletedCount: intOrZero(record?.deletedCount),
|
||||
skippedCount: intOrZero(record?.skippedCount),
|
||||
}
|
||||
const errorMessage =
|
||||
typeof record?.errorMessage === 'string' && record.errorMessage.trim()
|
||||
? record.errorMessage.trim()
|
||||
: undefined
|
||||
if (errorMessage) progress.errorMessage = errorMessage
|
||||
return progress
|
||||
}
|
||||
|
||||
/** 是否到达终态(success/failed)。 */
|
||||
export function queryAsinImportFinished(progress: Pick<QueryAsinImportProgress, 'status'>): boolean {
|
||||
return progress.status === 'success' || progress.status === 'failed'
|
||||
}
|
||||
|
||||
/** 是否成功完成。 */
|
||||
export function queryAsinImportSucceeded(progress: Pick<QueryAsinImportProgress, 'status'>): boolean {
|
||||
return progress.status === 'success'
|
||||
}
|
||||
|
||||
/** 结果文案:pending/running/success/failed 分别给等待、进度、完成/失败可读行。 */
|
||||
export function queryAsinImportOutcomeText(progress: QueryAsinImportProgress): string {
|
||||
switch (progress.status) {
|
||||
case 'running':
|
||||
return `导入处理中:已处理 ${progress.processedRows}/${progress.totalRows} 行`
|
||||
case 'success':
|
||||
return `导入完成:处理 ${progress.insertedCount} 条,跳过 ${progress.skippedCount} 条`
|
||||
case 'failed':
|
||||
return progress.errorMessage ? `导入失败:${progress.errorMessage}` : '导入失败,请重试'
|
||||
case 'pending':
|
||||
default:
|
||||
return '等待导入任务开始…'
|
||||
}
|
||||
}
|
||||
|
||||
/** 导入删除结果文案(任务 73):突出删除条数,成功/失败/等待各有可读行。 */
|
||||
export function queryAsinDeleteOutcomeText(progress: QueryAsinImportProgress): string {
|
||||
switch (progress.status) {
|
||||
case 'running':
|
||||
return `删除处理中:已处理 ${progress.processedRows}/${progress.totalRows} 行`
|
||||
case 'success':
|
||||
return `删除完成:删除 ${progress.deletedCount} 条,跳过 ${progress.skippedCount} 条`
|
||||
case 'failed':
|
||||
return progress.errorMessage ? `删除失败:${progress.errorMessage}` : '删除失败,请重试'
|
||||
case 'pending':
|
||||
default:
|
||||
return '等待删除任务开始…'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/** 查询 ASIN 列表查询模型(任务 71):解析 QueryAsinPageVo(camel) 与每店铺 5 国家 ASIN 宽行,纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
|
||||
|
||||
export const QUERY_ASIN_COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
|
||||
export type QueryAsinCountry = (typeof QUERY_ASIN_COUNTRIES)[number]
|
||||
|
||||
export interface QueryAsinItem {
|
||||
id: number
|
||||
groupId: number | null
|
||||
groupName: string
|
||||
shopName: string
|
||||
asinDe: string
|
||||
asinUk: string
|
||||
asinFr: string
|
||||
asinIt: string
|
||||
asinEs: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface QueryAsinPageResult {
|
||||
items: QueryAsinItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function emptyQueryAsinPage(): QueryAsinPageResult {
|
||||
return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const COUNTRY_COLUMN: Record<string, keyof Pick<QueryAsinItem, 'asinDe' | 'asinUk' | 'asinFr' | 'asinIt' | 'asinEs'>> = {
|
||||
DE: 'asinDe',
|
||||
UK: 'asinUk',
|
||||
FR: 'asinFr',
|
||||
IT: 'asinIt',
|
||||
ES: 'asinEs',
|
||||
}
|
||||
|
||||
/** 解析单条查询 ASIN 店铺行;缺 id 视为无效。 */
|
||||
export function toQueryAsinItem(raw: unknown): QueryAsinItem | 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 item: QueryAsinItem = {
|
||||
id,
|
||||
groupId: numberOrNull(record.groupId ?? record.group_id),
|
||||
groupName: text(record.groupName ?? record.group_name),
|
||||
shopName: text(record.shopName ?? record.shop_name),
|
||||
asinDe: text(record.asinDe ?? record.asin_de),
|
||||
asinUk: text(record.asinUk ?? record.asin_uk),
|
||||
asinFr: text(record.asinFr ?? record.asin_fr),
|
||||
asinIt: text(record.asinIt ?? record.asin_it),
|
||||
asinEs: text(record.asinEs ?? record.asin_es),
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** 归一化分页负载(信封或已解包 VO)为前端结果;缺省字段回默认。 */
|
||||
export function parseQueryAsinPage(payload: unknown): QueryAsinPageResult {
|
||||
const out = emptyQueryAsinPage()
|
||||
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) => toQueryAsinItem(raw))
|
||||
.filter((item): item is QueryAsinItem => item !== null)
|
||||
}
|
||||
if (typeof record.total === 'number') out.total = Math.floor(record.total)
|
||||
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
||||
const rawSize = record.pageSize ?? record.page_size
|
||||
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 取某国家列 ASIN;国家码不属白名单时返回空串。 */
|
||||
export function asinForCountry(item: QueryAsinItem, country: string): string {
|
||||
const column = COUNTRY_COLUMN[country]
|
||||
return column ? item[column] : ''
|
||||
}
|
||||
|
||||
/** 该店铺行是否存在任一国家 ASIN。 */
|
||||
export function queryAsinItemHasAnyAsin(item: QueryAsinItem): boolean {
|
||||
return QUERY_ASIN_COUNTRIES.some((code) => asinForCountry(item, code) !== '')
|
||||
}
|
||||
|
||||
/** 纵向行条目(对齐 admin.js renderQueryAsinEntries):按 DE/UK/FR/IT/ES 顺序收集有值国家;全空补一条空条目。 */
|
||||
export interface QueryAsinRowEntry {
|
||||
country: QueryAsinCountry | null
|
||||
asin: string
|
||||
}
|
||||
|
||||
export function queryAsinRowEntries(item: QueryAsinItem): QueryAsinRowEntry[] {
|
||||
const entries: QueryAsinRowEntry[] = []
|
||||
for (const code of QUERY_ASIN_COUNTRIES) {
|
||||
const asin = asinForCountry(item, code)
|
||||
if (asin) entries.push({ country: code, asin })
|
||||
}
|
||||
if (!entries.length) entries.push({ country: null, asin: '' })
|
||||
return entries
|
||||
}
|
||||
|
||||
/** 纵向展示行(对齐 admin.js renderQueryAsinRows):序号/分组/店铺/操作 按 rowspan 合并,逐国一行。 */
|
||||
export interface QueryAsinDisplayRow {
|
||||
key: string
|
||||
rowNo: number
|
||||
rowspan: number
|
||||
isFirst: boolean
|
||||
country: QueryAsinCountry | null
|
||||
asin: string
|
||||
item: QueryAsinItem
|
||||
}
|
||||
|
||||
export function queryAsinDisplayRows(items: QueryAsinItem[], startRowNo: number): QueryAsinDisplayRow[] {
|
||||
const out: QueryAsinDisplayRow[] = []
|
||||
let rowNo = Math.max(Math.floor(startRowNo || 1), 1)
|
||||
for (const item of items) {
|
||||
const entries = queryAsinRowEntries(item)
|
||||
entries.forEach((entry, index) => {
|
||||
out.push({
|
||||
key: `${item.id}:${index}`,
|
||||
rowNo,
|
||||
rowspan: entries.length,
|
||||
isFirst: index === 0,
|
||||
country: entry.country,
|
||||
asin: entry.asin,
|
||||
item,
|
||||
})
|
||||
})
|
||||
rowNo += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** 最低价 ASIN 列表查询适配(任务 75):GET /api/admin/skip-price-asins + snake 分页/筛选序列化。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import { parseSkipPricePage, type SkipPricePageResult } from './skip-price-model'
|
||||
import {
|
||||
normalizeSkipPriceParams,
|
||||
toSkipPriceQuery,
|
||||
type SkipPriceListParams,
|
||||
} from './skip-price-filter'
|
||||
|
||||
export const SKIP_PRICE_ENDPOINT = '/api/admin/skip-price-asins'
|
||||
|
||||
export async function fetchSkipPriceList(params: Partial<SkipPriceListParams> = {}): Promise<SkipPricePageResult> {
|
||||
const normalized = normalizeSkipPriceParams(params)
|
||||
const { data } = await http.get<unknown>(SKIP_PRICE_ENDPOINT, { params: toSkipPriceQuery(normalized) })
|
||||
return parseSkipPricePage(data)
|
||||
}
|
||||
|
||||
/** 新增/覆盖最低价 ASIN 载荷(对齐 Java SkipPriceAsinCreateRequest camelCase)。 */
|
||||
export interface SkipPriceCreatePayload {
|
||||
groupId: number
|
||||
shopName: string
|
||||
countries: string[]
|
||||
asin: string
|
||||
asinMappings: Record<string, string>
|
||||
minimumPriceMappings?: Record<string, number>
|
||||
}
|
||||
|
||||
/** 新增或覆盖最低价 ASIN:POST /api/admin/skip-price-asins,返回后端成功消息。 */
|
||||
export async function createSkipPriceAsin(payload: SkipPriceCreatePayload): Promise<string> {
|
||||
const { data } = await http.post<unknown>(SKIP_PRICE_ENDPOINT, payload)
|
||||
const record = unwrap<unknown>(data) as Record<string, unknown> | null | undefined
|
||||
const message = record && typeof record.message === 'string' ? record.message : ''
|
||||
return message || '保存成功'
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 最低价 ASIN 详情编辑抽屉保存适配(任务 77):PUT/DELETE /api/admin/skip-price-asins/{id}/countries/{country}。 */
|
||||
import { http } from '@/api/http'
|
||||
|
||||
export const SKIP_PRICE_DETAIL_ENDPOINT = '/api/admin/skip-price-asins'
|
||||
|
||||
function countryUrl(id: number, country: string): string {
|
||||
return `${SKIP_PRICE_DETAIL_ENDPOINT}/${id}/countries/${country}`
|
||||
}
|
||||
|
||||
/** 保存某国家 ASIN 与最低价(body { asin, minimumPrice });返回后由列表刷新回读最新行。 */
|
||||
export async function updateSkipPriceCountry(
|
||||
id: number,
|
||||
country: string,
|
||||
asin: string,
|
||||
minimumPrice: number | null,
|
||||
): Promise<void> {
|
||||
await http.put<unknown>(countryUrl(id, country), { asin, minimumPrice })
|
||||
}
|
||||
|
||||
/** 删除某国家最低价 ASIN(清空该站点)。 */
|
||||
export async function deleteSkipPriceCountry(id: number, country: string): Promise<void> {
|
||||
await http.delete<unknown>(countryUrl(id, country))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** 最低价 ASIN 详情编辑抽屉模型(任务 77):副标题、5 国家草稿(ASIN+最低价)与变更差异,纯逻辑。 */
|
||||
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
|
||||
import { skipPriceAsin, skipPriceMinimum, type SkipPriceItem } from './skip-price-model.ts'
|
||||
|
||||
/** 抽屉副标题:`分组 / 店铺名`;无分组时仅店铺名。 */
|
||||
export function skipPriceDetailSubtitle(item: Pick<SkipPriceItem, 'groupName' | 'shopName'>): string {
|
||||
const group = (item.groupName || '').trim()
|
||||
const shop = (item.shopName || '').trim()
|
||||
return group ? `${group} / ${shop}` : shop
|
||||
}
|
||||
|
||||
/** 抽屉单国家草稿(编辑前初值)。 */
|
||||
export interface SkipPriceCountryDraft {
|
||||
code: string
|
||||
label: string
|
||||
asin: string
|
||||
/** 最低价输入原文;空串表示清除。 */
|
||||
price: string
|
||||
}
|
||||
|
||||
/** 由列表行生成 5 国家草稿,保持支持国家序。 */
|
||||
export function skipPriceCountryDraftsOf(item: SkipPriceItem): SkipPriceCountryDraft[] {
|
||||
return ASIN_COUNTRY_CODES.map((code) => {
|
||||
const price = skipPriceMinimum(item, code)
|
||||
return {
|
||||
code,
|
||||
label: asinCountryLabel(code),
|
||||
asin: skipPriceAsin(item, code),
|
||||
price: price === null ? '' : String(price),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 非负可解析价格 → number;空/非法/负数返回 null。 */
|
||||
export function normalizeSkipPriceCellPrice(value: string): number | null {
|
||||
const trimmed = (value || '').trim()
|
||||
if (!trimmed) return null
|
||||
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
|
||||
const num = Number(trimmed)
|
||||
return Number.isFinite(num) && num >= 0 ? num : null
|
||||
}
|
||||
|
||||
function priceEquals(a: number | null, b: number | null): boolean {
|
||||
if (a === null || b === null) return a === b
|
||||
return a === b
|
||||
}
|
||||
|
||||
export type SkipPriceCountryChangeKind = 'update' | 'delete'
|
||||
|
||||
export interface SkipPriceCountryChange {
|
||||
code: string
|
||||
label: string
|
||||
kind: SkipPriceCountryChangeKind
|
||||
asin?: string
|
||||
minimumPrice?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 与当前行比较草稿产出变更:
|
||||
* - ASIN 与最低价均未变(归一后相等) → 跳过;
|
||||
* - ASIN 留空(当前有值) → delete;
|
||||
* - 否则 update(ASIN 大写 + 最低价随行,可为空以清价)。
|
||||
* 未知国家码或缺失草稿不产生变更,避免误删;结果按支持国家序稳定输出。
|
||||
*/
|
||||
export function diffSkipPriceCountryChanges(
|
||||
item: SkipPriceItem,
|
||||
drafts: SkipPriceCountryDraft[],
|
||||
): SkipPriceCountryChange[] {
|
||||
const byCode = new Map<string, SkipPriceCountryDraft>()
|
||||
for (const draft of drafts || []) {
|
||||
if (!draft || typeof draft.code !== 'string') continue
|
||||
byCode.set(draft.code.trim().toUpperCase(), draft)
|
||||
}
|
||||
const changes: SkipPriceCountryChange[] = []
|
||||
for (const code of ASIN_COUNTRY_CODES) {
|
||||
const draft = byCode.get(code)
|
||||
if (!draft) continue
|
||||
const nextAsin = (draft.asin || '').trim().toUpperCase()
|
||||
const nextPrice = normalizeSkipPriceCellPrice(draft.price)
|
||||
const currentAsin = skipPriceAsin(item, code).trim().toUpperCase()
|
||||
const currentPrice = skipPriceMinimum(item, code)
|
||||
if (nextAsin === currentAsin && priceEquals(currentPrice, nextPrice)) continue
|
||||
if (!nextAsin) {
|
||||
changes.push({ code, label: asinCountryLabel(code), kind: 'delete' })
|
||||
} else {
|
||||
changes.push({ code, label: asinCountryLabel(code), kind: 'update', asin: nextAsin, minimumPrice: nextPrice })
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/** 最低价 ASIN 页面筛选/分页状态(任务 75):分组/店铺名/ASIN/国家 + 最低价区间;Java 参数名 snake。 */
|
||||
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
|
||||
import { normalizeAsinCountry } from './asin-country.ts'
|
||||
|
||||
/** 后端 SkipPriceAsinService 对 pageSize 的上限;超发会被服务端静默截断,前端先行对齐。 */
|
||||
export const SKIP_PRICE_MAX_PAGE_SIZE = 100
|
||||
|
||||
export interface SkipPriceFilterState {
|
||||
groupId: number | null
|
||||
shopName: string
|
||||
asin: string
|
||||
/** '' 表示不限;否则为 DE/UK/FR/IT/ES 之一。 */
|
||||
country: string
|
||||
/** 最低价区间输入原文;空串不限。 */
|
||||
minimumPriceFrom: string
|
||||
minimumPriceTo: string
|
||||
}
|
||||
|
||||
export interface SkipPriceListParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
groupId?: number | null
|
||||
shopName?: string
|
||||
asin?: string
|
||||
country?: string
|
||||
minimumPriceFrom?: number | null
|
||||
minimumPriceTo?: number | null
|
||||
}
|
||||
|
||||
export interface SkipPriceQuery {
|
||||
page: number
|
||||
page_size: number
|
||||
group_id?: number
|
||||
shop_name?: string
|
||||
asin?: string
|
||||
country?: string
|
||||
minimum_price_from?: number
|
||||
minimum_price_to?: number
|
||||
}
|
||||
|
||||
export function createSkipPriceFilterState(): SkipPriceFilterState {
|
||||
return { groupId: null, shopName: '', asin: '', country: '', minimumPriceFrom: '', minimumPriceTo: '' }
|
||||
}
|
||||
|
||||
/** 非负可解析价格 → number;空/非法/负数返回 null。 */
|
||||
export function normalizePriceInput(value: string): number | null {
|
||||
const trimmed = (value || '').trim()
|
||||
if (!trimmed) return null
|
||||
if (!/^\d+(\.\d+)?$/.test(trimmed)) return null
|
||||
const num = Number(trimmed)
|
||||
return Number.isFinite(num) && num >= 0 ? num : null
|
||||
}
|
||||
|
||||
/** 是否有任一激活筛选条件。 */
|
||||
export function skipPriceFilterActive(state: SkipPriceFilterState): boolean {
|
||||
if (typeof state.groupId === 'number' && state.groupId >= 1) return true
|
||||
if ((state.shopName || '').trim()) return true
|
||||
if ((state.asin || '').trim()) return true
|
||||
if (normalizeAsinCountry(state.country) !== '') return true
|
||||
return normalizePriceInput(state.minimumPriceFrom) !== null || normalizePriceInput(state.minimumPriceTo) !== null
|
||||
}
|
||||
|
||||
/** 归一化列表查询参数:页码下限 1、页大小 1..100、空白与非法条件不下发。 */
|
||||
export function normalizeSkipPriceParams(raw: Partial<SkipPriceListParams>): SkipPriceListParams {
|
||||
const page = typeof raw.page === 'number' && Number.isFinite(raw.page) ? Math.max(Math.floor(raw.page), 1) : 1
|
||||
const rawSize = typeof raw.pageSize === 'number' && Number.isFinite(raw.pageSize) ? Math.floor(raw.pageSize) : NaN
|
||||
const pageSize = Number.isNaN(rawSize) || rawSize < 1 ? ASIN_PAGE_DEFAULT_SIZE : Math.min(rawSize, SKIP_PRICE_MAX_PAGE_SIZE)
|
||||
const groupId =
|
||||
typeof raw.groupId === 'number' && Number.isFinite(raw.groupId) && raw.groupId >= 1 ? Math.floor(raw.groupId) : null
|
||||
const shopName = typeof raw.shopName === 'string' ? raw.shopName.trim() : ''
|
||||
const asin = typeof raw.asin === 'string' ? raw.asin.trim() : ''
|
||||
const country = normalizeAsinCountry(typeof raw.country === 'string' ? raw.country : '')
|
||||
const minimumPriceFrom = typeof raw.minimumPriceFrom === 'number' && Number.isFinite(raw.minimumPriceFrom)
|
||||
? Math.max(raw.minimumPriceFrom, 0)
|
||||
: null
|
||||
const minimumPriceTo = typeof raw.minimumPriceTo === 'number' && Number.isFinite(raw.minimumPriceTo)
|
||||
? Math.max(raw.minimumPriceTo, 0)
|
||||
: null
|
||||
const params: SkipPriceListParams = { page, pageSize }
|
||||
if (groupId !== null) params.groupId = groupId
|
||||
if (shopName) params.shopName = shopName
|
||||
if (asin) params.asin = asin
|
||||
if (country) params.country = country
|
||||
if (minimumPriceFrom !== null) params.minimumPriceFrom = minimumPriceFrom
|
||||
if (minimumPriceTo !== null) params.minimumPriceTo = minimumPriceTo
|
||||
return params
|
||||
}
|
||||
|
||||
/** 筛选态 + 页码 → 列表查询参数(已归一,可幂等重入)。 */
|
||||
export function toSkipPriceParams(
|
||||
state: SkipPriceFilterState,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): SkipPriceListParams {
|
||||
return normalizeSkipPriceParams({
|
||||
page,
|
||||
pageSize,
|
||||
groupId: state.groupId,
|
||||
shopName: state.shopName,
|
||||
asin: state.asin,
|
||||
country: state.country,
|
||||
minimumPriceFrom: normalizePriceInput(state.minimumPriceFrom),
|
||||
minimumPriceTo: normalizePriceInput(state.minimumPriceTo),
|
||||
})
|
||||
}
|
||||
|
||||
/** 前端查询参数 → Java 查询参数;与 SkipPriceAsinController @RequestParam(snake) 对齐,仅下发非空字段。 */
|
||||
export function toSkipPriceQuery(params: SkipPriceListParams): SkipPriceQuery {
|
||||
const query: SkipPriceQuery = { page: params.page, page_size: params.pageSize }
|
||||
if (typeof params.groupId === 'number' && params.groupId >= 1) query.group_id = params.groupId
|
||||
if (params.shopName) query.shop_name = params.shopName
|
||||
if (params.asin) query.asin = params.asin
|
||||
if (params.country) query.country = params.country
|
||||
if (typeof params.minimumPriceFrom === 'number' && Number.isFinite(params.minimumPriceFrom)) {
|
||||
query.minimum_price_from = params.minimumPriceFrom
|
||||
}
|
||||
if (typeof params.minimumPriceTo === 'number' && Number.isFinite(params.minimumPriceTo)) {
|
||||
query.minimum_price_to = params.minimumPriceTo
|
||||
}
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** 最低价 ASIN 导入添加/删除适配(任务 76):multipart POST /import、/delete-import(file+group_id 必填) 及进度轮询。 */
|
||||
import { http } from '@/api/http'
|
||||
import { parseImportProgress, parseImportStart, type ImportProgress } from './import-progress-model'
|
||||
|
||||
export const SKIP_PRICE_IMPORT_ENDPOINT = '/api/admin/skip-price-asins/import'
|
||||
export const SKIP_PRICE_DELETE_IMPORT_ENDPOINT = '/api/admin/skip-price-asins/delete-import'
|
||||
|
||||
function requireGroupId(groupId: number): number {
|
||||
if (typeof groupId !== 'number' || !Number.isFinite(groupId) || groupId < 1) {
|
||||
throw new Error('请选择分组后再导入')
|
||||
}
|
||||
return Math.floor(groupId)
|
||||
}
|
||||
|
||||
function importForm(file: File, groupId: number): FormData {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
form.append('group_id', String(requireGroupId(groupId)))
|
||||
return form
|
||||
}
|
||||
|
||||
/** 启动导入添加(后端 group_id 必填)。 */
|
||||
export async function startSkipPriceImport(file: File, groupId: number): Promise<string> {
|
||||
const { data } = await http.post<unknown>(SKIP_PRICE_IMPORT_ENDPOINT, importForm(file, groupId))
|
||||
return parseImportStart(data)
|
||||
}
|
||||
|
||||
/** 查询导入添加进度:GET /import/{importId}。 */
|
||||
export async function fetchSkipPriceImportProgress(importId: string): Promise<ImportProgress> {
|
||||
const { data } = await http.get<unknown>(`${SKIP_PRICE_IMPORT_ENDPOINT}/${importId}`)
|
||||
return parseImportProgress(data)
|
||||
}
|
||||
|
||||
/** 启动导入删除(后端 group_id 必填)。 */
|
||||
export async function startSkipPriceDeleteImport(file: File, groupId: number): Promise<string> {
|
||||
const { data } = await http.post<unknown>(SKIP_PRICE_DELETE_IMPORT_ENDPOINT, importForm(file, groupId))
|
||||
return parseImportStart(data)
|
||||
}
|
||||
|
||||
/** 查询导入删除进度:GET /delete-import/{importId}。 */
|
||||
export async function fetchSkipPriceDeleteImportProgress(importId: string): Promise<ImportProgress> {
|
||||
const { data } = await http.get<unknown>(`${SKIP_PRICE_DELETE_IMPORT_ENDPOINT}/${importId}`)
|
||||
return parseImportProgress(data)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/** 最低价 ASIN 列表模型(任务 75):解析 SkipPriceAsinPageVo(camel) 宽行(每店铺 5 国家 ASIN+最低价),纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
import { ASIN_PAGE_DEFAULT_SIZE } from './asin-filter.ts'
|
||||
import { ASIN_COUNTRY_CODES, type AsinCountryCode } from './asin-country.ts'
|
||||
|
||||
export interface SkipPriceItem {
|
||||
id: number
|
||||
groupId: number | null
|
||||
groupName: string
|
||||
shopName: string
|
||||
asinDe: string
|
||||
minimumPriceDe: number | null
|
||||
asinUk: string
|
||||
minimumPriceUk: number | null
|
||||
asinFr: string
|
||||
minimumPriceFr: number | null
|
||||
asinIt: string
|
||||
minimumPriceIt: number | null
|
||||
asinEs: string
|
||||
minimumPriceEs: number | null
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface SkipPricePageResult {
|
||||
items: SkipPriceItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function emptySkipPricePage(): SkipPricePageResult {
|
||||
return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
/** 数字或可解析数字串 → number;否则 null(BigDecimal 可能以字符串下发)。 */
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed && Number.isFinite(Number(trimmed))) return Number(trimmed)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function idOrNull(value: unknown): number | null {
|
||||
const num = numberOrNull(value)
|
||||
return num === null ? null : Math.floor(num)
|
||||
}
|
||||
|
||||
function lowerCode(code: AsinCountryCode): string {
|
||||
return code.toLowerCase()
|
||||
}
|
||||
|
||||
/** 'DE' → 'De',拼出 VO 驼峰字段后缀(asinDe/minimumPriceDe)。 */
|
||||
function countrySuffix(code: string): string {
|
||||
return code.length > 1 ? code[0] + code.slice(1).toLowerCase() : code.toUpperCase()
|
||||
}
|
||||
|
||||
/** 解析单条最低价 ASIN 店铺行;缺 id 视为无效。 */
|
||||
export function toSkipPriceItem(raw: unknown): SkipPriceItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = idOrNull(record.id)
|
||||
if (id === null) return null
|
||||
const item: SkipPriceItem = {
|
||||
id,
|
||||
groupId: idOrNull(record.groupId ?? record.group_id),
|
||||
groupName: text(record.groupName ?? record.group_name),
|
||||
shopName: text(record.shopName ?? record.shop_name),
|
||||
asinDe: '',
|
||||
minimumPriceDe: null,
|
||||
asinUk: '',
|
||||
minimumPriceUk: null,
|
||||
asinFr: '',
|
||||
minimumPriceFr: null,
|
||||
asinIt: '',
|
||||
minimumPriceIt: null,
|
||||
asinEs: '',
|
||||
minimumPriceEs: null,
|
||||
}
|
||||
const writable = item as unknown as Record<string, unknown>
|
||||
for (const code of ASIN_COUNTRY_CODES) {
|
||||
const suffix = countrySuffix(code)
|
||||
const camelAsin = `asin${suffix}`
|
||||
const snakeAsin = `asin_${lowerCode(code)}`
|
||||
writable[camelAsin] = text(record[camelAsin] ?? record[snakeAsin])
|
||||
const camelPrice = `minimumPrice${suffix}`
|
||||
const snakePrice = `minimum_price_${lowerCode(code)}`
|
||||
writable[camelPrice] = numberOrNull(record[camelPrice] ?? record[snakePrice])
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** 归一化分页负载(信封或已解包 VO)为前端结果;缺省字段回默认。 */
|
||||
export function parseSkipPricePage(payload: unknown): SkipPricePageResult {
|
||||
const out = emptySkipPricePage()
|
||||
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) => toSkipPriceItem(raw))
|
||||
.filter((item): item is SkipPriceItem => item !== null)
|
||||
}
|
||||
if (typeof record.total === 'number') out.total = Math.floor(record.total)
|
||||
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
||||
const rawSize = record.pageSize ?? record.page_size
|
||||
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 取某国家列 ASIN;国家码不属白名单返回空串。 */
|
||||
export function skipPriceAsin(item: SkipPriceItem, country: string): string {
|
||||
if (!ASIN_COUNTRY_CODES.includes(country as AsinCountryCode)) return ''
|
||||
return item[`asin${countrySuffix(country)}` as keyof SkipPriceItem] as string
|
||||
}
|
||||
|
||||
/** 取某国家最低价;国家码不属白名单返回 null。 */
|
||||
export function skipPriceMinimum(item: SkipPriceItem, country: string): number | null {
|
||||
if (!ASIN_COUNTRY_CODES.includes(country as AsinCountryCode)) return null
|
||||
return item[`minimumPrice${countrySuffix(country)}` as keyof SkipPriceItem] as number | null
|
||||
}
|
||||
|
||||
/** 最低价展示文本(对齐 admin.js formatSkipPriceMinimumPrice:数字→两位小数,空→'',非法→原串)。 */
|
||||
export function formatSkipPriceText(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
const num = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(num)) return String(value)
|
||||
return num.toFixed(2)
|
||||
}
|
||||
|
||||
/** 纵向行条目(对齐 admin.js buildSkipPriceAsinEntries):有 ASIN 或最低价的国家一行;全空补一条空条目。 */
|
||||
export interface SkipPriceRowEntry {
|
||||
country: AsinCountryCode | null
|
||||
asin: string
|
||||
minimumPrice: string
|
||||
}
|
||||
|
||||
export function skipPriceRowEntries(item: SkipPriceItem): SkipPriceRowEntry[] {
|
||||
const entries: SkipPriceRowEntry[] = []
|
||||
for (const code of ASIN_COUNTRY_CODES) {
|
||||
const asin = skipPriceAsin(item, code)
|
||||
const minimumPrice = skipPriceMinimum(item, code)
|
||||
if (asin || minimumPrice != null) {
|
||||
entries.push({ country: code, asin, minimumPrice: formatSkipPriceText(minimumPrice) })
|
||||
}
|
||||
}
|
||||
if (!entries.length) entries.push({ country: null, asin: '', minimumPrice: '' })
|
||||
return entries
|
||||
}
|
||||
|
||||
/** 纵向展示行(对齐 admin.js renderSkipPriceAsinRows):序号/分组/店铺/操作 按 rowspan 合并,逐国一行。 */
|
||||
export interface SkipPriceDisplayRow {
|
||||
key: string
|
||||
rowNo: number
|
||||
rowspan: number
|
||||
isFirst: boolean
|
||||
country: AsinCountryCode | null
|
||||
asin: string
|
||||
minimumPrice: string
|
||||
item: SkipPriceItem
|
||||
}
|
||||
|
||||
export function skipPriceDisplayRows(items: SkipPriceItem[], startRowNo: number): SkipPriceDisplayRow[] {
|
||||
const out: SkipPriceDisplayRow[] = []
|
||||
let rowNo = Math.max(Math.floor(startRowNo || 1), 1)
|
||||
for (const item of items) {
|
||||
const entries = skipPriceRowEntries(item)
|
||||
entries.forEach((entry, index) => {
|
||||
out.push({
|
||||
key: `${item.id}:${index}`,
|
||||
rowNo,
|
||||
rowspan: entries.length,
|
||||
isFirst: index === 0,
|
||||
country: entry.country,
|
||||
asin: entry.asin,
|
||||
minimumPrice: entry.minimumPrice,
|
||||
item,
|
||||
})
|
||||
})
|
||||
rowNo += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** 品牌数据库(不符合ASIN)列表加载适配(任务 68):GET /api/admin/invalid-asin-data。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import { toInvalidAsinCreateRequest, toInvalidAsinUpdateRequest, type InvalidAsinForm } from './invalid-asin-form-model'
|
||||
import { parseInvalidAsinPage, type InvalidAsinPageResult } from './invalid-asin-model'
|
||||
|
||||
export const INVALID_ASIN_ENDPOINT = '/api/admin/invalid-asin-data'
|
||||
|
||||
export interface InvalidAsinQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
dataValue?: string
|
||||
brand?: string
|
||||
groupId?: number | null
|
||||
}
|
||||
|
||||
function trimOrUndefined(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function toQuery(query: InvalidAsinQuery): Record<string, string | number> {
|
||||
const params: Record<string, string | number> = { page: query.page, page_size: query.pageSize }
|
||||
const dataValue = trimOrUndefined(query.dataValue)
|
||||
const brand = trimOrUndefined(query.brand)
|
||||
if (dataValue) params.data_value = dataValue
|
||||
if (brand) params.brand = brand
|
||||
if (typeof query.groupId === 'number' && query.groupId >= 1) params.group_id = query.groupId
|
||||
return params
|
||||
}
|
||||
|
||||
export async function fetchInvalidAsinPage(query: InvalidAsinQuery): Promise<InvalidAsinPageResult> {
|
||||
const { data } = await http.get<unknown>(INVALID_ASIN_ENDPOINT, { params: toQuery(query) })
|
||||
return parseInvalidAsinPage(data)
|
||||
}
|
||||
|
||||
/** 新增品牌数据库记录:POST /api/admin/invalid-asin-data。 */
|
||||
export async function createInvalidAsin(form: InvalidAsinForm): Promise<void> {
|
||||
const { data } = await http.post<unknown>(INVALID_ASIN_ENDPOINT, toInvalidAsinCreateRequest(form))
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 编辑品牌数据库记录:PUT /api/admin/invalid-asin-data/{id}。 */
|
||||
export async function updateInvalidAsin(id: number, form: InvalidAsinForm, includeGroup = false): Promise<void> {
|
||||
const { data } = await http.put<unknown>(`${INVALID_ASIN_ENDPOINT}/${id}`, toInvalidAsinUpdateRequest(form, { includeGroup }))
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 删除品牌数据库记录:DELETE /api/admin/invalid-asin-data/{id}。 */
|
||||
export async function deleteInvalidAsin(id: number): Promise<void> {
|
||||
const { data } = await http.delete<unknown>(`${INVALID_ASIN_ENDPOINT}/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** 品牌数据库(不符合ASIN)表单模型(任务 69):新建/编辑表单校验与序列化,纯逻辑。 */
|
||||
import type { InvalidAsinItem } from './invalid-asin-model.ts'
|
||||
|
||||
export const INVALID_ASIN_FIELD_MAX_LEN = 128
|
||||
|
||||
export interface InvalidAsinForm {
|
||||
dataValue: string
|
||||
brand: string
|
||||
groupId: number | null
|
||||
}
|
||||
|
||||
export interface InvalidAsinFormErrors {
|
||||
dataValue?: string
|
||||
brand?: string
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export interface InvalidAsinCreatePayload {
|
||||
dataValue: string
|
||||
brand?: string
|
||||
groupId: number
|
||||
}
|
||||
|
||||
export interface InvalidAsinUpdatePayload {
|
||||
dataValue: string
|
||||
brand?: string
|
||||
groupId?: number
|
||||
}
|
||||
|
||||
export function createEmptyInvalidAsinForm(): InvalidAsinForm {
|
||||
return { dataValue: '', brand: '', groupId: null }
|
||||
}
|
||||
|
||||
/** 从列表项回填表单。 */
|
||||
export function invalidAsinFormFromItem(item: InvalidAsinItem): InvalidAsinForm {
|
||||
return { dataValue: item.dataValue, brand: item.brand, groupId: item.groupId }
|
||||
}
|
||||
|
||||
function text(value: string): string {
|
||||
return (value || '').trim()
|
||||
}
|
||||
|
||||
/** 校验表单(镜像后端 NotBlank/@Size/NotNull 文案);manual 新增必须选分组。 */
|
||||
export function validateInvalidAsinForm(
|
||||
form: InvalidAsinForm,
|
||||
options: { requireGroup: boolean },
|
||||
): { valid: boolean; errors: InvalidAsinFormErrors } {
|
||||
const errors: InvalidAsinFormErrors = {}
|
||||
const dataValue = text(form.dataValue)
|
||||
if (!dataValue) {
|
||||
errors.dataValue = 'ASIN 不能为空'
|
||||
} else if (dataValue.length > INVALID_ASIN_FIELD_MAX_LEN) {
|
||||
errors.dataValue = `ASIN 长度不能超过${INVALID_ASIN_FIELD_MAX_LEN}个字符`
|
||||
}
|
||||
const brand = text(form.brand)
|
||||
if (brand.length > INVALID_ASIN_FIELD_MAX_LEN) {
|
||||
errors.brand = `品牌长度不能超过${INVALID_ASIN_FIELD_MAX_LEN}个字符`
|
||||
}
|
||||
if (options.requireGroup && (typeof form.groupId !== 'number' || form.groupId < 1)) {
|
||||
errors.groupId = '请选择分组'
|
||||
}
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
/** 表单 → POST /api/admin/invalid-asin-data 请求体(camel)。 */
|
||||
export function toInvalidAsinCreateRequest(form: InvalidAsinForm): InvalidAsinCreatePayload {
|
||||
const payload: InvalidAsinCreatePayload = {
|
||||
dataValue: text(form.dataValue),
|
||||
groupId: typeof form.groupId === 'number' ? form.groupId : 0,
|
||||
}
|
||||
const brand = text(form.brand)
|
||||
if (brand) payload.brand = brand
|
||||
return payload
|
||||
}
|
||||
|
||||
/** 表单 → PUT /api/admin/invalid-asin-data/{id} 请求体;自动导入记录默认不迁组。 */
|
||||
export function toInvalidAsinUpdateRequest(
|
||||
form: InvalidAsinForm,
|
||||
options: { includeGroup?: boolean } = {},
|
||||
): InvalidAsinUpdatePayload {
|
||||
const payload: InvalidAsinUpdatePayload = { dataValue: text(form.dataValue) }
|
||||
const brand = text(form.brand)
|
||||
if (brand) payload.brand = brand
|
||||
if (options.includeGroup && typeof form.groupId === 'number' && form.groupId >= 1) {
|
||||
payload.groupId = form.groupId
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/** 品牌数据库(不符合ASIN)列表模型(任务 68):解析 InvalidAsinDataPageVo(camel),纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
import { ASIN_PAGE_DEFAULT_SIZE } from '../asin/asin-filter.ts'
|
||||
|
||||
export type InvalidAsinSource = 'MANUAL' | 'AUTO'
|
||||
|
||||
export interface InvalidAsinItem {
|
||||
id: number
|
||||
dataValue: string
|
||||
brand: string
|
||||
groupId: number | null
|
||||
groupName: string
|
||||
recordSource: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface InvalidAsinPageResult {
|
||||
items: InvalidAsinItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function emptyInvalidAsinPage(): InvalidAsinPageResult {
|
||||
return { items: [], total: 0, page: 1, pageSize: ASIN_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 来源展示标签:MANUAL 手动新增,其余(含空)自动导入。 */
|
||||
export function recordSourceLabel(source: string | null | undefined): string {
|
||||
return (source || '').trim().toUpperCase() === 'MANUAL' ? '手动新增' : '自动导入'
|
||||
}
|
||||
|
||||
/** 删除二次确认文案(对齐 admin.js:3775 原文)。 */
|
||||
export function invalidAsinDeleteText(item: Pick<InvalidAsinItem, 'dataValue'> | null | undefined): string {
|
||||
const value = item?.dataValue || '该 ASIN'
|
||||
return `确定删除“${value}”吗?`
|
||||
}
|
||||
|
||||
/**
|
||||
* 非超管分组锁定(对齐 admin.js getInvalidAsinDataLockedGroupId:超管不锁;
|
||||
* 非超管锁 lockedGroupId,缺失时退化为第一个分组;无分组返回 null)。
|
||||
*/
|
||||
export function invalidAsinLockedGroupId(
|
||||
groups: Array<{ id: number }>,
|
||||
lockedGroupId: number | null | undefined,
|
||||
operatorRole: string,
|
||||
): number | null {
|
||||
if ((operatorRole || '').toLowerCase() === 'super_admin') return null
|
||||
if (lockedGroupId != null && groups.some((group) => group.id === lockedGroupId)) return lockedGroupId
|
||||
return groups.length ? groups[0].id : null
|
||||
}
|
||||
|
||||
/** 解析单条品牌数据库项;缺 id 视为无效。 */
|
||||
export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | 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 item: InvalidAsinItem = {
|
||||
id,
|
||||
dataValue: text(record.dataValue ?? record.data_value),
|
||||
brand: text(record.brand),
|
||||
groupId: numberOrNull(record.groupId ?? record.group_id),
|
||||
groupName: text(record.groupName ?? record.group_name),
|
||||
recordSource: (text(record.recordSource ?? record.record_source)).toUpperCase(),
|
||||
}
|
||||
const createdAt = text(record.createdAt ?? record.created_at)
|
||||
if (createdAt) item.createdAt = createdAt
|
||||
return item
|
||||
}
|
||||
|
||||
/** 归一化分页负载(信封或已解包 VO)为前端结果。 */
|
||||
export function parseInvalidAsinPage(payload: unknown): InvalidAsinPageResult {
|
||||
const out = emptyInvalidAsinPage()
|
||||
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) => toInvalidAsinItem(raw))
|
||||
.filter((item): item is InvalidAsinItem => item !== null)
|
||||
}
|
||||
if (typeof record.total === 'number') out.total = Math.floor(record.total)
|
||||
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
||||
const rawSize = record.pageSize ?? record.page_size
|
||||
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
/** 数富AI 后台 Vue 登录页:复刻旧登录页「品牌视觉 + 登录卡」蓝白设计;POST /login 建会话后进入后台。 */
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { http } from '@/api/http'
|
||||
import { joinAdminPath } from '@/config/app'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useAdminSessionStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
||||
|
||||
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
|
||||
|
||||
function deviceId(): string {
|
||||
try {
|
||||
const key = 'aiimage_console_device_id'
|
||||
let value = localStorage.getItem(key)
|
||||
if (!value) {
|
||||
value = `web-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
return value
|
||||
} catch {
|
||||
return `web-${Date.now()}`
|
||||
}
|
||||
}
|
||||
|
||||
function safeRedirect(value: unknown): string {
|
||||
if (typeof value !== 'string') return '/'
|
||||
let target = value
|
||||
try {
|
||||
target = decodeURIComponent(target)
|
||||
} catch {
|
||||
// 忽略非法编码
|
||||
}
|
||||
if (!target.startsWith('/') || target.startsWith('//')) return '/'
|
||||
if (target.startsWith('/admin-vue')) target = target.slice('/admin-vue'.length) || '/'
|
||||
return target
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const user = username.value.trim()
|
||||
if (!user || !password.value) {
|
||||
errorMessage.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const did = deviceId()
|
||||
await http.post<unknown>('/login', { username: user, password: password.value, deviceId: did })
|
||||
session.$reset()
|
||||
await session.initialize()
|
||||
const target = safeRedirect(route.query.redirect)
|
||||
await router.replace(target)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : '用户名或密码错误'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (session.initialized && session.user) {
|
||||
router.replace('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<aside class="auth-visual" aria-label="数富AI 产品信息">
|
||||
<div class="auth-visual-content">
|
||||
<div class="auth-brand">
|
||||
<span class="auth-brand-mark" aria-hidden="true"><img :src="logoUrl" alt=""></span>
|
||||
<span class="auth-brand-copy">
|
||||
<span class="auth-brand-name">数富AI</span>
|
||||
<span class="auth-brand-sub">电商运营管理后台</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="auth-visual-copy">
|
||||
<span class="auth-kicker">数富AI · 运营工作台</span>
|
||||
<h2 class="auth-visual-title">让每一次运营动作,都有清晰的工作流。</h2>
|
||||
<p class="auth-visual-description">统一管理数据、店铺、任务与版本,让团队在一个可靠的运营工作台中快速协作。</p>
|
||||
<ul class="auth-feature-list" aria-label="工作台能力">
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg></span>
|
||||
<span>权限隔离,操作边界清晰可控</span>
|
||||
</li>
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 2"></path><circle cx="12" cy="12" r="9"></circle><path d="M3 4v5h5"></path><path d="M3.5 9A9 9 0 0 1 19 5.5"></path></svg></span>
|
||||
<span>任务状态,进度反馈及时透明</span>
|
||||
</li>
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="m7 15 3-4 3 2 4-6"></path><path d="M17 7h3v3"></path></svg></span>
|
||||
<span>数据工具,支撑日常电商运营</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-visual-footer">
|
||||
<span>数富AI · 管理控制台</span>
|
||||
<span class="auth-system-status">服务已就绪</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="auth-content">
|
||||
<section class="login-card" aria-labelledby="loginTitle">
|
||||
<header class="login-card-header">
|
||||
<p class="login-card-eyebrow">欢迎回来</p>
|
||||
<h1 class="login-title" id="loginTitle">登录工作台</h1>
|
||||
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
||||
</header>
|
||||
<form novalidate @submit.prevent="submit">
|
||||
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
|
||||
<div class="form-group">
|
||||
<label for="loginUsername">用户名</label>
|
||||
<div class="input-shell">
|
||||
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"></circle><path d="M4 21a8 8 0 0 1 16 0"></path></svg></span>
|
||||
<input id="loginUsername" v-model="username" type="text" autocomplete="username" placeholder="请输入用户名" autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginPassword">密码</label>
|
||||
<div class="input-shell">
|
||||
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="10" x="5" y="11" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path></svg></span>
|
||||
<input id="loginPassword" v-model="password" :type="inputType" autocomplete="current-password" placeholder="请输入密码" @keyup.enter="submit">
|
||||
<button type="button" class="password-toggle" :aria-label="showPassword ? '隐藏密码' : '显示密码'" @click="showPassword = !showPassword">
|
||||
<svg v-if="!showPassword" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 3 18 18"></path><path d="M10.6 5.1A9.7 9.7 0 0 1 12 5c6 0 9.5 7 9.5 7a17 17 0 0 1-2.3 3"></path><path d="M6.6 6.6C3.7 8.6 2.5 12 2.5 12s3.5 7 9.5 7a9.7 9.7 0 0 0 4.5-1.1"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-login" :class="{ 'is-loading': loading }" :disabled="loading" :aria-busy="loading">
|
||||
<span class="btn-login-label">{{ loading ? '登录中...' : '登录' }}</span>
|
||||
<span class="btn-login-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="login-security-note">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg>
|
||||
<span>请勿在公共设备保存账号凭据。登录状态由本地安全会话管理。</span>
|
||||
</p>
|
||||
<footer class="login-card-footer">数富AI · 电商运营管理工作台</footer>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.9fr) minmax(420px, 1.1fr);
|
||||
color: #24384d;
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(178, 205, 229, 0.32), transparent 34rem),
|
||||
radial-gradient(circle at 96% 100%, rgba(214, 226, 239, 0.28), transparent 28rem),
|
||||
#f4f7fb;
|
||||
}
|
||||
button, input { font: inherit; }
|
||||
|
||||
.auth-visual {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 100vh;
|
||||
padding: 36px clamp(32px, 5vw, 84px) 34px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #d4e0eb;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(232, 240, 248, 0.97), rgba(248, 251, 254, 0.99)),
|
||||
radial-gradient(circle at 20% 8%, rgba(112, 148, 186, 0.16), transparent 28rem);
|
||||
}
|
||||
.auth-visual::before,
|
||||
.auth-visual::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
.auth-visual::before {
|
||||
inset: 0;
|
||||
opacity: 0.3;
|
||||
background-image: linear-gradient(rgba(79, 120, 165, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(79, 120, 165, 0.1) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
||||
}
|
||||
.auth-visual::after {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
right: -150px;
|
||||
bottom: -160px;
|
||||
border: 1px solid rgba(79, 120, 165, 0.22);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 32px rgba(79, 120, 165, 0.055), 0 0 0 64px rgba(79, 120, 165, 0.035);
|
||||
}
|
||||
.auth-visual-content, .auth-visual-footer { position: relative; z-index: 1; }
|
||||
|
||||
.auth-brand { display: inline-flex; align-items: center; gap: 12px; width: fit-content; }
|
||||
.auth-brand-mark {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 46px; height: 46px; border-radius: 13px; overflow: hidden;
|
||||
background: #fff; box-shadow: 0 10px 24px rgba(79, 120, 165, 0.18);
|
||||
}
|
||||
.auth-brand-mark img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.auth-brand-copy { display: grid; gap: 1px; }
|
||||
.auth-brand-name { font-size: 22px; font-weight: 700; letter-spacing: 0.2px; }
|
||||
.auth-brand-sub { color: #77899b; font-size: 11px; }
|
||||
.auth-visual-content { max-width: 540px; margin: auto 0; padding: 72px 0 96px; }
|
||||
.auth-visual-copy { padding-top: 72px; }
|
||||
.auth-kicker {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
color: #2f5d8b; font-size: 11px; font-weight: 700; letter-spacing: 1.8px;
|
||||
}
|
||||
.auth-kicker::before { content: ""; width: 24px; height: 1px; background: #4f78a5; }
|
||||
.auth-visual-title {
|
||||
max-width: 560px; margin: 18px 0;
|
||||
font-size: clamp(30px, 4vw, 52px); font-weight: 700; letter-spacing: -1.8px; line-height: 1.12;
|
||||
}
|
||||
.auth-visual-description { max-width: 470px; margin: 0; color: #60748a; font-size: 16px; line-height: 1.75; }
|
||||
.auth-feature-list { display: grid; gap: 12px; margin: 34px 0 0; padding: 0; list-style: none; }
|
||||
.auth-feature-item { display: flex; align-items: center; gap: 12px; color: #465d73; }
|
||||
.auth-feature-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 30px; width: 30px; height: 30px;
|
||||
border: 1px solid #c2d3e3; border-radius: 9px; background: #edf5fb; color: #2f5d8b;
|
||||
}
|
||||
.auth-feature-icon svg { width: 16px; height: 16px; }
|
||||
.auth-visual-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #778b9f; font-size: 12px; }
|
||||
.auth-system-status { display: inline-flex; align-items: center; gap: 7px; color: #3d7158; }
|
||||
.auth-system-status::before {
|
||||
content: ""; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #4e806d; box-shadow: 0 0 0 4px rgba(78, 128, 108, 0.13);
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-width: 0; padding: 40px clamp(24px, 6vw, 96px);
|
||||
background: rgba(249, 251, 253, 0.5);
|
||||
}
|
||||
.login-card {
|
||||
width: min(100%, 452px);
|
||||
padding: clamp(28px, 4vw, 48px);
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 26px 70px -38px rgba(39, 67, 94, 0.34);
|
||||
}
|
||||
.login-card-header { margin-bottom: 30px; }
|
||||
.login-card-eyebrow { margin: 0 0 8px; color: #71859a; font-size: 15px; font-weight: 700; letter-spacing: 1.2px; }
|
||||
.login-title { margin: 0; font-size: 30px; font-weight: 700; letter-spacing: -0.8px; line-height: 1.2; }
|
||||
.login-subtitle { margin: 10px 0 0; color: #5b6f83; line-height: 1.65; }
|
||||
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; color: #5b6f83; font-size: 13px; font-weight: 600; }
|
||||
.input-shell { position: relative; }
|
||||
.input-icon { position: absolute; top: 50%; left: 14px; display: inline-flex; color: #8298ad; pointer-events: none; transform: translateY(-50%); }
|
||||
.input-icon svg { width: 18px; height: 18px; }
|
||||
.form-group input {
|
||||
width: 100%; min-height: 48px; padding: 12px 52px 12px 44px;
|
||||
border: 1px solid #cbd9e6; border-radius: 12px; outline: none;
|
||||
background: #f8fbfd; color: #24384d; font-size: 14px;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover { border-color: #9fb7cd; }
|
||||
.form-group input:focus { border-color: #5f85ad; background: #fff; box-shadow: 0 0 0 4px rgba(95, 133, 173, 0.16); }
|
||||
.form-group input::placeholder { color: #8293a5; }
|
||||
.password-toggle {
|
||||
position: absolute; top: 50%; right: 4px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 40px; border: 0; border-radius: 9px;
|
||||
background: transparent; color: #8298ad; cursor: pointer; transform: translateY(-50%);
|
||||
transition: background 160ms ease, color 160ms ease;
|
||||
}
|
||||
.password-toggle:hover { background: #edf5fb; color: #2f5d8b; }
|
||||
.password-toggle svg { width: 18px; height: 18px; }
|
||||
|
||||
.error-msg {
|
||||
display: flex; align-items: flex-start; gap: 9px;
|
||||
margin: -4px 0 18px; padding: 11px 12px;
|
||||
border: 1px solid #e4c2c5; border-radius: 11px; background: #f8ebeb; color: #91474f;
|
||||
font-size: 13px; line-height: 1.55;
|
||||
}
|
||||
.error-msg::before {
|
||||
content: "!";
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 18px; width: 18px; height: 18px;
|
||||
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||
width: 100%; min-height: 48px; padding: 12px 18px;
|
||||
border: 1px solid #7196ba; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #fff; cursor: pointer; font-size: 15px; font-weight: 800;
|
||||
box-shadow: 0 12px 24px -16px rgba(79, 120, 165, 0.82);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
.btn-login:hover:not(:disabled) { background: linear-gradient(135deg, #7094ba, #5d83ac); color: #fff; box-shadow: 0 16px 28px -15px rgba(79, 120, 165, 0.86); transform: translateY(-1px); }
|
||||
.btn-login:active:not(:disabled) { transform: translateY(1px) scale(0.99); }
|
||||
.btn-login:disabled { cursor: wait; opacity: 0.7; }
|
||||
.btn-login-spinner { display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.35); border-top-color: #fff; border-radius: 50%; animation: login-spin 0.8s linear infinite; }
|
||||
.btn-login.is-loading .btn-login-spinner { display: inline-block; }
|
||||
@keyframes login-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.login-security-note { display: flex; align-items: flex-start; gap: 9px; margin: 22px 0 0; color: #71859a; font-size: 12px; line-height: 1.55; }
|
||||
.login-security-note svg { flex: 0 0 auto; width: 16px; height: 16px; margin-top: 1px; color: #4e806d; }
|
||||
.login-card-footer { margin-top: 34px; padding-top: 18px; border-top: 1px solid #dce5ee; color: #778b9f; font-size: 12px; text-align: center; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.auth-page { display: block; }
|
||||
.auth-visual { min-height: auto; padding: 22px 24px; border-right: 0; border-bottom: 1px solid #d4e0eb; }
|
||||
.auth-visual-content { display: block; max-width: none; margin: 0; padding: 0; }
|
||||
.auth-visual-copy { display: none; }
|
||||
.auth-visual-footer { display: none; }
|
||||
.auth-content { min-height: calc(100vh - 87px); padding: 32px 24px 44px; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.auth-visual { padding: 18px 16px; }
|
||||
.auth-content { padding: 24px 14px 32px; }
|
||||
.login-card { padding: 26px 20px; border-radius: 18px; }
|
||||
.login-title { font-size: 27px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<script setup lang="ts">
|
||||
/** 数字人版本管理页(module 13 task 261 对齐 admin panel-digital-human-version;本轮补齐分页/三态/上传进度/下载语义)。 */
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
deleteDigitalHumanVersion,
|
||||
fetchDigitalHumanDownloadUrl,
|
||||
fetchDigitalHumanVersions,
|
||||
releaseDigitalHumanVersion,
|
||||
setLatestDigitalHumanVersion,
|
||||
uploadDigitalHumanVersion,
|
||||
} from './digitalhuman-api.ts'
|
||||
import type { DigitalHumanVersion } from './digitalhuman-dto.ts'
|
||||
import {
|
||||
digitalHumanChangelogShort,
|
||||
digitalHumanDeleteConfirmText,
|
||||
digitalHumanVersionFileSizeText,
|
||||
} from './digitalhuman-dto.ts'
|
||||
import { validateDigitalHumanUpload } from './digitalhuman-upload-model.ts'
|
||||
import { canReleaseDigitalHumanVersion, releaseDigitalHumanConfirmText } from './digitalhuman-release.ts'
|
||||
import { canSetLatestDigitalHumanVersion, setLatestDigitalHumanConfirmText } from './digitalhuman-latest.ts'
|
||||
import { digitalHumanStatusDisplay } from './digitalhuman-status-display.ts'
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<DigitalHumanVersion[]>([])
|
||||
|
||||
/** 分页(对齐 admin.js:6311 pageSize=20 + 数字页码 + 跳转;Java 列表接口不分页,前端分页呈现)。 */
|
||||
const pageSize = 20
|
||||
const page = ref(1)
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return items.value.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref<number | null>(null)
|
||||
const form = ref({ version: '', file: null as { name?: string; size?: number } | null, changelog: '', minClientVersion: '' })
|
||||
|
||||
const confirming = ref<string | null>(null)
|
||||
const downloadingVersion = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = (await fetchDigitalHumanVersions()).items
|
||||
// 数据收缩后页码越界回钳。
|
||||
const last = Math.max(Math.ceil(items.value.length / pageSize), 1)
|
||||
if (page.value > last) page.value = last
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(file: File) {
|
||||
form.value.file = file
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
const { valid, errors } = validateDigitalHumanUpload({ ...form.value, file: form.value.file })
|
||||
if (!valid) {
|
||||
ElMessage.warning(errors.version || errors.file || '请完善上传信息')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
const version = (form.value.version || '').trim()
|
||||
try {
|
||||
await uploadDigitalHumanVersion(
|
||||
{ version, file: form.value.file, changelog: form.value.changelog, minClientVersion: form.value.minClientVersion },
|
||||
form.value.file?.name,
|
||||
(percent) => {
|
||||
uploadProgress.value = percent
|
||||
},
|
||||
)
|
||||
ElMessage.success(`上传成功!版本:${version}(状态:草稿,请在列表中点击“发布”按钮)`)
|
||||
uploadVisible.value = false
|
||||
uploadProgress.value = null
|
||||
form.value = { version: '', file: null, changelog: '', minClientVersion: '' }
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function release(row: DigitalHumanVersion) {
|
||||
const text = releaseDigitalHumanConfirmText(row.version)
|
||||
if (!text) return
|
||||
confirming.value = row.version
|
||||
try {
|
||||
await ElMessageBox.confirm(text, '发布版本', { type: 'warning', confirmButtonText: '发布', cancelButtonText: '取消' })
|
||||
await releaseDigitalHumanVersion(row.version)
|
||||
ElMessage.success('已发布')
|
||||
load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(error instanceof Error ? error.message : '发布失败')
|
||||
} finally {
|
||||
confirming.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function setLatest(row: DigitalHumanVersion) {
|
||||
const text = setLatestDigitalHumanConfirmText(row.version)
|
||||
if (!text) return
|
||||
confirming.value = row.version
|
||||
try {
|
||||
await ElMessageBox.confirm(text, '设为最新', { type: 'warning', confirmButtonText: '设为最新', cancelButtonText: '取消' })
|
||||
await setLatestDigitalHumanVersion(row.version)
|
||||
ElMessage.success('已设为最新')
|
||||
load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
confirming.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载(对齐 admin.js downloadDigitalHumanVersion:RELEASED 恒显示,点击取链接并触发下载)。 */
|
||||
async function download(row: DigitalHumanVersion) {
|
||||
if (!row.version) return
|
||||
downloadingVersion.value = row.version
|
||||
try {
|
||||
const url = await fetchDigitalHumanDownloadUrl(row.version)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `ShuFuDigitalHuman-${row.version}.zip`
|
||||
anchor.target = '_blank'
|
||||
anchor.rel = 'noopener'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
||||
} finally {
|
||||
downloadingVersion.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: DigitalHumanVersion) {
|
||||
const text = digitalHumanDeleteConfirmText(row.version)
|
||||
if (!text) return
|
||||
confirming.value = row.version
|
||||
try {
|
||||
await ElMessageBox.confirm(text, '删除版本', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await deleteDigitalHumanVersion(row.version)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
} finally {
|
||||
confirming.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function canDelete(row: DigitalHumanVersion): boolean {
|
||||
return row.isLatest !== true
|
||||
}
|
||||
|
||||
function statusOf(row: DigitalHumanVersion): { text: string; tag: 'info' | 'success' | 'danger' } {
|
||||
return digitalHumanStatusDisplay(row.status)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>数字人版本管理</h2>
|
||||
<p>管理数字人客户端的草稿/已发布/已废弃版本。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="uploadVisible = true">上传(草稿状态)</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无数字人版本">
|
||||
<el-table-column prop="version" label="版本号" min-width="150" />
|
||||
<el-table-column label="状态" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusOf(row as DigitalHumanVersion).tag" size="small">
|
||||
{{ statusOf(row as DigitalHumanVersion).text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最新" min-width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<span v-if="(row as DigitalHumanVersion).isLatest" class="star" title="最新版本">★</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="文件大小" min-width="100">
|
||||
<template #default="{ row }">{{ digitalHumanVersionFileSizeText((row as DigitalHumanVersion).fileSize) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="MD5" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span :title="(row as DigitalHumanVersion).md5 || ''">{{ (row as DigitalHumanVersion).md5 ? `${((row as DigitalHumanVersion).md5 || '').substring(0, 12)}...` : '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新说明" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span :title="(row as DigitalHumanVersion).changelog || ''">{{ digitalHumanChangelogShort((row as DigitalHumanVersion).changelog) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDateTime((row as DigitalHumanVersion).releasedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canReleaseDigitalHumanVersion((row as DigitalHumanVersion).status)" text type="warning" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="release(row as DigitalHumanVersion)">发布</el-button>
|
||||
<el-button v-if="!canReleaseDigitalHumanVersion((row as DigitalHumanVersion).status) && canSetLatestDigitalHumanVersion((row as DigitalHumanVersion).status) && !(row as DigitalHumanVersion).isLatest" text type="primary" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="setLatest(row as DigitalHumanVersion)">设为最新</el-button>
|
||||
<el-button v-if="canSetLatestDigitalHumanVersion((row as DigitalHumanVersion).status)" text type="success" size="small" :loading="downloadingVersion === (row as DigitalHumanVersion).version" @click="download(row as DigitalHumanVersion)">下载</el-button>
|
||||
<el-button v-if="canDelete(row as DigitalHumanVersion)" text type="danger" size="small" :loading="confirming === (row as DigitalHumanVersion).version" @click="remove(row as DigitalHumanVersion)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 {{ items.length.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
:total="items.length"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="uploadVisible" title="上传数字人版本" width="560px">
|
||||
<p class="upload-desc">上传数字人程序 ZIP 包,系统会自动计算 MD5 并存储到 OSS。上传后状态为草稿,需要手动发布。</p>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="版本号" required>
|
||||
<el-input v-model="form.version" placeholder="如 1.0.2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="压缩包" required>
|
||||
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (form.file = null)">
|
||||
<el-button>选择文件</el-button>
|
||||
</el-upload>
|
||||
<div v-if="form.file" class="dim">{{ form.file.name }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低客户端">
|
||||
<el-input v-model="form.minClientVersion" placeholder="可选,客户端最低版本要求" />
|
||||
</el-form-item>
|
||||
<el-form-item label="更新说明">
|
||||
<el-input v-model="form.changelog" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-alert v-if="uploading && uploadProgress != null" :title="`正在上传:${uploadProgress}%`" type="info" :closable="false" show-icon />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="submitUpload">上传(草稿状态)</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.actions { display: flex; align-items: center; gap: 10px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.star { color: #e6a23c; font-weight: 700; }
|
||||
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
/** 历史生成记录页(module 13 task 259 对齐 admin panel-history):指定用户下拉 + 起止时间筛选、类型中文、≤3 缩略图、大图预览、空态。 */
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { fetchHistoryList, fetchHistoryUserOptions, clearHistory } from './history-api.ts'
|
||||
import type { HistoryUserOption } from './history-user-option.ts'
|
||||
import type { HistoryRecordItem } from './history-dto.ts'
|
||||
import { historyEmptyText, historyErrorText } from './history-feedback.ts'
|
||||
import { historyPanelTypeLabel } from './history-type.ts'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const loading = ref(false)
|
||||
const userLoading = ref(false)
|
||||
const rows = ref<HistoryRecordItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const userOptions = ref<HistoryUserOption[]>([])
|
||||
const errorText = ref('')
|
||||
const filter = reactive({ userId: '', timeStart: '', timeEnd: '' })
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewItem = ref<HistoryRecordItem | null>(null)
|
||||
|
||||
const hasFilter = computed(() => Boolean(filter.userId || filter.timeStart || filter.timeEnd))
|
||||
const emptyText = computed(() => historyEmptyText({ hasFilter: hasFilter.value, total: total.value }))
|
||||
|
||||
/** 预览图顺序:长图在前 + 结果图去重(对齐 admin 缩略图 [long,...result])。 */
|
||||
function previewUrls(item: HistoryRecordItem): string[] {
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const push = (url: string) => {
|
||||
const clean = typeof url === 'string' ? url.trim() : ''
|
||||
if (!clean || seen.has(clean)) return
|
||||
seen.add(clean)
|
||||
out.push(clean)
|
||||
}
|
||||
if (item.longImageUrl) push(item.longImageUrl)
|
||||
for (const url of item.resultUrls || []) push(url)
|
||||
return out
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorText.value = ''
|
||||
try {
|
||||
const result = await fetchHistoryList({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
userId: filter.userId ? Number(filter.userId) : undefined,
|
||||
timeStart: filter.timeStart || undefined,
|
||||
timeEnd: filter.timeEnd || undefined,
|
||||
})
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
errorText.value = historyErrorText(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserOptions() {
|
||||
userLoading.value = true
|
||||
try {
|
||||
userOptions.value = await fetchHistoryUserOptions()
|
||||
} catch {
|
||||
userOptions.value = []
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filter.userId = ''
|
||||
filter.timeStart = ''
|
||||
filter.timeEnd = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function retry() {
|
||||
load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
function openPreview(item: HistoryRecordItem) {
|
||||
previewItem.value = item
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const clearing = ref(false)
|
||||
|
||||
/** 清空全部生成记录:二次确认后调后端 DELETE /api/admin/history。 */
|
||||
async function clearAll() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定清空全部生成记录吗?该操作会删除所有历史数据,无法恢复。', '清理生成记录', {
|
||||
confirmButtonText: '确认清空',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
clearing.value = true
|
||||
try {
|
||||
const removed = await clearHistory()
|
||||
ElMessage.success(`已清空 ${removed} 条生成记录`)
|
||||
page.value = 1
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '清理失败')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadUserOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>生成记录</h2>
|
||||
<p>查看各用户在后台生成的历史记录与结果图。</p>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<el-button type="danger" plain :loading="clearing" @click="clearAll">清理数据</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<div class="filter-grid">
|
||||
<div class="f-item">
|
||||
<label>指定用户</label>
|
||||
<el-select v-model="filter.userId" clearable filterable placeholder="全部用户" style="width: 100%" :loading="userLoading">
|
||||
<el-option label="全部用户" value="" />
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>开始时间</label>
|
||||
<el-date-picker
|
||||
v-model="filter.timeStart"
|
||||
type="datetime"
|
||||
placeholder="开始时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="f-item">
|
||||
<label>结束时间</label>
|
||||
<el-date-picker
|
||||
v-model="filter.timeEnd"
|
||||
type="datetime"
|
||||
placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="f-item btn-row">
|
||||
<el-button type="primary" @click="apply">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-alert
|
||||
v-if="errorText"
|
||||
:title="errorText"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<template #default>
|
||||
<el-button link type="primary" @click="retry">重试</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-table v-loading="loading" :data="rows" stripe :empty-text="emptyText">
|
||||
<el-table-column prop="id" label="ID" min-width="90" />
|
||||
<el-table-column prop="username" label="用户" min-width="140" />
|
||||
<el-table-column label="类型" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ historyPanelTypeLabel((row as HistoryRecordItem).panelType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime((row as HistoryRecordItem).createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果预览" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div v-if="previewUrls(row as HistoryRecordItem).length" class="thumbs">
|
||||
<el-image
|
||||
v-for="(url, index) in previewUrls(row as HistoryRecordItem).slice(0, 3)"
|
||||
:key="url"
|
||||
:src="url"
|
||||
:preview-src-list="previewUrls(row as HistoryRecordItem)"
|
||||
:initial-index="index"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
class="thumb"
|
||||
/>
|
||||
<span v-if="previewUrls(row as HistoryRecordItem).length > 3" class="dim">+{{ previewUrls(row as HistoryRecordItem).length - 3 }}</span>
|
||||
</div>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openPreview(row as HistoryRecordItem)">查看大图</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="previewVisible" :title="previewItem ? `记录 #${previewItem.id} 结果图` : '结果图'" width="820px" top="6vh">
|
||||
<div v-if="previewItem" class="preview-meta">
|
||||
<el-tag size="small">{{ previewItem.username }}</el-tag>
|
||||
<span>{{ historyPanelTypeLabel(previewItem.panelType) }}</span>
|
||||
<span>{{ formatDateTime(previewItem.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="previewItem && previewUrls(previewItem).length" class="preview-grid">
|
||||
<el-image
|
||||
v-for="(url, index) in previewUrls(previewItem)"
|
||||
:key="url"
|
||||
:src="url"
|
||||
:preview-src-list="previewUrls(previewItem)"
|
||||
:initial-index="index"
|
||||
preview-teleported
|
||||
fit="contain"
|
||||
class="preview-img"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-else description="无结果图片" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card { margin-bottom: 14px; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 190px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.f-item.btn-row { flex-direction: row; align-items: flex-end; gap: 8px; width: auto; }
|
||||
.thumbs { display: flex; align-items: center; gap: 6px; }
|
||||
.thumb { width: 56px; height: 56px; border-radius: 4px; border: 1px solid var(--el-border-color-lighter); flex: none; }
|
||||
.table-footer { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; }
|
||||
.table-footer span { color: var(--el-text-color-secondary); font-size: 12.5px; }
|
||||
.table-footer b { color: var(--el-text-color-primary); }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.preview-meta { display: flex; align-items: center; gap: 12px; color: var(--el-text-color-secondary); font-size: 12px; margin-bottom: 12px; }
|
||||
.preview-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||
.preview-img { width: 100%; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 软件版本管理页:客户端版本列表 + 上传新版本(成功留驻弹窗含链接,对齐 admin panel-version)。 */
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchSoftwareVersions, uploadSoftwareVersion } from './version-api.ts'
|
||||
import type { SoftwareVersionItem } from './version-dto.ts'
|
||||
import { isNonEmptyVersion } from './version-dto.ts'
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<SoftwareVersionItem[]>([])
|
||||
// 客户端分页:全量拉取后按页切片展示。
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const total = computed(() => items.value.length)
|
||||
const pagedItems = computed(() => items.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
|
||||
function changePage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const newVersion = ref('')
|
||||
const pickedFile = ref<File | null>(null)
|
||||
/** 弹窗内成功/失败文案(对齐 admin.js msgVersion 留驻)。 */
|
||||
const uploadMsg = ref('')
|
||||
const uploadMsgOk = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = (await fetchSoftwareVersions()).items
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '版本列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange(file: File) {
|
||||
pickedFile.value = file
|
||||
}
|
||||
|
||||
function openUpload() {
|
||||
uploadMsg.value = ''
|
||||
uploadMsgOk.value = false
|
||||
newVersion.value = ''
|
||||
pickedFile.value = null
|
||||
uploadVisible.value = true
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
uploadMsg.value = ''
|
||||
uploadMsgOk.value = false
|
||||
if (!isNonEmptyVersion(newVersion.value)) {
|
||||
uploadMsg.value = '请填写版本号'
|
||||
return
|
||||
}
|
||||
if (!pickedFile.value) {
|
||||
uploadMsg.value = '请选择 zip 压缩包'
|
||||
return
|
||||
}
|
||||
if (!/\.zip$/i.test(pickedFile.value.name)) {
|
||||
uploadMsg.value = '仅支持 .zip 格式'
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
const created = await uploadSoftwareVersion(newVersion.value.trim(), pickedFile.value, pickedFile.value.name)
|
||||
const version = created?.version || newVersion.value.trim()
|
||||
const link = created?.fileUrl
|
||||
// 对齐 admin.js:6484-6488:弹窗保持打开,成功文案(含链接)留驻可复制,仅清空输入并刷新列表。
|
||||
uploadMsg.value = link ? `发布成功。版本:${version},链接:${link}` : `发布成功。版本:${version}`
|
||||
uploadMsgOk.value = true
|
||||
newVersion.value = ''
|
||||
pickedFile.value = null
|
||||
load()
|
||||
} catch (error) {
|
||||
uploadMsg.value = error instanceof Error ? error.message : '上传失败'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>软件版本管理</h2>
|
||||
<p>管理客户端更新包版本(用于 /api/version/latest 下发)。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="openUpload">上传新版本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="pagedItems" stripe border empty-text="暂无版本记录">
|
||||
<el-table-column prop="version" label="版本号" min-width="180" />
|
||||
<el-table-column label="下载链接" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" target="_blank" rel="noopener">{{ (row as SoftwareVersionItem).fileUrl }}</a>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="190">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<a v-if="(row as SoftwareVersionItem).fileUrl" class="el-link el-link--primary" :href="(row as SoftwareVersionItem).fileUrl" download>下载</a>
|
||||
<span v-else class="dim">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="uploadVisible" title="上传新版本" width="520px">
|
||||
<p class="upload-desc">上传软件 ZIP 包,系统会自动计算 MD5 并存储到 OSS。</p>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="版本号" required>
|
||||
<el-input v-model="newVersion" placeholder="例如:1.0.0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="ZIP 压缩包" required>
|
||||
<el-upload :auto-upload="false" :limit="1" accept=".zip" :on-change="(file: any) => onFileChange(file.raw)" :on-remove="() => (pickedFile = null)">
|
||||
<el-button>选择文件</el-button>
|
||||
</el-upload>
|
||||
<p class="zip-hint">仅支持 .zip 格式</p>
|
||||
<div v-if="pickedFile" class="dim">{{ pickedFile.name }}</div>
|
||||
</el-form-item>
|
||||
<el-alert v-if="uploadMsg" :title="uploadMsg" :type="uploadMsgOk ? 'success' : 'error'" :closable="false" show-icon class="upload-msg" />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="submitUpload">上传软件版本</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.actions { display: flex; align-items: center; gap: 10px; }
|
||||
.dim { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.upload-desc { margin: 0 0 12px; color: var(--el-text-color-secondary); font-size: 12.5px; line-height: 1.6; }
|
||||
.zip-hint { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.upload-msg { margin-bottom: 8px; }
|
||||
.upload-msg :deep(.el-alert__title) { word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
/** 数字人版本适配(任务 132-136):/api/digital-human/versions 系列端点。 */
|
||||
import { http } from '@/api/http'
|
||||
import { parseDigitalHumanVersions } from './digitalhuman-model.ts'
|
||||
import { buildDigitalHumanUploadFields, type DigitalHumanUploadForm } from './digitalhuman-upload-model.ts'
|
||||
import type { DigitalHumanVersionList } from './digitalhuman-dto.ts'
|
||||
|
||||
export const DIGITAL_HUMAN_VERSIONS_ENDPOINT = '/api/digital-human/versions'
|
||||
|
||||
export async function fetchDigitalHumanVersions(): Promise<DigitalHumanVersionList> {
|
||||
const { data } = await http.get<unknown>(DIGITAL_HUMAN_VERSIONS_ENDPOINT)
|
||||
return parseDigitalHumanVersions(data)
|
||||
}
|
||||
|
||||
/** 上传数字人版本(multipart:字段 + file);onProgress 接收 0-100 进度(对齐 admin.js:6543-6550 正在上传 N%)。 */
|
||||
export async function uploadDigitalHumanVersion(
|
||||
form: DigitalHumanUploadForm,
|
||||
filename = '',
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<void> {
|
||||
const body = new FormData()
|
||||
const fields = buildDigitalHumanUploadFields(form)
|
||||
Object.entries(fields).forEach(([key, value]) => body.append(key, value))
|
||||
if (form.file) body.append('file', form.file as unknown as Blob, filename || (form.file.name || 'dh.zip'))
|
||||
await http.post<unknown>(`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/upload`, body, {
|
||||
onUploadProgress: (event) => {
|
||||
if (!onProgress) return
|
||||
const total = event.total || 0
|
||||
onProgress(total > 0 ? Math.min(Math.round((event.loaded / total) * 100), 100) : 0)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取数字人版本下载链接(对齐 admin.js downloadDigitalHumanVersion:点击才取链接)。 */
|
||||
export async function fetchDigitalHumanDownloadUrl(version: string): Promise<string> {
|
||||
const { data } = await http.get<unknown>(
|
||||
`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/${encodeURIComponent(version)}/download-url`,
|
||||
)
|
||||
const core = data as { data?: unknown } | null | undefined
|
||||
const payload = (core && core.data) || data
|
||||
if (typeof payload === 'string') return payload
|
||||
if (payload && typeof payload === 'object') {
|
||||
const record = payload as Record<string, unknown>
|
||||
const url = record.downloadUrl ?? record.url ?? record.download_url
|
||||
if (typeof url === 'string' && url) return url
|
||||
}
|
||||
throw new Error('Java 后端未返回下载链接')
|
||||
}
|
||||
|
||||
/** 发布数字人版本:POST /{version}/release。 */
|
||||
export async function releaseDigitalHumanVersion(version: string): Promise<void> {
|
||||
await http.post<unknown>(`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/${encodeURIComponent(version)}/release`)
|
||||
}
|
||||
|
||||
/** 设为最新版本:POST /{version}/set-latest。 */
|
||||
export async function setLatestDigitalHumanVersion(version: string): Promise<void> {
|
||||
await http.post<unknown>(`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/${encodeURIComponent(version)}/set-latest`)
|
||||
}
|
||||
|
||||
/** 删除数字人版本:DELETE /{version}。 */
|
||||
export async function deleteDigitalHumanVersion(version: string): Promise<void> {
|
||||
await http.delete<unknown>(`${DIGITAL_HUMAN_VERSIONS_ENDPOINT}/${encodeURIComponent(version)}`)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** 数字人版本 DTO(任务 131):数字人版本行/状态类型;与软件版本 DTO 分离,纯逻辑。 */
|
||||
|
||||
export type DigitalHumanVersionStatus = 'DRAFT' | 'RELEASED' | 'DEPRECATED'
|
||||
|
||||
/** 数字人版本行(GET /api/digital-human/versions,Java DigitalHumanVersionVo camel)。 */
|
||||
export interface DigitalHumanVersion {
|
||||
id: number
|
||||
version: string
|
||||
fileSize?: number
|
||||
md5?: string
|
||||
changelog?: string
|
||||
minClientVersion?: string
|
||||
isLatest?: boolean
|
||||
status: DigitalHumanVersionStatus
|
||||
downloadUrl?: string
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
releasedAt?: string
|
||||
}
|
||||
|
||||
export interface DigitalHumanVersionList {
|
||||
items: DigitalHumanVersion[]
|
||||
}
|
||||
|
||||
export function emptyDigitalHumanVersionList(): DigitalHumanVersionList {
|
||||
return { items: [] }
|
||||
}
|
||||
|
||||
/** 状态归一(对齐 admin.js:6337 三态:DRAFT 草稿 / RELEASED 已发布 / 其余一律已废弃)。 */
|
||||
export function normalizeDigitalHumanStatus(value: unknown): DigitalHumanVersionStatus {
|
||||
const text = typeof value === 'string' ? value.trim().toUpperCase() : ''
|
||||
if (text === 'RELEASED') return 'RELEASED'
|
||||
if (text === 'DRAFT') return 'DRAFT'
|
||||
return 'DEPRECATED'
|
||||
}
|
||||
|
||||
/** 文件大小展示(对齐 admin.js:6340 固定两位小数 MB;空/0 显示 -)。 */
|
||||
export function digitalHumanVersionFileSizeText(fileSize: number | null | undefined): string {
|
||||
if (typeof fileSize !== 'number' || !Number.isFinite(fileSize) || fileSize <= 0) return '-'
|
||||
return `${(fileSize / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
/** 更新说明 50 字符截断(对齐 admin.js:6342-6343;空显示 -)。 */
|
||||
export function digitalHumanChangelogShort(changelog: string | null | undefined): string {
|
||||
const text = (changelog || '').trim() || '-'
|
||||
if (text === '-') return text
|
||||
if (text.length > 50) return `${text.substring(0, 50)}...`
|
||||
return text
|
||||
}
|
||||
|
||||
/** 删除数字人版本确认文案(对齐 admin.js:6356)。 */
|
||||
export function digitalHumanDeleteConfirmText(version: string): string {
|
||||
const v = (version || '').trim()
|
||||
return v ? `确认删除版本 ${v} 吗?该操作会删除对应文件,无法恢复。` : ''
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 数字人版本展示格式化(module 13 task 261):MD5 截断展示;纯逻辑。 */
|
||||
|
||||
/** MD5 前 len 位(默认 12,对齐 admin)展示;空值回空串。 */
|
||||
export function md5Short(md5: string | null | undefined, len = 12): string {
|
||||
const text = typeof md5 === 'string' ? md5.trim() : ''
|
||||
if (!text) return ''
|
||||
const max = typeof len === 'number' && Number.isFinite(len) && len >= 1 ? Math.floor(len) : 12
|
||||
return text.length <= max ? text : `${text.slice(0, max)}…`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 数字人版本设为最新操作(任务 136):仅已发布版本可设为最新;纯逻辑。 */
|
||||
|
||||
export function canSetLatestDigitalHumanVersion(status: unknown): boolean {
|
||||
return typeof status === 'string' && status.trim().toUpperCase() === 'RELEASED'
|
||||
}
|
||||
|
||||
/** 设为最新确认文案(对齐 admin.js:6350);空版本返回 null。 */
|
||||
export function setLatestDigitalHumanConfirmText(version: string): string | null {
|
||||
const v = (version || '').trim()
|
||||
return v ? `确认将版本 ${v} 设为最新吗?客户端会自动检测更新。` : null
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** 数字人版本列表加载模型(任务 132):解析 /api/digital-human/versions 数组负载(camel);纯逻辑。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
import { normalizeDigitalHumanStatus, type DigitalHumanVersion, type DigitalHumanVersionList } from './digitalhuman-dto.ts'
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||
}
|
||||
|
||||
/** 解析单条数字人版本;缺 id 视为无效。 */
|
||||
export function toDigitalHumanVersion(raw: unknown): DigitalHumanVersion | 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 version: DigitalHumanVersion = { id, version: text(r.version), status: normalizeDigitalHumanStatus(r.status) }
|
||||
const fileSize = numberOrNull(r.fileSize)
|
||||
if (fileSize !== null) version.fileSize = fileSize
|
||||
const md5 = text(r.md5)
|
||||
if (md5) version.md5 = md5
|
||||
const changelog = text(r.changelog)
|
||||
if (changelog) version.changelog = changelog
|
||||
const minClientVersion = text(r.minClientVersion)
|
||||
if (minClientVersion) version.minClientVersion = minClientVersion
|
||||
const downloadUrl = text(r.downloadUrl)
|
||||
if (downloadUrl) version.downloadUrl = downloadUrl
|
||||
const createdBy = text(r.createdBy)
|
||||
if (createdBy) version.createdBy = createdBy
|
||||
const createdAt = text(r.createdAt)
|
||||
if (createdAt) version.createdAt = createdAt
|
||||
const releasedAt = text(r.releasedAt)
|
||||
if (releasedAt) version.releasedAt = releasedAt
|
||||
if (typeof r.isLatest === 'boolean') version.isLatest = r.isLatest
|
||||
return version
|
||||
}
|
||||
|
||||
/** 归一化版本列表负载(data 数组)为前端列表;缺省回空。 */
|
||||
export function parseDigitalHumanVersions(payload: unknown): DigitalHumanVersionList {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const list = Array.isArray(data) ? data : data && typeof data === 'object' ? (data as Record<string, unknown>).items : null
|
||||
if (!Array.isArray(list)) return { items: [] }
|
||||
return {
|
||||
items: list
|
||||
.map((raw) => toDigitalHumanVersion(raw))
|
||||
.filter((version): version is DigitalHumanVersion => version !== null),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 数字人版本发布操作(任务 135):可发布判定与确认文案;纯逻辑。 */
|
||||
|
||||
/** 仅明确为草稿(DRAFT)的版本可发布;未知/已发布不可。 */
|
||||
export function canReleaseDigitalHumanVersion(status: unknown): boolean {
|
||||
return typeof status === 'string' && status.trim().toUpperCase() === 'DRAFT'
|
||||
}
|
||||
|
||||
/** 发布确认文案(对齐 admin.js:6347);空版本返回 null。 */
|
||||
export function releaseDigitalHumanConfirmText(version: string): string | null {
|
||||
const v = (version || '').trim()
|
||||
return v ? `确认发布版本 ${v} 吗?` : null
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user