task-283(后台迁移收尾/发布): admin-vue 后台工程入库 + Flask 后台整体退役

- 新后台 admin-frontend-vue 整工程入库(base=/admin-vue/、History 路由、Nginx 静态托管方案与 verify-dist 校验)
- Java AdminConsoleController 改为入口重定向: /admin|/admin.html、/login|/login.html -> /admin-vue/; 删除 classpath:static 旧 admin.html/login.html 单页与 admin.js 副本
- Flask 后台整体退役: 删除 admin_api/auth/main 蓝图、web_source 页面、static 脚本与 admin 相关测试; app.py 收敛为仅注册 version_bp(/api/version、/api/version/latest, 供桌面端更新检查)
- AdminApiGuardFilterTest 豁免样例路径 /admin.html -> /admin-vue/
This commit is contained in:
2026-09-06 10:41:47 +08:00
parent 6bd935dc6b
commit 4c88964473
71 changed files with 974 additions and 39150 deletions
@@ -2,20 +2,21 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
/** 对齐 admin.html:5298-5300(店铺密钥 序号/紫鸟账号名称) 与 5339-5348(店铺管理列序与列名)。 */
/** 验收反馈:列表不再展示"序号/ID"列。对齐 admin.html:5298-5300(紫鸟账号名称) 与 5339-5348(店铺管理列序与列名)。 */
test('align_shop_keys_columns', () => {
const page = readSource('src/pages/shop/ShopKeysPage.vue')
assert.match(page, /label="序号"/, '首列改序号')
assert.match(page, /type="index"/, '序号列按行号生成')
assert.doesNotMatch(page, /label="序号"/, '不再展示序号')
assert.doesNotMatch(page, /type="index"/, '不用行号列')
assert.doesNotMatch(page, /label="ID"/, '不展示 ID 列')
assert.match(page, /label="备注名"/, '首业务列按备注名起')
assert.match(page, /紫鸟账号名称/, '列名对齐「紫鸟账号名称」')
assert.doesNotMatch(page, /label="ID"/, '去掉 ID 列')
})
test('align_shop_manage_columns_order_and_labels', () => {
const page = readSource('src/pages/shop/ShopManagePage.vue')
assert.match(page, /label="序号"/, '首列改序号')
assert.doesNotMatch(page, /label="ID"/, '去掉 ID 列')
assert.doesNotMatch(page, /label="序号"/, '不再展示序号')
assert.doesNotMatch(page, /label="ID"/, '不展示 ID 列')
// 列序:分组 → 店铺名 → 店铺商城名 → 自动化账号 → 账号 → 密码(对齐 admin.html:5339-5348)。
const table = page.slice(page.indexOf('<el-table'))
const orderMatch = /label="分组"[\s\S]*?label="店铺名"[\s\S]*?label="店铺商城名"[\s\S]*?label="自动化账号"[\s\S]*?label="账号"[\s\S]*?label="密码"/.exec(table)
@@ -0,0 +1,120 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseDuplicateConsole, parseLedgerPage, parseConsoleGroup, parseLedgerRow } from '../src/pages/tasks/duplicate-console-model.ts'
import {
indexShopGroups,
dupWithinGroup,
storeColumnsOf,
} from '../src/pages/tasks/duplicate-console-logic.ts'
function occurrence(asin: string, shop: string, group: string, country: string, date: string) {
return { asin, date, price: '', brand: 'B', shop_name: shop, group_name: group, country_codes: [country], country }
}
/** 已解析模型形态(camel)的 occurrence,供纯逻辑派生入参。 */
function camelOcc(asin: string, shop: string, group: string, country: string, date: string) {
return { asin, date, price: '', brand: 'B', shopName: shop, groupName: group, countryCodes: [country], country }
}
test('test_dup_console_model_parses_overview_dup_and_groups', () => {
const consoleData = parseDuplicateConsole({
success: true,
data: {
pending: false,
scanned_at: '2026-09-04 03:10:00',
summary: { shop_count: 4, asin_total: 6, record_total: 10, duplicate_asin_total: 3, duplicate_shop_count: 4, site_count: 3, asin_per_shop: 2.5, source: 'job' },
shops: [
{ shop_name: 'ShopA', group_name: 'GroupA', country_codes: ['UK', 'DE'], asin_count: 3, record_count: 3 },
{ shop_name: 'ShopB', group_name: 'GroupA', country_codes: ['UK'], asin_count: 3, record_count: 3 },
{ shop_name: 'ShopC', group_name: 'GroupC', country_codes: ['FR'], asin_count: 2, record_count: 2 },
],
dup: [
{ asin: 'E0000001', shop_count: 3, record_count: 3, occurrences: [
occurrence('E0000001', 'ShopA', 'GroupA', 'UK', '2026-08-29'),
occurrence('E0000001', 'ShopB', 'GroupA', 'UK', '2026-08-30'),
occurrence('E0000001', 'ShopC', 'GroupC', 'FR', '2026-08-31'),
] },
],
groups: [{ name: 'GroupA', shop_count: 2, asin_unique: 4, record_count: 6, dup_count: 2 }],
total_dup: 1,
},
})
assert.equal(consoleData.overview.pending, false)
assert.equal(consoleData.overview.summary?.asinTotal, 6)
assert.equal(consoleData.overview.shops.length, 3)
assert.equal(consoleData.totalDup, 1)
assert.equal(consoleData.dup[0].asin, 'E0000001')
assert.equal(consoleData.dup[0].occurrences.length, 3)
const group = consoleData.groups[0]
assert.deepEqual(group, { name: 'GroupA', shopCount: 2, asinUnique: 4, recordCount: 6, dupCount: 2 })
})
test('test_dup_console_parse_group_ignores_missing_name', () => {
assert.equal(parseConsoleGroup({ shop_count: 1 }), null)
assert.ok(parseConsoleGroup({ name: 'G', shop_count: 2 }))
})
test('test_dup_console_group_derivation_restricts_to_members', () => {
const dupA = { asin: 'A', shopCount: 2, recordCount: 2, occurrences: [
camelOcc('A', 'ShopA', 'GroupA', 'UK', '2026-08-01'),
camelOcc('A', 'ShopB', 'GroupA', 'UK', '2026-08-02'),
] }
const dupE = { asin: 'E', shopCount: 3, recordCount: 3, occurrences: [
camelOcc('E', 'ShopA', 'GroupA', 'UK', '2026-08-01'),
camelOcc('E', 'ShopB', 'GroupA', 'UK', '2026-08-02'),
camelOcc('E', 'ShopC', 'GroupC', 'FR', '2026-08-03'),
] }
const groups = indexShopGroups([
{ shopName: 'ShopA', groupName: 'GroupA', countryCodes: [], asinCount: 0, recordCount: 0 },
{ shopName: 'ShopB', groupName: 'GroupA', countryCodes: [], asinCount: 0, recordCount: 0 },
{ shopName: 'ShopC', groupName: 'GroupC', countryCodes: [], asinCount: 0, recordCount: 0 },
])
assert.deepEqual(groups.members.GroupA, ['ShopA', 'ShopB'])
assert.deepEqual(groups.shopGroupNames.ShopC, ['GroupC'])
const inGroupA = dupWithinGroup([dupA, dupE], groups.members.GroupA)
assert.equal(inGroupA.length, 2)
// E 组内只剩 ShopA/ShopB 两条记录。
const e = inGroupA.find((item) => item.asin === 'E')
assert.equal(e?.shopCount, 2)
assert.equal(e?.recordCount, 2)
// 单店成员组内不会命中(ShopD 不在数据里)。
assert.deepEqual(dupWithinGroup([dupA, dupE], ['ShopD']), [])
})
test('test_dup_console_store_columns_order_and_restriction', () => {
const dupA = { asin: 'A', shopCount: 2, recordCount: 2, occurrences: [
camelOcc('A', 'ShopB', 'GroupA', 'UK', '2026-08-01'),
camelOcc('A', 'ShopA', 'GroupA', 'UK', '2026-08-02'),
] }
assert.deepEqual(storeColumnsOf([dupA]), ['ShopB', 'ShopA'])
assert.deepEqual(storeColumnsOf([dupA], ['ShopA', 'ShopB', 'ShopC']), ['ShopB', 'ShopA'])
// 空行 + 显式成员列:仍输出成员店铺。
assert.deepEqual(storeColumnsOf([], ['ShopA', 'ShopB']), ['ShopA', 'ShopB'])
})
test('test_dup_console_ledger_parse_rows_and_paging', () => {
const page = parseLedgerPage({
success: true,
data: {
pending: false,
scanned_at: '2026-09-04 03:10:00',
items: [
{ asin: 'E0000001', brand: 'BrandE', store_count: 3, stores: ['ShopA', 'ShopB', 'ShopC'], groups: ['GroupA', 'GroupC'], countries: ['UK', 'FR'], record_count: 3, earliest: '2026-08-29 00:00:00', latest: '2026-08-31 00:00:00', occurrences: [occurrence('E0000001', 'ShopA', 'GroupA', 'UK', '2026-08-29')] },
],
total: 1,
page: 1,
page_size: 200,
},
})
assert.equal(page.pending, false)
assert.equal(page.total, 1)
assert.equal(page.pageSize, 200)
const row = page.items[0]
assert.equal(row.asin, 'E0000001')
assert.equal(row.storeCount, 3)
assert.equal(row.occurrences.length, 1)
const single = parseLedgerRow({ asin: 'X0000001', store_count: 0, stores: [], groups: [], countries: [], record_count: 0, earliest: '', latest: '', occurrences: [] })
assert.equal(single?.storeCount, 0)
assert.equal(parseLedgerRow({}), null)
})
@@ -0,0 +1,49 @@
import test from 'node:test'
import assert from 'node:assert/strict'
const BASE = process.env.AIIMAGE_LIVE_BASE || 'http://127.0.0.1:18080'
test('test_task_180_public_version_live_reachability', async () => {
// 公开版本路径可达。
const res = await fetch(`${BASE}/api/version`)
assert.equal(res.status, 200)
const body = (await res.json()) as Record<string, unknown>
assert.ok('version' in body)
assert.ok('desc' in body)
assert.ok('url' in body)
})
test('test_task_180_public_version_live_latest_contract', async () => {
// latest 契约含 version/file_url(允许 null)。
const res = await fetch(`${BASE}/api/version/latest`)
assert.equal(res.status, 200)
const body = (await res.json()) as Record<string, unknown>
assert.ok('version' in body)
assert.ok('file_url' in body)
})
test('test_task_180_public_version_live_no_envelope', async () => {
// 公开接口无 success 包装。
const body = (await (await fetch(`${BASE}/api/version`)).json()) as Record<string, unknown>
assert.equal('success' in body, false, '公开接口不包装 ApiResponse')
assert.equal('data' in body, false)
})
test('test_task_180_public_version_live_values_type', async () => {
const body = (await (await fetch(`${BASE}/api/version`)).json()) as Record<string, unknown>
for (const key of ['version', 'desc', 'url']) {
const v = body[key]
assert.ok(v === null || typeof v === 'string' || typeof v === 'number', key)
}
})
test('test_task_180_public_version_live_latest_keys_only', async () => {
const body = (await (await fetch(`${BASE}/api/version/latest`)).json()) as Record<string, unknown>
const keys = Object.keys(body).sort()
assert.deepEqual(keys, ['file_url', 'version'], 'latest 仅含两个字段')
})
test('test_task_180_public_version_live_download_url_preserved', async () => {
const body = (await (await fetch(`${BASE}/api/version/latest`)).json()) as Record<string, unknown>
if (body.file_url) assert.match(String(body.file_url), /^https?:\/\//)
})
+1 -1
View File
@@ -13,7 +13,7 @@ import { adminPages } from '../src/router/routes.ts'
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
assert.equal(adminPages.length, 3)
assert.equal(adminPages.length, 16)
for (const page of adminPages) {
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
}
+1 -1
View File
@@ -34,7 +34,7 @@ test('test_task_012_route_error_page_normal_repeated_operation_is_idempotent', (
test('test_task_012_route_error_page_boundary_empty_input', () => {
// 边界空值:错误页文件存在,且不进入业务路由注册表。
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
assert.equal(adminPages.length, 3, '错误页不应计入业务路由')
assert.equal(adminPages.length, 16, '错误页不应计入业务路由')
})
test('test_task_012_route_error_page_boundary_single_item', () => {
+1 -1
View File
@@ -75,6 +75,6 @@ test('test_task_016_shell_logout_interaction_dependency_failure_returns_actionab
assert.match(layout, /runLogout/)
const store = readSource('src/stores/admin-session.ts')
assert.match(store, /\$reset\(\)/)
assert.match(store, /location\.assign\('\/login'\)/)
assert.match(store, /location\.assign\(joinAdminPath\('login'\)\)/)
assert.match(layout, /LOGOUT_CONFIRM_MESSAGE/)
})
+1 -1
View File
@@ -7,7 +7,7 @@ const store = readSource('src/stores/admin-session.ts')
test('test_task_029_menu_tree_init_normal_primary_path', () => {
// 正常主路径:用户拉取成功后拉取菜单树并写入 store.menuTree。
assert.match(store, /this\.menuTree\s*=\s*await fetchAdminMenuTree\(\)/)
assert.match(store, /this\.menuTree\s*=\s*canonicalizeMenuTree\(await fetchAdminMenuTree\(\)\)/)
})
test('test_task_029_menu_tree_init_normal_variant_input', () => {
+1 -1
View File
@@ -59,7 +59,7 @@ test('test_task_036_logout_clear_state_dependency_failure_returns_actionable_mes
const store = readSource('src/stores/admin-session.ts')
assert.match(store, /async signOut\(\)/)
assert.match(store, /\$reset\(\)/)
assert.match(store, /location\.assign\('\/login'\)/)
assert.match(store, /location\.assign\(joinAdminPath\('login'\)\)/)
const session = readSource('src/api/session.ts')
assert.match(session, /\/logout/, '登出走 auth 模块根端点 POST /logout')
assert.equal(session.includes('/api/admin/logout'), false, '不应指向不存在的 /api/admin/logout')
+2 -2
View File
@@ -12,7 +12,7 @@ test('test_task_005_topbar_title_model_normal_primary_path', () => {
assert.equal(pageTitleOf('用户管理'), '用户管理')
const vm = topbarUserOf({ id: 1, username: 'admin', role: 'super_admin' })
assert.equal(vm.username, 'admin')
assert.equal(vm.role, 'super_admin')
assert.equal(vm.role, '超级管理员')
assert.equal(vm.hasUser, true)
})
@@ -46,7 +46,7 @@ test('test_task_005_topbar_title_model_boundary_single_item', () => {
assert.equal(pageTitleOf('首页'), '首页')
const single = topbarUserOf({ id: 9, username: 'root', role: 'admin' })
assert.equal(single.username, 'root')
assert.equal(single.role, 'admin')
assert.equal(single.role, '管理员')
})
test('test_task_005_topbar_title_model_boundary_limit_or_missing_field', () => {
+1 -1
View File
@@ -10,7 +10,7 @@ import {
test('test_task_008_domain_route_registry_normal_primary_path', () => {
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
assert.equal(adminPages.length, 3)
assert.equal(adminPages.length, 16)
assert.equal(adminRouteRecords.length, adminPages.length)
const first = adminRouteRecords[0]
assert.equal(first.path, 'account/users')