task-283(后台迁移收尾/发布): admin-vue 后台工程入库 + Flask 后台整体退役
- 新后台 admin-frontend-vue 整工程入库(base=/admin-vue/、History 路由、Nginx 静态托管方案与 verify-dist 校验) - Java AdminConsoleController 改为入口重定向: /admin|/admin.html、/login|/login.html -> /admin-vue/; 删除 classpath:static 旧 admin.html/login.html 单页与 admin.js 副本 - Flask 后台整体退役: 删除 admin_api/auth/main 蓝图、web_source 页面、static 脚本与 admin 相关测试; app.py 收敛为仅注册 version_bp(/api/version、/api/version/latest, 供桌面端更新检查) - AdminApiGuardFilterTest 豁免样例路径 /admin.html -> /admin-vue/
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -14,7 +14,7 @@ function redirectToLogin(requestUrl?: string): void {
|
||||
// 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。
|
||||
if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return
|
||||
const target = loginRedirectTarget(window.location)
|
||||
window.location.assign(`/login?redirect=${target}`)
|
||||
window.location.assign('/admin-vue/login?redirect=' + target)
|
||||
}
|
||||
|
||||
http.interceptors.response.use(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
|
||||
import type { AdminUser } from '../types/admin'
|
||||
|
||||
export const USER_PAGE_DEFAULT_SIZE = 15
|
||||
export const USER_PAGE_DEFAULT_SIZE = 10
|
||||
export const USER_PAGE_MIN_PAGE = 1
|
||||
export const USER_PAGE_MAX_SIZE = 200
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AdminUser } from '../types/admin'
|
||||
import type { AdminUser } from '../types/admin.ts'
|
||||
import { roleLabel } from '../types/admin.ts'
|
||||
|
||||
/** 无 meta.title 时的壳层缺省标题。 */
|
||||
export const ADMIN_DEFAULT_TITLE = '管理后台'
|
||||
@@ -18,5 +19,5 @@ export interface TopbarUserViewModel {
|
||||
/** 顶部栏用户模型:用户信息或未登录空态统一为可渲染视图模型。 */
|
||||
export function topbarUserOf(user: AdminUser | null | undefined): TopbarUserViewModel {
|
||||
if (!user) return { username: '当前用户', role: '', hasUser: false }
|
||||
return { username: user.username || '当前用户', role: user.role || '', hasUser: true }
|
||||
return { username: user.username || '当前用户', role: roleLabel(user.role) || user.role || '', hasUser: true }
|
||||
}
|
||||
|
||||
@@ -18,14 +18,19 @@ const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
const currentUserId = computed(() => session.user?.id ?? null)
|
||||
// 客户端分页:全量拉取后按页切片展示。
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const pageSize = ref(10)
|
||||
const total = computed(() => rows.value.length)
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
function changePage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function canEditOf(group: ShopGroupItem): boolean {
|
||||
if (isSuperAdmin.value) return true
|
||||
return currentUserId.value != null && group.leaderUserId != null && currentUserId.value === group.leaderUserId
|
||||
@@ -75,7 +80,6 @@ onMounted(loadGroups)
|
||||
<h2>数据权限分组</h2>
|
||||
<p>管理店铺数据访问分组和组员范围。组长不可更改,组员为普通账号。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新建分组</el-button>
|
||||
</div>
|
||||
<el-row :gutter="12" style="margin-bottom: 12px">
|
||||
<el-col :span="8">
|
||||
@@ -96,8 +100,10 @@ onMounted(loadGroups)
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新建分组</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="pagedRows" stripe>
|
||||
<el-table-column type="index" label="序号" min-width="80" />
|
||||
<el-table-column prop="name" label="分组名称" min-width="180" />
|
||||
<el-table-column prop="leaderUsername" label="组长" min-width="170" />
|
||||
<el-table-column prop="memberCount" label="组员数量" min-width="100" />
|
||||
@@ -123,7 +129,7 @@ onMounted(loadGroups)
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="changePage" />
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="changePage" @size-change="changeSize" />
|
||||
</div>
|
||||
</el-card>
|
||||
<GroupEditorDialog
|
||||
|
||||
@@ -24,13 +24,18 @@ const loading = ref(false)
|
||||
const rows = ref<MenuManageNode[]>([])
|
||||
// 客户端分页:列表展示统一带分页组件。
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const pageSize = ref(10)
|
||||
const total = computed(() => rows.value.length)
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
function changePage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
}
|
||||
const createVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingNode = ref<MenuManageNode | null>(null)
|
||||
@@ -186,10 +191,12 @@ onMounted(loadMenus)
|
||||
<h2>菜单管理</h2>
|
||||
<p>维护后台菜单树和页面路由。权限粒度仅到菜单/页面。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增菜单</el-button>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增菜单</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="pagedRows" row-key="id" default-expand-all stripe>
|
||||
<el-table-column label="排序" min-width="64">
|
||||
<template #default="{ row }">
|
||||
@@ -205,7 +212,6 @@ onMounted(loadMenus)
|
||||
>⠿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="ID" min-width="70" />
|
||||
<el-table-column prop="name" label="菜单名称" min-width="180" />
|
||||
<el-table-column label="菜单类型" min-width="110">
|
||||
<template #default="{ row }">
|
||||
@@ -230,11 +236,13 @@ onMounted(loadMenus)
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="changePage"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -104,6 +104,12 @@ function changePage(page: number) {
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
filters.pageSize = size
|
||||
filters.page = 1
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
@@ -114,7 +120,6 @@ onMounted(loadUsers)
|
||||
<h2>用户管理</h2>
|
||||
<p>当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新建用户</el-button>
|
||||
</div>
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form inline @submit.prevent="search">
|
||||
@@ -156,8 +161,10 @@ onMounted(loadUsers)
|
||||
<el-button link type="primary" @click="retry">重试</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新建用户</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe :empty-text="userListEmptyHint()">
|
||||
<el-table-column prop="id" label="ID" min-width="90" />
|
||||
<el-table-column prop="username" label="用户名" min-width="180" />
|
||||
<el-table-column label="角色" min-width="140">
|
||||
<template #default="{ row }">
|
||||
@@ -186,11 +193,13 @@ onMounted(loadUsers)
|
||||
<span>共 {{ total }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:page-size="filters.pageSize"
|
||||
:current-page="filters.page"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="changePage"
|
||||
@size-change="changeSize"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -16,7 +16,7 @@ const loading = ref(false)
|
||||
const rows = ref<InvalidAsinItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
|
||||
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
|
||||
@@ -55,7 +55,7 @@ async function load() {
|
||||
try {
|
||||
const result = await fetchInvalidAsinPage({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
pageSize: pageSize.value,
|
||||
dataValue: filter.dataValue.trim() || undefined,
|
||||
brand: filter.brand.trim() || undefined,
|
||||
groupId: filter.groupId,
|
||||
@@ -155,7 +155,6 @@ onMounted(() => {
|
||||
<h2>不符合 ASIN 数据</h2>
|
||||
<p>管理命中"不符合 ASIN"的品牌数据库记录。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
@@ -182,6 +181,9 @@ onMounted(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column prop="dataValue" label="ASIN" min-width="160" />
|
||||
<el-table-column prop="brand" label="品牌" min-width="140">
|
||||
@@ -207,7 +209,7 @@ onMounted(() => {
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="(p: number) => { page = p; load() }" />
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="() => { page = 1; load() }" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const loading = ref(false)
|
||||
const rows = ref<DedupeTotalItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const pageSize = ref(10)
|
||||
const filter = reactive(createDedupeTotalFilterState())
|
||||
const groups = ref<DedupeGroupOption[]>([])
|
||||
|
||||
@@ -78,7 +78,7 @@ function stopExportWait(): void {
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize))
|
||||
const result = await fetchDedupeTotalList(toDedupeListParams(filter, page.value, pageSize.value))
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
if (result.page >= 1) page.value = result.page
|
||||
@@ -294,11 +294,6 @@ onMounted(() => {
|
||||
<h2>数据去重总数据</h2>
|
||||
<p>去重总数据的 ASIN 值台账,可按 ASIN/用户名/分组/国家/日期筛选,支持 Excel 导入与导出。</p>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
|
||||
<el-button @click="openImportDelete">删除导入</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="group-summary-card">
|
||||
@@ -357,8 +352,12 @@ onMounted(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openImportAdd">新增导入</el-button>
|
||||
<el-button @click="openImportDelete">删除导入</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column prop="id" label="ID" min-width="80" />
|
||||
<el-table-column prop="dataValue" label="ASIN" min-width="170" />
|
||||
<el-table-column label="国家" min-width="100" align="center">
|
||||
<template #default="{ row }">{{ asinCountryLabel((row as DedupeTotalItem).country || '') }}</template>
|
||||
@@ -381,11 +380,13 @@ onMounted(() => {
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -19,16 +19,16 @@ const loading = ref(false)
|
||||
const rows = ref<QueryAsinItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||
|
||||
/** 纵向展示行:序号/分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
||||
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize + 1))
|
||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
||||
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||
|
||||
/** rowspan 合并:序号(0)/分组(1)/店铺(2)/操作(5) 仅首行占位,其余合并。 */
|
||||
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/操作4):分组(0)/店铺(1)/操作(4) 仅首行占位,其余合并。 */
|
||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 2 || columnIndex === 5) {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 4) {
|
||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||
}
|
||||
return [1, 1]
|
||||
@@ -282,7 +282,7 @@ async function load() {
|
||||
try {
|
||||
const result = await fetchQueryAsinList({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
pageSize: pageSize.value,
|
||||
groupId: filter.groupId,
|
||||
shopName: filter.shopName.trim() || undefined,
|
||||
asin: filter.asin.trim() || undefined,
|
||||
@@ -322,12 +322,6 @@ onMounted(() => {
|
||||
<h2>查询 ASIN</h2>
|
||||
<p>每店铺在 5 个站点查询到的 ASIN 清单;行内可逐站配置 ASIN。</p>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
||||
<el-button @click="openImportAdd">导入添加</el-button>
|
||||
<el-button @click="openImportDelete">导入删除</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
@@ -360,10 +354,13 @@ onMounted(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
||||
<el-button @click="openImportAdd">导入添加</el-button>
|
||||
<el-button @click="openImportDelete">导入删除</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
|
||||
<el-table-column label="序号" min-width="80">
|
||||
<template #default="{ row }">{{ row.rowNo }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分组" min-width="130">
|
||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -392,11 +389,13 @@ onMounted(() => {
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -20,7 +20,7 @@ const loading = ref(false)
|
||||
const rows = ref<SkipPriceItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
||||
|
||||
@@ -37,12 +37,12 @@ function priceOf(row: SkipPriceItem, code: string): number | null {
|
||||
return typeof value === 'number' ? value : null
|
||||
}
|
||||
|
||||
/** 纵向展示行:序号/分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
||||
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize + 1))
|
||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
||||
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||
|
||||
/** rowspan 合并:序号(0)/分组(1)/店铺(2)/操作(6) 仅首行占位,其余合并。 */
|
||||
/** rowspan 合并(去序号后列序 分组0/店铺1/ASIN2/国家3/最低价4/操作5):分组(0)/店铺(1)/操作(5) 仅首行占位,其余合并。 */
|
||||
function spanMethod({ row, columnIndex }: { row: { isFirst: boolean; rowspan: number }; columnIndex: number }): [number, number] {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 2 || columnIndex === 6) {
|
||||
if (columnIndex === 0 || columnIndex === 1 || columnIndex === 5) {
|
||||
return row.isFirst ? [row.rowspan, 1] : [0, 0]
|
||||
}
|
||||
return [1, 1]
|
||||
@@ -325,7 +325,7 @@ async function loadGroups() {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize))
|
||||
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize.value))
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
@@ -360,12 +360,6 @@ onMounted(() => {
|
||||
<h2>最低价 ASIN / 跳过跟价</h2>
|
||||
<p>每店铺在 5 个站点设定的最低价 ASIN 清单;行内可逐站配置 ASIN 与最低价。</p>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
||||
<el-button @click="openImportAdd">导入添加</el-button>
|
||||
<el-button @click="openImportDelete">导入删除</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
@@ -406,10 +400,13 @@ onMounted(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增 ASIN</el-button>
|
||||
<el-button @click="openImportAdd">导入添加</el-button>
|
||||
<el-button @click="openImportDelete">导入删除</el-button>
|
||||
<el-button @click="doExport">导出</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="displayRows" :span-method="spanMethod" stripe border>
|
||||
<el-table-column label="序号" min-width="80">
|
||||
<template #default="{ row }">{{ row.rowNo }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分组" min-width="130">
|
||||
<template #default="{ row }">{{ row.item.groupName || '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -444,11 +441,13 @@ onMounted(() => {
|
||||
<span>共 {{ total.toLocaleString() }} 条</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper"
|
||||
layout="sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:current-page="page"
|
||||
@current-change="(p: number) => { page = p; void load() }"
|
||||
@size-change="() => { page = 1; void load() }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<script setup lang="ts">
|
||||
/** 数富AI 后台 Vue 登录页:复刻旧登录页「品牌视觉 + 登录卡」蓝白设计;POST /login 建会话后进入后台。 */
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { http } from '@/api/http'
|
||||
import { joinAdminPath } from '@/config/app'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useAdminSessionStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
||||
|
||||
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
|
||||
|
||||
function deviceId(): string {
|
||||
try {
|
||||
const key = 'aiimage_console_device_id'
|
||||
let value = localStorage.getItem(key)
|
||||
if (!value) {
|
||||
value = `web-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`
|
||||
localStorage.setItem(key, value)
|
||||
}
|
||||
return value
|
||||
} catch {
|
||||
return `web-${Date.now()}`
|
||||
}
|
||||
}
|
||||
|
||||
function safeRedirect(value: unknown): string {
|
||||
if (typeof value !== 'string') return '/'
|
||||
let target = value
|
||||
try {
|
||||
target = decodeURIComponent(target)
|
||||
} catch {
|
||||
// 忽略非法编码
|
||||
}
|
||||
if (!target.startsWith('/') || target.startsWith('//')) return '/'
|
||||
if (target.startsWith('/admin-vue')) target = target.slice('/admin-vue'.length) || '/'
|
||||
return target
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const user = username.value.trim()
|
||||
if (!user || !password.value) {
|
||||
errorMessage.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const did = deviceId()
|
||||
await http.post<unknown>('/login', { username: user, password: password.value, deviceId: did })
|
||||
session.$reset()
|
||||
await session.initialize()
|
||||
const target = safeRedirect(route.query.redirect)
|
||||
await router.replace(target)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : '用户名或密码错误'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (session.initialized && session.user) {
|
||||
router.replace('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<aside class="auth-visual" aria-label="数富AI 产品信息">
|
||||
<div class="auth-visual-content">
|
||||
<div class="auth-brand">
|
||||
<span class="auth-brand-mark" aria-hidden="true"><img :src="logoUrl" alt=""></span>
|
||||
<span class="auth-brand-copy">
|
||||
<span class="auth-brand-name">数富AI</span>
|
||||
<span class="auth-brand-sub">电商运营管理后台</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="auth-visual-copy">
|
||||
<span class="auth-kicker">数富AI · 运营工作台</span>
|
||||
<h2 class="auth-visual-title">让每一次运营动作,都有清晰的工作流。</h2>
|
||||
<p class="auth-visual-description">统一管理数据、店铺、任务与版本,让团队在一个可靠的运营工作台中快速协作。</p>
|
||||
<ul class="auth-feature-list" aria-label="工作台能力">
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg></span>
|
||||
<span>权限隔离,操作边界清晰可控</span>
|
||||
</li>
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 2"></path><circle cx="12" cy="12" r="9"></circle><path d="M3 4v5h5"></path><path d="M3.5 9A9 9 0 0 1 19 5.5"></path></svg></span>
|
||||
<span>任务状态,进度反馈及时透明</span>
|
||||
</li>
|
||||
<li class="auth-feature-item">
|
||||
<span class="auth-feature-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="m7 15 3-4 3 2 4-6"></path><path d="M17 7h3v3"></path></svg></span>
|
||||
<span>数据工具,支撑日常电商运营</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-visual-footer">
|
||||
<span>数富AI · 管理控制台</span>
|
||||
<span class="auth-system-status">服务已就绪</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="auth-content">
|
||||
<section class="login-card" aria-labelledby="loginTitle">
|
||||
<header class="login-card-header">
|
||||
<p class="login-card-eyebrow">欢迎回来</p>
|
||||
<h1 class="login-title" id="loginTitle">登录工作台</h1>
|
||||
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
||||
</header>
|
||||
<form novalidate @submit.prevent="submit">
|
||||
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
|
||||
<div class="form-group">
|
||||
<label for="loginUsername">用户名</label>
|
||||
<div class="input-shell">
|
||||
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"></circle><path d="M4 21a8 8 0 0 1 16 0"></path></svg></span>
|
||||
<input id="loginUsername" v-model="username" type="text" autocomplete="username" placeholder="请输入用户名" autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginPassword">密码</label>
|
||||
<div class="input-shell">
|
||||
<span class="input-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="10" x="5" y="11" rx="2"></rect><path d="M8 11V7a4 4 0 0 1 8 0v4"></path></svg></span>
|
||||
<input id="loginPassword" v-model="password" :type="inputType" autocomplete="current-password" placeholder="请输入密码" @keyup.enter="submit">
|
||||
<button type="button" class="password-toggle" :aria-label="showPassword ? '隐藏密码' : '显示密码'" @click="showPassword = !showPassword">
|
||||
<svg v-if="!showPassword" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 3 18 18"></path><path d="M10.6 5.1A9.7 9.7 0 0 1 12 5c6 0 9.5 7 9.5 7a17 17 0 0 1-2.3 3"></path><path d="M6.6 6.6C3.7 8.6 2.5 12 2.5 12s3.5 7 9.5 7a9.7 9.7 0 0 0 4.5-1.1"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-login" :class="{ 'is-loading': loading }" :disabled="loading" :aria-busy="loading">
|
||||
<span class="btn-login-label">{{ loading ? '登录中...' : '登录' }}</span>
|
||||
<span class="btn-login-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</form>
|
||||
<p class="login-security-note">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3 4 7v5c0 4.5 3.4 7.7 8 9 4.6-1.3 8-4.5 8-9V7l-8-4Z"></path><path d="m8.5 12 2.2 2.2 4.8-5"></path></svg>
|
||||
<span>请勿在公共设备保存账号凭据。登录状态由本地安全会话管理。</span>
|
||||
</p>
|
||||
<footer class="login-card-footer">数富AI · 电商运营管理工作台</footer>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.9fr) minmax(420px, 1.1fr);
|
||||
color: #24384d;
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(178, 205, 229, 0.32), transparent 34rem),
|
||||
radial-gradient(circle at 96% 100%, rgba(214, 226, 239, 0.28), transparent 28rem),
|
||||
#f4f7fb;
|
||||
}
|
||||
button, input { font: inherit; }
|
||||
|
||||
.auth-visual {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 100vh;
|
||||
padding: 36px clamp(32px, 5vw, 84px) 34px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #d4e0eb;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(232, 240, 248, 0.97), rgba(248, 251, 254, 0.99)),
|
||||
radial-gradient(circle at 20% 8%, rgba(112, 148, 186, 0.16), transparent 28rem);
|
||||
}
|
||||
.auth-visual::before,
|
||||
.auth-visual::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
.auth-visual::before {
|
||||
inset: 0;
|
||||
opacity: 0.3;
|
||||
background-image: linear-gradient(rgba(79, 120, 165, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(79, 120, 165, 0.1) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 78%);
|
||||
}
|
||||
.auth-visual::after {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
right: -150px;
|
||||
bottom: -160px;
|
||||
border: 1px solid rgba(79, 120, 165, 0.22);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 32px rgba(79, 120, 165, 0.055), 0 0 0 64px rgba(79, 120, 165, 0.035);
|
||||
}
|
||||
.auth-visual-content, .auth-visual-footer { position: relative; z-index: 1; }
|
||||
|
||||
.auth-brand { display: inline-flex; align-items: center; gap: 12px; width: fit-content; }
|
||||
.auth-brand-mark {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 46px; height: 46px; border-radius: 13px; overflow: hidden;
|
||||
background: #fff; box-shadow: 0 10px 24px rgba(79, 120, 165, 0.18);
|
||||
}
|
||||
.auth-brand-mark img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.auth-brand-copy { display: grid; gap: 1px; }
|
||||
.auth-brand-name { font-size: 22px; font-weight: 700; letter-spacing: 0.2px; }
|
||||
.auth-brand-sub { color: #77899b; font-size: 11px; }
|
||||
.auth-visual-content { max-width: 540px; margin: auto 0; padding: 72px 0 96px; }
|
||||
.auth-visual-copy { padding-top: 72px; }
|
||||
.auth-kicker {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
color: #2f5d8b; font-size: 11px; font-weight: 700; letter-spacing: 1.8px;
|
||||
}
|
||||
.auth-kicker::before { content: ""; width: 24px; height: 1px; background: #4f78a5; }
|
||||
.auth-visual-title {
|
||||
max-width: 560px; margin: 18px 0;
|
||||
font-size: clamp(30px, 4vw, 52px); font-weight: 700; letter-spacing: -1.8px; line-height: 1.12;
|
||||
}
|
||||
.auth-visual-description { max-width: 470px; margin: 0; color: #60748a; font-size: 16px; line-height: 1.75; }
|
||||
.auth-feature-list { display: grid; gap: 12px; margin: 34px 0 0; padding: 0; list-style: none; }
|
||||
.auth-feature-item { display: flex; align-items: center; gap: 12px; color: #465d73; }
|
||||
.auth-feature-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 30px; width: 30px; height: 30px;
|
||||
border: 1px solid #c2d3e3; border-radius: 9px; background: #edf5fb; color: #2f5d8b;
|
||||
}
|
||||
.auth-feature-icon svg { width: 16px; height: 16px; }
|
||||
.auth-visual-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #778b9f; font-size: 12px; }
|
||||
.auth-system-status { display: inline-flex; align-items: center; gap: 7px; color: #3d7158; }
|
||||
.auth-system-status::before {
|
||||
content: ""; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: #4e806d; box-shadow: 0 0 0 4px rgba(78, 128, 108, 0.13);
|
||||
}
|
||||
|
||||
.auth-content {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-width: 0; padding: 40px clamp(24px, 6vw, 96px);
|
||||
background: rgba(249, 251, 253, 0.5);
|
||||
}
|
||||
.login-card {
|
||||
width: min(100%, 452px);
|
||||
padding: clamp(28px, 4vw, 48px);
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 26px 70px -38px rgba(39, 67, 94, 0.34);
|
||||
}
|
||||
.login-card-header { margin-bottom: 30px; }
|
||||
.login-card-eyebrow { margin: 0 0 8px; color: #71859a; font-size: 15px; font-weight: 700; letter-spacing: 1.2px; }
|
||||
.login-title { margin: 0; font-size: 30px; font-weight: 700; letter-spacing: -0.8px; line-height: 1.2; }
|
||||
.login-subtitle { margin: 10px 0 0; color: #5b6f83; line-height: 1.65; }
|
||||
|
||||
.form-group { margin-bottom: 20px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; color: #5b6f83; font-size: 13px; font-weight: 600; }
|
||||
.input-shell { position: relative; }
|
||||
.input-icon { position: absolute; top: 50%; left: 14px; display: inline-flex; color: #8298ad; pointer-events: none; transform: translateY(-50%); }
|
||||
.input-icon svg { width: 18px; height: 18px; }
|
||||
.form-group input {
|
||||
width: 100%; min-height: 48px; padding: 12px 52px 12px 44px;
|
||||
border: 1px solid #cbd9e6; border-radius: 12px; outline: none;
|
||||
background: #f8fbfd; color: #24384d; font-size: 14px;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover { border-color: #9fb7cd; }
|
||||
.form-group input:focus { border-color: #5f85ad; background: #fff; box-shadow: 0 0 0 4px rgba(95, 133, 173, 0.16); }
|
||||
.form-group input::placeholder { color: #8293a5; }
|
||||
.password-toggle {
|
||||
position: absolute; top: 50%; right: 4px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 40px; border: 0; border-radius: 9px;
|
||||
background: transparent; color: #8298ad; cursor: pointer; transform: translateY(-50%);
|
||||
transition: background 160ms ease, color 160ms ease;
|
||||
}
|
||||
.password-toggle:hover { background: #edf5fb; color: #2f5d8b; }
|
||||
.password-toggle svg { width: 18px; height: 18px; }
|
||||
|
||||
.error-msg {
|
||||
display: flex; align-items: flex-start; gap: 9px;
|
||||
margin: -4px 0 18px; padding: 11px 12px;
|
||||
border: 1px solid #e4c2c5; border-radius: 11px; background: #f8ebeb; color: #91474f;
|
||||
font-size: 13px; line-height: 1.55;
|
||||
}
|
||||
.error-msg::before {
|
||||
content: "!";
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 18px; width: 18px; height: 18px;
|
||||
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||
width: 100%; min-height: 48px; padding: 12px 18px;
|
||||
border: 1px solid #7196ba; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #fff; cursor: pointer; font-size: 15px; font-weight: 800;
|
||||
box-shadow: 0 12px 24px -16px rgba(79, 120, 165, 0.82);
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
.btn-login:hover:not(:disabled) { background: linear-gradient(135deg, #7094ba, #5d83ac); color: #fff; box-shadow: 0 16px 28px -15px rgba(79, 120, 165, 0.86); transform: translateY(-1px); }
|
||||
.btn-login:active:not(:disabled) { transform: translateY(1px) scale(0.99); }
|
||||
.btn-login:disabled { cursor: wait; opacity: 0.7; }
|
||||
.btn-login-spinner { display: none; width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.35); border-top-color: #fff; border-radius: 50%; animation: login-spin 0.8s linear infinite; }
|
||||
.btn-login.is-loading .btn-login-spinner { display: inline-block; }
|
||||
@keyframes login-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.login-security-note { display: flex; align-items: flex-start; gap: 9px; margin: 22px 0 0; color: #71859a; font-size: 12px; line-height: 1.55; }
|
||||
.login-security-note svg { flex: 0 0 auto; width: 16px; height: 16px; margin-top: 1px; color: #4e806d; }
|
||||
.login-card-footer { margin-top: 34px; padding-top: 18px; border-top: 1px solid #dce5ee; color: #778b9f; font-size: 12px; text-align: center; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.auth-page { display: block; }
|
||||
.auth-visual { min-height: auto; padding: 22px 24px; border-right: 0; border-bottom: 1px solid #d4e0eb; }
|
||||
.auth-visual-content { display: block; max-width: none; margin: 0; padding: 0; }
|
||||
.auth-visual-copy { display: none; }
|
||||
.auth-visual-footer { display: none; }
|
||||
.auth-content { min-height: calc(100vh - 87px); padding: 32px 24px 44px; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.auth-visual { padding: 18px 16px; }
|
||||
.auth-content { padding: 24px 14px 32px; }
|
||||
.login-card { padding: 26px 20px; border-radius: 18px; }
|
||||
.login-title { font-size: 27px; }
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@ export interface HistoryRecordItem {
|
||||
originalUrls: string[]
|
||||
resultUrls: string[]
|
||||
longImageUrl: string
|
||||
params: Record<string, unknown>
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface HistoryPageResult {
|
||||
|
||||
@@ -12,7 +12,7 @@ const loading = ref(false)
|
||||
const rows = ref<ShopKeyItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const pageSize = ref(10)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
@@ -40,7 +40,7 @@ function whitelistTooltip(row: ShopKeyItem): string {
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchShopKeyList({ page: page.value, pageSize })
|
||||
const result = await fetchShopKeyList({ page: page.value, pageSize: pageSize.value })
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
@@ -121,12 +121,13 @@ onMounted(load)
|
||||
<h2>店铺密钥管理</h2>
|
||||
<p>管理各店铺的紫鸟浏览器账号与令牌。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增密钥</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增密钥</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column type="index" label="序号" min-width="80" :index="(i: number) => (page - 1) * pageSize + i + 1" />
|
||||
<el-table-column prop="remarkName" label="备注名" min-width="140">
|
||||
<template #default="{ row }">{{ (row as ShopKeyItem).remarkName || '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -162,7 +163,7 @@ onMounted(load)
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="(p: number) => { page = p; load() }" />
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="() => { page = 1; load() }" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const loading = ref(false)
|
||||
const rows = ref<ShopSummary[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 15
|
||||
const pageSize = ref(10)
|
||||
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const filter = reactive({ shopName: '', groupId: null as number | null })
|
||||
@@ -75,7 +75,7 @@ async function load() {
|
||||
try {
|
||||
const result = await fetchShopManageList({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
pageSize: pageSize.value,
|
||||
groupId: filter.groupId,
|
||||
shopName: filter.shopName.trim() || undefined,
|
||||
})
|
||||
@@ -94,6 +94,12 @@ function apply() {
|
||||
load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filter.shopName = ''
|
||||
filter.groupId = null
|
||||
@@ -196,7 +202,6 @@ onMounted(() => {
|
||||
<h2>店铺管理</h2>
|
||||
<p>管理店铺账号、所属分组与自动化账号。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">新增店铺</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 14px">
|
||||
@@ -219,8 +224,10 @@ onMounted(() => {
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增店铺</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="rows" stripe border>
|
||||
<el-table-column type="index" label="序号" min-width="80" :index="(i: number) => (page - 1) * pageSize + i + 1" />
|
||||
<el-table-column prop="groupName" label="分组" min-width="120" />
|
||||
<el-table-column prop="shopName" label="店铺名" min-width="160" />
|
||||
<el-table-column prop="mallName" label="店铺商城名" min-width="120" />
|
||||
@@ -258,7 +265,7 @@ onMounted(() => {
|
||||
</el-table>
|
||||
<div class="table-footer">
|
||||
<span>共 <b>{{ total.toLocaleString() }}</b> 条</span>
|
||||
<el-pagination background layout="prev, pager, next, jumper" :total="total" :page-size="pageSize" :current-page="page" @current-change="(p: number) => { page = p; load() }" />
|
||||
<el-pagination background layout="sizes, prev, pager, next, jumper" :total="total" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" :current-page="page" @current-change="(p: number) => { page = p; load() }" @size-change="changeSize" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@ onMounted(() => {
|
||||
<button class="tab" :class="{ active: activeTab === 'dup' }" @click="switchTab('dup')">撞款监控 <span class="t-count">{{ totalDupCount }}</span></button>
|
||||
<button class="tab" :class="{ active: activeTab === 'ledger' }" @click="switchTab('ledger')">全部ASIN台账 <span class="t-count">{{ ledgerTotal }}</span></button>
|
||||
</div>
|
||||
<el-button text type="primary" @click="groupInfoVisible = true">分组说明</el-button>
|
||||
<el-button class="help-btn" size="small" @click="groupInfoVisible = true">分组说明</el-button>
|
||||
</div>
|
||||
|
||||
<!-- ============ 撞款监控 ============ -->
|
||||
@@ -607,9 +607,9 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.dup-console { max-width: 1560px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
.page-heading .actions { display: flex; align-items: center; gap: 10px; }
|
||||
.updated { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.page-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.page-heading .actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; margin-left: auto; }
|
||||
.updated { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; }
|
||||
.filter-grid { display: flex; flex-wrap: wrap; gap: 12px 18px; }
|
||||
.f-item { display: flex; flex-direction: column; gap: 6px; width: 170px; }
|
||||
.f-item label { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
@@ -642,6 +642,8 @@ onMounted(() => {
|
||||
.tab.active { background: var(--el-color-primary); color: #fff; }
|
||||
.t-count { background: rgba(0, 0, 0, 0.12); border-radius: 10px; padding: 0 7px; font-size: 11px; }
|
||||
.tab.active .t-count { background: rgba(255, 255, 255, 0.25); }
|
||||
.help-btn.el-button { color: #2f4a63; border-color: #a9c3d9; background: #fff; }
|
||||
.help-btn.el-button:hover { color: #1e3b57; border-color: #7fa6c6; background: #edf5fb; }
|
||||
.scope-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.seg { display: flex; gap: 4px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 9px; padding: 4px; }
|
||||
.seg-btn { padding: 7px 16px; border-radius: 7px; font-size: 13px; font-weight: 600; color: var(--el-text-color-regular); border: none; background: transparent; cursor: pointer; }
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/** 撞款控制台/台账解析(reference console 对齐的只读契约):纯逻辑,snake 兼容。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
import {
|
||||
parseDuplicateItem,
|
||||
parseDuplicateOccurrence,
|
||||
parseDuplicateOverview,
|
||||
type DuplicateItem,
|
||||
type DuplicateOccurrence,
|
||||
type DuplicateOverview,
|
||||
} from './duplicate-model.ts'
|
||||
|
||||
/** 后端分组的组内统计。 */
|
||||
export interface ConsoleGroupStat {
|
||||
name: string
|
||||
shopCount: number
|
||||
asinUnique: number
|
||||
recordCount: number
|
||||
dupCount: number
|
||||
}
|
||||
|
||||
/** 撞款控制台单次快照:总览(指标/店铺分布) + 撞款全集 + 分组统计。 */
|
||||
export interface DuplicateConsole {
|
||||
overview: DuplicateOverview
|
||||
dup: DuplicateItem[]
|
||||
groups: ConsoleGroupStat[]
|
||||
totalDup: number
|
||||
}
|
||||
|
||||
/** 台账单行(含 occurrences,供明细抽屉还原每店上架时间线)。 */
|
||||
export interface LedgerRow {
|
||||
asin: string
|
||||
brand: string
|
||||
storeCount: number
|
||||
stores: string[]
|
||||
groups: string[]
|
||||
countries: string[]
|
||||
recordCount: number
|
||||
earliest: string
|
||||
latest: string
|
||||
occurrences: DuplicateOccurrence[]
|
||||
}
|
||||
|
||||
export interface LedgerPage {
|
||||
pending: boolean
|
||||
scannedAt: string
|
||||
items: LedgerRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function count(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((v) => text(v)).filter(Boolean) : []
|
||||
}
|
||||
|
||||
/** 解析单条分组统计。 */
|
||||
export function parseConsoleGroup(raw: unknown): ConsoleGroupStat | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const name = text(r.name)
|
||||
if (!name) return null
|
||||
return {
|
||||
name,
|
||||
shopCount: count(r.shop_count ?? r.shopCount),
|
||||
asinUnique: count(r.asin_unique ?? r.asinUnique),
|
||||
recordCount: count(r.record_count ?? r.recordCount),
|
||||
dupCount: count(r.dup_count ?? r.dupCount),
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 /duplicate-check-console 响应体。overview 复用总览解析(忽略多余字段)。 */
|
||||
export function parseDuplicateConsole(payload: unknown): DuplicateConsole {
|
||||
const core = unwrap<unknown>(payload)
|
||||
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : {}
|
||||
const overview = parseDuplicateOverview(payload)
|
||||
const dup = Array.isArray(record.dup)
|
||||
? record.dup.map((raw) => parseDuplicateItem(raw)).filter((item): item is DuplicateItem => item !== null)
|
||||
: []
|
||||
const groups = Array.isArray(record.groups)
|
||||
? record.groups.map((raw) => parseConsoleGroup(raw)).filter((group): group is ConsoleGroupStat => group !== null)
|
||||
: []
|
||||
return { overview, dup, groups, totalDup: count(record.total_dup ?? record.totalDup) }
|
||||
}
|
||||
|
||||
/** 解析单条台账行。 */
|
||||
export function parseLedgerRow(raw: unknown): LedgerRow | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const asin = text(r.asin)
|
||||
if (!asin) return null
|
||||
return {
|
||||
asin,
|
||||
brand: text(r.brand),
|
||||
storeCount: count(r.store_count ?? r.storeCount),
|
||||
stores: strings(r.stores),
|
||||
groups: strings(r.groups),
|
||||
countries: strings(r.countries),
|
||||
recordCount: count(r.record_count ?? r.recordCount),
|
||||
earliest: text(r.earliest),
|
||||
latest: text(r.latest),
|
||||
occurrences: Array.isArray(r.occurrences)
|
||||
? r.occurrences.map((occ) => parseDuplicateOccurrence(occ)).filter((occ): occ is DuplicateOccurrence => occ !== null)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 /duplicate-check-ledger 分页负载。 */
|
||||
export function parseLedgerPage(payload: unknown): LedgerPage {
|
||||
const core = unwrap<unknown>(payload)
|
||||
const record = core && typeof core === 'object' ? (core as Record<string, unknown>) : {}
|
||||
const items = Array.isArray(record.items)
|
||||
? record.items.map((raw) => parseLedgerRow(raw)).filter((row): row is LedgerRow => row !== null)
|
||||
: []
|
||||
const pageNumber = count(record.page) >= 1 ? count(record.page) : 1
|
||||
const rawSize = record.page_size ?? record.pageSize
|
||||
return {
|
||||
pending: record.pending === true,
|
||||
scannedAt: text(record.scanned_at ?? record.scannedAt),
|
||||
items,
|
||||
total: count(record.total),
|
||||
page: pageNumber,
|
||||
pageSize: count(rawSize) >= 1 ? count(rawSize) : 20,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import AdminLayout from '@/layout/AdminLayout.vue'
|
||||
import LoginPage from '@/pages/login/LoginPage.vue'
|
||||
import NotFoundPage from '@/pages/error/NotFoundPage.vue'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { APP_BASE_PATH } from '@/config/app'
|
||||
@@ -10,6 +11,11 @@ import { isRouteAllowed } from './helpers'
|
||||
const router = createRouter({
|
||||
history: createWebHistory(APP_BASE_PATH),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
component: LoginPage,
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
@@ -26,10 +32,15 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const session = useAdminSessionStore()
|
||||
// 登录页是公开页;已登录访问则回根路由。
|
||||
if (to.path === '/login') {
|
||||
return session.initialized && session.user ? '/' : true
|
||||
}
|
||||
if (!session.initialized) {
|
||||
try {
|
||||
await session.initialize()
|
||||
} catch {
|
||||
// 未登录:401 由 http 拦截器整页跳转 SPA 登录页;此处放行当前导航由守卫收口。
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 菜单树 route 归一:column_key → 前端注册的规范页面路径。
|
||||
* 共享库 columns.route_path 仍是旧式 slug(切机迁移未应用),这里按注册表 key 映射为新 Vue 路由,
|
||||
* 保证侧边栏/面包屑/首页跳转都能命中已注册页面,无需改写共享库。纯逻辑。 */
|
||||
import { adminPages } from './routes'
|
||||
import type { AdminMenuNode } from '@/types/admin'
|
||||
|
||||
const CANONICAL_BY_KEY = new Map<string, string>()
|
||||
for (const page of adminPages) {
|
||||
if (page.menuKey) {
|
||||
CANONICAL_BY_KEY.set(page.menuKey, `/${page.path.replace(/^\/+/, '')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** column_key → 规范路由(带前导 /,无 base 前缀);未登记返回 undefined。 */
|
||||
export function canonicalRouteOf(key: string | undefined | null): string | undefined {
|
||||
return typeof key === 'string' ? CANONICAL_BY_KEY.get(key) : undefined
|
||||
}
|
||||
|
||||
/** 递归把菜单树节点的 route 替换为规范路由;分组/未登记节点保持原值。 */
|
||||
export function canonicalizeMenuTree(nodes: AdminMenuNode[] | null | undefined): AdminMenuNode[] {
|
||||
return (nodes || []).map((node) => {
|
||||
const canonical = canonicalRouteOf(node.key)
|
||||
const children = canonicalizeMenuTree(node.children)
|
||||
const route = canonical && node.route != null ? canonical : node.route
|
||||
return { ...node, route, children }
|
||||
})
|
||||
}
|
||||
@@ -17,6 +17,19 @@ export const adminPages: AdminPageDef[] = [
|
||||
{ path: 'account/users', menuKey: 'admin_users', title: '用户管理', load: () => import('@/pages/account/UsersPage.vue') },
|
||||
{ path: 'account/menus', menuKey: 'admin_columns', title: '菜单管理', load: () => import('@/pages/account/MenusPage.vue') },
|
||||
{ path: 'account/groups', menuKey: 'admin_group_manage', title: '数据权限分组', load: () => import('@/pages/account/GroupsPage.vue') },
|
||||
{ path: 'shop-center/duplicate-check', menuKey: 'admin_shop_data_duplicate_check', title: '店铺撞款监控', load: () => import('@/pages/tasks/DuplicateCheckPage.vue') },
|
||||
{ path: 'shop-center/keys', menuKey: 'admin_shop_keys', title: '店铺密钥管理', load: () => import('@/pages/shop/ShopKeysPage.vue') },
|
||||
{ path: 'shop-center/shops', menuKey: 'admin_shop_manage', title: '店铺管理', load: () => import('@/pages/shop/ShopManagePage.vue') },
|
||||
{ path: 'records/history', menuKey: 'admin_history', title: '生成记录', load: () => import('@/pages/records/RecordsHistoryPage.vue') },
|
||||
{ path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') },
|
||||
{ path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') },
|
||||
{ path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') },
|
||||
{ path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据任务/记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') },
|
||||
{ path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') },
|
||||
{ path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') },
|
||||
{ path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') },
|
||||
{ path: 'asin-center/categories', menuKey: 'admin_product_categories', title: '商品类目', load: () => import('@/pages/asin/ProductCategoryPage.vue') },
|
||||
{ path: 'asin-center/skip-price', menuKey: 'admin_skip_price_asin', title: '最低价ASIN', load: () => import('@/pages/asin/SkipPricePage.vue') },
|
||||
]
|
||||
|
||||
export function routeOf(page: AdminPageDef): RouteRecordRaw {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { fetchAdminMenuTree, fetchCurrentUser, logout } from '@/api/session'
|
||||
import { joinAdminPath } from '@/config/app'
|
||||
import type { AdminMenuNode, AdminUser } from '@/types/admin'
|
||||
import { isSuperAdminRole } from '@/types/admin'
|
||||
import { firstVisiblePath } from '@/router/helpers'
|
||||
import { canonicalizeMenuTree } from '@/router/menu-route-map'
|
||||
|
||||
export const useAdminSessionStore = defineStore('admin-session', {
|
||||
state: () => ({
|
||||
@@ -25,7 +27,7 @@ export const useAdminSessionStore = defineStore('admin-session', {
|
||||
this.error = ''
|
||||
try {
|
||||
this.user = await fetchCurrentUser()
|
||||
this.menuTree = await fetchAdminMenuTree()
|
||||
this.menuTree = canonicalizeMenuTree(await fetchAdminMenuTree())
|
||||
this.initialized = true
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : '后台初始化失败'
|
||||
@@ -37,7 +39,7 @@ export const useAdminSessionStore = defineStore('admin-session', {
|
||||
async signOut() {
|
||||
await logout()
|
||||
this.$reset()
|
||||
window.location.assign('/login')
|
||||
window.location.assign(joinAdminPath('login'))
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -70,10 +70,10 @@ button, input, textarea, select { font: inherit; }
|
||||
.admin-menu .el-sub-menu__title,
|
||||
.admin-menu .el-menu-item {
|
||||
height: 42px; line-height: 42px; margin: 3px 0; padding: 0 12px;
|
||||
border-radius: 8px; color: var(--admin-sidebar-text);
|
||||
border-radius: 8px; color: #2f4a63;
|
||||
}
|
||||
.admin-menu .el-sub-menu__title:hover,
|
||||
.admin-menu .el-menu-item:hover { color: var(--admin-primary-strong); background: rgba(79, 120, 165, 0.09); }
|
||||
.admin-menu .el-menu-item:hover { color: #1e3b57; background: rgba(47, 93, 139, 0.16); }
|
||||
.admin-menu .el-menu-item.is-active {
|
||||
color: var(--admin-primary-strong); font-weight: 600;
|
||||
background: linear-gradient(90deg, #dce9f5, #edf4fa);
|
||||
@@ -81,9 +81,9 @@ button, input, textarea, select { font: inherit; }
|
||||
box-shadow: inset 3px 0 0 #5f85ad, 0 8px 18px -16px rgba(79, 120, 165, 0.55);
|
||||
}
|
||||
.admin-menu .el-menu { background: transparent; }
|
||||
.admin-menu .el-sub-menu .el-sub-menu__icon-arrow { color: var(--admin-sidebar-text); }
|
||||
.menu-group-title { font-size: 15px; font-weight: 600; letter-spacing: .4px; color: var(--admin-sidebar-text); }
|
||||
.menu-group-title:hover { color: var(--admin-primary-strong); }
|
||||
.admin-menu .el-sub-menu .el-sub-menu__icon-arrow { color: #405b73; }
|
||||
.menu-group-title { font-size: 15px; font-weight: 600; letter-spacing: .4px; color: #405b73; }
|
||||
.menu-group-title:hover { color: #2f4a63; }
|
||||
|
||||
.skip-link { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
|
||||
.skip-link:focus {
|
||||
@@ -118,6 +118,8 @@ button, input, textarea, select { font: inherit; }
|
||||
|
||||
.admin-content { flex: 1; min-width: 0; padding: 30px 32px 56px; overflow: auto; }
|
||||
.page-stack { display: flex; flex-direction: column; gap: 18px; }
|
||||
/* 表格操作按钮工具栏:置于表格卡片顶部、右对齐(新增/删除/导入/导出等贴近表格行操作)。 */
|
||||
.table-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.page-heading h2 { margin: 0; font-size: 20px; color: var(--admin-text); }
|
||||
.page-heading p { margin: 7px 0 0; color: var(--admin-muted); font-size: 13px; }
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user