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
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env*
!.env.example
test-results/
vite-dev.log
+39
View File
@@ -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 菜单迁移后,路由权限守卫即可按最终菜单树工作。
+13
View File
@@ -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

+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
+1 -1
View File
@@ -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 -1
View File
@@ -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,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 }
}
@@ -18,14 +18,19 @@ const isSuperAdmin = computed(() => session.isSuperAdmin)
const currentUserId = computed(() => session.user?.id ?? null)
// 客户端分页:全量拉取后按页切片展示。
const page = ref(1)
const pageSize = 10
const pageSize = ref(10)
const total = computed(() => rows.value.length)
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
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
@@ -75,7 +80,6 @@ onMounted(loadGroups)
<h2>数据权限分组</h2>
<p>管理店铺数据访问分组和组员范围组长不可更改组员为普通账号</p>
</div>
<el-button type="primary" @click="openCreate">新建分组</el-button>
</div>
<el-row :gutter="12" style="margin-bottom: 12px">
<el-col :span="8">
@@ -96,8 +100,10 @@ onMounted(loadGroups)
</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 type="index" label="序号" min-width="80" />
<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" />
@@ -123,7 +129,7 @@ onMounted(loadGroups)
</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" />
<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
@@ -24,13 +24,18 @@ const loading = ref(false)
const rows = ref<MenuManageNode[]>([])
// 客户端分页:列表展示统一带分页组件。
const page = ref(1)
const pageSize = 10
const pageSize = ref(10)
const total = computed(() => rows.value.length)
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
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)
@@ -186,10 +191,12 @@ onMounted(loadMenus)
<h2>菜单管理</h2>
<p>维护后台菜单树和页面路由权限粒度仅到菜单/页面</p>
</div>
<el-button type="primary" @click="openCreate">新增菜单</el-button>
</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 }">
@@ -205,7 +212,6 @@ onMounted(loadMenus)
></span>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" min-width="70" />
<el-table-column prop="name" label="菜单名称" min-width="180" />
<el-table-column label="菜单类型" min-width="110">
<template #default="{ row }">
@@ -230,11 +236,13 @@ onMounted(loadMenus)
<div class="table-footer">
<el-pagination
background
layout="prev, pager, next, jumper"
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>
@@ -104,6 +104,12 @@ function changePage(page: number) {
void loadUsers()
}
function changeSize(size: number) {
filters.pageSize = size
filters.page = 1
void loadUsers()
}
onMounted(loadUsers)
</script>
@@ -114,7 +120,6 @@ onMounted(loadUsers)
<h2>用户管理</h2>
<p>当前没有开放注册入口仅管理员可在此创建用户层级关系超级管理员 -&gt; 管理员 -&gt; 普通账号</p>
</div>
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="search">
@@ -156,8 +161,10 @@ onMounted(loadUsers)
<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="id" label="ID" min-width="90" />
<el-table-column prop="username" label="用户名" min-width="180" />
<el-table-column label="角色" min-width="140">
<template #default="{ row }">
@@ -186,11 +193,13 @@ onMounted(loadUsers)
<span> {{ total }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
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>
@@ -16,7 +16,7 @@ const loading = ref(false)
const rows = ref<InvalidAsinItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
@@ -55,7 +55,7 @@ async function load() {
try {
const result = await fetchInvalidAsinPage({
page: page.value,
pageSize,
pageSize: pageSize.value,
dataValue: filter.dataValue.trim() || undefined,
brand: filter.brand.trim() || undefined,
groupId: filter.groupId,
@@ -155,7 +155,6 @@ onMounted(() => {
<h2>不符合 ASIN 数据</h2>
<p>管理命中"不符合 ASIN"的品牌数据库记录</p>
</div>
<el-button type="primary" @click="openCreate">新增</el-button>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
@@ -182,6 +181,9 @@ onMounted(() => {
</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">
@@ -207,7 +209,7 @@ onMounted(() => {
</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="(p: number) => { page = p; load() }" />
<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>
@@ -30,7 +30,7 @@ const loading = ref(false)
const rows = ref<DedupeTotalItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const pageSize = ref(10)
const filter = reactive(createDedupeTotalFilterState())
const groups = ref<DedupeGroupOption[]>([])
@@ -78,7 +78,7 @@ function stopExportWait(): void {
async function load(): Promise<void> {
loading.value = true
try {
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize))
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
@@ -294,11 +294,6 @@ onMounted(() => {
<h2>数据去重总数据</h2>
<p>去重总数据的 ASIN 值台账可按 ASIN/用户名/分组/国家/日期筛选支持 Excel 导入与导出</p>
</div>
<div class="heading-actions">
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
<el-button @click="openImportDelete">删除导入</el-button>
<el-button :loading="exporting" @click="doExport">导出</el-button>
</div>
</div>
<el-card shadow="never" class="group-summary-card">
@@ -357,8 +352,12 @@ onMounted(() => {
</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="id" label="ID" min-width="80" />
<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>
@@ -381,11 +380,13 @@ onMounted(() => {
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
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>
@@ -19,16 +19,16 @@ const loading = ref(false)
const rows = ref<QueryAsinItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
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 + 1))
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
/** rowspan 合并:序号(0)/分组(1)/店铺(2)/操作(5) 仅首行占位,其余合并。 */
/** 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 === 2 || columnIndex === 5) {
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 4) {
return row.isFirst ? [row.rowspan, 1] : [0, 0]
}
return [1, 1]
@@ -282,7 +282,7 @@ async function load() {
try {
const result = await fetchQueryAsinList({
page: page.value,
pageSize,
pageSize: pageSize.value,
groupId: filter.groupId,
shopName: filter.shopName.trim() || undefined,
asin: filter.asin.trim() || undefined,
@@ -322,12 +322,6 @@ onMounted(() => {
<h2>查询 ASIN</h2>
<p>每店铺在 5 个站点查询到的 ASIN 清单行内可逐站配置 ASIN</p>
</div>
<div class="heading-actions">
<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>
</div>
<el-card shadow="never">
@@ -360,10 +354,13 @@ onMounted(() => {
</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="80">
<template #default="{ row }">{{ row.rowNo }}</template>
</el-table-column>
<el-table-column label="分组" min-width="130">
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
</el-table-column>
@@ -392,11 +389,13 @@ onMounted(() => {
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
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>
@@ -20,7 +20,7 @@ const loading = ref(false)
const rows = ref<SkipPriceItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
@@ -37,12 +37,12 @@ function priceOf(row: SkipPriceItem, code: string): number | null {
return typeof value === 'number' ? value : null
}
/** 纵向展示行:序号/分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize + 1))
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
/** rowspan 合并:序号(0)/分组(1)/店铺(2)/操作(6) 仅首行占位,其余合并。 */
/** 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 === 2 || columnIndex === 6) {
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 5) {
return row.isFirst ? [row.rowspan, 1] : [0, 0]
}
return [1, 1]
@@ -325,7 +325,7 @@ async function loadGroups() {
async function load() {
loading.value = true
try {
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize))
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize.value))
rows.value = result.items
total.value = result.total
page.value = result.page
@@ -360,12 +360,6 @@ onMounted(() => {
<h2>最低价 ASIN / 跳过跟价</h2>
<p>每店铺在 5 个站点设定的最低价 ASIN 清单行内可逐站配置 ASIN 与最低价</p>
</div>
<div class="heading-actions">
<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>
</div>
<el-card shadow="never">
@@ -406,10 +400,13 @@ onMounted(() => {
</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="80">
<template #default="{ row }">{{ row.rowNo }}</template>
</el-table-column>
<el-table-column label="分组" min-width="130">
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
</el-table-column>
@@ -444,11 +441,13 @@ onMounted(() => {
<span> {{ total.toLocaleString() }} </span>
<el-pagination
background
layout="prev, pager, next, jumper"
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>
@@ -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>
@@ -17,7 +17,7 @@ export interface HistoryRecordItem {
originalUrls: string[]
resultUrls: string[]
longImageUrl: string
params: Record<string, unknown>
params?: Record<string, unknown>
}
export interface HistoryPageResult {
@@ -12,7 +12,7 @@ const loading = ref(false)
const rows = ref<ShopKeyItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const pageSize = ref(10)
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
@@ -40,7 +40,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
async function load() {
loading.value = true
try {
const result = await fetchShopKeyList({ page: page.value, pageSize })
const result = await fetchShopKeyList({ page: page.value, pageSize: pageSize.value })
rows.value = result.items
total.value = result.total
page.value = result.page
@@ -121,12 +121,13 @@ onMounted(load)
<h2>店铺密钥管理</h2>
<p>管理各店铺的紫鸟浏览器账号与令牌</p>
</div>
<el-button type="primary" @click="openCreate">新增密钥</el-button>
</div>
<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 type="index" label="序号" min-width="80" :index="(i: number) => (page - 1) * pageSize + i + 1" />
<el-table-column prop="remarkName" label="备注名" min-width="140">
<template #default="{ row }">{{ (row as ShopKeyItem).remarkName || '—' }}</template>
</el-table-column>
@@ -162,7 +163,7 @@ onMounted(load)
</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="(p: number) => { page = p; load() }" />
<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>
@@ -20,7 +20,7 @@ const loading = ref(false)
const rows = ref<ShopSummary[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 15
const pageSize = ref(10)
const groups = ref<ShopGroupOption[]>([])
const filter = reactive({ shopName: '', groupId: null as number | null })
@@ -75,7 +75,7 @@ async function load() {
try {
const result = await fetchShopManageList({
page: page.value,
pageSize,
pageSize: pageSize.value,
groupId: filter.groupId,
shopName: filter.shopName.trim() || undefined,
})
@@ -94,6 +94,12 @@ function apply() {
load()
}
function changeSize(size: number) {
pageSize.value = size
page.value = 1
load()
}
function reset() {
filter.shopName = ''
filter.groupId = null
@@ -196,7 +202,6 @@ onMounted(() => {
<h2>店铺管理</h2>
<p>管理店铺账号所属分组与自动化账号</p>
</div>
<el-button type="primary" @click="openCreate">新增店铺</el-button>
</div>
<el-card shadow="never" style="margin-bottom: 14px">
@@ -219,8 +224,10 @@ onMounted(() => {
</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 type="index" label="序号" min-width="80" :index="(i: number) => (page - 1) * pageSize + i + 1" />
<el-table-column prop="groupName" label="分组" min-width="120" />
<el-table-column prop="shopName" label="店铺名" min-width="160" />
<el-table-column prop="mallName" label="店铺商城名" min-width="120" />
@@ -258,7 +265,7 @@ onMounted(() => {
</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="(p: number) => { page = p; load() }" />
<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="changeSize" />
</div>
</el-card>
@@ -403,7 +403,7 @@ onMounted(() => {
<button class="tab" :class="{ active: activeTab === 'dup' }" @click="switchTab('dup')">撞款监控 <span class="t-count">{{ totalDupCount }}</span></button>
<button class="tab" :class="{ active: activeTab === 'ledger' }" @click="switchTab('ledger')">全部ASIN台账 <span class="t-count">{{ ledgerTotal }}</span></button>
</div>
<el-button text type="primary" @click="groupInfoVisible = true">分组说明</el-button>
<el-button class="help-btn" size="small" @click="groupInfoVisible = true">分组说明</el-button>
</div>
<!-- ============ 撞款监控 ============ -->
@@ -607,9 +607,9 @@ onMounted(() => {
<style scoped>
.dup-console { max-width: 1560px; }
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.page-heading .actions { display: flex; align-items: center; gap: 10px; }
.updated { color: var(--el-text-color-secondary); font-size: 12px; }
.page-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.page-heading .actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
.updated { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; }
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
@@ -642,6 +642,8 @@ onMounted(() => {
.tab.active { background: var(--el-color-primary); color: #fff; }
.t-count { background: rgba(0, 0, 0, 0.12); border-radius: 10px; padding: 0 7px; font-size: 11px; }
.tab.active .t-count { background: rgba(255, 255, 255, 0.25); }
.help-btn.el-button { color: #2f4a63; border-color: #a9c3d9; background: #fff; }
.help-btn.el-button:hover { color: #1e3b57; border-color: #7fa6c6; background: #edf5fb; }
.scope-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap; }
.seg { display: flex; gap: 4px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 9px; padding: 4px; }
.seg-btn { padding: 7px 16px; border-radius: 7px; font-size: 13px; font-weight: 600; color: var(--el-text-color-regular); border: none; background: transparent; cursor: pointer; }
@@ -0,0 +1,132 @@
/** 撞款控制台/台账解析(reference console 对齐的只读契约):纯逻辑,snake 兼容。 */
import { unwrap } from '../../api/envelope.ts'
import {
parseDuplicateItem,
parseDuplicateOccurrence,
parseDuplicateOverview,
type DuplicateItem,
type DuplicateOccurrence,
type DuplicateOverview,
} from './duplicate-model.ts'
/** 后端分组的组内统计。 */
export interface ConsoleGroupStat {
name: string
shopCount: number
asinUnique: number
recordCount: number
dupCount: number
}
/** 撞款控制台单次快照:总览(指标/店铺分布) + 撞款全集 + 分组统计。 */
export interface DuplicateConsole {
overview: DuplicateOverview
dup: DuplicateItem[]
groups: ConsoleGroupStat[]
totalDup: number
}
/** 台账单行(含 occurrences,供明细抽屉还原每店上架时间线)。 */
export interface LedgerRow {
asin: string
brand: string
storeCount: number
stores: string[]
groups: string[]
countries: string[]
recordCount: number
earliest: string
latest: string
occurrences: DuplicateOccurrence[]
}
export interface LedgerPage {
pending: boolean
scannedAt: string
items: LedgerRow[]
total: number
page: number
pageSize: number
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function count(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
}
function strings(value: unknown): string[] {
return Array.isArray(value) ? value.map((v) => text(v)).filter(Boolean) : []
}
/** 解析单条分组统计。 */
export function parseConsoleGroup(raw: unknown): ConsoleGroupStat | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const name = text(r.name)
if (!name) return null
return {
name,
shopCount: count(r.shop_count ?? r.shopCount),
asinUnique: count(r.asin_unique ?? r.asinUnique),
recordCount: count(r.record_count ?? r.recordCount),
dupCount: count(r.dup_count ?? r.dupCount),
}
}
/** 解析 /duplicate-check-console 响应体。overview 复用总览解析(忽略多余字段)。 */
export function parseDuplicateConsole(payload: unknown): DuplicateConsole {
const core = unwrap<unknown>(payload)
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : {}
const overview = parseDuplicateOverview(payload)
const dup = Array.isArray(record.dup)
? record.dup.map((raw) => parseDuplicateItem(raw)).filter((item): item is DuplicateItem => item !== null)
: []
const groups = Array.isArray(record.groups)
? record.groups.map((raw) => parseConsoleGroup(raw)).filter((group): group is ConsoleGroupStat => group !== null)
: []
return { overview, dup, groups, totalDup: count(record.total_dup ?? record.totalDup) }
}
/** 解析单条台账行。 */
export function parseLedgerRow(raw: unknown): LedgerRow | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const asin = text(r.asin)
if (!asin) return null
return {
asin,
brand: text(r.brand),
storeCount: count(r.store_count ?? r.storeCount),
stores: strings(r.stores),
groups: strings(r.groups),
countries: strings(r.countries),
recordCount: count(r.record_count ?? r.recordCount),
earliest: text(r.earliest),
latest: text(r.latest),
occurrences: Array.isArray(r.occurrences)
? r.occurrences.map((occ) => parseDuplicateOccurrence(occ)).filter((occ): occ is DuplicateOccurrence => occ !== null)
: [],
}
}
/** 解析 /duplicate-check-ledger 分页负载。 */
export function parseLedgerPage(payload: unknown): LedgerPage {
const core = unwrap<unknown>(payload)
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : {}
const items = Array.isArray(record.items)
? record.items.map((raw) => parseLedgerRow(raw)).filter((row): row is LedgerRow => row !== null)
: []
const pageNumber = count(record.page) >= 1 ? count(record.page) : 1
const rawSize = record.page_size ?? record.pageSize
return {
pending: record.pending === true,
scannedAt: text(record.scanned_at ?? record.scannedAt),
items,
total: count(record.total),
page: pageNumber,
pageSize: count(rawSize) >= 1 ? count(rawSize) : 20,
}
}
+11
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import AdminLayout from '@/layout/AdminLayout.vue'
import LoginPage from '@/pages/login/LoginPage.vue'
import NotFoundPage from '@/pages/error/NotFoundPage.vue'
import { useAdminSessionStore } from '@/stores/admin-session'
import { APP_BASE_PATH } from '@/config/app'
@@ -10,6 +11,11 @@ import { isRouteAllowed } from './helpers'
const router = createRouter({
history: createWebHistory(APP_BASE_PATH),
routes: [
{
path: '/login',
component: LoginPage,
meta: { title: '登录' },
},
{
path: '/',
component: AdminLayout,
@@ -26,10 +32,15 @@ const router = createRouter({
router.beforeEach(async (to) => {
const session = useAdminSessionStore()
// 登录页是公开页;已登录访问则回根路由。
if (to.path === '/login') {
return session.initialized && session.user ? '/' : true
}
if (!session.initialized) {
try {
await session.initialize()
} catch {
// 未登录:401 由 http 拦截器整页跳转 SPA 登录页;此处放行当前导航由守卫收口。
return false
}
}
@@ -0,0 +1,27 @@
/** 菜单树 route 归一:column_key → 前端注册的规范页面路径。
* 共享库 columns.route_path 仍是旧式 slug(切机迁移未应用),这里按注册表 key 映射为新 Vue 路由,
* 保证侧边栏/面包屑/首页跳转都能命中已注册页面,无需改写共享库。纯逻辑。 */
import { adminPages } from './routes'
import type { AdminMenuNode } from '@/types/admin'
const CANONICAL_BY_KEY = new Map<string, string>()
for (const page of adminPages) {
if (page.menuKey) {
CANONICAL_BY_KEY.set(page.menuKey, `/${page.path.replace(/^\/+/, '')}`)
}
}
/** column_key → 规范路由(带前导 /,无 base 前缀);未登记返回 undefined。 */
export function canonicalRouteOf(key: string | undefined | null): string | undefined {
return typeof key === 'string' ? CANONICAL_BY_KEY.get(key) : undefined
}
/** 递归把菜单树节点的 route 替换为规范路由;分组/未登记节点保持原值。 */
export function canonicalizeMenuTree(nodes: AdminMenuNode[] | null | undefined): AdminMenuNode[] {
return (nodes || []).map((node) => {
const canonical = canonicalRouteOf(node.key)
const children = canonicalizeMenuTree(node.children)
const route = canonical && node.route != null ? canonical : node.route
return { ...node, route, children }
})
}
+13
View File
@@ -17,6 +17,19 @@ export const adminPages: AdminPageDef[] = [
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据任务/记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
{ path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') },
{ path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') },
{ path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') },
{ path: 'asin-center/categories', menuKey: 'admin_product_categories', title: '商品类目', load: () => import('@/pages/asin/ProductCategoryPage.vue') },
{ path: 'asin-center/skip-price', menuKey: 'admin_skip_price_asin', title: '最低价ASIN', load: () => import('@/pages/asin/SkipPricePage.vue') },
]
export function routeOf(page: AdminPageDef): RouteRecordRaw {
@@ -1,8 +1,10 @@
import { defineStore } from 'pinia'
import { fetchAdminMenuTree, fetchCurrentUser, logout } from '@/api/session'
import { joinAdminPath } from '@/config/app'
import type { AdminMenuNode, AdminUser } from '@/types/admin'
import { isSuperAdminRole } from '@/types/admin'
import { firstVisiblePath } from '@/router/helpers'
import { canonicalizeMenuTree } from '@/router/menu-route-map'
export const useAdminSessionStore = defineStore('admin-session', {
state: () => ({
@@ -25,7 +27,7 @@ export const useAdminSessionStore = defineStore('admin-session', {
this.error = ''
try {
this.user = await fetchCurrentUser()
this.menuTree = await fetchAdminMenuTree()
this.menuTree = canonicalizeMenuTree(await fetchAdminMenuTree())
this.initialized = true
} catch (error) {
this.error = error instanceof Error ? error.message : '后台初始化失败'
@@ -37,7 +39,7 @@ export const useAdminSessionStore = defineStore('admin-session', {
async signOut() {
await logout()
this.$reset()
window.location.assign('/login')
window.location.assign(joinAdminPath('login'))
},
},
})
+7 -5
View File
@@ -70,10 +70,10 @@ button, input, textarea, select { font: inherit; }
.admin-menu .el-sub-menu__title,
.admin-menu .el-menu-item {
height: 42px; line-height: 42px; margin: 3px 0; padding: 0 12px;
border-radius: 8px; color: var(--admin-sidebar-text);
border-radius: 8px; color: #2f4a63;
}
.admin-menu .el-sub-menu__title:hover,
.admin-menu .el-menu-item:hover { color: var(--admin-primary-strong); background: rgba(79, 120, 165, 0.09); }
.admin-menu .el-menu-item:hover { color: #1e3b57; background: rgba(47, 93, 139, 0.16); }
.admin-menu .el-menu-item.is-active {
color: var(--admin-primary-strong); font-weight: 600;
background: linear-gradient(90deg, #dce9f5, #edf4fa);
@@ -81,9 +81,9 @@ button, input, textarea, select { font: inherit; }
box-shadow: inset 3px 0 0 #5f85ad, 0 8px 18px -16px rgba(79, 120, 165, 0.55);
}
.admin-menu .el-menu { background: transparent; }
.admin-menu .el-sub-menu .el-sub-menu__icon-arrow { color: var(--admin-sidebar-text); }
.menu-group-title { font-size: 15px; font-weight: 600; letter-spacing: .4px; color: var(--admin-sidebar-text); }
.menu-group-title:hover { color: var(--admin-primary-strong); }
.admin-menu .el-sub-menu .el-sub-menu__icon-arrow { color: #405b73; }
.menu-group-title { font-size: 15px; font-weight: 600; letter-spacing: .4px; color: #405b73; }
.menu-group-title:hover { color: #2f4a63; }
.skip-link { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.skip-link:focus {
@@ -118,6 +118,8 @@ button, input, textarea, select { font: inherit; }
.admin-content { flex: 1; min-width: 0; padding: 30px 32px 56px; overflow: auto; }
.page-stack { display: flex; flex-direction: column; gap: 18px; }
/* 表格操作按钮工具栏:置于表格卡片顶部、右对齐(新增/删除/导入/导出等贴近表格行操作)。 */
.table-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.page-heading h2 { margin: 0; font-size: 20px; color: var(--admin-text); }
.page-heading p { margin: 7px 0 0; color: var(--admin-muted); font-size: 13px; }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
@@ -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')
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+9
View File
@@ -14,10 +14,19 @@ export default defineConfig({
server: {
port: 5174,
proxy: {
// 统一把 Java 侧的登录/登出/业务接口反代到本地后端,保证同源 Cookie 会话可用。
'/api': {
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:18080',
changeOrigin: true,
},
'/login': {
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:18080',
changeOrigin: true,
},
'/logout': {
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:18080',
changeOrigin: true,
},
},
},
build: {
@@ -18,15 +18,18 @@ import java.util.List;
import java.util.Map;
/**
* 管理后台页面与"当前管理员"自描述接口Java 收敛自 Flask 侧 web_source/admin.html 的依赖)
* 管理后台入口路由与"当前管理员"自描述接口。
*
* <p>页面经静态资源托管于同源(classpath:/static 下的 admin.html / login.html),
* 登录/登出沿用 auth 模块(POST /login、POST /logout)。这里提供 GET 页面转发与
* /api/admin/current-user、/current-user/menus,保持与旧前端契约一致({item}、{items})。
* <p>管理后台页面已整体迁移到 Vue SPA(Nginx 静态托管于 /admin-vue/,见 docs/specs/10、11),
* 旧 classpath:/static 下的 admin.html / login.html 与 Flask 侧 web_source 已删除(task-283)。
* 这里只保留入口重定向:/、/admin → /admin-vue//login → /admin-vue/loginSPA 登录页);
* /admin.html、/login.html 兜底重定向到新入口,避免旧书签直接 404。
* 登录/登出本体沿用 auth 模块(POST /login、POST /logout)。这里提供 GET 页面转发与
* /api/admin/current-user、/current-user/menus,保持与前端契约一致({item}、{items})。
*/
@RestController
@RequiredArgsConstructor
@Tag(name = "管理后台页面", description = "管理后台静态页路由与当前管理员信息")
@Tag(name = "管理后台页面", description = "管理后台入口重定向与当前管理员信息")
public class AdminConsoleController {
private final AdminAuthSupport adminAuthSupport;
@@ -38,22 +41,22 @@ public class AdminConsoleController {
return "redirect:/admin-vue/";
}
@GetMapping("/admin")
@Operation(summary = "管理后台入口", hidden = true)
@GetMapping({"/admin", "/admin.html"})
@Operation(summary = "管理后台入口(含旧 /admin.html 兜底)", hidden = true)
public String adminPage() {
return "redirect:/admin-vue/";
}
@GetMapping("/login")
@Operation(summary = "登录页入口", hidden = true)
@GetMapping({"/login", "/login.html"})
@Operation(summary = "登录页入口(含旧 /login.html 兜底)", hidden = true)
public String loginPage() {
return "forward:/login.html";
return "redirect:/admin-vue/login";
}
@GetMapping("/logout")
@Operation(summary = "登出 GET 兜底(POST 走 auth 模块)", hidden = true)
public String logoutPage() {
return "redirect:/login.html";
return "redirect:/admin-vue/login";
}
@GetMapping("/api/admin/current-user")
File diff suppressed because it is too large Load Diff
@@ -1,887 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:,">
<meta name="theme-color" content="#0b1220">
<title>登录 - 数富AI</title>
<style>
:root {
color-scheme: dark;
--auth-bg: #0b1220;
--auth-surface: #141e31;
--auth-surface-raised: #1a2740;
--auth-border: rgba(148, 163, 184, 0.2);
--auth-border-strong: rgba(148, 163, 184, 0.34);
--auth-text: #f1f5f9;
--auth-text-muted: #b8c4d8;
--auth-text-subtle: #8795ad;
--auth-primary: #818cf8;
--auth-primary-strong: #a5b4fc;
--auth-success: #4ade80;
--auth-danger: #fb7185;
--auth-radius: 18px;
--auth-fast: 160ms;
--auth-base: 220ms;
}
* { box-sizing: border-box; }
html {
min-width: 320px;
min-height: 100%;
background: var(--auth-bg);
scroll-behavior: smooth;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
font-family: Inter, "SF Pro Display", "Microsoft YaHei", "PingFang SC", "Helvetica Neue", sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--auth-text);
background:
radial-gradient(circle at 8% 0%, rgba(99, 102, 241, 0.19), transparent 32rem),
radial-gradient(circle at 92% 100%, rgba(34, 197, 94, 0.06), transparent 28rem),
var(--auth-bg);
}
button,
input { font: inherit; }
a { color: inherit; }
.auth-skip-link {
position: fixed;
top: 12px;
left: 12px;
z-index: 20;
padding: 10px 14px;
border-radius: 10px;
background: var(--auth-primary);
color: #0b1220;
font-weight: 700;
text-decoration: none;
transform: translateY(-160%);
transition: transform var(--auth-fast) ease;
}
.auth-skip-link:focus { transform: translateY(0); }
.auth-page {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(420px, 1.1fr);
}
.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 rgba(148, 163, 184, 0.13);
background:
linear-gradient(160deg, rgba(16, 26, 46, 0.92), rgba(8, 15, 29, 0.98)),
radial-gradient(circle at 20% 8%, rgba(129, 140, 248, 0.18), transparent 28rem);
}
.auth-visual::before,
.auth-visual::after {
content: "";
position: absolute;
pointer-events: none;
}
.auth-visual::before {
inset: 0;
opacity: 0.24;
background-image: linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(148, 163, 184, 0.08) 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(129, 140, 248, 0.18);
border-radius: 50%;
box-shadow: 0 0 0 32px rgba(129, 140, 248, 0.035), 0 0 0 64px rgba(129, 140, 248, 0.025);
}
.auth-visual-content,
.auth-visual-footer { position: relative; z-index: 1; }
.auth-brand {
display: inline-flex;
align-items: center;
gap: 12px;
width: fit-content;
text-decoration: none;
}
.auth-brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: 13px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(79, 70, 229, 0.20);
overflow: hidden;
}
.auth-brand-mark img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.login-logo {
display: block;
width: 56px;
height: 56px;
margin-bottom: 16px;
border-radius: 14px;
box-shadow: 0 10px 24px -12px rgba(39, 67, 94, 0.45);
}
.auth-brand-copy { display: grid; gap: 1px; }
.auth-brand-name {
color: #f8fafc;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.2px;
}
.auth-brand-sub {
color: var(--auth-text-subtle);
font-size: 11px;
}
.auth-visual-content {
max-width: 520px;
margin: auto 0;
padding: 72px 0 96px;
}
.auth-visual-copy {
padding-top: 72px;
}
.auth-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--auth-primary-strong);
font-size: 11px;
font-weight: 700;
letter-spacing: 1.8px;
}
.auth-kicker::before {
content: "";
width: 24px;
height: 1px;
background: var(--auth-primary);
}
.auth-visual-title {
max-width: 560px;
margin: 18px 0 18px;
color: #f8fafc;
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: var(--auth-text-muted);
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: #dbe4f1;
}
.auth-feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 30px;
width: 30px;
height: 30px;
border: 1px solid rgba(129, 140, 248, 0.25);
border-radius: 9px;
background: rgba(129, 140, 248, 0.11);
color: var(--auth-primary-strong);
}
.auth-feature-icon svg { width: 16px; height: 16px; }
.auth-visual-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: #72819a;
font-size: 12px;
}
.auth-system-status {
display: inline-flex;
align-items: center;
gap: 7px;
color: #86efac;
}
.auth-system-status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--auth-success);
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.12);
}
.auth-content {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 40px clamp(24px, 6vw, 96px);
}
.login-card {
width: min(100%, 452px);
padding: clamp(28px, 4vw, 48px);
border: 1px solid var(--auth-border);
border-radius: 22px;
background: linear-gradient(145deg, rgba(20, 30, 49, 0.98), rgba(16, 26, 46, 0.96));
box-shadow: 0 26px 70px -38px rgba(2, 6, 23, 0.95);
}
.login-card-header { margin-bottom: 30px; }
.login-card-eyebrow {
margin: 0 0 8px;
color: var(--auth-text-subtle);
font-size: 12px;
font-weight: 700;
letter-spacing: 1.2px;
}
.login-title {
margin: 0;
color: var(--auth-text);
font-size: 30px;
font-weight: 700;
letter-spacing: -0.8px;
line-height: 1.2;
}
.login-subtitle {
margin: 10px 0 0;
color: var(--auth-text-muted);
line-height: 1.65;
}
.form-group { margin-bottom: 20px; }
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--auth-text-muted);
font-size: 13px;
font-weight: 600;
}
.input-shell { position: relative; }
.input-icon {
position: absolute;
top: 50%;
left: 14px;
display: inline-flex;
color: #8190a8;
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 var(--auth-border);
border-radius: 12px;
outline: none;
background: rgba(11, 18, 32, 0.72);
color: var(--auth-text);
font-size: 14px;
color-scheme: dark;
transition: border-color var(--auth-fast) ease, box-shadow var(--auth-fast) ease, background var(--auth-fast) ease;
}
.form-group input:hover { border-color: var(--auth-border-strong); }
.form-group input:focus {
border-color: var(--auth-primary);
background: rgba(11, 18, 32, 0.92);
box-shadow: 0 0 0 4px rgba(129, 140, 248, 0.15);
}
.form-group input::placeholder { color: #71809a; }
.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: #8190a8;
cursor: pointer;
transform: translateY(-50%);
transition: background var(--auth-fast) ease, color var(--auth-fast) ease;
}
.password-toggle:hover {
background: rgba(129, 140, 248, 0.12);
color: var(--auth-primary-strong);
}
.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 rgba(251, 113, 133, 0.28);
border-radius: 11px;
background: rgba(251, 113, 133, 0.1);
color: #fda4af;
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 rgba(165, 180, 252, 0.32);
border-radius: 12px;
background: linear-gradient(135deg, #818cf8, #6366f1);
color: #0b1220;
cursor: pointer;
font-size: 15px;
font-weight: 800;
box-shadow: 0 12px 24px -16px rgba(129, 140, 248, 0.95);
transition: transform var(--auth-fast) ease, box-shadow var(--auth-fast) ease, background var(--auth-fast) ease, opacity var(--auth-fast) ease;
}
.btn-login:hover:not(:disabled) {
background: linear-gradient(135deg, #a5b4fc, #818cf8);
box-shadow: 0 16px 28px -15px rgba(129, 140, 248, 0.98);
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(11, 18, 32, 0.28);
border-top-color: #0b1220;
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: var(--auth-text-subtle);
font-size: 12px;
line-height: 1.55;
}
.login-security-note svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
margin-top: 1px;
color: var(--auth-success);
}
.login-card-footer {
margin-top: 34px;
padding-top: 18px;
border-top: 1px solid rgba(148, 163, 184, 0.13);
color: #72819a;
font-size: 12px;
text-align: center;
}
:focus-visible {
outline: 2px solid var(--auth-primary-strong);
outline-offset: 3px;
}
@media (max-width: 900px) {
.auth-page { display: block; }
.auth-visual {
min-height: auto;
padding: 22px 24px;
border-right: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.13);
}
.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; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ===== 登录页莫兰迪亮色主题 ===== */
:root {
color-scheme: light;
--auth-bg: #f2f4f1;
--auth-surface: #ffffff;
--auth-surface-raised: #f8faf8;
--auth-border: #dbe3dd;
--auth-border-strong: #b9c9bd;
--auth-text: #29362f;
--auth-text-muted: #5d6c63;
--auth-text-subtle: #7f8d84;
--auth-primary: #607a6d;
--auth-primary-strong: #456052;
--auth-success: #4e8068;
--auth-danger: #a9545d;
}
html { background: var(--auth-bg); }
body {
color: var(--auth-text);
background:
radial-gradient(circle at 8% 0%, rgba(177, 198, 185, 0.34), transparent 34rem),
radial-gradient(circle at 96% 100%, rgba(213, 190, 178, 0.2), transparent 28rem),
var(--auth-bg);
}
.auth-skip-link {
background: var(--auth-primary);
color: #ffffff;
}
.auth-visual {
border-right-color: #d5ded7;
background:
linear-gradient(160deg, rgba(232, 239, 234, 0.96), rgba(246, 248, 245, 0.98)),
radial-gradient(circle at 20% 8%, rgba(127, 153, 138, 0.16), transparent 28rem);
}
.auth-visual::before {
opacity: 0.32;
background-image: linear-gradient(rgba(96, 122, 109, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(96, 122, 109, 0.1) 1px, transparent 1px);
}
.auth-visual::after {
border-color: rgba(96, 122, 109, 0.22);
box-shadow: 0 0 0 32px rgba(96, 122, 109, 0.055), 0 0 0 64px rgba(96, 122, 109, 0.035);
}
.auth-brand-mark {
border-radius: 13px;
background: #ffffff;
box-shadow: 0 10px 24px rgba(96, 122, 109, 0.18);
}
.auth-brand-name,
.auth-visual-title { color: var(--auth-text); }
.auth-brand-sub { color: #76857b; }
.auth-kicker { color: var(--auth-primary-strong); }
.auth-kicker::before { background: var(--auth-primary); }
.auth-visual-description { color: #607069; }
.auth-feature-item { color: #46564d; }
.auth-feature-icon { border-color: #c5d5c9; background: #edf3ee; color: var(--auth-primary-strong); }
.auth-visual-footer { color: #77857d; }
.auth-system-status { color: #3f7258; }
.auth-system-status::before { background: var(--auth-success); box-shadow: 0 0 0 4px rgba(78, 128, 104, 0.13); }
.auth-content {
background: rgba(248, 250, 248, 0.45);
}
.login-card {
border-color: var(--auth-border);
background: linear-gradient(145deg, #ffffff, #f9fbf9);
box-shadow: 0 26px 70px -38px rgba(60, 77, 67, 0.34);
}
.login-card-eyebrow { color: #718078; }
.login-title { color: var(--auth-text); }
.login-subtitle { color: var(--auth-text-muted); }
.form-group label { color: var(--auth-text-muted); }
.input-icon { color: #82938a; }
.form-group input {
background: #f7faf7;
border-color: #cfdad2;
color: var(--auth-text);
color-scheme: light;
}
.form-group input:hover { border-color: #aebfb3; }
.form-group input:focus { background: #ffffff; border-color: #6f8b7b; box-shadow: 0 0 0 4px rgba(111, 139, 123, 0.16); }
.form-group input::placeholder { color: #87958c; }
.password-toggle { color: #82938a; }
.password-toggle:hover { background: #edf4ee; color: var(--auth-primary-strong); }
.error-msg { border-color: #e4c2c5; background: #f8ebeb; color: #91474f; }
.btn-login {
border-color: #7d9988;
background: linear-gradient(135deg, #718b7c, #607a6d);
color: #ffffff;
box-shadow: 0 12px 24px -16px rgba(96, 122, 109, 0.82);
}
.btn-login:hover:not(:disabled) {
background: linear-gradient(135deg, #829b8b, #6b8577);
color: #ffffff;
box-shadow: 0 16px 28px -15px rgba(96, 122, 109, 0.86);
}
.btn-login-spinner { border-color: rgba(255, 255, 255, 0.35); border-top-color: #ffffff; }
.login-security-note { color: #718078; }
.login-security-note svg { color: var(--auth-success); }
.login-card-footer { border-top-color: #dce5de; color: #77857d; }
:focus-visible { outline-color: #607a6d; }
@media (max-width: 900px) {
.auth-visual { border-bottom-color: #d5ded7; }
}
/* ===== 登录页统一蓝白色调 ===== */
:root {
color-scheme: light;
--auth-bg: #f4f7fb;
--auth-surface: #ffffff;
--auth-surface-raised: #f9fbfd;
--auth-border: #d8e3ee;
--auth-border-strong: #b7c9db;
--auth-text: #24384d;
--auth-text-muted: #5b6f83;
--auth-text-subtle: #8293a5;
--auth-primary: #4f78a5;
--auth-primary-strong: #2f5d8b;
--auth-success: #4e806d;
--auth-danger: #b35f6a;
}
html, body { background: var(--auth-bg); }
body {
color: var(--auth-text);
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),
var(--auth-bg);
}
.auth-skip-link { background: var(--auth-primary); color: #ffffff; }
.auth-visual {
border-right-color: #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 { 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); }
.auth-visual::after { border-color: rgba(79, 120, 165, 0.22); box-shadow: 0 0 0 32px rgba(79, 120, 165, 0.055), 0 0 0 64px rgba(79, 120, 165, 0.035); }
.auth-brand-mark { border-radius: 13px; background: #ffffff; box-shadow: 0 10px 24px rgba(79, 120, 165, 0.18); }
.auth-brand-name, .auth-visual-title { color: var(--auth-text); }
.auth-brand-sub { color: #77899b; }
.auth-kicker { color: var(--auth-primary-strong); }
.auth-kicker::before { background: var(--auth-primary); }
.auth-visual-description { color: #60748a; }
.auth-feature-item { color: #465d73; }
.auth-feature-icon { border-color: #c2d3e3; background: #edf5fb; color: var(--auth-primary-strong); }
.auth-visual-footer { color: #778b9f; }
.auth-system-status { color: #3d7158; }
.auth-system-status::before { background: var(--auth-success); box-shadow: 0 0 0 4px rgba(78, 128, 104, 0.13); }
.auth-content { background: rgba(249, 251, 253, 0.5); }
.login-card { border-color: var(--auth-border); background: linear-gradient(145deg, #ffffff, #f9fbfd); box-shadow: 0 26px 70px -38px rgba(39, 67, 94, 0.34); }
.login-card-eyebrow { color: #71859a; }
.login-title { color: var(--auth-text); }
.login-subtitle, .form-group label { color: var(--auth-text-muted); }
.input-icon, .password-toggle { color: #8298ad; }
.form-group input { background: #f8fbfd; border-color: #cbd9e6; color: var(--auth-text); color-scheme: light; }
.form-group input:hover { border-color: #9fb7cd; }
.form-group input:focus { background: #ffffff; border-color: #5f85ad; box-shadow: 0 0 0 4px rgba(95, 133, 173, 0.16); }
.form-group input::placeholder { color: #8293a5; }
.password-toggle:hover { background: #edf5fb; color: var(--auth-primary-strong); }
.error-msg { border-color: #e4c2c5; background: #f8ebeb; color: #91474f; }
.btn-login { border-color: #7196ba; background: linear-gradient(135deg, #5f85ad, #4f78a5); color: #ffffff; box-shadow: 0 12px 24px -16px rgba(79, 120, 165, 0.82); }
.btn-login:hover:not(:disabled) { background: linear-gradient(135deg, #7094ba, #5d83ac); color: #ffffff; box-shadow: 0 16px 28px -15px rgba(79, 120, 165, 0.86); }
.btn-login-spinner { border-color: rgba(255,255,255,0.35); border-top-color: #ffffff; }
.login-security-note { color: #71859a; }
.login-security-note svg { color: var(--auth-success); }
.login-card-footer { border-top-color: #dce5ee; color: #778b9f; }
:focus-visible { outline-color: #4f78a5; }
@media (max-width: 900px) { .auth-visual { border-bottom-color: #d4e0eb; } }
</style>
</head>
<body>
<a class="auth-skip-link" href="#loginMain">跳转到登录表单</a>
<div class="auth-page">
<aside class="auth-visual" aria-label="数富AI产品信息">
<div class="auth-visual-content">
<a class="auth-brand" href="/login" aria-label="数富AI 登录页">
<span class="auth-brand-mark" aria-hidden="true"><img src="/static/logo_thumb.png" alt=""></span>
<span class="auth-brand-copy">
<span class="auth-brand-name">数富AI</span>
<span class="auth-brand-sub">电商运营管理后台</span>
</span>
</a>
<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" id="loginMain">
<section class="login-card" aria-labelledby="loginTitle">
<header class="login-card-header">
<img class="login-logo" src="/static/logo_thumb.png" alt="数富AI">
<p class="login-card-eyebrow">欢迎回来</p>
<h1 class="login-title" id="loginTitle">登录工作台</h1>
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
</header>
<form id="loginForm" method="POST" action="/login" novalidate>
<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 type="text" id="loginUsername" name="username" autocomplete="username" placeholder="请输入用户名" required 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 type="password" id="loginPassword" name="password" autocomplete="current-password" placeholder="请输入密码" required>
<button type="button" class="password-toggle" id="togglePassword" aria-label="显示密码" aria-pressed="false" title="显示密码">
<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="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>
</button>
</div>
</div>
<button type="submit" class="btn-login" id="btnLogin" aria-busy="false">
<span class="btn-login-label">登录</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>
<script>
(function () {
var form = document.getElementById('loginForm');
var btn = document.getElementById('btnLogin');
var label = btn ? btn.querySelector('.btn-login-label') : null;
var password = document.getElementById('loginPassword');
var togglePassword = document.getElementById('togglePassword');
if (!form || !btn) return;
if (password && togglePassword) {
togglePassword.addEventListener('click', function () {
var visible = password.type === 'password';
password.type = visible ? 'text' : 'password';
togglePassword.setAttribute('aria-pressed', visible ? 'true' : 'false');
togglePassword.setAttribute('aria-label', visible ? '隐藏密码' : '显示密码');
togglePassword.setAttribute('title', visible ? '隐藏密码' : '显示密码');
});
}
function setError(message) {
var errEl = document.getElementById('loginError') || document.querySelector('.error-msg');
if (!errEl) {
errEl = document.createElement('p');
errEl.id = 'loginError';
errEl.className = 'error-msg';
errEl.setAttribute('role', 'alert');
errEl.setAttribute('aria-live', 'assertive');
form.insertBefore(errEl, form.firstChild);
}
errEl.textContent = message || '登录失败';
}
function setLoading(loading) {
btn.disabled = loading;
btn.classList.toggle('is-loading', loading);
btn.setAttribute('aria-busy', loading ? 'true' : 'false');
if (label) label.textContent = loading ? '登录中...' : '登录';
}
function deviceId() {
try {
var key = 'aiimage_console_device_id';
var 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 (e) {
return 'web-console-fallback';
}
}
// 已登录(Java cookie 有效)时直接进入工作台。
fetch('/check_login', { method: 'GET', credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (res) {
if (res && res.success) window.location.replace('/admin.html');
})
.catch(function () { /* 忽略,保持登录页 */ });
form.addEventListener('submit', function (event) {
event.preventDefault();
if (btn.disabled) return;
var username = (document.getElementById('loginUsername').value || '').trim();
var password = document.getElementById('loginPassword').value || '';
if (!username || !password) {
setError('请输入用户名和密码');
return;
}
var did = deviceId();
setLoading(true);
fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Device-Id': did },
body: JSON.stringify({ username: username, password: password, deviceId: did }),
credentials: 'same-origin'
})
.then(function (response) {
return response.json().catch(function () {
return { success: false, message: '登录响应异常,请重试' };
});
})
.then(function (result) {
if (result && result.success) {
window.location.replace('/admin.html');
return;
}
setError((result && (result.error || result.message)) || '用户名或密码错误');
setLoading(false);
})
.catch(function () {
setError('无法连接服务,请稍后重试');
setLoading(false);
});
});
})();
</script>
</body>
</html>
@@ -1,333 +0,0 @@
(function () {
'use strict';
if (window.__adminInteractionLayerInstalled) return;
window.__adminInteractionLayerInstalled = true;
var toastRegion = document.getElementById('adminToastRegion');
var confirmMask = document.getElementById('adminConfirmModal');
var confirmTitle = document.getElementById('adminConfirmTitle');
var confirmMessage = document.getElementById('adminConfirmMessage');
var confirmAccept = document.getElementById('adminConfirmAccept');
var confirmCancel = document.getElementById('adminConfirmCancel');
var guide = document.getElementById('adminOperationGuide');
var guideText = document.getElementById('adminOperationGuideText');
var guideSteps = document.getElementById('adminOperationGuideSteps');
var guideToggle = document.getElementById('adminOperationGuideToggle');
var pendingConfirmButton = null;
var previousFocus = null;
var busyButton = null;
var busyWasDisabled = false;
var activeFetches = 0;
var activeXhrs = 0;
var mainContent = document.getElementById('adminContent');
var guides = {
users: { text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。', steps: ['筛选账号', '编辑权限', '确认保存'] },
columns: { text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。', steps: ['新增或调整菜单', '设置层级', '拖动排序'] },
'dedupe-total-data': { text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。', steps: ['选择分组', '筛选或导入', '核对并导出'] },
'invalid-asin-data': { text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。', steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'] },
'shop-keys': { text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。', steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'] },
'shop-manage': { text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。', steps: ['选择分组', '维护店铺信息', '筛选核对结果'] },
'skip-price-asin': { text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。', steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'] },
'query-asin': { text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。', steps: ['筛选店铺', '打开配置抽屉', '保存或导出'] },
'product-categories': { text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。', steps: ['搜索类目', '展开层级', '新增或编辑'] },
'image-video-tasks': { text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。', steps: ['设置筛选', '查看任务结果', '按需处理任务'] },
'shop-data-crawl-tasks': { text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。', steps: ['筛选任务', '检查状态', '执行批量操作'] },
history: { text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。', steps: ['设置时间范围', '筛选记录', '查看结果预览'] },
version: { text: '上传版本后请核对版本号和下载链接,再通知用户更新。', steps: ['上传压缩包', '检查版本记录', '维护历史版本'] },
'digital-human-version': { text: '数字人版本需先上传草稿,再发布并标记最新版本。', steps: ['上传草稿', '确认更新日志', '发布或设为最新'] }
};
function cleanText(value) {
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
}
function showToast(message, type) {
var value = cleanText(message);
if (!value || !toastRegion) return;
var item = document.createElement('div');
item.className = 'admin-toast' + (type === 'error' ? ' is-error' : type === 'info' ? ' is-info' : '');
var content = document.createElement('span');
content.className = 'admin-toast__text';
content.textContent = value;
item.appendChild(content);
toastRegion.appendChild(item);
window.setTimeout(function () {
item.style.opacity = '0';
item.style.transform = 'translateY(-6px)';
item.style.transition = 'opacity 160ms ease, transform 160ms ease';
window.setTimeout(function () { item.remove(); }, 180);
}, type === 'error' ? 5200 : 3200);
}
window.__adminToast = showToast;
function activeTabName() {
var tab = document.querySelector('#adminMenu .tab.active');
return tab ? (tab.dataset.tab || '') : '';
}
function updateGuide(tabName) {
if (!guide || !guideText || !guideSteps) return;
var config = guides[tabName] || { text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。', steps: ['选择筛选条件', '处理记录', '核对反馈'] };
guideText.textContent = config.text;
guideSteps.innerHTML = (config.steps || []).map(function (step) { return '<li>' + step + '</li>'; }).join('');
guide.dataset.tab = tabName || '';
}
window.__adminUpdateOperationGuide = updateGuide;
function setGuideCollapsed(collapsed) {
if (!guide || !guideToggle) return;
guide.classList.toggle('is-collapsed', collapsed);
guideToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
guideToggle.textContent = collapsed ? '展开提示' : '收起提示';
try { localStorage.setItem('shufuAdminGuideCollapsed', collapsed ? '1' : '0'); } catch (error) {}
}
if (guideToggle) {
var collapsed = false;
try { collapsed = localStorage.getItem('shufuAdminGuideCollapsed') === '1'; } catch (error) {}
setGuideCollapsed(collapsed);
guideToggle.addEventListener('click', function () {
setGuideCollapsed(!guide.classList.contains('is-collapsed'));
});
}
function hashTabName() {
var raw = (window.location.hash || '').replace(/^#/, '');
var match = raw.match(/(?:^|&)tab=([^&]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function syncTabHash(tabName) {
if (!tabName || !window.history || !window.history.replaceState) return;
var next = '#tab=' + encodeURIComponent(tabName);
if (window.location.hash !== next) window.history.replaceState(null, '', next);
}
function navigateToHash(attemptsLeft) {
var tabName = hashTabName();
if (!tabName) {
updateGuide(activeTabName());
return;
}
var tab = document.querySelector('#adminMenu .tab[data-tab="' + tabName + '"]');
if (tab && typeof tab.onclick === 'function') {
if (!tab.classList.contains('active')) tab.click();
else updateGuide(tabName);
return;
}
if (attemptsLeft > 0) {
window.setTimeout(function () { navigateToHash(attemptsLeft - 1); }, 80);
} else {
updateGuide(activeTabName());
}
}
function closeConfirm() {
if (!confirmMask) return;
confirmMask.classList.remove('show');
confirmMask.setAttribute('aria-hidden', 'true');
document.body.classList.remove('admin-confirm-open');
var focus = previousFocus;
pendingConfirmButton = null;
previousFocus = null;
if (focus && focus.isConnected) focus.focus();
}
function openConfirm(button) {
if (!confirmMask || !confirmMessage || !confirmAccept) return;
pendingConfirmButton = button;
previousFocus = document.activeElement;
var customMessage = cleanText(button.dataset.confirmMessage);
var label = cleanText(button.getAttribute('aria-label') || button.textContent || '删除');
var subject = cleanText(button.dataset.name || button.dataset.value || button.dataset.shopManageName || button.dataset.shopName || button.dataset.ziniaoAccountName || '');
var country = cleanText(button.dataset.country || '');
if (!customMessage && country && subject) subject += '' + country + '';
if (!customMessage && subject) customMessage = (button.classList.contains('btn-danger') ? '确认删除“' : '确认执行“') + subject + '”吗?此操作可能影响已有数据。';
if (confirmTitle) confirmTitle.textContent = button.classList.contains('btn-danger') ? '删除前确认' : '请确认操作';
confirmMessage.textContent = customMessage || ('确认执行“' + label + '”吗?此操作可能影响已有数据。');
confirmAccept.textContent = button.dataset.confirmActionLabel || (button.classList.contains('btn-danger') ? '确认删除' : '确认操作');
confirmMask.classList.add('show');
confirmMask.setAttribute('aria-hidden', 'false');
document.body.classList.add('admin-confirm-open');
window.setTimeout(function () { confirmAccept.focus(); }, 0);
}
function keepConfirmFocus(event) {
if (!confirmMask || !confirmMask.classList.contains('show') || event.key !== 'Tab') return;
var focusable = Array.prototype.filter.call(confirmMask.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), function (el) {
return !el.disabled && el.offsetParent !== null;
});
if (!focusable.length) return;
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
if (confirmCancel) confirmCancel.addEventListener('click', closeConfirm);
if (confirmMask) confirmMask.addEventListener('click', function (event) {
if (event.target === confirmMask) closeConfirm();
});
if (confirmAccept) confirmAccept.addEventListener('click', function () {
var target = pendingConfirmButton;
closeConfirm();
if (!target) return;
window.__adminConfirmBypass = true;
try { target.click(); }
finally { window.setTimeout(function () { window.__adminConfirmBypass = false; }, 0); }
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Enter' && event.target && event.target.matches && event.target.matches('input:not([type="file"]), select') && !event.target.closest('textarea')) {
var searchScope = event.target.closest('.form-row, .form-box');
var searchButton = searchScope && searchScope.querySelector('button[id^="btnSearch"], button[id*="Search"]');
if (searchButton && !searchButton.disabled) {
event.preventDefault();
searchButton.click();
return;
}
}
if (event.key === 'Escape' && confirmMask && confirmMask.classList.contains('show')) {
event.preventDefault();
closeConfirm();
return;
}
keepConfirmFocus(event);
});
var nativeAlert = window.alert ? window.alert.bind(window) : null;
window.alert = function (message) {
showToast(message, /失败|错误|无权|不能为空|不正确|异常/.test(String(message || '')) ? 'error' : 'info');
};
var nativeConfirm = window.confirm ? window.confirm.bind(window) : null;
window.confirm = function (message) {
if (window.__adminConfirmBypass) return true;
return nativeConfirm ? nativeConfirm(message) : false;
};
function updatePageBusy() {
if (!mainContent) return;
mainContent.setAttribute('aria-busy', activeFetches || activeXhrs ? 'true' : 'false');
}
function startBusy() {
var button = window.__adminLastActionButton;
if (!button || !button.isConnected || button.disabled || button.classList.contains('tab') || button.classList.contains('menu-group-title') || button === confirmAccept) return;
busyButton = button;
busyWasDisabled = button.disabled;
button.classList.add('is-busy');
button.setAttribute('aria-busy', 'true');
button.disabled = true;
}
function finishBusy() {
var finishedButton = busyButton;
if (finishedButton && finishedButton.isConnected) {
finishedButton.classList.remove('is-busy');
finishedButton.removeAttribute('aria-busy');
if (!busyWasDisabled) finishedButton.disabled = false;
}
if (window.__adminLastActionButton === finishedButton) window.__adminLastActionButton = null;
busyButton = null;
busyWasDisabled = false;
}
if (window.fetch) {
var nativeFetch = window.fetch.bind(window);
window.fetch = function () {
activeFetches += 1;
updatePageBusy();
if (activeFetches === 1) startBusy();
var request;
try { request = nativeFetch.apply(window, arguments); }
catch (error) { activeFetches = Math.max(0, activeFetches - 1); if (!activeFetches && !activeXhrs) finishBusy(); throw error; }
return Promise.resolve(request).finally(function () {
activeFetches = Math.max(0, activeFetches - 1);
updatePageBusy();
if (!activeFetches && !activeXhrs) finishBusy();
});
};
}
if (window.XMLHttpRequest) {
var nativeSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function () {
activeXhrs += 1;
updatePageBusy();
if (activeXhrs === 1) startBusy();
this.addEventListener('loadend', function () {
activeXhrs = Math.max(0, activeXhrs - 1);
updatePageBusy();
if (!activeFetches && !activeXhrs) finishBusy();
}, { once: true });
try { return nativeSend.apply(this, arguments); }
catch (error) { activeXhrs = Math.max(0, activeXhrs - 1); if (!activeFetches && !activeXhrs) finishBusy(); throw error; }
};
}
function addButtonHint(button) {
if (!button || button.title) return;
var label = cleanText(button.textContent);
if (label === '编辑') button.title = '编辑当前记录';
else if (label === '删除') button.title = '删除当前记录,需二次确认';
else if (label === '查询') button.title = '按当前筛选条件查询';
else if (/^导出/.test(label)) button.title = '导出当前筛选结果';
else if (/^上传并/.test(label)) button.title = '上传文件并执行相应操作';
else if (label === '管理分组') button.title = '新增、编辑或删除分组';
else if (label === '选择店铺') button.title = '从店铺列表选择并回填';
}
function enhance(root) {
var scope = root && root.querySelectorAll ? root : document;
scope.querySelectorAll('button').forEach(addButtonHint);
scope.querySelectorAll('.table-ellipsis').forEach(function (element) {
if (!element.title) element.title = cleanText(element.textContent);
});
scope.querySelectorAll('.empty-tip').forEach(function (element) { element.setAttribute('role', 'status'); });
scope.querySelectorAll('.msg').forEach(function (element) {
var value = cleanText(element.textContent);
if (!value || (!element.classList.contains('ok') && !element.classList.contains('err'))) return;
var key = value + '|' + element.className;
if (element.dataset.adminToastKey === key) return;
element.dataset.adminToastKey = key;
showToast(value, element.classList.contains('err') ? 'error' : 'success');
});
}
document.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('button') : null;
if (!button || button.disabled) return;
if (button.classList.contains('tab') || button.classList.contains('menu-group-title')) {
window.__adminLastActionButton = null;
} else if (button !== confirmAccept) {
window.__adminLastActionButton = button;
}
if (button.classList.contains('tab')) {
window.setTimeout(function () {
var tabName = activeTabName();
updateGuide(tabName);
syncTabHash(tabName);
}, 0);
}
if ((!button.matches('.btn-danger') && !button.hasAttribute('data-admin-confirm')) || button === confirmAccept || window.__adminConfirmBypass) return;
event.preventDefault();
event.stopImmediatePropagation();
openConfirm(button);
}, true);
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
enhance(mutation.target && mutation.target.nodeType === 1 ? mutation.target : document);
if (mutation.type === 'attributes' && mutation.target.matches && mutation.target.matches('#adminMenu .tab')) {
window.setTimeout(function () { updateGuide(activeTabName()); }, 0);
}
});
});
enhance(document);
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
window.addEventListener('hashchange', function () { navigateToHash(0); });
navigateToHash(25);
})();
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

@@ -37,7 +37,7 @@ class AdminApiGuardFilterTest {
void nonAdminApiPathIsNotGuarded() throws Exception {
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin.html");
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-vue/");
MockHttpServletResponse response = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
@@ -0,0 +1,42 @@
package com.nanri.aiimage.modules.imagehistory.service;
import com.nanri.aiimage.modules.imagehistory.mapper.ImageHistoryMapper;
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** 验收反馈:生成记录数据要可清理——DELETE 清空全表并返回清理条数。 */
class ImageHistoryServiceTest {
@Test
void clearAllHistoryDeletesAndReturnsCount() {
ImageHistoryMapper historyMapper = mock(ImageHistoryMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
ImageHistoryService service = new ImageHistoryService(historyMapper, userMapper, new ObjectMapper());
when(historyMapper.selectCount(any())).thenReturn(42L);
long removed = service.clearAllHistory("admin");
assertThat(removed).isEqualTo(42L);
verify(historyMapper).delete(any());
}
@Test
void clearAllHistoryEmptyTableReturnsZero() {
ImageHistoryMapper historyMapper = mock(ImageHistoryMapper.class);
AdminUserMapper userMapper = mock(AdminUserMapper.class);
ImageHistoryService service = new ImageHistoryService(historyMapper, userMapper, new ObjectMapper());
when(historyMapper.selectCount(any())).thenReturn(0L);
assertThat(service.clearAllHistory("admin")).isZero();
verify(historyMapper).delete(any());
}
}
-215
View File
@@ -1,215 +0,0 @@
"""对象存储上传工具(MinIO,S3 协议兼容)
原实现基于阿里云 OSS SDKalibabacloud_oss_v2),现改为 boto3 对接 MinIO。
对外函数名与返回值保持不变,业务代码无需修改。
"""
import base64
import io
import mimetypes
import os
import re
import tempfile
import threading
import time
import boto3
import requests
from botocore.config import Config
from config import (
region,
endpoint,
bucket,
file_url_pre,
bucket_path,
accessKeyId,
accessKeySecret,
)
from utils.ssrf import is_internal_url
_client = None
_client_lock = threading.Lock()
_REMOTE_IMAGE_MAX_BYTES = int(os.getenv("OSS_UPLOAD_IMAGE_MAX_BYTES", str(10 * 1024 * 1024)))
_REMOTE_IMAGE_TIMEOUT = (5, 30)
def get_client():
"""获取(懒加载)S3 客户端,MinIO 必须使用 path-style 寻址"""
global _client
if _client is None:
with _client_lock:
if _client is None:
_client = boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=accessKeyId,
aws_secret_access_key=accessKeySecret,
region_name=region,
config=Config(
signature_version="s3v4",
s3={"addressing_style": "path"}, # MinIO 必须
retries={"max_attempts": 3, "mode": "standard"},
),
)
return _client
def _guess_content_type(key: str) -> str:
content_type, _ = mimetypes.guess_type(key)
return content_type or "application/octet-stream"
def get_presigned_url(key: str, expires: int = 7 * 24 * 3600) -> str:
"""生成临时访问链接(存储桶未开放匿名读时使用),默认有效 7 天"""
return get_client().generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key.lstrip("/")},
ExpiresIn=expires,
)
class _LimitedReader:
"""Keep a streamed upload from accepting an unexpectedly huge object."""
def __init__(self, source, max_bytes: int):
self._source = source
self._max_bytes = max_bytes
self._read_bytes = 0
def read(self, size=-1):
remaining = self._max_bytes - self._read_bytes
if remaining < 0:
raise ValueError("upload exceeds configured size limit")
read_size = remaining + 1 if size is None or size < 0 else min(size, remaining + 1)
data = self._source.read(read_size)
if not data:
return data
self._read_bytes += len(data)
if self._read_bytes > self._max_bytes:
raise ValueError("upload exceeds configured size limit")
return data
def seek(self, offset, whence=0):
position = self._source.seek(offset, whence)
self._read_bytes = max(0, position)
return position
def tell(self):
return self._source.tell()
def upload_fileobj(file_obj, key: str, max_bytes: int = 0, content_type: str = ""):
"""Stream a file-like object to S3/MinIO without materializing it."""
key = key.lstrip("/")
if isinstance(file_obj, (bytes, bytearray)):
file_obj = io.BytesIO(file_obj)
if not hasattr(file_obj, "read"):
raise TypeError("file_obj must be bytes or a readable file-like object")
stream_size = None
try:
file_obj.seek(0)
file_obj.seek(0, os.SEEK_END)
stream_size = file_obj.tell()
file_obj.seek(0)
except (AttributeError, OSError, TypeError, ValueError):
try:
file_obj.seek(0)
except (AttributeError, OSError, TypeError, ValueError):
pass
if max_bytes and max_bytes > 0 and stream_size is not None and stream_size > max_bytes:
raise ValueError("upload exceeds configured size limit")
client = get_client()
body = _LimitedReader(file_obj, max_bytes) if max_bytes and max_bytes > 0 else file_obj
client.upload_fileobj(
body,
bucket,
key,
ExtraArgs={"ContentType": content_type or _guess_content_type(key)},
)
return file_url_pre + key
def upload_file(file_content: bytes, key: str):
"""上传字节内容到 MinIO,返回可访问链接"""
return upload_fileobj(file_content, key)
def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str:
"""
将 base64 data URL 上传到对象存储,返回图片链接
data_url: data:image/png;base64,xxxx 或 data:image/jpeg;base64,xxxx
prefix: 对象 key 前缀
key_hint: 可选后缀避免重名,如 "_0", "_1"
"""
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
if not match:
raise ValueError('无效的 data URL 格式')
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
encoded_payload = match.group(2)
if len(encoded_payload) > ((max(_REMOTE_IMAGE_MAX_BYTES, 0) + 2) // 3) * 4:
raise ValueError("image exceeds configured upload size limit")
file_content = base64.b64decode(encoded_payload, validate=True)
if len(file_content) > _REMOTE_IMAGE_MAX_BYTES:
raise ValueError("image exceeds configured upload size limit")
ts = int(time.time() * 1000)
key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}"
return upload_fileobj(io.BytesIO(file_content), key, _REMOTE_IMAGE_MAX_BYTES)
def upload_data_urls(data_urls: list, prefix: str = "history") -> list:
"""批量上传图片;每张图完成上传后立即释放其缓冲区。"""
urls = []
ts = int(time.time() * 1000)
for i, data_url in enumerate(data_urls or []):
if not data_url or not isinstance(data_url, str):
continue
if not data_url.startswith("http"):
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
if not match:
continue
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
encoded_payload = match.group(2)
if len(encoded_payload) > ((max(_REMOTE_IMAGE_MAX_BYTES, 0) + 2) // 3) * 4:
raise ValueError("image exceeds configured upload size limit")
file_content = base64.b64decode(encoded_payload, validate=True)
if len(file_content) > _REMOTE_IMAGE_MAX_BYTES:
raise ValueError("image exceeds configured upload size limit")
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
urls.append(upload_fileobj(io.BytesIO(file_content), key, _REMOTE_IMAGE_MAX_BYTES))
del file_content
continue
if is_internal_url(data_url):
raise ValueError("拒绝下载内网/本机地址的图片")
with requests.get(data_url, stream=True, timeout=_REMOTE_IMAGE_TIMEOUT) as response:
response.raise_for_status()
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > _REMOTE_IMAGE_MAX_BYTES:
raise ValueError("image exceeds configured upload size limit")
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
extension = mimetypes.guess_extension(content_type) or ".png"
ext = extension.lstrip(".") or "png"
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
with tempfile.SpooledTemporaryFile(max_size=2 * 1024 * 1024, mode="w+b") as image_file:
total = 0
for chunk in response.iter_content(chunk_size=64 * 1024):
if not chunk:
continue
total += len(chunk)
if total > _REMOTE_IMAGE_MAX_BYTES:
raise ValueError("image exceeds configured upload size limit")
image_file.write(chunk)
image_file.seek(0)
urls.append(upload_fileobj(image_file, key, _REMOTE_IMAGE_MAX_BYTES, content_type))
return urls
# 脚本入口,当文件被直接运行时调用main函数
if __name__ == "__main__":
with open("D:\\pack\\nanri\\main.dist\\SHUFU.zip", "rb") as f:
file_content = f.read()
res = upload_file(file_content, key=bucket_path + "versions/1.0.46.zip")
print(res)
+8 -19
View File
@@ -1,24 +1,23 @@
"""
卖相AI - Flask 后端
按功能拆分为蓝图:认证(auth)、主页面(main)、管理员API(admin_api)、版本(version)
数富AI - Flask 后端
管理后台(页面 web_source/ 与 /api/admin/* 蓝图)已整体迁移至 Java + Vue
/admin-vue/,见 docs/specs/11-flask-removal-cutover.md,旧代码于 task-283 删除)。
本进程当前只保留客户端仍在使用的版本公开 API/api/version、/api/version/latest
(桌面客户端 app_client 的更新检查仍以本进程 15124 为 base_url,切换前勿下线)。
"""
import os
import secrets
from datetime import timedelta
from flask import Flask
from flask_cors import CORS
from utils.db import init_db
from blueprints.auth import auth
from blueprints.main import main
from blueprints.admin_api import admin_api
from blueprints.version import version_bp
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app = Flask(__name__, template_folder=BASE_DIR, static_folder=BASE_DIR)
app = Flask(__name__)
# CORS配置:限制为可信域名
# CORS配置:限制为可信域名(版本检查由桌面客户端/网页发起,跨域仍受限)
CORS(app, resources={
r"/api/*": {
"origins": [
@@ -33,18 +32,8 @@ CORS(app, resources={
}
})
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
# 文件上传大小限制:2GB(数字人 ZIP 包等大文件)
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024
# 会话安全配置
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# 注册蓝图
app.register_blueprint(auth)
app.register_blueprint(main)
app.register_blueprint(admin_api)
# 注册蓝图:仅版本公开 API(管理后台 API 已迁 Java,见模块 docstring
app.register_blueprint(version_bp)
File diff suppressed because it is too large Load Diff
-87
View File
@@ -1,87 +0,0 @@
"""
认证蓝图登录登出登录状态校验
"""
from flask import Blueprint, request, redirect, url_for, session, jsonify, make_response, current_app
from werkzeug.security import check_password_hash
from utils.db import get_db
from utils.auth import login_required, is_session_user_valid
from utils.render import render_html
auth = Blueprint('auth', __name__, url_prefix='')
@auth.route('/login', methods=['GET', 'POST'])
def login():
force_relogin = request.args.get('logout') == '1' or request.args.get('switch') == '1'
if request.method == 'GET' and force_relogin:
session.clear()
response = make_response(render_html('login.html'))
response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
if request.method == 'GET' and session.get('user_id') and is_session_user_valid():
return redirect(url_for('main.admin_page'))
if request.method == 'POST':
session.clear()
wants_json = request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest'
data = request.get_json() if request.is_json else request.form
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
if wants_json:
return jsonify({'success': False, 'error': '请输入用户名和密码'})
return render_html('login.html', error='请输入用户名和密码')
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
(username,)
)
row = cur.fetchone()
conn.close()
if row and check_password_hash(row['password_hash'], password):
session.permanent = True
session['user_id'] = row['id']
session['username'] = username
if wants_json:
return jsonify({'success': True, 'redirect': url_for('main.admin_page')})
return redirect(url_for('main.admin_page'))
except Exception as exc:
current_app.logger.error('[auth] login error: %s', exc, exc_info=True)
if wants_json:
return jsonify({'success': False, 'error': '登录失败,请稍后重试'})
return render_html('login.html', error='登录失败,请稍后重试')
if wants_json:
return jsonify({'success': False, 'error': '用户名或密码错误'})
return render_html('login.html', error='用户名或密码错误')
return render_html('login.html')
@auth.route('/api/auth/check')
@login_required
def api_auth_check():
"""校验登录状态,用于页面加载时判断是否已登录。"""
if not session.get('user_id'):
return jsonify({'logged_in': False})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
if not row:
return jsonify({'logged_in': False})
except Exception:
return jsonify({'logged_in': False})
return jsonify({'logged_in': True, 'redirect': url_for('main.admin_page')})
@auth.route('/logout')
def logout():
session.clear()
response = redirect(url_for('auth.login', logout='1'))
response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session'))
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
-54
View File
@@ -1,54 +0,0 @@
"""
主页面蓝图首页管理后台页静态文件
"""
import os
from flask import Blueprint, current_app, redirect, url_for, send_file, session
from werkzeug.utils import safe_join
from utils.auth import login_required, is_session_user_valid
from utils.render import render_html
main = Blueprint('main', __name__, url_prefix='')
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATIC_DIR = os.path.join(BASE_DIR, 'static')
@main.route('/')
def index():
if session.get('user_id') and is_session_user_valid():
return redirect(url_for('main.admin_page'))
return redirect(url_for('auth.login'))
@main.route('/admin')
@login_required
def admin_page():
"""管理后台页:服务端直接渲染当前用户有权限的菜单,避免客户端二次渲染造成闪烁。"""
context = {}
try:
# 复用权限菜单加载逻辑;Java 权限接口不可用时降级为无菜单(JS 侧会走 API 兜底)
from blueprints.admin_api import _load_current_backend_menu_items
_, _, items, denied = _load_current_backend_menu_items()
if denied is None and items:
context['admin_menu_items'] = items
context['admin_menu_rendered'] = True
else:
# 权限接口失败:真实页面同样会失败,服务端不渲染菜单,避免展示越权菜单
context['admin_menu_rendered'] = False
current_app.logger.warning('[admin] 服务端菜单渲染失败,降级为客户端加载: %s',
(denied[0].get_json() if denied and len(denied) > 0 else None))
except Exception as exc:
# 兜底:任何异常都不得阻断管理页打开,JS 会自行请求菜单接口
current_app.logger.exception('[admin] 服务端渲染菜单异常: %s', exc)
context['admin_menu_rendered'] = False
return render_html('admin.html', **context)
@main.route('/static/<path:filename>')
def serve_static(filename):
"""提供 static 目录及子目录下的静态文件访问"""
filepath = safe_join(STATIC_DIR, filename)
if filepath is None or not os.path.isfile(filepath):
return '', 404
return send_file(filepath, as_attachment=False)
-333
View File
@@ -1,333 +0,0 @@
(function () {
'use strict';
if (window.__adminInteractionLayerInstalled) return;
window.__adminInteractionLayerInstalled = true;
var toastRegion = document.getElementById('adminToastRegion');
var confirmMask = document.getElementById('adminConfirmModal');
var confirmTitle = document.getElementById('adminConfirmTitle');
var confirmMessage = document.getElementById('adminConfirmMessage');
var confirmAccept = document.getElementById('adminConfirmAccept');
var confirmCancel = document.getElementById('adminConfirmCancel');
var guide = document.getElementById('adminOperationGuide');
var guideText = document.getElementById('adminOperationGuideText');
var guideSteps = document.getElementById('adminOperationGuideSteps');
var guideToggle = document.getElementById('adminOperationGuideToggle');
var pendingConfirmButton = null;
var previousFocus = null;
var busyButton = null;
var busyWasDisabled = false;
var activeFetches = 0;
var activeXhrs = 0;
var mainContent = document.getElementById('adminContent');
var guides = {
users: { text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。', steps: ['筛选账号', '编辑权限', '确认保存'] },
columns: { text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。', steps: ['新增或调整菜单', '设置层级', '拖动排序'] },
'dedupe-total-data': { text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。', steps: ['选择分组', '筛选或导入', '核对并导出'] },
'invalid-asin-data': { text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。', steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'] },
'shop-keys': { text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。', steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'] },
'shop-manage': { text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。', steps: ['选择分组', '维护店铺信息', '筛选核对结果'] },
'skip-price-asin': { text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。', steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'] },
'query-asin': { text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。', steps: ['筛选店铺', '打开配置抽屉', '保存或导出'] },
'product-categories': { text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。', steps: ['搜索类目', '展开层级', '新增或编辑'] },
'image-video-tasks': { text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。', steps: ['设置筛选', '查看任务结果', '按需处理任务'] },
'shop-data-crawl-tasks': { text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。', steps: ['筛选任务', '检查状态', '执行批量操作'] },
history: { text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。', steps: ['设置时间范围', '筛选记录', '查看结果预览'] },
version: { text: '上传版本后请核对版本号和下载链接,再通知用户更新。', steps: ['上传压缩包', '检查版本记录', '维护历史版本'] },
'digital-human-version': { text: '数字人版本需先上传草稿,再发布并标记最新版本。', steps: ['上传草稿', '确认更新日志', '发布或设为最新'] }
};
function cleanText(value) {
return String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
}
function showToast(message, type) {
var value = cleanText(message);
if (!value || !toastRegion) return;
var item = document.createElement('div');
item.className = 'admin-toast' + (type === 'error' ? ' is-error' : type === 'info' ? ' is-info' : '');
var content = document.createElement('span');
content.className = 'admin-toast__text';
content.textContent = value;
item.appendChild(content);
toastRegion.appendChild(item);
window.setTimeout(function () {
item.style.opacity = '0';
item.style.transform = 'translateY(-6px)';
item.style.transition = 'opacity 160ms ease, transform 160ms ease';
window.setTimeout(function () { item.remove(); }, 180);
}, type === 'error' ? 5200 : 3200);
}
window.__adminToast = showToast;
function activeTabName() {
var tab = document.querySelector('#adminMenu .tab.active');
return tab ? (tab.dataset.tab || '') : '';
}
function updateGuide(tabName) {
if (!guide || !guideText || !guideSteps) return;
var config = guides[tabName] || { text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。', steps: ['选择筛选条件', '处理记录', '核对反馈'] };
guideText.textContent = config.text;
guideSteps.innerHTML = (config.steps || []).map(function (step) { return '<li>' + step + '</li>'; }).join('');
guide.dataset.tab = tabName || '';
}
window.__adminUpdateOperationGuide = updateGuide;
function setGuideCollapsed(collapsed) {
if (!guide || !guideToggle) return;
guide.classList.toggle('is-collapsed', collapsed);
guideToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
guideToggle.textContent = collapsed ? '展开提示' : '收起提示';
try { localStorage.setItem('shufuAdminGuideCollapsed', collapsed ? '1' : '0'); } catch (error) {}
}
if (guideToggle) {
var collapsed = false;
try { collapsed = localStorage.getItem('shufuAdminGuideCollapsed') === '1'; } catch (error) {}
setGuideCollapsed(collapsed);
guideToggle.addEventListener('click', function () {
setGuideCollapsed(!guide.classList.contains('is-collapsed'));
});
}
function hashTabName() {
var raw = (window.location.hash || '').replace(/^#/, '');
var match = raw.match(/(?:^|&)tab=([^&]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function syncTabHash(tabName) {
if (!tabName || !window.history || !window.history.replaceState) return;
var next = '#tab=' + encodeURIComponent(tabName);
if (window.location.hash !== next) window.history.replaceState(null, '', next);
}
function navigateToHash(attemptsLeft) {
var tabName = hashTabName();
if (!tabName) {
updateGuide(activeTabName());
return;
}
var tab = document.querySelector('#adminMenu .tab[data-tab="' + tabName + '"]');
if (tab && typeof tab.onclick === 'function') {
if (!tab.classList.contains('active')) tab.click();
else updateGuide(tabName);
return;
}
if (attemptsLeft > 0) {
window.setTimeout(function () { navigateToHash(attemptsLeft - 1); }, 80);
} else {
updateGuide(activeTabName());
}
}
function closeConfirm() {
if (!confirmMask) return;
confirmMask.classList.remove('show');
confirmMask.setAttribute('aria-hidden', 'true');
document.body.classList.remove('admin-confirm-open');
var focus = previousFocus;
pendingConfirmButton = null;
previousFocus = null;
if (focus && focus.isConnected) focus.focus();
}
function openConfirm(button) {
if (!confirmMask || !confirmMessage || !confirmAccept) return;
pendingConfirmButton = button;
previousFocus = document.activeElement;
var customMessage = cleanText(button.dataset.confirmMessage);
var label = cleanText(button.getAttribute('aria-label') || button.textContent || '删除');
var subject = cleanText(button.dataset.name || button.dataset.value || button.dataset.shopManageName || button.dataset.shopName || button.dataset.ziniaoAccountName || '');
var country = cleanText(button.dataset.country || '');
if (!customMessage && country && subject) subject += '' + country + '';
if (!customMessage && subject) customMessage = (button.classList.contains('btn-danger') ? '确认删除“' : '确认执行“') + subject + '”吗?此操作可能影响已有数据。';
if (confirmTitle) confirmTitle.textContent = button.classList.contains('btn-danger') ? '删除前确认' : '请确认操作';
confirmMessage.textContent = customMessage || ('确认执行“' + label + '”吗?此操作可能影响已有数据。');
confirmAccept.textContent = button.dataset.confirmActionLabel || (button.classList.contains('btn-danger') ? '确认删除' : '确认操作');
confirmMask.classList.add('show');
confirmMask.setAttribute('aria-hidden', 'false');
document.body.classList.add('admin-confirm-open');
window.setTimeout(function () { confirmAccept.focus(); }, 0);
}
function keepConfirmFocus(event) {
if (!confirmMask || !confirmMask.classList.contains('show') || event.key !== 'Tab') return;
var focusable = Array.prototype.filter.call(confirmMask.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'), function (el) {
return !el.disabled && el.offsetParent !== null;
});
if (!focusable.length) return;
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
if (confirmCancel) confirmCancel.addEventListener('click', closeConfirm);
if (confirmMask) confirmMask.addEventListener('click', function (event) {
if (event.target === confirmMask) closeConfirm();
});
if (confirmAccept) confirmAccept.addEventListener('click', function () {
var target = pendingConfirmButton;
closeConfirm();
if (!target) return;
window.__adminConfirmBypass = true;
try { target.click(); }
finally { window.setTimeout(function () { window.__adminConfirmBypass = false; }, 0); }
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Enter' && event.target && event.target.matches && event.target.matches('input:not([type="file"]), select') && !event.target.closest('textarea')) {
var searchScope = event.target.closest('.form-row, .form-box');
var searchButton = searchScope && searchScope.querySelector('button[id^="btnSearch"], button[id*="Search"]');
if (searchButton && !searchButton.disabled) {
event.preventDefault();
searchButton.click();
return;
}
}
if (event.key === 'Escape' && confirmMask && confirmMask.classList.contains('show')) {
event.preventDefault();
closeConfirm();
return;
}
keepConfirmFocus(event);
});
var nativeAlert = window.alert ? window.alert.bind(window) : null;
window.alert = function (message) {
showToast(message, /失败|错误|无权|不能为空|不正确|异常/.test(String(message || '')) ? 'error' : 'info');
};
var nativeConfirm = window.confirm ? window.confirm.bind(window) : null;
window.confirm = function (message) {
if (window.__adminConfirmBypass) return true;
return nativeConfirm ? nativeConfirm(message) : false;
};
function updatePageBusy() {
if (!mainContent) return;
mainContent.setAttribute('aria-busy', activeFetches || activeXhrs ? 'true' : 'false');
}
function startBusy() {
var button = window.__adminLastActionButton;
if (!button || !button.isConnected || button.disabled || button.classList.contains('tab') || button.classList.contains('menu-group-title') || button === confirmAccept) return;
busyButton = button;
busyWasDisabled = button.disabled;
button.classList.add('is-busy');
button.setAttribute('aria-busy', 'true');
button.disabled = true;
}
function finishBusy() {
var finishedButton = busyButton;
if (finishedButton && finishedButton.isConnected) {
finishedButton.classList.remove('is-busy');
finishedButton.removeAttribute('aria-busy');
if (!busyWasDisabled) finishedButton.disabled = false;
}
if (window.__adminLastActionButton === finishedButton) window.__adminLastActionButton = null;
busyButton = null;
busyWasDisabled = false;
}
if (window.fetch) {
var nativeFetch = window.fetch.bind(window);
window.fetch = function () {
activeFetches += 1;
updatePageBusy();
if (activeFetches === 1) startBusy();
var request;
try { request = nativeFetch.apply(window, arguments); }
catch (error) { activeFetches = Math.max(0, activeFetches - 1); if (!activeFetches && !activeXhrs) finishBusy(); throw error; }
return Promise.resolve(request).finally(function () {
activeFetches = Math.max(0, activeFetches - 1);
updatePageBusy();
if (!activeFetches && !activeXhrs) finishBusy();
});
};
}
if (window.XMLHttpRequest) {
var nativeSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function () {
activeXhrs += 1;
updatePageBusy();
if (activeXhrs === 1) startBusy();
this.addEventListener('loadend', function () {
activeXhrs = Math.max(0, activeXhrs - 1);
updatePageBusy();
if (!activeFetches && !activeXhrs) finishBusy();
}, { once: true });
try { return nativeSend.apply(this, arguments); }
catch (error) { activeXhrs = Math.max(0, activeXhrs - 1); if (!activeFetches && !activeXhrs) finishBusy(); throw error; }
};
}
function addButtonHint(button) {
if (!button || button.title) return;
var label = cleanText(button.textContent);
if (label === '编辑') button.title = '编辑当前记录';
else if (label === '删除') button.title = '删除当前记录,需二次确认';
else if (label === '查询') button.title = '按当前筛选条件查询';
else if (/^导出/.test(label)) button.title = '导出当前筛选结果';
else if (/^上传并/.test(label)) button.title = '上传文件并执行相应操作';
else if (label === '管理分组') button.title = '新增、编辑或删除分组';
else if (label === '选择店铺') button.title = '从店铺列表选择并回填';
}
function enhance(root) {
var scope = root && root.querySelectorAll ? root : document;
scope.querySelectorAll('button').forEach(addButtonHint);
scope.querySelectorAll('.table-ellipsis').forEach(function (element) {
if (!element.title) element.title = cleanText(element.textContent);
});
scope.querySelectorAll('.empty-tip').forEach(function (element) { element.setAttribute('role', 'status'); });
scope.querySelectorAll('.msg').forEach(function (element) {
var value = cleanText(element.textContent);
if (!value || (!element.classList.contains('ok') && !element.classList.contains('err'))) return;
var key = value + '|' + element.className;
if (element.dataset.adminToastKey === key) return;
element.dataset.adminToastKey = key;
showToast(value, element.classList.contains('err') ? 'error' : 'success');
});
}
document.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('button') : null;
if (!button || button.disabled) return;
if (button.classList.contains('tab') || button.classList.contains('menu-group-title')) {
window.__adminLastActionButton = null;
} else if (button !== confirmAccept) {
window.__adminLastActionButton = button;
}
if (button.classList.contains('tab')) {
window.setTimeout(function () {
var tabName = activeTabName();
updateGuide(tabName);
syncTabHash(tabName);
}, 0);
}
if ((!button.matches('.btn-danger') && !button.hasAttribute('data-admin-confirm')) || button === confirmAccept || window.__adminConfirmBypass) return;
event.preventDefault();
event.stopImmediatePropagation();
openConfirm(button);
}, true);
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
enhance(mutation.target && mutation.target.nodeType === 1 ? mutation.target : document);
if (mutation.type === 'attributes' && mutation.target.matches && mutation.target.matches('#adminMenu .tab')) {
window.setTimeout(function () { updateGuide(activeTabName()); }, 0);
}
});
});
enhance(document);
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
window.addEventListener('hashchange', function () { navigateToHash(0); });
navigateToHash(25);
})();
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

@@ -1,170 +0,0 @@
import io
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
class _FakeExportResponse:
status_code = 200
content = b'xlsx'
headers = {
'Content-Disposition': 'attachment; filename=dedupe.xlsx',
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}
def iter_content(self, chunk_size=None):
yield self.content
def close(self):
return None
class AdminDedupeTotalDataTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
def _menu_access(self):
return patch.object(
admin_api,
'_ensure_backend_menu_access',
return_value=('admin', {'id': 7}, None),
)
def test_list_forwards_group_and_formats_item(self):
java_response = {
'data': {
'items': [{
'id': 9,
'dataValue': 'B012345678',
'groupId': 3,
'groupName': 'group-a',
'uploaderUserId': 7,
'username': 'operator',
'createdAt': '2026-08-08T12:30:00',
}],
'total': 1,
'page': 1,
'pageSize': 15,
},
}
with self.app.test_request_context(
'/api/admin/dedupe-total-data?page=1&page_size=15&group_id=3'):
with self._menu_access(), patch.object(
admin_api,
'_proxy_permission_java',
return_value=(java_response, None, 200),
) as proxy:
response = admin_api.list_dedupe_total_data.__wrapped__()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()['items'][0]['group_name'], 'group-a')
self.assertEqual(proxy.call_args.kwargs['params'], {
'page': 1,
'pageSize': 15,
'keyword': '',
'username': '',
'operatorId': 7,
'groupId': 3,
})
def test_create_and_update_forward_group(self):
java_response = {
'message': 'ok',
'data': {'id': 9, 'dataValue': 'B012345678', 'groupId': 3, 'groupName': 'group-a'},
}
with self.app.test_request_context(
'/api/admin/dedupe-total-data', method='POST',
json={'data_value': ' B012345678 ', 'group_id': 3}):
with self._menu_access(), patch.object(
admin_api,
'_proxy_permission_java',
return_value=(java_response, None, 200),
) as proxy:
response = admin_api.create_dedupe_total_data.__wrapped__()
self.assertEqual(response.status_code, 200)
self.assertEqual(proxy.call_args.kwargs['json_data'], {
'dataValue': 'B012345678',
'groupId': 3,
})
with self.app.test_request_context(
'/api/admin/dedupe-total-data/9', method='PUT',
json={'data_value': ' C012345678 ', 'group_id': 8}):
with self._menu_access(), patch.object(
admin_api,
'_proxy_permission_java',
return_value=(java_response, None, 200),
) as proxy:
response = admin_api.update_dedupe_total_data.__wrapped__(9)
self.assertEqual(response.status_code, 200)
self.assertEqual(proxy.call_args.kwargs['json_data'], {
'dataValue': 'C012345678',
'groupId': 8,
})
def test_import_forwards_multipart_group(self):
with self.app.test_request_context(
'/api/admin/dedupe-total-data/import', method='POST',
data={'file': (io.BytesIO(b'xlsx'), 'data.xlsx'), 'group_id': '3'}):
with self._menu_access(), patch.object(
admin_api,
'_proxy_permission_java',
return_value=({'message': 'started', 'data': {'importId': 'task-1'}}, None, 200),
) as proxy:
response = admin_api.import_dedupe_total_data.__wrapped__()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()['import_id'], 'task-1')
self.assertEqual(proxy.call_args.kwargs['data'], {'groupId': 3})
self.assertEqual(set(proxy.call_args.kwargs['files']), {'file'})
def test_export_forwards_group_filter_and_internal_token(self):
class _Session:
def get(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
return _FakeExportResponse()
session = _Session()
with self.app.test_request_context(
'/api/admin/dedupe-total-data/export?group_id=3&username=operator'):
with self._menu_access(), patch.object(
admin_api,
'_get_backend_java_session',
return_value=session,
), patch.object(admin_api, '_resolve_internal_token', return_value='token'):
response = admin_api.export_dedupe_total_data.__wrapped__()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_data(), b'xlsx')
self.assertEqual(session.kwargs['params'], {
'operatorId': 7,
'username': 'operator',
'groupId': 3,
})
self.assertEqual(session.kwargs['headers'], {'X-Internal-Token': 'token'})
self.assertTrue(session.kwargs['stream'])
self.assertEqual(session.kwargs['timeout'], (10, 1800))
def test_import_requires_group(self):
with self.app.test_request_context(
'/api/admin/dedupe-total-data/import', method='POST',
data={'file': (io.BytesIO(b'xlsx'), 'data.xlsx')}):
with self._menu_access(), patch.object(admin_api, '_proxy_permission_java') as proxy:
response = admin_api.import_dedupe_total_data.__wrapped__()
self.assertEqual(response.status_code, 200)
self.assertFalse(response.get_json()['success'])
proxy.assert_not_called()
if __name__ == '__main__':
unittest.main()
@@ -1,143 +0,0 @@
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
class AdminInvalidAsinDataTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
def test_list_forwards_operator_and_formats_group_fields(self):
java_response = {
'data': {
'items': [{
'id': 9,
'dataValue': 'B012345678',
'brand': 'acme',
'groupId': 3,
'groupName': 'group-a',
'recordSource': 'MANUAL',
'createdAt': '2026-08-08T12:30:00',
}],
'total': 1,
'page': 1,
'pageSize': 15,
},
}
with self.app.test_request_context('/api/admin/invalid-asin-data?page=1&page_size=15&data_value=B01&brand=acme&group_id=3'):
with patch.object(
admin_api,
'_ensure_backend_menu_access',
return_value=('admin', {'id': 7}, None),
), patch.object(
admin_api,
'_proxy_permission_java',
return_value=(java_response, None, 200),
) as proxy:
response = admin_api.list_invalid_asin_data.__wrapped__()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()['items'], [{
'id': 9,
'data_value': 'B012345678',
'brand': 'acme',
'group_id': 3,
'group_name': 'group-a',
'record_source': 'MANUAL',
'created_at': '2026-08-08 12:30',
}])
self.assertEqual(
proxy.call_args.kwargs['params'],
{
'page': 1,
'pageSize': 15,
'keyword': '',
'dataValue': 'B01',
'brand': 'acme',
'groupId': 3,
},
)
self.assertEqual(proxy.call_args.kwargs['current_row'], {'id': 7})
def test_create_forwards_group_with_trusted_current_user(self):
java_response = {
'message': 'created',
'data': {
'id': 9,
'dataValue': 'B012345678',
'brand': 'acme',
'groupId': 3,
'groupName': 'group-a',
'recordSource': 'MANUAL',
},
}
with self.app.test_request_context(
'/api/admin/invalid-asin-data',
method='POST',
json={'data_value': ' B012345678 ', 'brand': 'Acme', 'group_id': 3},
):
with patch.object(
admin_api,
'_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None),
), patch.object(
admin_api,
'_proxy_permission_java',
return_value=(java_response, None, 200),
) as proxy:
response = admin_api.create_invalid_asin_data.__wrapped__()
self.assertEqual(response.status_code, 200)
self.assertNotIn('params', proxy.call_args.kwargs)
self.assertEqual(proxy.call_args.kwargs['current_row'], {'id': 1})
self.assertEqual(proxy.call_args.kwargs['json_data'], {
'dataValue': 'B012345678',
'brand': 'Acme',
'groupId': 3,
})
def test_group_list_marks_first_group_as_locked_for_normal_account(self):
groups = [
{'id': 3, 'groupName': 'group-a'},
{'id': 8, 'groupName': 'group-b'},
]
with self.app.test_request_context('/api/admin/shop-manage-groups'):
with patch.object(
admin_api,
'_ensure_backend_menu_access',
return_value=('admin', {'id': 7}, None),
), patch.object(
admin_api,
'_load_expanded_shop_manage_groups',
return_value=(groups, None, 200),
):
response = admin_api.list_shop_manage_groups.__wrapped__()
body = response.get_json()
self.assertEqual(body['locked_group_id'], 3)
self.assertEqual([item['id'] for item in body['items']], [3, 8])
def test_group_list_does_not_lock_super_admin(self):
with self.app.test_request_context('/api/admin/shop-manage-groups'):
with patch.object(
admin_api,
'_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None),
), patch.object(
admin_api,
'_load_expanded_shop_manage_groups',
return_value=([{'id': 3, 'groupName': 'group-a'}], None, 200),
):
response = admin_api.list_shop_manage_groups.__wrapped__()
self.assertIsNone(response.get_json()['locked_group_id'])
if __name__ == '__main__':
unittest.main()
@@ -1,111 +0,0 @@
"""回归测试:Flask 代理转发 Java 时默认携带 X-Internal-Token。
背景A1 管理后台收敛后Java 管理接口普遍要求身份requireAdminOrInternal
浏览器打到 15124 的请求不带 Java JWT/aiimage_token Cookielist_shop_manages
裸代理调用若只转发浏览器 Cookie 会被 Java 未登录生产后台店铺列表报
加载失败:未登录本文件锁定 _proxy_backend_java 默认补内部令牌的行为
"""
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
class _FakeSession:
"""记录请求的伪 requests.Session,返回 Java ApiResponse 成功体。"""
def __init__(self):
self.calls = []
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
response = Mock()
response.status_code = 200
response.json.return_value = {'success': True, 'data': {'items': []}}
return response
class AdminProxyInternalTokenTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.session = _FakeSession()
self.patches = [
patch.object(admin_api, '_get_backend_java_session', return_value=self.session),
patch.object(admin_api, '_resolve_internal_token', return_value='tk-internal-test'),
]
for p in self.patches:
p.start()
self.addCleanup(p.stop)
def _call(self, **kwargs):
with self.app.test_request_context('/api/admin/shop-manages?page=1&page_size=15'):
return admin_api._proxy_backend_java('GET', '/api/admin/shop-manages', **kwargs)
def test_default_carries_internal_token(self):
"""默认转发必须带 X-Internal-Token(否则 Java requireAdminOrInternal 判未登录)。"""
result, error_response, status = self._call(params={'page': 1, 'pageSize': 15})
self.assertEqual(status, 200)
self.assertIsNone(error_response)
self.assertTrue(result.get('success'))
method, url, kwargs = self.session.calls[0]
self.assertEqual(method, 'GET')
self.assertEqual(url, f"{admin_api.backend_java_base_url}/api/admin/shop-manages")
self.assertEqual(kwargs['headers'].get('X-Internal-Token'), 'tk-internal-test')
def test_auto_injects_operator_id_from_session(self):
"""默认转发自动补当前操作用户 operatorIdJava 内部通道要求令牌+operatorId 双条件)。"""
with self.app.test_request_context('/api/admin/shop-manages?page=1&page_size=15'):
with patch.object(admin_api, '_resolve_current_operator_id', return_value=7):
result, error_response, status = admin_api._proxy_backend_java(
'GET', '/api/admin/shop-manages', params={'page': 1, 'pageSize': 15})
self.assertEqual(status, 200)
self.assertIsNone(error_response)
method, url, kwargs = self.session.calls[0]
self.assertEqual(kwargs['params'].get('operatorId'), 7)
def test_explicit_operator_id_wins(self):
"""调用方显式传的 operatorId 优先于自动注入(如超管 role 参数场景)。"""
with self.app.test_request_context('/api/admin/shop-manages?page=1&page_size=15'):
with patch.object(admin_api, '_resolve_current_operator_id', return_value=7):
result, error_response, status = admin_api._proxy_backend_java(
'GET', '/api/admin/shop-manages',
params={'page': 1, 'pageSize': 15, 'operatorId': 99, 'superAdmin': 'true'})
self.assertEqual(status, 200)
method, url, kwargs = self.session.calls[0]
self.assertEqual(kwargs['params'].get('operatorId'), 99)
def test_no_session_no_operator_id(self):
"""无请求上下文(定时任务线程)时不注入 operatorId,由调用方自行决定。"""
result, error_response, status = admin_api._proxy_backend_java(
'GET', '/api/admin/shop-manages', params={'page': 1})
self.assertEqual(status, 200)
method, url, kwargs = self.session.calls[0]
self.assertNotIn('operatorId', kwargs.get('params') or {})
def test_opt_out_omits_internal_token(self):
"""显式 use_internal_token=False 时不带内部令牌(供转发浏览器原始 JWT 的场景)。"""
result, error_response, status = self._call(
params={'page': 1}, use_internal_token=False)
self.assertEqual(status, 200)
self.assertIsNone(error_response)
method, url, kwargs = self.session.calls[0]
self.assertNotIn('X-Internal-Token', kwargs.get('headers') or {})
self.assertNotIn('operatorId', kwargs.get('params') or {})
def test_explicit_headers_override_merge(self):
"""显式传 headers 时与内部令牌合并,不互相覆盖。"""
result, error_response, status = self._call(headers={'X-Forwarded-For': '1.2.3.4'})
self.assertEqual(status, 200)
method, url, kwargs = self.session.calls[0]
self.assertEqual(kwargs['headers'].get('X-Internal-Token'), 'tk-internal-test')
self.assertEqual(kwargs['headers'].get('X-Forwarded-For'), '1.2.3.4')
if __name__ == '__main__':
unittest.main()
@@ -1,326 +0,0 @@
"""店铺数据重复检查接口(撞款 duplicate-check)转发契约测试。
四个端点已迁移到 Java/api/admin/shop-data-crawl/duplicate-check-{overview,items,detail,export}
Flask 侧仅做本地菜单/数据权限预检 operatorId + X-Internal-Token 转发 Java
Java ApiResponse.data 原样透传并加 success 包装本文件验证透传形状参数名映射与错误码映射
筛选/裁剪/排序/统计语义由 backend-java 模块的 Java 单测覆盖
"""
import io
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
def _java_ok(data):
"""模拟 Java ApiResponse 成功体:{'success': True, 'data': {...}, 'message': ...}。"""
return {'success': True, 'data': data, 'message': '操作成功'}, None, 200
def _java_fail_json(code, message):
"""构建转发失败态:error_response 为 Flask jsonify 对象(需在请求上下文内调用)。"""
return ({'success': False, 'message': message, 'code': code},
admin_api.jsonify({'success': False, 'error': message}),
code)
class DuplicateCheckProxyTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.app.config['SECRET_KEY'] = 'test-secret'
self.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},
{'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'asin_count': 2, 'record_count': 2},
]
self.items_all = [
{'asin': 'E0000001', 'shop_count': 3, 'record_count': 3, 'occurrences': [
{'asin': 'E0000001', 'date': '2026年8月29日 上午4:34', 'price': 'GBP 12.00', 'brand': 'BrandE',
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
{'asin': 'E0000001', 'date': '2026-08-30', 'price': 'GBP 12.00', 'brand': 'BrandE',
'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
{'asin': 'E0000001', 'date': '2026-08-31', 'price': 'EUR 12.00', 'brand': 'BrandE',
'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'country': 'FR'},
]},
{'asin': 'A0000001', 'shop_count': 2, 'record_count': 2, 'occurrences': [
{'asin': 'A0000001', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA',
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'country': 'UK'},
{'asin': 'A0000001', 'date': '2026-08-30 08:30:00', 'price': 'GBP 8.50', 'brand': 'BrandA',
'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
]},
{'asin': 'F0000001', 'shop_count': 2, 'record_count': 2, 'occurrences': [
{'asin': 'F0000001', 'date': '2026-08-28', 'price': 'GBP 6.00', 'brand': 'BrandF',
'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'},
{'asin': 'F0000001', 'date': '2026-08-30', 'price': 'GBP 6.00', 'brand': 'BrandF',
'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'country': 'UK'},
]},
{'asin': 'B0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
{'asin': 'B0000001', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA',
'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['DE'], 'country': 'DE'},
]},
{'asin': 'C0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
{'asin': 'C0000001', 'date': '2026-08-29', 'price': 'EUR 7.50', 'brand': 'BrandC',
'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'country': 'FR'},
]},
{'asin': 'D0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [
{'asin': 'D0000001', 'date': '2026-08-28', 'price': 'GBP 5.00', 'brand': 'BrandD',
'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'country': 'UK'},
]},
]
self.overview_all = {
'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': self.shops,
}
def _patched(self, data):
return patch.object(admin_api, '_proxy_backend_java', side_effect=lambda *a, **k: _java_ok(data))
def _call(self, view_name, url, data, role='super_admin', current_row=None):
with self.app.test_request_context(url):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=(role, current_row or {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=(role, current_row or {'id': 1}, None)), \
self._patched(data):
return getattr(admin_api, view_name)()
def test_overview_super_admin_passthrough(self):
response = self._call('shop_data_crawl_duplicate_check_overview',
'/api/admin/shop-data-crawl/duplicate-check-overview',
self.overview_all)
body = response.get_json()
self.assertTrue(body['success'])
self.assertFalse(body['pending'])
self.assertEqual(body['summary']['asin_total'], 6)
self.assertEqual(len(body['shops']), 4)
def test_overview_pending_empty_summary(self):
response = self._call('shop_data_crawl_duplicate_check_overview',
'/api/admin/shop-data-crawl/duplicate-check-overview',
{'pending': True, 'scanned_at': '', 'summary': {}, 'shops': []})
body = response.get_json()
self.assertTrue(body['success'])
self.assertTrue(body['pending'])
self.assertEqual(body['summary'], {})
def test_overview_force_passed_through(self):
captured = {}
data = dict(self.overview_all)
def side_effect(*args, **kwargs):
captured['params'] = kwargs.get('params')
captured['timeout'] = kwargs.get('timeout')
return _java_ok(data)
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview?force=1'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_proxy_backend_java', side_effect=side_effect):
response = admin_api.shop_data_crawl_duplicate_check_overview()
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual((captured['params'] or {}).get('force'), '1')
# force 同步扫描需要长超时
self.assertEqual(captured['timeout'], (10, 1800))
def test_overview_scan_conflict_409(self):
def side_effect(*args, **kwargs):
return _java_fail_json(409, '扫描进行中,请稍后刷新')
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview?force=1'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_proxy_backend_java', side_effect=side_effect):
response = admin_api.shop_data_crawl_duplicate_check_overview()
self.assertEqual(response[1], 409)
self.assertFalse(response[0].get_json()['success'])
self.assertEqual(response[0].get_json()['error'], '扫描进行中,请稍后刷新')
def test_items_matrix_monitor_total_and_columns(self):
data = {'pending': False, 'items': self.items_all[:3], 'shops': self.shops,
'total': 3, 'page': 1, 'page_size': 20, 'scanned_at': '2026-09-04 03:10:00'}
response = self._call('shop_data_crawl_duplicate_check_items',
'/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=monitor',
data)
body = response.get_json()
self.assertTrue(body['success'])
self.assertEqual(body['total'], 3)
self.assertEqual([shop['shop_name'] for shop in body['shops']],
['ShopA', 'ShopB', 'ShopC', 'ShopD'])
def test_items_all_view_passthrough(self):
data = {'pending': False, 'items': self.items_all, 'shops': self.shops,
'total': 6, 'page': 1, 'page_size': 20, 'scanned_at': '2026-09-04 03:10:00'}
response = self._call('shop_data_crawl_duplicate_check_items',
'/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all',
data)
self.assertEqual(response.get_json()['total'], 6)
def test_items_shop_name_alias_merged_and_camel_params(self):
captured = {}
def side_effect(*args, **kwargs):
captured['params'] = kwargs.get('params') or {}
return _java_ok({'pending': False, 'items': [], 'shops': [],
'total': 0, 'page': 1, 'page_size': 20, 'scanned_at': ''})
with self.app.test_request_context(
'/api/admin/shop-data-crawl/duplicate-check-items?page=2&page_size=50&view=all&shop=ShopA&asin=abc'):
# 内部代理 operatorId 从 flask session 取当前登录管理员
admin_api.session['user_id'] = 1
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_proxy_backend_java', side_effect=side_effect):
admin_api.shop_data_crawl_duplicate_check_items()
params = captured['params']
self.assertEqual(params['page'], '2')
self.assertEqual(params['pageSize'], '50')
self.assertEqual(params['shopName'], 'ShopA') # shop 别名合并进 shop_name→shopName
self.assertEqual(params['asin'], 'abc')
self.assertIn('operatorId', params)
def test_items_denied_403_message_preserved(self):
def denied_menu(*args, **kwargs):
return ('admin', {'id': 5}, (
admin_api.jsonify({'success': False, 'error': '无权访问店铺数据记录模块'}), 403))
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-items'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, '_ensure_backend_menu_access', side_effect=denied_menu), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('admin', {'id': 5}, None)), \
patch.object(admin_api, '_proxy_backend_java',
side_effect=lambda *a, **k: _java_ok({})):
response = admin_api.shop_data_crawl_duplicate_check_items()
self.assertEqual(response[1], 403)
body = response[0].get_json()
self.assertEqual(body['error'], '无权访问店铺数据记录模块')
def test_detail_sorted_passthrough(self):
detail_items = [
{'asin': 'E0000001', 'shop_count': 3, 'record_count': 3, 'shop_names': ['ShopA', 'ShopB', 'ShopC'],
'brand': 'BrandE', 'first_date': '2026-08-29 04:34:00', 'occurrences': self.items_all[0]['occurrences']},
{'asin': 'A0000001', 'shop_count': 2, 'record_count': 2, 'shop_names': ['ShopA', 'ShopB'],
'brand': 'BrandA', 'first_date': '2026-08-30 00:00:00', 'occurrences': self.items_all[1]['occurrences']},
{'asin': 'F0000001', 'shop_count': 2, 'record_count': 2, 'shop_names': ['ShopB', 'ShopD'],
'brand': 'BrandF', 'first_date': '2026-08-28 00:00:00', 'occurrences': self.items_all[2]['occurrences']},
]
data = {'pending': False, 'items': detail_items, 'total': 3,
'page': 1, 'page_size': 6, 'scanned_at': '2026-09-04 03:10:00'}
response = self._call('shop_data_crawl_duplicate_check_detail',
'/api/admin/shop-data-crawl/duplicate-check-detail?page=1&page_size=6',
data)
body = response.get_json()
self.assertEqual(body['total'], 3)
self.assertEqual(body['items'][0]['asin'], 'E0000001')
self.assertEqual(body['items'][0]['first_date'], '2026-08-29 04:34:00')
def test_detail_pending(self):
response = self._call('shop_data_crawl_duplicate_check_detail',
'/api/admin/shop-data-crawl/duplicate-check-detail',
{'pending': True, 'items': [], 'total': 0, 'page': 1, 'page_size': 6, 'scanned_at': ''})
body = response.get_json()
self.assertTrue(body['pending'])
self.assertEqual(body['items'], [])
def test_export_streams_java_csv(self):
csv_bytes = ('' + 'ASIN,店铺数,店铺,分组,国家,上架时间,价格,品牌\r\n'
'E0000001,3,ShopA,GroupA,英国,2026年8月29日 上午4:34,GBP 12.00,BrandE\r\n').encode('utf-8')
class FakeResp:
status_code = 200
headers = {'Content-Disposition': 'attachment; filename="shop-data-duplicate-check.csv"',
'Content-Type': 'text/csv; charset=utf-8'}
def iter_content(self, chunk_size=1):
yield csv_bytes
def close(self):
pass
def json(self):
return {}
class FakeSession:
def get(self, *args, **kwargs):
return FakeResp()
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?view=monitor'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, 'get_current_admin_role',
return_value=('super_admin', {'id': 1})), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_get_backend_java_session', return_value=FakeSession()):
response = admin_api.shop_data_crawl_duplicate_check_export()
body = b''.join(response.response)
self.assertTrue(body.startswith(b'\xef\xbb\xbf'))
self.assertIn('ASIN,店铺数'.encode('utf-8'), body)
self.assertIn('E0000001'.encode('utf-8'), body)
def test_export_no_scan_400(self):
class FakeResp:
status_code = 400
headers = {}
def json(self):
return {'success': False, 'message': '暂无扫描结果,请先点击「重新分析」'}
def close(self):
pass
def iter_content(self, chunk_size=1):
return iter(())
class FakeSession:
def get(self, *args, **kwargs):
return FakeResp()
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export'):
with patch('utils.auth.session', {'user_id': 1}), \
patch('utils.auth.is_session_user_valid', return_value=True), \
patch.object(admin_api, 'get_current_admin_role',
return_value=('super_admin', {'id': 1})), \
patch.object(admin_api, '_ensure_backend_menu_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access',
return_value=('super_admin', {'id': 1}, None)), \
patch.object(admin_api, '_get_backend_java_session', return_value=FakeSession()):
response = admin_api.shop_data_crawl_duplicate_check_export()
self.assertEqual(response[1], 400)
self.assertEqual(response[0].get_json()['error'], '暂无扫描结果,请先点击「重新分析」')
if __name__ == '__main__':
unittest.main()
@@ -1,226 +0,0 @@
import sys
import unittest
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
class _FakeCursor:
def __init__(self, group_rows, result_rows, group_names):
self.group_rows = group_rows
self.result_rows = result_rows
self.group_names = group_names
self.kind = None
self.current_shop = None
self.windowed_results = False
self.group_limit = None
self.group_offset = 0
self.calls = []
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, sql, params=()):
self.calls.append((sql, tuple(params)))
if 'COUNT(*) AS total' in sql:
self.kind = 'count'
elif 'AS latest_created_at' in sql and 'GROUP BY' in sql:
self.kind = 'groups'
self.group_limit = int(params[-2])
self.group_offset = int(params[-1])
elif 'GROUP_CONCAT' in sql:
self.kind = 'group_names'
else:
self.kind = 'results'
self.windowed_results = 'ROW_NUMBER() OVER' in sql
self.current_shop = None if self.windowed_results else (str(params[-1]) if params else None)
def fetchone(self):
return {'total': len(self.group_rows)}
def fetchall(self):
if self.kind == 'groups':
end = self.group_offset + self.group_limit
return self.group_rows[self.group_offset:end]
if self.kind == 'group_names':
return self.group_names
if self.kind == 'results':
rows = [row for row in self.result_rows if str(row.get('result_file_url') or '').strip()]
if self.windowed_results:
counts = {}
limited = []
for row in rows:
shop_key = row['shop_name'].strip().casefold()
if counts.get(shop_key, 0) >= 1:
continue
counts[shop_key] = counts.get(shop_key, 0) + 1
limited.append(row)
return limited
if self.current_shop is not None:
rows = [row for row in rows if row['shop_name'].strip() == self.current_shop]
return rows[:1]
return []
class _FakeConnection:
def __init__(self, cursor):
self.cursor_value = cursor
def cursor(self):
return self.cursor_value
def close(self):
pass
class AdminShopDataGroupTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.group_rows = [
{'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 7, 5, 21, 45)},
{'shop_name': '', 'latest_created_at': datetime(2026, 8, 4, 12, 0)},
]
self.result_rows = [
self._result_row(6, 'Shop A', '2026-08-05T13:00:00', result_file_url=''),
self._result_row(5, 'Shop A', '2026-08-05T12:00:00'),
self._result_row(4, 'Shop A', '2026-08-04T12:00:00'),
self._result_row(3, 'Shop A', '2026-08-03T12:00:00'),
self._result_row(2, 'Shop A', '2026-08-02T12:00:00'),
self._result_row(1, '', '2026-08-04T11:00:00'),
]
@staticmethod
def _result_row(
result_id, shop_name, created_at, result_file_url=None, latest_file_updated_at=None):
return {
'result_id': result_id,
'task_id': result_id + 100,
'user_id': 7,
'shop_name': shop_name,
'shop_id': shop_name.lower(),
'task_no': f'task-{result_id}',
'task_status': 'SUCCESS',
'result_success': 1,
'result_error': None,
'task_error': None,
'file_error': None,
'result_file_url': f'object-{result_id}' if result_file_url is None else result_file_url,
'result_filename': f'result-{result_id}.xlsx',
'result_file_size': 10,
'row_count': 2,
'request_json': '{}',
'created_at': created_at,
'updated_at': created_at,
'finished_at': created_at,
'latest_file_updated_at': latest_file_updated_at or created_at,
'file_job_id': None,
'file_status': 'SUCCESS',
'username': 'operator',
}
def test_group_item_caps_children_and_preserves_child_result_ids(self):
group = admin_api._shop_data_crawl_group_item(
{'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 5, 12, 0)},
{'shop a': [row for row in self.result_rows if row['result_file_url']][:4]},
{'shop a': 'Group 1'},
)
self.assertEqual(group['shop_name'], 'Shop A')
self.assertEqual(group['group_name'], 'Group 1')
self.assertEqual([item['result_id'] for item in group['results']], [5])
self.assertEqual(group['results'][0]['result_file_url'], 'object-5')
def test_group_item_falls_back_to_daily_file_update_time(self):
row = self._result_row(
21427,
'Shop A',
'2026-08-06T15:20:43',
latest_file_updated_at=datetime(2026, 8, 7, 5, 21, 45),
)
group = admin_api._shop_data_crawl_group_item(
{'shop_name': 'Shop A', 'latest_created_at': None},
{'shop a': [row]},
{},
)
self.assertEqual(group['latest_created_at'], '2026-08-07 05:21:45')
def test_list_paginates_groups_and_ignores_removed_user_status_filters(self):
cursor = _FakeCursor(
self.group_rows,
self.result_rows,
[{'shop_name': 'Shop A', 'group_name': 'Group 1'}],
)
connection = _FakeConnection(cursor)
with self.app.test_request_context(
'/api/admin/shop-data-crawl-tasks?page=1&page_size=10'
'&username=should-not-filter&status=FAILED&shop_name=Shop&group_name=Group'
'&created_from=2026-08-01T00:00'
):
with patch.object(admin_api, 'get_db', return_value=connection), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)):
response = admin_api.list_shop_data_crawl_tasks.__wrapped__()
self.assertEqual(response.status_code, 200)
body = response.get_json()
self.assertEqual(body['items'], body['data']['items'])
payload = body['data']
self.assertEqual(payload['total'], 2)
self.assertEqual(payload['page'], 1)
self.assertEqual(payload['items'][0]['shop_name'], 'Shop A')
self.assertEqual(payload['items'][0]['latest_created_at'], '2026-08-07 05:21:45')
self.assertEqual(len(payload['items'][0]['results']), 1)
self.assertEqual(
[item['result_id'] for item in payload['items'][0]['results']],
[5],
)
self.assertEqual(payload['items'][1]['shop_name'], '未命名')
self.assertEqual(len(payload['items'][1]['results']), 1)
params = [param for _sql, call_params in cursor.calls for param in call_params]
self.assertNotIn('should-not-filter', params)
self.assertNotIn('FAILED', params)
self.assertTrue(any('GROUP BY TRIM(COALESCE(r.source_filename, ' in sql for sql, _ in cursor.calls))
self.assertTrue(any('ROW_NUMBER() OVER' in sql for sql, _ in cursor.calls))
self.assertTrue(any('shop_row_number <= 1' in sql for sql, _ in cursor.calls))
self.assertTrue(any(
'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id' in sql
for sql, _ in cursor.calls
))
self.assertTrue(any(
'MAX(COALESCE(df.last_success_at, df.updated_at, t.finished_at, t.updated_at, t.created_at))'
in sql for sql, _ in cursor.calls
))
self.assertTrue(any('TRIM(COALESCE(sm.shop_name' in sql for sql, _ in cursor.calls))
self.assertTrue(any("TRIM(COALESCE(r.result_file_url, '')) <> ''" in sql for sql, _ in cursor.calls))
def test_list_returns_empty_items_when_group_page_is_out_of_range(self):
cursor = _FakeCursor(self.group_rows, self.result_rows, [])
connection = _FakeConnection(cursor)
with self.app.test_request_context(
'/api/admin/shop-data-crawl-tasks?page=2&page_size=10'
):
with patch.object(admin_api, 'get_db', return_value=connection), \
patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \
patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)):
response = admin_api.list_shop_data_crawl_tasks.__wrapped__()
payload = response.get_json()['data']
self.assertEqual(payload['total'], 2)
self.assertEqual(payload['page'], 2)
self.assertEqual(payload['items'], [])
self.assertFalse(any('ROW_NUMBER() OVER' in sql for sql, _ in cursor.calls))
if __name__ == '__main__':
unittest.main()
@@ -1,117 +0,0 @@
"""只读契约冻结:Python→Java HTTP 回调/代理侧超时与重试不受 Java 配置影响(task-175)。
背景模块 10 Java 外部客户端配置治理只动 Java 内部客户端Coze/品牌/紫鸟/图片下载
spec §3 明确不改 Python Worker requests 调用 / 不改 Python 回调请求超时和重试约定
本仓库内 Python Java HTTP 面是 blueprints.admin_api _get_backend_java_session /
_proxy_backend_javarequests.Session + HTTPAdapter(max_retries=0)默认 timeout=10s
全部为 Python 侧字面量/参数默认值不读取任何 aiimage.http-client.* Java 配置
本文件把这些约定固化为快照断言防止将来误把 Java 配置引进来改变 Python 行为
只读任务不改任何生产 Python 代码只新增本测试
"""
import inspect
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
class _FakeSession:
"""记录请求的伪 requests.Session:捕获 kwargs,返回 Java ApiResponse 成功体。"""
def __init__(self, raise_on_request=False):
self.calls = []
self.raise_on_request = raise_on_request
def request(self, method, url, **kwargs):
self.calls.append((method, url, kwargs))
if self.raise_on_request:
import requests
raise requests.RequestException("backend-java 服务不可用")
class _Resp:
status_code = 200
def json(self):
return {"success": True, "data": []}
return _Resp()
class PythonJavaHttpContractTest(unittest.TestCase):
def _proxy_default_http_timeout(self):
return inspect.signature(admin_api._proxy_backend_java).parameters["timeout"].default
def test_default_timeout_is_python_side_ten_seconds(self):
# 契约:未显式传 timeout 时默认 (连接/读) 10s,且是签名里的字面量,非来自任何配置
self.assertEqual(10, self._proxy_default_http_timeout())
self.assertTrue(isinstance(self._proxy_default_http_timeout(), int))
def test_override_timeout_forwarded_verbatim(self):
fake = _FakeSession()
with patch.object(admin_api, "_get_backend_java_session", return_value=fake):
result, error_response, status = admin_api._proxy_backend_java(
"GET", "/api/foo", params={"a": "1"}, timeout=(10, 1800))
self.assertIsNone(error_response)
method, url, kwargs = fake.calls[0]
self.assertEqual((10, 1800), kwargs["timeout"], "显式超时应原样转发给 requests")
def test_session_never_auto_retries(self):
# 契约:Python→Java session 不自动重试(max_retries=0),失败即报错由上层处理
session = admin_api._get_backend_java_session()
http_adapter = session.get_adapter("http://")
self.assertEqual(0, http_adapter.max_retries.total)
def test_session_mounts_http_and_https(self):
session = admin_api._get_backend_java_session()
self.assertIsNotNone(session.get_adapter("http://"))
self.assertIsNotNone(session.get_adapter("https://"))
def test_timeout_is_not_read_from_java_http_client_config(self):
# 契约:Python 侧无 aiimage.http-client.* 读取点;超时仅来自参数/字面量
source = inspect.getsource(admin_api._proxy_backend_java)
self.assertNotIn("http-client", source)
self.assertNotIn("AIIMAGE_HTTP_CLIENT", source)
self.assertNotIn("connect-timeout", source)
def test_timeout_or_connection_failure_maps_to_502(self):
# 契约:Python→Java 超时/连接失败统一转 502,不静默吞掉也不自动重试
fake = _FakeSession(raise_on_request=True)
with patch.object(admin_api, "_get_backend_java_session", return_value=fake):
with admin_api_app_context():
result, error_response, status = admin_api._proxy_backend_java("GET", "/api/foo")
self.assertIsNone(result)
self.assertEqual(502, status)
def test_java_config_namespace_absent_in_python_sources(self):
# 快照:仓库非测试 Python 代码不存在 aiimage.http-client 配置引用,Java 治理不会外溢
repo_py_root = Path(admin_api.__file__).resolve().parents[2]
hits = []
for py in repo_py_root.rglob("*.py"):
if "__pycache__" in str(py) or "/tests/" in str(py).replace("\\", "/"):
continue
text = py.read_text(encoding="utf-8", errors="ignore")
if "aiimage.http-client" in text or "aiimage_http_client" in text:
hits.append(str(py))
self.assertEqual([], hits, f"Python 侧不应引用 Java 统一命名空间: {hits}")
def test_contract_frozen_documented_values_match_code(self):
# 自检快照:文档化的 Python 回调契约(timeout=10 / max_retries=0)与代码一致
self.assertEqual(10, self._proxy_default_http_timeout())
session = admin_api._get_backend_java_session()
self.assertEqual(0, session.get_adapter("http://").max_retries.total)
self.assertEqual(0, session.get_adapter("https://").max_retries.total)
def admin_api_app_context():
from flask import Flask
app = Flask(__name__)
return app.app_context()
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1 +1 @@
# Utils 包:数据库、认证装饰器、模板渲染等
# Utils 包:数据库连接(管理后台旧代码 task-283 删除后仅保留 db 工具)
-113
View File
@@ -1,113 +0,0 @@
"""
认证装饰器与 session 校验
"""
from functools import wraps
from flask import request, redirect, url_for, session, jsonify, g
from utils.db import get_db
def is_session_user_valid():
"""校验 session 中的 user_id 是否仍存在;不存在则清空 session。"""
uid = session.get('user_id')
if not uid:
return False
cached_user = getattr(g, '_current_user_row', None)
if cached_user and cached_user.get('id') == uid:
return True
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
(uid,)
)
row = cur.fetchone()
conn.close()
if not row:
session.clear()
return False
g._current_user_row = row
return True
except Exception:
session.clear()
return False
def get_current_admin_role():
"""返回当前登录用户的管理角色:super_admin / admin / None。"""
uid = session.get('user_id')
if not uid:
return None, None
try:
row = getattr(g, '_current_user_row', None)
if not row or row.get('id') != uid:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
(uid,)
)
row = cur.fetchone()
conn.close()
if not row:
return None, None
g._current_user_row = row
role = (row.get('role') or '').strip().lower()
if not role:
role = 'super_admin' if row.get('is_admin') and row.get('created_by_id') is None else (
'admin' if row.get('is_admin') else 'normal'
)
return role, row
except Exception:
return None, None
def is_current_user_admin():
role, _ = get_current_admin_role()
return role in ('super_admin', 'admin')
def _is_ajax_request():
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return True
if request.path.startswith('/api/'):
return True
accept = (request.headers.get('Accept') or '').lower()
if 'application/json' in accept:
return True
return False
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('user_id') or not is_session_user_valid():
if _is_ajax_request():
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('user_id') or not is_session_user_valid():
if _is_ajax_request():
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
try:
role, _ = get_current_admin_role()
if role not in ('super_admin', 'admin'):
if _is_ajax_request():
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
return redirect(url_for('auth.login'))
except Exception as exc:
if _is_ajax_request():
return jsonify({'success': False, 'error': '服务器内部错误,请稍后重试'}), 500
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated
-22
View File
@@ -1,22 +0,0 @@
"""
模板渲染支持加密 HTML 解密后渲染
"""
import os
from flask import render_template, render_template_string
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def render_html(template_name: str, **context):
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
path = os.path.join(BASE_DIR, "web_source", template_name)
if not os.path.isfile(path):
return render_template(template_name, **context)
with open(path, "rb") as f:
raw = f.read()
try:
from html_crypto import decrypt
content = decrypt(raw).decode("utf-8")
except Exception:
content = raw.decode("utf-8", errors="replace")
return render_template_string(content, **context)
-58
View File
@@ -1,58 +0,0 @@
"""
SSRF 防护检测 URL 是否指向内网/本机地址禁止服务端请求
"""
import ipaddress
import socket
from urllib.parse import urlparse
_PRIVATE_NETWORKS = [
ipaddress.ip_network('0.0.0.0/8'),
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('100.64.0.0/10'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.0.0.0/24'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('198.18.0.0/15'),
ipaddress.ip_network('224.0.0.0/4'),
ipaddress.ip_network('240.0.0.0/4'),
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'),
ipaddress.ip_network('fe80::/10'),
]
_LOCAL_HOSTNAMES = {
'localhost',
'localhost.localdomain',
'metadata.google.internal',
'metadata.azure.internal',
'169.254.169.254',
}
def is_internal_url(url):
"""判断 URL 是否解析到内网/本机/保留地址。解析失败视为不可信返回 True。"""
if not url or not isinstance(url, str):
return True
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return True
host = parsed.hostname
if not host:
return True
host_lower = host.lower().rstrip('.')
if host_lower in _LOCAL_HOSTNAMES:
return True
try:
infos = socket.getaddrinfo(host, parsed.port or 80)
except socket.gaierror:
return True
for info in infos:
try:
ip = ipaddress.ip_address(info[4][0])
except ValueError:
continue
if any(ip in network for network in _PRIVATE_NETWORKS):
return True
return False
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-862
View File
@@ -1,862 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:,">
<meta name="theme-color" content="#0b1220">
<title>登录 - 数富AI</title>
<style>
:root {
color-scheme: dark;
--auth-bg: #0b1220;
--auth-surface: #141e31;
--auth-surface-raised: #1a2740;
--auth-border: rgba(148, 163, 184, 0.2);
--auth-border-strong: rgba(148, 163, 184, 0.34);
--auth-text: #f1f5f9;
--auth-text-muted: #b8c4d8;
--auth-text-subtle: #8795ad;
--auth-primary: #818cf8;
--auth-primary-strong: #a5b4fc;
--auth-success: #4ade80;
--auth-danger: #fb7185;
--auth-radius: 18px;
--auth-fast: 160ms;
--auth-base: 220ms;
}
* { box-sizing: border-box; }
html {
min-width: 320px;
min-height: 100%;
background: var(--auth-bg);
scroll-behavior: smooth;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
font-family: Inter, "SF Pro Display", "Microsoft YaHei", "PingFang SC", "Helvetica Neue", sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--auth-text);
background:
radial-gradient(circle at 8% 0%, rgba(99, 102, 241, 0.19), transparent 32rem),
radial-gradient(circle at 92% 100%, rgba(34, 197, 94, 0.06), transparent 28rem),
var(--auth-bg);
}
button,
input { font: inherit; }
a { color: inherit; }
.auth-skip-link {
position: fixed;
top: 12px;
left: 12px;
z-index: 20;
padding: 10px 14px;
border-radius: 10px;
background: var(--auth-primary);
color: #0b1220;
font-weight: 700;
text-decoration: none;
transform: translateY(-160%);
transition: transform var(--auth-fast) ease;
}
.auth-skip-link:focus { transform: translateY(0); }
.auth-page {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(420px, 1.1fr);
}
.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 rgba(148, 163, 184, 0.13);
background:
linear-gradient(160deg, rgba(16, 26, 46, 0.92), rgba(8, 15, 29, 0.98)),
radial-gradient(circle at 20% 8%, rgba(129, 140, 248, 0.18), transparent 28rem);
}
.auth-visual::before,
.auth-visual::after {
content: "";
position: absolute;
pointer-events: none;
}
.auth-visual::before {
inset: 0;
opacity: 0.24;
background-image: linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(148, 163, 184, 0.08) 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(129, 140, 248, 0.18);
border-radius: 50%;
box-shadow: 0 0 0 32px rgba(129, 140, 248, 0.035), 0 0 0 64px rgba(129, 140, 248, 0.025);
}
.auth-visual-content,
.auth-visual-footer { position: relative; z-index: 1; }
.auth-brand {
display: inline-flex;
align-items: center;
gap: 12px;
width: fit-content;
text-decoration: none;
}
.auth-brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: 13px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(79, 70, 229, 0.20);
overflow: hidden;
}
.auth-brand-mark img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.login-logo {
display: block;
width: 56px;
height: 56px;
margin-bottom: 16px;
border-radius: 14px;
box-shadow: 0 10px 24px -12px rgba(39, 67, 94, 0.45);
}
.auth-brand-copy { display: grid; gap: 1px; }
.auth-brand-name {
color: #f8fafc;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.2px;
}
.auth-brand-sub {
color: var(--auth-text-subtle);
font-size: 11px;
}
.auth-visual-content {
max-width: 520px;
margin: auto 0;
padding: 72px 0 96px;
}
.auth-visual-copy {
padding-top: 72px;
}
.auth-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--auth-primary-strong);
font-size: 11px;
font-weight: 700;
letter-spacing: 1.8px;
}
.auth-kicker::before {
content: "";
width: 24px;
height: 1px;
background: var(--auth-primary);
}
.auth-visual-title {
max-width: 560px;
margin: 18px 0 18px;
color: #f8fafc;
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: var(--auth-text-muted);
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: #dbe4f1;
}
.auth-feature-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 30px;
width: 30px;
height: 30px;
border: 1px solid rgba(129, 140, 248, 0.25);
border-radius: 9px;
background: rgba(129, 140, 248, 0.11);
color: var(--auth-primary-strong);
}
.auth-feature-icon svg { width: 16px; height: 16px; }
.auth-visual-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: #72819a;
font-size: 12px;
}
.auth-system-status {
display: inline-flex;
align-items: center;
gap: 7px;
color: #86efac;
}
.auth-system-status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--auth-success);
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.12);
}
.auth-content {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 40px clamp(24px, 6vw, 96px);
}
.login-card {
width: min(100%, 452px);
padding: clamp(28px, 4vw, 48px);
border: 1px solid var(--auth-border);
border-radius: 22px;
background: linear-gradient(145deg, rgba(20, 30, 49, 0.98), rgba(16, 26, 46, 0.96));
box-shadow: 0 26px 70px -38px rgba(2, 6, 23, 0.95);
}
.login-card-header { margin-bottom: 30px; }
.login-card-eyebrow {
margin: 0 0 8px;
color: var(--auth-text-subtle);
font-size: 12px;
font-weight: 700;
letter-spacing: 1.2px;
}
.login-title {
margin: 0;
color: var(--auth-text);
font-size: 30px;
font-weight: 700;
letter-spacing: -0.8px;
line-height: 1.2;
}
.login-subtitle {
margin: 10px 0 0;
color: var(--auth-text-muted);
line-height: 1.65;
}
.form-group { margin-bottom: 20px; }
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--auth-text-muted);
font-size: 13px;
font-weight: 600;
}
.input-shell { position: relative; }
.input-icon {
position: absolute;
top: 50%;
left: 14px;
display: inline-flex;
color: #8190a8;
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 var(--auth-border);
border-radius: 12px;
outline: none;
background: rgba(11, 18, 32, 0.72);
color: var(--auth-text);
font-size: 14px;
color-scheme: dark;
transition: border-color var(--auth-fast) ease, box-shadow var(--auth-fast) ease, background var(--auth-fast) ease;
}
.form-group input:hover { border-color: var(--auth-border-strong); }
.form-group input:focus {
border-color: var(--auth-primary);
background: rgba(11, 18, 32, 0.92);
box-shadow: 0 0 0 4px rgba(129, 140, 248, 0.15);
}
.form-group input::placeholder { color: #71809a; }
.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: #8190a8;
cursor: pointer;
transform: translateY(-50%);
transition: background var(--auth-fast) ease, color var(--auth-fast) ease;
}
.password-toggle:hover {
background: rgba(129, 140, 248, 0.12);
color: var(--auth-primary-strong);
}
.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 rgba(251, 113, 133, 0.28);
border-radius: 11px;
background: rgba(251, 113, 133, 0.1);
color: #fda4af;
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 rgba(165, 180, 252, 0.32);
border-radius: 12px;
background: linear-gradient(135deg, #818cf8, #6366f1);
color: #0b1220;
cursor: pointer;
font-size: 15px;
font-weight: 800;
box-shadow: 0 12px 24px -16px rgba(129, 140, 248, 0.95);
transition: transform var(--auth-fast) ease, box-shadow var(--auth-fast) ease, background var(--auth-fast) ease, opacity var(--auth-fast) ease;
}
.btn-login:hover:not(:disabled) {
background: linear-gradient(135deg, #a5b4fc, #818cf8);
box-shadow: 0 16px 28px -15px rgba(129, 140, 248, 0.98);
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(11, 18, 32, 0.28);
border-top-color: #0b1220;
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: var(--auth-text-subtle);
font-size: 12px;
line-height: 1.55;
}
.login-security-note svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
margin-top: 1px;
color: var(--auth-success);
}
.login-card-footer {
margin-top: 34px;
padding-top: 18px;
border-top: 1px solid rgba(148, 163, 184, 0.13);
color: #72819a;
font-size: 12px;
text-align: center;
}
:focus-visible {
outline: 2px solid var(--auth-primary-strong);
outline-offset: 3px;
}
@media (max-width: 900px) {
.auth-page { display: block; }
.auth-visual {
min-height: auto;
padding: 22px 24px;
border-right: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.13);
}
.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; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* ===== 登录页莫兰迪亮色主题 ===== */
:root {
color-scheme: light;
--auth-bg: #f2f4f1;
--auth-surface: #ffffff;
--auth-surface-raised: #f8faf8;
--auth-border: #dbe3dd;
--auth-border-strong: #b9c9bd;
--auth-text: #29362f;
--auth-text-muted: #5d6c63;
--auth-text-subtle: #7f8d84;
--auth-primary: #607a6d;
--auth-primary-strong: #456052;
--auth-success: #4e8068;
--auth-danger: #a9545d;
}
html { background: var(--auth-bg); }
body {
color: var(--auth-text);
background:
radial-gradient(circle at 8% 0%, rgba(177, 198, 185, 0.34), transparent 34rem),
radial-gradient(circle at 96% 100%, rgba(213, 190, 178, 0.2), transparent 28rem),
var(--auth-bg);
}
.auth-skip-link {
background: var(--auth-primary);
color: #ffffff;
}
.auth-visual {
border-right-color: #d5ded7;
background:
linear-gradient(160deg, rgba(232, 239, 234, 0.96), rgba(246, 248, 245, 0.98)),
radial-gradient(circle at 20% 8%, rgba(127, 153, 138, 0.16), transparent 28rem);
}
.auth-visual::before {
opacity: 0.32;
background-image: linear-gradient(rgba(96, 122, 109, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(96, 122, 109, 0.1) 1px, transparent 1px);
}
.auth-visual::after {
border-color: rgba(96, 122, 109, 0.22);
box-shadow: 0 0 0 32px rgba(96, 122, 109, 0.055), 0 0 0 64px rgba(96, 122, 109, 0.035);
}
.auth-brand-mark {
border-radius: 13px;
background: #ffffff;
box-shadow: 0 10px 24px rgba(96, 122, 109, 0.18);
}
.auth-brand-name,
.auth-visual-title { color: var(--auth-text); }
.auth-brand-sub { color: #76857b; }
.auth-kicker { color: var(--auth-primary-strong); }
.auth-kicker::before { background: var(--auth-primary); }
.auth-visual-description { color: #607069; }
.auth-feature-item { color: #46564d; }
.auth-feature-icon { border-color: #c5d5c9; background: #edf3ee; color: var(--auth-primary-strong); }
.auth-visual-footer { color: #77857d; }
.auth-system-status { color: #3f7258; }
.auth-system-status::before { background: var(--auth-success); box-shadow: 0 0 0 4px rgba(78, 128, 104, 0.13); }
.auth-content {
background: rgba(248, 250, 248, 0.45);
}
.login-card {
border-color: var(--auth-border);
background: linear-gradient(145deg, #ffffff, #f9fbf9);
box-shadow: 0 26px 70px -38px rgba(60, 77, 67, 0.34);
}
.login-card-eyebrow { color: #718078; }
.login-title { color: var(--auth-text); }
.login-subtitle { color: var(--auth-text-muted); }
.form-group label { color: var(--auth-text-muted); }
.input-icon { color: #82938a; }
.form-group input {
background: #f7faf7;
border-color: #cfdad2;
color: var(--auth-text);
color-scheme: light;
}
.form-group input:hover { border-color: #aebfb3; }
.form-group input:focus { background: #ffffff; border-color: #6f8b7b; box-shadow: 0 0 0 4px rgba(111, 139, 123, 0.16); }
.form-group input::placeholder { color: #87958c; }
.password-toggle { color: #82938a; }
.password-toggle:hover { background: #edf4ee; color: var(--auth-primary-strong); }
.error-msg { border-color: #e4c2c5; background: #f8ebeb; color: #91474f; }
.btn-login {
border-color: #7d9988;
background: linear-gradient(135deg, #718b7c, #607a6d);
color: #ffffff;
box-shadow: 0 12px 24px -16px rgba(96, 122, 109, 0.82);
}
.btn-login:hover:not(:disabled) {
background: linear-gradient(135deg, #829b8b, #6b8577);
color: #ffffff;
box-shadow: 0 16px 28px -15px rgba(96, 122, 109, 0.86);
}
.btn-login-spinner { border-color: rgba(255, 255, 255, 0.35); border-top-color: #ffffff; }
.login-security-note { color: #718078; }
.login-security-note svg { color: var(--auth-success); }
.login-card-footer { border-top-color: #dce5de; color: #77857d; }
:focus-visible { outline-color: #607a6d; }
@media (max-width: 900px) {
.auth-visual { border-bottom-color: #d5ded7; }
}
/* ===== 登录页统一蓝白色调 ===== */
:root {
color-scheme: light;
--auth-bg: #f4f7fb;
--auth-surface: #ffffff;
--auth-surface-raised: #f9fbfd;
--auth-border: #d8e3ee;
--auth-border-strong: #b7c9db;
--auth-text: #24384d;
--auth-text-muted: #5b6f83;
--auth-text-subtle: #8293a5;
--auth-primary: #4f78a5;
--auth-primary-strong: #2f5d8b;
--auth-success: #4e806d;
--auth-danger: #b35f6a;
}
html, body { background: var(--auth-bg); }
body {
color: var(--auth-text);
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),
var(--auth-bg);
}
.auth-skip-link { background: var(--auth-primary); color: #ffffff; }
.auth-visual {
border-right-color: #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 { 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); }
.auth-visual::after { border-color: rgba(79, 120, 165, 0.22); box-shadow: 0 0 0 32px rgba(79, 120, 165, 0.055), 0 0 0 64px rgba(79, 120, 165, 0.035); }
.auth-brand-mark { border-radius: 13px; background: #ffffff; box-shadow: 0 10px 24px rgba(79, 120, 165, 0.18); }
.auth-brand-name, .auth-visual-title { color: var(--auth-text); }
.auth-brand-sub { color: #77899b; }
.auth-kicker { color: var(--auth-primary-strong); }
.auth-kicker::before { background: var(--auth-primary); }
.auth-visual-description { color: #60748a; }
.auth-feature-item { color: #465d73; }
.auth-feature-icon { border-color: #c2d3e3; background: #edf5fb; color: var(--auth-primary-strong); }
.auth-visual-footer { color: #778b9f; }
.auth-system-status { color: #3d7158; }
.auth-system-status::before { background: var(--auth-success); box-shadow: 0 0 0 4px rgba(78, 128, 104, 0.13); }
.auth-content { background: rgba(249, 251, 253, 0.5); }
.login-card { border-color: var(--auth-border); background: linear-gradient(145deg, #ffffff, #f9fbfd); box-shadow: 0 26px 70px -38px rgba(39, 67, 94, 0.34); }
.login-card-eyebrow { color: #71859a; }
.login-title { color: var(--auth-text); }
.login-subtitle, .form-group label { color: var(--auth-text-muted); }
.input-icon, .password-toggle { color: #8298ad; }
.form-group input { background: #f8fbfd; border-color: #cbd9e6; color: var(--auth-text); color-scheme: light; }
.form-group input:hover { border-color: #9fb7cd; }
.form-group input:focus { background: #ffffff; border-color: #5f85ad; box-shadow: 0 0 0 4px rgba(95, 133, 173, 0.16); }
.form-group input::placeholder { color: #8293a5; }
.password-toggle:hover { background: #edf5fb; color: var(--auth-primary-strong); }
.error-msg { border-color: #e4c2c5; background: #f8ebeb; color: #91474f; }
.btn-login { border-color: #7196ba; background: linear-gradient(135deg, #5f85ad, #4f78a5); color: #ffffff; box-shadow: 0 12px 24px -16px rgba(79, 120, 165, 0.82); }
.btn-login:hover:not(:disabled) { background: linear-gradient(135deg, #7094ba, #5d83ac); color: #ffffff; box-shadow: 0 16px 28px -15px rgba(79, 120, 165, 0.86); }
.btn-login-spinner { border-color: rgba(255,255,255,0.35); border-top-color: #ffffff; }
.login-security-note { color: #71859a; }
.login-security-note svg { color: var(--auth-success); }
.login-card-footer { border-top-color: #dce5ee; color: #778b9f; }
:focus-visible { outline-color: #4f78a5; }
@media (max-width: 900px) { .auth-visual { border-bottom-color: #d4e0eb; } }
</style>
</head>
<body>
<a class="auth-skip-link" href="#loginMain">跳转到登录表单</a>
<div class="auth-page">
<aside class="auth-visual" aria-label="数富AI产品信息">
<div class="auth-visual-content">
<a class="auth-brand" href="/login" aria-label="数富AI 登录页">
<span class="auth-brand-mark" aria-hidden="true"><img src="/static/logo_thumb.png" alt=""></span>
<span class="auth-brand-copy">
<span class="auth-brand-name">数富AI</span>
<span class="auth-brand-sub">电商运营管理后台</span>
</span>
</a>
<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" id="loginMain">
<section class="login-card" aria-labelledby="loginTitle">
<header class="login-card-header">
<img class="login-logo" src="/static/logo_thumb.png" alt="数富AI">
<p class="login-card-eyebrow">欢迎回来</p>
<h1 class="login-title" id="loginTitle">登录工作台</h1>
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
</header>
<form id="loginForm" method="POST" action="/login" novalidate>
{% if error %}
<p class="error-msg" id="loginError" role="alert" aria-live="assertive">{{ error }}</p>
{% endif %}
<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 type="text" id="loginUsername" name="username" autocomplete="username" placeholder="请输入用户名" required 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 type="password" id="loginPassword" name="password" autocomplete="current-password" placeholder="请输入密码" required>
<button type="button" class="password-toggle" id="togglePassword" aria-label="显示密码" aria-pressed="false" title="显示密码">
<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="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>
</button>
</div>
</div>
<button type="submit" class="btn-login" id="btnLogin" aria-busy="false">
<span class="btn-login-label">登录</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>
<script>
(function () {
var form = document.getElementById('loginForm');
var btn = document.getElementById('btnLogin');
var label = btn ? btn.querySelector('.btn-login-label') : null;
var password = document.getElementById('loginPassword');
var togglePassword = document.getElementById('togglePassword');
if (!form || !btn) return;
if (password && togglePassword) {
togglePassword.addEventListener('click', function () {
var visible = password.type === 'password';
password.type = visible ? 'text' : 'password';
togglePassword.setAttribute('aria-pressed', visible ? 'true' : 'false');
togglePassword.setAttribute('aria-label', visible ? '隐藏密码' : '显示密码');
togglePassword.setAttribute('title', visible ? '隐藏密码' : '显示密码');
});
}
function setError(message) {
var errEl = document.getElementById('loginError') || document.querySelector('.error-msg');
if (!errEl) {
errEl = document.createElement('p');
errEl.id = 'loginError';
errEl.className = 'error-msg';
errEl.setAttribute('role', 'alert');
errEl.setAttribute('aria-live', 'assertive');
form.insertBefore(errEl, form.firstChild);
}
errEl.textContent = message || '登录失败';
}
function setLoading(loading) {
btn.disabled = loading;
btn.classList.toggle('is-loading', loading);
btn.setAttribute('aria-busy', loading ? 'true' : 'false');
if (label) label.textContent = loading ? '登录中...' : '登录';
}
form.addEventListener('submit', function (event) {
event.preventDefault();
if (btn.disabled) return;
setLoading(true);
var data = new FormData(form);
fetch(form.action, {
method: 'POST',
body: data,
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin'
})
.then(function (response) {
return response.json().catch(function () {
return { success: false, error: '登录响应异常,请重试' };
});
})
.then(function (result) {
if (result.success) {
window.location.href = result.redirect || '/admin';
return;
}
setError(result.error || '用户名或密码错误');
setLoading(false);
})
.catch(function () {
// API 不可用时保留原生表单提交作为兜底路径。
form.submit();
});
});
})();
</script>
</body>
</html>