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/
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
test-results/
|
||||||
|
vite-dev.log
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Admin Frontend Vue
|
||||||
|
|
||||||
|
独立的后台管理前端工程,与客户端 `frontend-vue` 完全分离。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
$env:VITE_API_TARGET = "http://127.0.0.1:18080"
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发入口:`http://localhost:5174/admin-vue/`
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物在 `dist/`,部署到 Nginx 的 `/admin-vue/` 根目录。该工程不写入客户端的 `new_web_source`,也不打进 Java JAR。
|
||||||
|
|
||||||
|
## 运行时契约
|
||||||
|
|
||||||
|
- 登录态:复用同源 Cookie/Session。
|
||||||
|
- 当前用户:`GET /api/admin/current-user`。
|
||||||
|
- 菜单树:`GET /api/admin/current-user/menus`,Java 返回树形菜单。
|
||||||
|
- 退出登录:`POST /api/admin/logout`。
|
||||||
|
- 前端路由:History 模式,基准路径 `/admin-vue/`。
|
||||||
|
- 权限粒度:菜单/页面级,不定义按钮级权限。
|
||||||
|
|
||||||
|
## 首批页面
|
||||||
|
|
||||||
|
- `/admin-vue/account/users`
|
||||||
|
- `/admin-vue/account/menus`
|
||||||
|
- `/admin-vue/account/groups`
|
||||||
|
|
||||||
|
当前首批页面已接入 Java 现有用户、菜单和数据权限分组 API;Java 菜单接口完成树形响应和 Flyway 菜单迁移后,路由权限守卫即可按最终菜单树工作。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="数富AI后台管理系统" />
|
||||||
|
<title>数富AI - 后台管理</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
@@ -14,7 +14,7 @@ function redirectToLogin(requestUrl?: string): void {
|
|||||||
// 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。
|
// 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。
|
||||||
if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return
|
if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return
|
||||||
const target = loginRedirectTarget(window.location)
|
const target = loginRedirectTarget(window.location)
|
||||||
window.location.assign(`/login?redirect=${target}`)
|
window.location.assign('/admin-vue/login?redirect=' + target)
|
||||||
}
|
}
|
||||||
|
|
||||||
http.interceptors.response.use(
|
http.interceptors.response.use(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
|
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
|
||||||
import type { AdminUser } from '../types/admin'
|
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_MIN_PAGE = 1
|
||||||
export const USER_PAGE_MAX_SIZE = 200
|
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 时的壳层缺省标题。 */
|
/** 无 meta.title 时的壳层缺省标题。 */
|
||||||
export const ADMIN_DEFAULT_TITLE = '管理后台'
|
export const ADMIN_DEFAULT_TITLE = '管理后台'
|
||||||
@@ -18,5 +19,5 @@ export interface TopbarUserViewModel {
|
|||||||
/** 顶部栏用户模型:用户信息或未登录空态统一为可渲染视图模型。 */
|
/** 顶部栏用户模型:用户信息或未登录空态统一为可渲染视图模型。 */
|
||||||
export function topbarUserOf(user: AdminUser | null | undefined): TopbarUserViewModel {
|
export function topbarUserOf(user: AdminUser | null | undefined): TopbarUserViewModel {
|
||||||
if (!user) return { username: '当前用户', role: '', hasUser: false }
|
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 currentUserId = computed(() => session.user?.id ?? null)
|
||||||
// 客户端分页:全量拉取后按页切片展示。
|
// 客户端分页:全量拉取后按页切片展示。
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 10
|
const pageSize = ref(10)
|
||||||
const total = computed(() => rows.value.length)
|
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) {
|
function changePage(p: number) {
|
||||||
page.value = p
|
page.value = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
function canEditOf(group: ShopGroupItem): boolean {
|
function canEditOf(group: ShopGroupItem): boolean {
|
||||||
if (isSuperAdmin.value) return true
|
if (isSuperAdmin.value) return true
|
||||||
return currentUserId.value != null && group.leaderUserId != null && currentUserId.value === group.leaderUserId
|
return currentUserId.value != null && group.leaderUserId != null && currentUserId.value === group.leaderUserId
|
||||||
@@ -75,7 +80,6 @@ onMounted(loadGroups)
|
|||||||
<h2>数据权限分组</h2>
|
<h2>数据权限分组</h2>
|
||||||
<p>管理店铺数据访问分组和组员范围。组长不可更改,组员为普通账号。</p>
|
<p>管理店铺数据访问分组和组员范围。组长不可更改,组员为普通账号。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">新建分组</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
<el-row :gutter="12" style="margin-bottom: 12px">
|
<el-row :gutter="12" style="margin-bottom: 12px">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
@@ -96,8 +100,10 @@ onMounted(loadGroups)
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-card shadow="never">
|
<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 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="name" label="分组名称" min-width="180" />
|
||||||
<el-table-column prop="leaderUsername" label="组长" min-width="170" />
|
<el-table-column prop="leaderUsername" label="组长" min-width="170" />
|
||||||
<el-table-column prop="memberCount" label="组员数量" min-width="100" />
|
<el-table-column prop="memberCount" label="组员数量" min-width="100" />
|
||||||
@@ -123,7 +129,7 @@ onMounted(loadGroups)
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div class="table-footer">
|
<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>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<GroupEditorDialog
|
<GroupEditorDialog
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ const loading = ref(false)
|
|||||||
const rows = ref<MenuManageNode[]>([])
|
const rows = ref<MenuManageNode[]>([])
|
||||||
// 客户端分页:列表展示统一带分页组件。
|
// 客户端分页:列表展示统一带分页组件。
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 10
|
const pageSize = ref(10)
|
||||||
const total = computed(() => rows.value.length)
|
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) {
|
function changePage(p: number) {
|
||||||
page.value = p
|
page.value = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
const createVisible = ref(false)
|
const createVisible = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editingNode = ref<MenuManageNode | null>(null)
|
const editingNode = ref<MenuManageNode | null>(null)
|
||||||
@@ -186,10 +191,12 @@ onMounted(loadMenus)
|
|||||||
<h2>菜单管理</h2>
|
<h2>菜单管理</h2>
|
||||||
<p>维护后台菜单树和页面路由。权限粒度仅到菜单/页面。</p>
|
<p>维护后台菜单树和页面路由。权限粒度仅到菜单/页面。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">新增菜单</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
<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 v-loading="loading" :data="pagedRows" row-key="id" default-expand-all stripe>
|
||||||
<el-table-column label="排序" min-width="64">
|
<el-table-column label="排序" min-width="64">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -205,7 +212,6 @@ onMounted(loadMenus)
|
|||||||
>⠿</span>
|
>⠿</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 prop="name" label="菜单名称" min-width="180" />
|
||||||
<el-table-column label="菜单类型" min-width="110">
|
<el-table-column label="菜单类型" min-width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -230,11 +236,13 @@ onMounted(loadMenus)
|
|||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
background
|
background
|
||||||
layout="prev, pager, next, jumper"
|
layout="sizes, prev, pager, next, jumper"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
:current-page="page"
|
:current-page="page"
|
||||||
@current-change="changePage"
|
@current-change="changePage"
|
||||||
|
@size-change="changeSize"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ function changePage(page: number) {
|
|||||||
void loadUsers()
|
void loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
filters.pageSize = size
|
||||||
|
filters.page = 1
|
||||||
|
void loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -114,7 +120,6 @@ onMounted(loadUsers)
|
|||||||
<h2>用户管理</h2>
|
<h2>用户管理</h2>
|
||||||
<p>当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</p>
|
<p>当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">新建用户</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
<el-card shadow="never" class="filter-card">
|
<el-card shadow="never" class="filter-card">
|
||||||
<el-form inline @submit.prevent="search">
|
<el-form inline @submit.prevent="search">
|
||||||
@@ -156,8 +161,10 @@ onMounted(loadUsers)
|
|||||||
<el-button link type="primary" @click="retry">重试</el-button>
|
<el-button link type="primary" @click="retry">重试</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</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 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 prop="username" label="用户名" min-width="180" />
|
||||||
<el-table-column label="角色" min-width="140">
|
<el-table-column label="角色" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -186,11 +193,13 @@ onMounted(loadUsers)
|
|||||||
<span>共 {{ total }} 条</span>
|
<span>共 {{ total }} 条</span>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
background
|
background
|
||||||
layout="prev, pager, next, jumper"
|
layout="sizes, prev, pager, next, jumper"
|
||||||
:page-size="filters.pageSize"
|
:page-size="filters.pageSize"
|
||||||
:current-page="filters.page"
|
:current-page="filters.page"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
:total="total"
|
:total="total"
|
||||||
@current-change="changePage"
|
@current-change="changePage"
|
||||||
|
@size-change="changeSize"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const loading = ref(false)
|
|||||||
const rows = ref<InvalidAsinItem[]>([])
|
const rows = ref<InvalidAsinItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 15
|
const pageSize = ref(10)
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
|
|
||||||
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
|
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
|
||||||
@@ -55,7 +55,7 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const result = await fetchInvalidAsinPage({
|
const result = await fetchInvalidAsinPage({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize,
|
pageSize: pageSize.value,
|
||||||
dataValue: filter.dataValue.trim() || undefined,
|
dataValue: filter.dataValue.trim() || undefined,
|
||||||
brand: filter.brand.trim() || undefined,
|
brand: filter.brand.trim() || undefined,
|
||||||
groupId: filter.groupId,
|
groupId: filter.groupId,
|
||||||
@@ -155,7 +155,6 @@ onMounted(() => {
|
|||||||
<h2>不符合 ASIN 数据</h2>
|
<h2>不符合 ASIN 数据</h2>
|
||||||
<p>管理命中"不符合 ASIN"的品牌数据库记录。</p>
|
<p>管理命中"不符合 ASIN"的品牌数据库记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">新增</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" style="margin-bottom: 14px">
|
<el-card shadow="never" style="margin-bottom: 14px">
|
||||||
@@ -182,6 +181,9 @@ onMounted(() => {
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<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 v-loading="loading" :data="rows" stripe border>
|
||||||
<el-table-column prop="dataValue" label="ASIN" min-width="160" />
|
<el-table-column prop="dataValue" label="ASIN" min-width="160" />
|
||||||
<el-table-column prop="brand" label="品牌" min-width="140">
|
<el-table-column prop="brand" label="品牌" min-width="140">
|
||||||
@@ -207,7 +209,7 @@ onMounted(() => {
|
|||||||
</el-table>
|
</el-table>
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
<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>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const loading = ref(false)
|
|||||||
const rows = ref<DedupeTotalItem[]>([])
|
const rows = ref<DedupeTotalItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 15
|
const pageSize = ref(10)
|
||||||
const filter = reactive(createDedupeTotalFilterState())
|
const filter = reactive(createDedupeTotalFilterState())
|
||||||
const groups = ref<DedupeGroupOption[]>([])
|
const groups = ref<DedupeGroupOption[]>([])
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ function stopExportWait(): void {
|
|||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize))
|
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize.value))
|
||||||
rows.value = result.items
|
rows.value = result.items
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
if (result.page >= 1) page.value = result.page
|
if (result.page >= 1) page.value = result.page
|
||||||
@@ -294,11 +294,6 @@ onMounted(() => {
|
|||||||
<h2>数据去重总数据</h2>
|
<h2>数据去重总数据</h2>
|
||||||
<p>去重总数据的 ASIN 值台账,可按 ASIN/用户名/分组/国家/日期筛选,支持 Excel 导入与导出。</p>
|
<p>去重总数据的 ASIN 值台账,可按 ASIN/用户名/分组/国家/日期筛选,支持 Excel 导入与导出。</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" class="group-summary-card">
|
<el-card shadow="never" class="group-summary-card">
|
||||||
@@ -357,8 +352,12 @@ onMounted(() => {
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<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 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 prop="dataValue" label="ASIN" min-width="170" />
|
||||||
<el-table-column label="国家" min-width="100" align="center">
|
<el-table-column label="国家" min-width="100" align="center">
|
||||||
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
|
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
|
||||||
@@ -381,11 +380,13 @@ onMounted(() => {
|
|||||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
background
|
background
|
||||||
layout="prev, pager, next, jumper"
|
layout="sizes, prev, pager, next, jumper"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
:current-page="page"
|
:current-page="page"
|
||||||
@current-change="(p: number) => { page = p; void load() }"
|
@current-change="(p: number) => { page = p; void load() }"
|
||||||
|
@size-change="() => { page = 1; void load() }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ const loading = ref(false)
|
|||||||
const rows = ref<QueryAsinItem[]>([])
|
const rows = ref<QueryAsinItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 15
|
const pageSize = ref(10)
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||||
|
|
||||||
/** 纵向展示行:序号/分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
||||||
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize + 1))
|
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] {
|
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 row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||||
}
|
}
|
||||||
return [1, 1]
|
return [1, 1]
|
||||||
@@ -282,7 +282,7 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const result = await fetchQueryAsinList({
|
const result = await fetchQueryAsinList({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize,
|
pageSize: pageSize.value,
|
||||||
groupId: filter.groupId,
|
groupId: filter.groupId,
|
||||||
shopName: filter.shopName.trim() || undefined,
|
shopName: filter.shopName.trim() || undefined,
|
||||||
asin: filter.asin.trim() || undefined,
|
asin: filter.asin.trim() || undefined,
|
||||||
@@ -322,12 +322,6 @@ onMounted(() => {
|
|||||||
<h2>查询 ASIN</h2>
|
<h2>查询 ASIN</h2>
|
||||||
<p>每店铺在 5 个站点查询到的 ASIN 清单;行内可逐站配置 ASIN。</p>
|
<p>每店铺在 5 个站点查询到的 ASIN 清单;行内可逐站配置 ASIN。</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
@@ -360,10 +354,13 @@ onMounted(() => {
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<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 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">
|
<el-table-column label="分组" min-width="130">
|
||||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -392,11 +389,13 @@ onMounted(() => {
|
|||||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
background
|
background
|
||||||
layout="prev, pager, next, jumper"
|
layout="sizes, prev, pager, next, jumper"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
:current-page="page"
|
:current-page="page"
|
||||||
@current-change="(p: number) => { page = p; void load() }"
|
@current-change="(p: number) => { page = p; void load() }"
|
||||||
|
@size-change="() => { page = 1; void load() }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const loading = ref(false)
|
|||||||
const rows = ref<SkipPriceItem[]>([])
|
const rows = ref<SkipPriceItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 15
|
const pageSize = ref(10)
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
||||||
|
|
||||||
@@ -37,12 +37,12 @@ function priceOf(row: SkipPriceItem, code: string): number | null {
|
|||||||
return typeof value === 'number' ? value : null
|
return typeof value === 'number' ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 纵向展示行:序号/分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
||||||
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize + 1))
|
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] {
|
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 row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||||
}
|
}
|
||||||
return [1, 1]
|
return [1, 1]
|
||||||
@@ -325,7 +325,7 @@ async function loadGroups() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize))
|
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize.value))
|
||||||
rows.value = result.items
|
rows.value = result.items
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
page.value = result.page
|
page.value = result.page
|
||||||
@@ -360,12 +360,6 @@ onMounted(() => {
|
|||||||
<h2>最低价 ASIN / 跳过跟价</h2>
|
<h2>最低价 ASIN / 跳过跟价</h2>
|
||||||
<p>每店铺在 5 个站点设定的最低价 ASIN 清单;行内可逐站配置 ASIN 与最低价。</p>
|
<p>每店铺在 5 个站点设定的最低价 ASIN 清单;行内可逐站配置 ASIN 与最低价。</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
@@ -406,10 +400,13 @@ onMounted(() => {
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<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 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">
|
<el-table-column label="分组" min-width="130">
|
||||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -444,11 +441,13 @@ onMounted(() => {
|
|||||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||||
<el-pagination
|
<el-pagination
|
||||||
background
|
background
|
||||||
layout="prev, pager, next, jumper"
|
layout="sizes, prev, pager, next, jumper"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
:current-page="page"
|
:current-page="page"
|
||||||
@current-change="(p: number) => { page = p; void load() }"
|
@current-change="(p: number) => { page = p; void load() }"
|
||||||
|
@size-change="() => { page = 1; void load() }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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[]
|
originalUrls: string[]
|
||||||
resultUrls: string[]
|
resultUrls: string[]
|
||||||
longImageUrl: string
|
longImageUrl: string
|
||||||
params: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryPageResult {
|
export interface HistoryPageResult {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const loading = ref(false)
|
|||||||
const rows = ref<ShopKeyItem[]>([])
|
const rows = ref<ShopKeyItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 15
|
const pageSize = ref(10)
|
||||||
|
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
@@ -40,7 +40,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await fetchShopKeyList({ page: page.value, pageSize })
|
const result = await fetchShopKeyList({ page: page.value, pageSize: pageSize.value })
|
||||||
rows.value = result.items
|
rows.value = result.items
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
page.value = result.page
|
page.value = result.page
|
||||||
@@ -121,12 +121,13 @@ onMounted(load)
|
|||||||
<h2>店铺密钥管理</h2>
|
<h2>店铺密钥管理</h2>
|
||||||
<p>管理各店铺的紫鸟浏览器账号与令牌。</p>
|
<p>管理各店铺的紫鸟浏览器账号与令牌。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">新增密钥</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<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 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">
|
<el-table-column prop="remarkName" label="备注名" min-width="140">
|
||||||
<template #default="{ row }">{{ (row as ShopKeyItem).remarkName || '—' }}</template>
|
<template #default="{ row }">{{ (row as ShopKeyItem).remarkName || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -162,7 +163,7 @@ onMounted(load)
|
|||||||
</el-table>
|
</el-table>
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
<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>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const loading = ref(false)
|
|||||||
const rows = ref<ShopSummary[]>([])
|
const rows = ref<ShopSummary[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 15
|
const pageSize = ref(10)
|
||||||
|
|
||||||
const groups = ref<ShopGroupOption[]>([])
|
const groups = ref<ShopGroupOption[]>([])
|
||||||
const filter = reactive({ shopName: '', groupId: null as number | null })
|
const filter = reactive({ shopName: '', groupId: null as number | null })
|
||||||
@@ -75,7 +75,7 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const result = await fetchShopManageList({
|
const result = await fetchShopManageList({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize,
|
pageSize: pageSize.value,
|
||||||
groupId: filter.groupId,
|
groupId: filter.groupId,
|
||||||
shopName: filter.shopName.trim() || undefined,
|
shopName: filter.shopName.trim() || undefined,
|
||||||
})
|
})
|
||||||
@@ -94,6 +94,12 @@ function apply() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
filter.shopName = ''
|
filter.shopName = ''
|
||||||
filter.groupId = null
|
filter.groupId = null
|
||||||
@@ -196,7 +202,6 @@ onMounted(() => {
|
|||||||
<h2>店铺管理</h2>
|
<h2>店铺管理</h2>
|
||||||
<p>管理店铺账号、所属分组与自动化账号。</p>
|
<p>管理店铺账号、所属分组与自动化账号。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="openCreate">新增店铺</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" style="margin-bottom: 14px">
|
<el-card shadow="never" style="margin-bottom: 14px">
|
||||||
@@ -219,8 +224,10 @@ onMounted(() => {
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card shadow="never">
|
<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 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="groupName" label="分组" min-width="120" />
|
||||||
<el-table-column prop="shopName" label="店铺名" min-width="160" />
|
<el-table-column prop="shopName" label="店铺名" min-width="160" />
|
||||||
<el-table-column prop="mallName" label="店铺商城名" min-width="120" />
|
<el-table-column prop="mallName" label="店铺商城名" min-width="120" />
|
||||||
@@ -258,7 +265,7 @@ onMounted(() => {
|
|||||||
</el-table>
|
</el-table>
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
<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>
|
</div>
|
||||||
</el-card>
|
</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 === '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>
|
<button class="tab" :class="{ active: activeTab === 'ledger' }" @click="switchTab('ledger')">全部ASIN台账 <span class="t-count">{{ ledgerTotal }}</span></button>
|
||||||
</div>
|
</div>
|
||||||
<el-button text type="primary" @click="groupInfoVisible = true">分组说明</el-button>
|
<el-button class="help-btn" size="small" @click="groupInfoVisible = true">分组说明</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============ 撞款监控 ============ -->
|
<!-- ============ 撞款监控 ============ -->
|
||||||
@@ -607,9 +607,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dup-console { max-width: 1560px; }
|
.dup-console { max-width: 1560px; }
|
||||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
.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: 10px; }
|
.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; }
|
.updated { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; }
|
||||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
|
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
|
||||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
.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; }
|
.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; }
|
.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); }
|
.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; }
|
.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 { 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; }
|
.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import AdminLayout from '@/layout/AdminLayout.vue'
|
import AdminLayout from '@/layout/AdminLayout.vue'
|
||||||
|
import LoginPage from '@/pages/login/LoginPage.vue'
|
||||||
import NotFoundPage from '@/pages/error/NotFoundPage.vue'
|
import NotFoundPage from '@/pages/error/NotFoundPage.vue'
|
||||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
import { APP_BASE_PATH } from '@/config/app'
|
import { APP_BASE_PATH } from '@/config/app'
|
||||||
@@ -10,6 +11,11 @@ import { isRouteAllowed } from './helpers'
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(APP_BASE_PATH),
|
history: createWebHistory(APP_BASE_PATH),
|
||||||
routes: [
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
component: LoginPage,
|
||||||
|
meta: { title: '登录' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: AdminLayout,
|
component: AdminLayout,
|
||||||
@@ -26,10 +32,15 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const session = useAdminSessionStore()
|
const session = useAdminSessionStore()
|
||||||
|
// 登录页是公开页;已登录访问则回根路由。
|
||||||
|
if (to.path === '/login') {
|
||||||
|
return session.initialized && session.user ? '/' : true
|
||||||
|
}
|
||||||
if (!session.initialized) {
|
if (!session.initialized) {
|
||||||
try {
|
try {
|
||||||
await session.initialize()
|
await session.initialize()
|
||||||
} catch {
|
} catch {
|
||||||
|
// 未登录:401 由 http 拦截器整页跳转 SPA 登录页;此处放行当前导航由守卫收口。
|
||||||
return false
|
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,6 +17,19 @@ export const adminPages: AdminPageDef[] = [
|
|||||||
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
|
{ 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/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
||||||
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
||||||
|
{ path: '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 {
|
export function routeOf(page: AdminPageDef): RouteRecordRaw {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { fetchAdminMenuTree, fetchCurrentUser, logout } from '@/api/session'
|
import { fetchAdminMenuTree, fetchCurrentUser, logout } from '@/api/session'
|
||||||
|
import { joinAdminPath } from '@/config/app'
|
||||||
import type { AdminMenuNode, AdminUser } from '@/types/admin'
|
import type { AdminMenuNode, AdminUser } from '@/types/admin'
|
||||||
import { isSuperAdminRole } from '@/types/admin'
|
import { isSuperAdminRole } from '@/types/admin'
|
||||||
import { firstVisiblePath } from '@/router/helpers'
|
import { firstVisiblePath } from '@/router/helpers'
|
||||||
|
import { canonicalizeMenuTree } from '@/router/menu-route-map'
|
||||||
|
|
||||||
export const useAdminSessionStore = defineStore('admin-session', {
|
export const useAdminSessionStore = defineStore('admin-session', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -25,7 +27,7 @@ export const useAdminSessionStore = defineStore('admin-session', {
|
|||||||
this.error = ''
|
this.error = ''
|
||||||
try {
|
try {
|
||||||
this.user = await fetchCurrentUser()
|
this.user = await fetchCurrentUser()
|
||||||
this.menuTree = await fetchAdminMenuTree()
|
this.menuTree = canonicalizeMenuTree(await fetchAdminMenuTree())
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error instanceof Error ? error.message : '后台初始化失败'
|
this.error = error instanceof Error ? error.message : '后台初始化失败'
|
||||||
@@ -37,7 +39,7 @@ export const useAdminSessionStore = defineStore('admin-session', {
|
|||||||
async signOut() {
|
async signOut() {
|
||||||
await logout()
|
await logout()
|
||||||
this.$reset()
|
this.$reset()
|
||||||
window.location.assign('/login')
|
window.location.assign(joinAdminPath('login'))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ button, input, textarea, select { font: inherit; }
|
|||||||
.admin-menu .el-sub-menu__title,
|
.admin-menu .el-sub-menu__title,
|
||||||
.admin-menu .el-menu-item {
|
.admin-menu .el-menu-item {
|
||||||
height: 42px; line-height: 42px; margin: 3px 0; padding: 0 12px;
|
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-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 {
|
.admin-menu .el-menu-item.is-active {
|
||||||
color: var(--admin-primary-strong); font-weight: 600;
|
color: var(--admin-primary-strong); font-weight: 600;
|
||||||
background: linear-gradient(90deg, #dce9f5, #edf4fa);
|
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);
|
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-menu { background: transparent; }
|
||||||
.admin-menu .el-sub-menu .el-sub-menu__icon-arrow { color: var(--admin-sidebar-text); }
|
.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: var(--admin-sidebar-text); }
|
.menu-group-title { font-size: 15px; font-weight: 600; letter-spacing: .4px; color: #405b73; }
|
||||||
.menu-group-title:hover { color: var(--admin-primary-strong); }
|
.menu-group-title:hover { color: #2f4a63; }
|
||||||
|
|
||||||
.skip-link { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
.skip-link { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
||||||
.skip-link:focus {
|
.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; }
|
.admin-content { flex: 1; min-width: 0; padding: 30px 32px 56px; overflow: auto; }
|
||||||
.page-stack { display: flex; flex-direction: column; gap: 18px; }
|
.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 { 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 h2 { margin: 0; font-size: 20px; color: var(--admin-text); }
|
||||||
.page-heading p { margin: 7px 0 0; color: var(--admin-muted); font-size: 13px; }
|
.page-heading p { margin: 7px 0 0; color: var(--admin-muted); font-size: 13px; }
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -2,20 +2,21 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readSource } from './helpers.ts'
|
import { readSource } from './helpers.ts'
|
||||||
|
|
||||||
/** 对齐 admin.html:5298-5300(店铺密钥 序号/紫鸟账号名称) 与 5339-5348(店铺管理列序与列名)。 */
|
/** 验收反馈:列表不再展示"序号/ID"列。对齐 admin.html:5298-5300(紫鸟账号名称) 与 5339-5348(店铺管理列序与列名)。 */
|
||||||
|
|
||||||
test('align_shop_keys_columns', () => {
|
test('align_shop_keys_columns', () => {
|
||||||
const page = readSource('src/pages/shop/ShopKeysPage.vue')
|
const page = readSource('src/pages/shop/ShopKeysPage.vue')
|
||||||
assert.match(page, /label="序号"/, '首列改序号')
|
assert.doesNotMatch(page, /label="序号"/, '不再展示序号列')
|
||||||
assert.match(page, /type="index"/, '序号列按行号生成')
|
assert.doesNotMatch(page, /type="index"/, '不用行号列')
|
||||||
|
assert.doesNotMatch(page, /label="ID"/, '不展示 ID 列')
|
||||||
|
assert.match(page, /label="备注名"/, '首业务列按备注名起')
|
||||||
assert.match(page, /紫鸟账号名称/, '列名对齐「紫鸟账号名称」')
|
assert.match(page, /紫鸟账号名称/, '列名对齐「紫鸟账号名称」')
|
||||||
assert.doesNotMatch(page, /label="ID"/, '去掉 ID 列')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('align_shop_manage_columns_order_and_labels', () => {
|
test('align_shop_manage_columns_order_and_labels', () => {
|
||||||
const page = readSource('src/pages/shop/ShopManagePage.vue')
|
const page = readSource('src/pages/shop/ShopManagePage.vue')
|
||||||
assert.match(page, /label="序号"/, '首列改序号')
|
assert.doesNotMatch(page, /label="序号"/, '不再展示序号列')
|
||||||
assert.doesNotMatch(page, /label="ID"/, '去掉 ID 列')
|
assert.doesNotMatch(page, /label="ID"/, '不展示 ID 列')
|
||||||
// 列序:分组 → 店铺名 → 店铺商城名 → 自动化账号 → 账号 → 密码(对齐 admin.html:5339-5348)。
|
// 列序:分组 → 店铺名 → 店铺商城名 → 自动化账号 → 账号 → 密码(对齐 admin.html:5339-5348)。
|
||||||
const table = page.slice(page.indexOf('<el-table'))
|
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)
|
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?:\/\//)
|
||||||
|
})
|
||||||
@@ -13,7 +13,7 @@ import { adminPages } from '../src/router/routes.ts'
|
|||||||
|
|
||||||
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
|
test('test_task_010_lazy_page_boundary_normal_primary_path', () => {
|
||||||
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
|
// 正常主路径:所有注册页面都是懒加载器,可被路由异步边界包裹。
|
||||||
assert.equal(adminPages.length, 3)
|
assert.equal(adminPages.length, 16)
|
||||||
for (const page of adminPages) {
|
for (const page of adminPages) {
|
||||||
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
|
assert.equal(isLazyLoader(page.load), true, `页面 ${page.path} 必须是懒加载函数`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ test('test_task_012_route_error_page_normal_repeated_operation_is_idempotent', (
|
|||||||
test('test_task_012_route_error_page_boundary_empty_input', () => {
|
test('test_task_012_route_error_page_boundary_empty_input', () => {
|
||||||
// 边界空值:错误页文件存在,且不进入业务路由注册表。
|
// 边界空值:错误页文件存在,且不进入业务路由注册表。
|
||||||
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
|
assert.equal(existsSync(join(process.cwd(), NOT_FOUND)), true)
|
||||||
assert.equal(adminPages.length, 3, '错误页不应计入业务路由')
|
assert.equal(adminPages.length, 16, '错误页不应计入业务路由')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test_task_012_route_error_page_boundary_single_item', () => {
|
test('test_task_012_route_error_page_boundary_single_item', () => {
|
||||||
|
|||||||
@@ -75,6 +75,6 @@ test('test_task_016_shell_logout_interaction_dependency_failure_returns_actionab
|
|||||||
assert.match(layout, /runLogout/)
|
assert.match(layout, /runLogout/)
|
||||||
const store = readSource('src/stores/admin-session.ts')
|
const store = readSource('src/stores/admin-session.ts')
|
||||||
assert.match(store, /\$reset\(\)/)
|
assert.match(store, /\$reset\(\)/)
|
||||||
assert.match(store, /location\.assign\('\/login'\)/)
|
assert.match(store, /location\.assign\(joinAdminPath\('login'\)\)/)
|
||||||
assert.match(layout, /LOGOUT_CONFIRM_MESSAGE/)
|
assert.match(layout, /LOGOUT_CONFIRM_MESSAGE/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const store = readSource('src/stores/admin-session.ts')
|
|||||||
|
|
||||||
test('test_task_029_menu_tree_init_normal_primary_path', () => {
|
test('test_task_029_menu_tree_init_normal_primary_path', () => {
|
||||||
// 正常主路径:用户拉取成功后拉取菜单树并写入 store.menuTree。
|
// 正常主路径:用户拉取成功后拉取菜单树并写入 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', () => {
|
test('test_task_029_menu_tree_init_normal_variant_input', () => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ test('test_task_036_logout_clear_state_dependency_failure_returns_actionable_mes
|
|||||||
const store = readSource('src/stores/admin-session.ts')
|
const store = readSource('src/stores/admin-session.ts')
|
||||||
assert.match(store, /async signOut\(\)/)
|
assert.match(store, /async signOut\(\)/)
|
||||||
assert.match(store, /\$reset\(\)/)
|
assert.match(store, /\$reset\(\)/)
|
||||||
assert.match(store, /location\.assign\('\/login'\)/)
|
assert.match(store, /location\.assign\(joinAdminPath\('login'\)\)/)
|
||||||
const session = readSource('src/api/session.ts')
|
const session = readSource('src/api/session.ts')
|
||||||
assert.match(session, /\/logout/, '登出走 auth 模块根端点 POST /logout')
|
assert.match(session, /\/logout/, '登出走 auth 模块根端点 POST /logout')
|
||||||
assert.equal(session.includes('/api/admin/logout'), false, '不应指向不存在的 /api/admin/logout')
|
assert.equal(session.includes('/api/admin/logout'), false, '不应指向不存在的 /api/admin/logout')
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test('test_task_005_topbar_title_model_normal_primary_path', () => {
|
|||||||
assert.equal(pageTitleOf('用户管理'), '用户管理')
|
assert.equal(pageTitleOf('用户管理'), '用户管理')
|
||||||
const vm = topbarUserOf({ id: 1, username: 'admin', role: 'super_admin' })
|
const vm = topbarUserOf({ id: 1, username: 'admin', role: 'super_admin' })
|
||||||
assert.equal(vm.username, 'admin')
|
assert.equal(vm.username, 'admin')
|
||||||
assert.equal(vm.role, 'super_admin')
|
assert.equal(vm.role, '超级管理员')
|
||||||
assert.equal(vm.hasUser, true)
|
assert.equal(vm.hasUser, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ test('test_task_005_topbar_title_model_boundary_single_item', () => {
|
|||||||
assert.equal(pageTitleOf('首页'), '首页')
|
assert.equal(pageTitleOf('首页'), '首页')
|
||||||
const single = topbarUserOf({ id: 9, username: 'root', role: 'admin' })
|
const single = topbarUserOf({ id: 9, username: 'root', role: 'admin' })
|
||||||
assert.equal(single.username, 'root')
|
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', () => {
|
test('test_task_005_topbar_title_model_boundary_limit_or_missing_field', () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
|
|
||||||
test('test_task_008_domain_route_registry_normal_primary_path', () => {
|
test('test_task_008_domain_route_registry_normal_primary_path', () => {
|
||||||
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
|
// 正常主路径:注册表首批 account 域页面登记为可消费路由记录。
|
||||||
assert.equal(adminPages.length, 3)
|
assert.equal(adminPages.length, 16)
|
||||||
assert.equal(adminRouteRecords.length, adminPages.length)
|
assert.equal(adminRouteRecords.length, adminPages.length)
|
||||||
const first = adminRouteRecords[0]
|
const first = adminRouteRecords[0]
|
||||||
assert.equal(first.path, 'account/users')
|
assert.equal(first.path, 'account/users')
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -14,10 +14,19 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
// 统一把 Java 侧的登录/登出/业务接口反代到本地后端,保证同源 Cookie 会话可用。
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:18080',
|
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:18080',
|
||||||
changeOrigin: true,
|
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: {
|
build: {
|
||||||
|
|||||||
@@ -18,15 +18,18 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理后台页面与"当前管理员"自描述接口(Java 收敛自 Flask 侧 web_source/admin.html 的依赖)。
|
* 管理后台入口路由与"当前管理员"自描述接口。
|
||||||
*
|
*
|
||||||
* <p>页面经静态资源托管于同源(classpath:/static 下的 admin.html / login.html),
|
* <p>管理后台页面已整体迁移到 Vue SPA(Nginx 静态托管于 /admin-vue/,见 docs/specs/10、11),
|
||||||
* 登录/登出沿用 auth 模块(POST /login、POST /logout)。这里提供 GET 页面转发与
|
* 旧 classpath:/static 下的 admin.html / login.html 与 Flask 侧 web_source 已删除(task-283)。
|
||||||
* /api/admin/current-user、/current-user/menus,保持与旧前端契约一致({item}、{items})。
|
* 这里只保留入口重定向:/、/admin → /admin-vue/;/login → /admin-vue/login(SPA 登录页);
|
||||||
|
* /admin.html、/login.html 兜底重定向到新入口,避免旧书签直接 404。
|
||||||
|
* 登录/登出本体沿用 auth 模块(POST /login、POST /logout)。这里提供 GET 页面转发与
|
||||||
|
* /api/admin/current-user、/current-user/menus,保持与前端契约一致({item}、{items})。
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Tag(name = "管理后台页面", description = "管理后台静态页路由与当前管理员信息")
|
@Tag(name = "管理后台页面", description = "管理后台入口重定向与当前管理员信息")
|
||||||
public class AdminConsoleController {
|
public class AdminConsoleController {
|
||||||
|
|
||||||
private final AdminAuthSupport adminAuthSupport;
|
private final AdminAuthSupport adminAuthSupport;
|
||||||
@@ -38,22 +41,22 @@ public class AdminConsoleController {
|
|||||||
return "redirect:/admin-vue/";
|
return "redirect:/admin-vue/";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admin")
|
@GetMapping({"/admin", "/admin.html"})
|
||||||
@Operation(summary = "管理后台入口", hidden = true)
|
@Operation(summary = "管理后台入口(含旧 /admin.html 兜底)", hidden = true)
|
||||||
public String adminPage() {
|
public String adminPage() {
|
||||||
return "redirect:/admin-vue/";
|
return "redirect:/admin-vue/";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/login")
|
@GetMapping({"/login", "/login.html"})
|
||||||
@Operation(summary = "登录页入口", hidden = true)
|
@Operation(summary = "登录页入口(含旧 /login.html 兜底)", hidden = true)
|
||||||
public String loginPage() {
|
public String loginPage() {
|
||||||
return "forward:/login.html";
|
return "redirect:/admin-vue/login";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/logout")
|
@GetMapping("/logout")
|
||||||
@Operation(summary = "登出 GET 兜底(POST 走 auth 模块)", hidden = true)
|
@Operation(summary = "登出 GET 兜底(POST 走 auth 模块)", hidden = true)
|
||||||
public String logoutPage() {
|
public String logoutPage() {
|
||||||
return "redirect:/login.html";
|
return "redirect:/admin-vue/login";
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/admin/current-user")
|
@GetMapping("/api/admin/current-user")
|
||||||
|
|||||||
@@ -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);
|
|
||||||
})();
|
|
||||||
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -37,7 +37,7 @@ class AdminApiGuardFilterTest {
|
|||||||
void nonAdminApiPathIsNotGuarded() throws Exception {
|
void nonAdminApiPathIsNotGuarded() throws Exception {
|
||||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||||
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
|
AdminApiGuardFilter filter = newFilter(authSupport, true, "");
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin.html");
|
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/admin-vue/");
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
MockFilterChain chain = new MockFilterChain();
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
"""对象存储上传工具(MinIO,S3 协议兼容)
|
|
||||||
|
|
||||||
原实现基于阿里云 OSS SDK(alibabacloud_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)
|
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
卖相AI - Flask 后端
|
数富AI - Flask 后端
|
||||||
按功能拆分为蓝图:认证(auth)、主页面(main)、管理员API(admin_api)、版本(version)
|
管理后台(页面 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 os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from utils.db import init_db
|
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
|
from blueprints.version import version_bp
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
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={
|
CORS(app, resources={
|
||||||
r"/api/*": {
|
r"/api/*": {
|
||||||
"origins": [
|
"origins": [
|
||||||
@@ -33,18 +32,8 @@ CORS(app, resources={
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
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'
|
|
||||||
|
|
||||||
# 注册蓝图
|
# 注册蓝图:仅版本公开 API(管理后台 API 已迁 Java,见模块 docstring)
|
||||||
app.register_blueprint(auth)
|
|
||||||
app.register_blueprint(main)
|
|
||||||
app.register_blueprint(admin_api)
|
|
||||||
app.register_blueprint(version_bp)
|
app.register_blueprint(version_bp)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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);
|
|
||||||
})();
|
|
||||||
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
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 Cookie,list_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):
|
|
||||||
"""默认转发自动补当前操作用户 operatorId(Java 内部通道要求令牌+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_java:requests.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 @@
|
|||||||
# Utils 包:数据库、认证装饰器、模板渲染等
|
# Utils 包:数据库连接(管理后台旧代码 task-283 删除后仅保留 db 工具)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||