feat(web): 前端 SPA 化 + 工具页任务面板统一与历史批量删除
- SPA 化:22 个 MPA html 入口与 *-main.ts 合并为 index.html + vue-router(URL 无 .html 后缀), 页面跳转全部 router-link,/new_web_source/xxx.html 旧路径归一为 /xxx - 任务面板统一:共享 TaskCenterPanel/TaskItemCard/TaskStatCards/HistoryTaskLayer, 16 个工具页右侧统一为统计卡 + 当前任务 + 历史任务弹层(任务ID/开始/结束/状态必展示) - 历史记录支持单条删除 + 批量勾选删除(确认框/全选/失败提示) - Java 7 模块(dedupe/convert/split/productrisk/shopmatch/pricetrack/deletebrand) history 接口补齐任务时间字段(VO+Service,复用 biz_file_task 列) - 图片工作台/API 层(brand/permission/user)既有未提交改动一并提交
This commit is contained in:
@@ -30,6 +30,8 @@ export interface BrandTaskItem {
|
||||
desc?: string
|
||||
file_paths?: string[]
|
||||
created_at?: string
|
||||
/** 最后更新(终态时即完成时间,作为结束时间兜底) */
|
||||
updated_at?: string
|
||||
progress_total?: number
|
||||
progress_current?: number
|
||||
error_message?: string
|
||||
|
||||
@@ -12,6 +12,16 @@ export interface ConvertResultItem {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
downloadUrl?: string;
|
||||
/** 所属任务ID(biz_file_task.id) */
|
||||
taskId?: number;
|
||||
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
|
||||
taskStatus?: string;
|
||||
/** 任务创建时间 */
|
||||
createdAt?: string;
|
||||
/** 任务开始时间 */
|
||||
startedAt?: string;
|
||||
/** 任务结束时间(未结束为空) */
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface ConvertRunVo {
|
||||
|
||||
@@ -12,6 +12,16 @@ export interface DedupeResultItem {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
downloadUrl?: string;
|
||||
/** 所属任务ID(biz_file_task.id) */
|
||||
taskId?: number;
|
||||
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
|
||||
taskStatus?: string;
|
||||
/** 任务创建时间 */
|
||||
createdAt?: string;
|
||||
/** 任务开始时间 */
|
||||
startedAt?: string;
|
||||
/** 任务结束时间(未结束为空) */
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface DedupeRunVo {
|
||||
|
||||
@@ -54,7 +54,8 @@ function normalizeColumnKeys(items: PermissionMenuItem[] | undefined) {
|
||||
return Array.from(keys)
|
||||
}
|
||||
|
||||
export async function getCurrentUserAppColumnKeys() {
|
||||
/** 拉取当前用户 app 列权限(菜单项 + 本地缓存写入);权限接口失败时抛出 */
|
||||
async function fetchAppColumnPermissions() {
|
||||
const uid = getCurrentUserId()
|
||||
const cacheKey = getAppPermissionCacheKey(uid)
|
||||
|
||||
@@ -81,5 +82,15 @@ export async function getCurrentUserAppColumnKeys() {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(items))
|
||||
} catch (_error) {}
|
||||
|
||||
return normalizeColumnKeys(items)
|
||||
return items
|
||||
}
|
||||
|
||||
/** 归一化后的列权限键(菜单入口/卡片显隐用,见 AmazonConsolePage 等) */
|
||||
export async function getCurrentUserAppColumnKeys() {
|
||||
return normalizeColumnKeys(await fetchAppColumnPermissions())
|
||||
}
|
||||
|
||||
/** 原始权限菜单项(页面需要自行计算权限状态时用,见 DesktopHomePage) */
|
||||
export async function getCurrentUserAppColumnRaw() {
|
||||
return fetchAppColumnPermissions()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ export interface SplitResultItem {
|
||||
downloadUrl?: string;
|
||||
entryCount?: number;
|
||||
entries?: SplitArchiveEntry[];
|
||||
/** 所属任务ID(biz_file_task.id) */
|
||||
taskId?: number;
|
||||
/** 任务状态:PENDING / RUNNING / SUCCESS / FAILED */
|
||||
taskStatus?: string;
|
||||
/** 任务创建时间 */
|
||||
createdAt?: string;
|
||||
/** 任务开始时间 */
|
||||
startedAt?: string;
|
||||
/** 任务结束时间(未结束为空) */
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface SplitRunVo {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { post } from './http.ts'
|
||||
|
||||
export function getCurrentUserId() {
|
||||
const raw =
|
||||
typeof window === "undefined"
|
||||
@@ -9,3 +11,32 @@ export function getCurrentUserId() {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 桌面端登录接口响应 data 段 */
|
||||
export interface LoginData {
|
||||
token?: string
|
||||
userId?: number | string
|
||||
username?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
msg?: string
|
||||
data?: LoginData
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面端登录(原页面裸 window.fetch('/newApi/login') 收敛到 shared 层:
|
||||
* 页面不得直连 /newApi,见 shared-api-allowlist 测试约定)
|
||||
*/
|
||||
export function loginWithDevice(account: string, password: string, deviceId: string) {
|
||||
return post<LoginResponse>(
|
||||
'/newApi/login',
|
||||
{ username: account, password, deviceId },
|
||||
{
|
||||
headers: { 'X-Device-Id': deviceId, 'X-Requested-With': 'XMLHttpRequest' },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/**
|
||||
* 页面登录态引导
|
||||
*
|
||||
* 桌面客户端由 Flask 在页面 <head> 注入 localStorage.uid;前端 Web 独立部署后没有注入,
|
||||
* 直接访问子页面(书签/刷新)时 uid 缺失会导致用户信息取用失败。
|
||||
* 页面入口先执行 ensureAuth 再挂载:
|
||||
* SPA 化后由 src/main.ts 的路由守卫调用;本模块只做检查与 uid/username 恢复,
|
||||
* 不主动跳转(跳转统一由 router.beforeEach 返回 { name: 'login' })。
|
||||
* - localStorage.uid 已存在(桌面注入 / 登录页已写入)→ 直接放行,零额外请求;
|
||||
* - 无 uid 但有 JWT → 调 /newApi/check_login 恢复 uid/username;
|
||||
* - 无 token 或校验失败 → 清本地登录态并跳转登录页。
|
||||
* - 无 token 或校验失败 → 返回 false,由守卫跳转登录页。
|
||||
*/
|
||||
|
||||
import { get } from '@/shared/api/http'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
||||
const UID_KEY = 'uid'
|
||||
@@ -20,11 +18,7 @@ interface CheckLoginData {
|
||||
username?: string
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
window.location.replace(resolvePageHref('/new_web_source/login.html'))
|
||||
}
|
||||
|
||||
/** 确保当前页面具备登录态;返回是否可继续挂载应用 */
|
||||
/** 确保当前页面具备登录态;返回是否可继续进入页面 */
|
||||
export async function ensureAuth(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return true
|
||||
try {
|
||||
@@ -34,7 +28,6 @@ export async function ensureAuth(): Promise<boolean> {
|
||||
}
|
||||
const token = window.localStorage.getItem(AUTH_TOKEN_KEY) || ''
|
||||
if (!token) {
|
||||
redirectToLogin()
|
||||
return false
|
||||
}
|
||||
const response = await get<{ success: boolean; data?: CheckLoginData }>('/newApi/check_login')
|
||||
@@ -47,13 +40,42 @@ export async function ensureAuth(): Promise<boolean> {
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
// token 失效:清掉本地登录态后回登录页
|
||||
// token 失效:清掉本地登录态(跳转交给路由守卫)
|
||||
try {
|
||||
window.localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
redirectToLogin()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尽力恢复登录用户快照(ensureAuth 的"软"版本):有 token 时调 /newApi/check_login
|
||||
* 回写 uid/username,无 token 或失败静默返回 null、不跳转登录页(首页等不强制登录的场景用)。
|
||||
*/
|
||||
export async function restoreLoginUser(): Promise<{ username?: string } | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const rawUid = window.localStorage.getItem(UID_KEY) || ''
|
||||
if (rawUid && Number(rawUid) > 0) {
|
||||
// 已有 uid(桌面端注入或登录页已写)无需再查
|
||||
return null
|
||||
}
|
||||
const token = window.localStorage.getItem(AUTH_TOKEN_KEY) || ''
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
const response = await get<{ success: boolean; data?: CheckLoginData }>('/newApi/check_login')
|
||||
if (response?.success !== true || !response.data?.userId) {
|
||||
return null
|
||||
}
|
||||
window.localStorage.setItem(UID_KEY, String(response.data.userId))
|
||||
if (response.data.username) {
|
||||
window.localStorage.setItem('username', String(response.data.username))
|
||||
}
|
||||
return { username: response.data.username }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@ export interface VariantTaskListItem {
|
||||
task_id: string;
|
||||
status?: number | string;
|
||||
task_type?: string;
|
||||
/** 最近更新时间(后台有则返回) */
|
||||
update_time?: string;
|
||||
/** 任务创建时间(后台有则返回) */
|
||||
create_time?: string;
|
||||
/** 任务完成时间(后台有则返回) */
|
||||
finish_time?: string;
|
||||
res_type?: number | string;
|
||||
res_mes?: string;
|
||||
file_url?: string[] | string;
|
||||
@@ -121,6 +126,8 @@ export interface PywebviewApi {
|
||||
not_found?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
/** 用系统默认浏览器打开链接(桌面端下载教程包等走外部浏览器,可看到下载进度) */
|
||||
open_external_url?: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
/** 版本更新(桌面专属):下载更新包并启动 update.exe 后退出主程序(原 Flask /api/update/do 桥化) */
|
||||
do_update_app?: (fileUrl: string) => Promise<{ success: boolean; error?: string }>;
|
||||
launch_yaoayanui?: () => Promise<{
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="history-task-layer">
|
||||
<button type="button" class="history-btn" @click="open = true">
|
||||
历史任务<template v-if="count > 0">({{ count }})</template>
|
||||
</button>
|
||||
|
||||
<el-drawer v-model="open" :title="historyTitle" size="640px" class="task-history-drawer">
|
||||
<div class="history-body">
|
||||
<!-- 批量删除工具条(传入 onBatchDelete 时显示) -->
|
||||
<div v-if="onBatchDelete" class="history-toolbar">
|
||||
<label class="select-all">
|
||||
<input v-model="allSelected" type="checkbox" title="全选/取消全选" />
|
||||
<span>全选</span>
|
||||
</label>
|
||||
<span class="selected-count" v-if="selectedKeys.size > 0">已选 {{ selectedKeys.size }} 项</span>
|
||||
<button type="button" class="btn-delete" :disabled="!selectedKeys.size || batchDeleting"
|
||||
@click="confirmBatchDelete">
|
||||
{{ batchDeleting ? '删除中...' : `批量删除${selectedKeys.size ? `(${selectedKeys.size})` : ''}` }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!items.length" class="empty-tasks">{{ emptyText }}</div>
|
||||
<ul v-else class="history-list">
|
||||
<li v-for="item in items" :key="item.key" class="history-item-row" :class="{ 'is-selected': isSelected(item.key) }">
|
||||
<label v-if="onBatchDelete" class="row-check" @click.stop>
|
||||
<input v-model="selectedSet" type="checkbox" :value="item.key" :disabled="batchDeleting" />
|
||||
</label>
|
||||
<div class="history-item-card">
|
||||
<TaskItemCard :item="item">
|
||||
<template #extra="scope">
|
||||
<slot name="history-item-extra" v-bind="scope" />
|
||||
</template>
|
||||
<template #actions="scope">
|
||||
<slot name="history-item-actions" v-bind="scope" />
|
||||
</template>
|
||||
</TaskItemCard>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
import TaskItemCard from './TaskItemCard.vue'
|
||||
import type { TaskItemView } from './types'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 历史任务数量(按钮角标) */
|
||||
count: number
|
||||
/** 弹层标题 */
|
||||
historyTitle?: string
|
||||
/** 历史任务列表 */
|
||||
items: TaskItemView[]
|
||||
/** 空列表提示 */
|
||||
emptyText?: string
|
||||
/** 批量删除回调(传入后历史列表支持勾选批量删除;不传则不显示复选框) */
|
||||
onBatchDelete?: (items: TaskItemView[]) => Promise<void> | void
|
||||
}>(), {
|
||||
historyTitle: '历史任务',
|
||||
emptyText: '暂无历史任务',
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
const batchDeleting = ref(false)
|
||||
/** 勾选集合:以 TaskItemView.key 为维度 */
|
||||
const selectedSet = ref<Set<string>>(new Set())
|
||||
|
||||
const selectedKeys = computed(() => selectedSet.value)
|
||||
const allSelected = computed({
|
||||
get: () => Boolean(props.items.length) && selectedSet.value.size === props.items.length,
|
||||
set: (checked: boolean) => {
|
||||
const next = new Set<string>()
|
||||
if (checked) {
|
||||
props.items.forEach((item) => next.add(item.key))
|
||||
}
|
||||
selectedSet.value = next
|
||||
},
|
||||
})
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) {
|
||||
selectedSet.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
function isSelected(key: string) {
|
||||
return selectedSet.value.has(key)
|
||||
}
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
if (!props.onBatchDelete || !selectedSet.value.size || batchDeleting.value) return
|
||||
const target = props.items.filter((item) => selectedSet.value.has(item.key))
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定批量删除选中的 ${target.length} 条历史记录吗?删除后不可恢复。`,
|
||||
'批量删除历史记录',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
batchDeleting.value = true
|
||||
try {
|
||||
await props.onBatchDelete(target)
|
||||
selectedSet.value = new Set()
|
||||
} finally {
|
||||
batchDeleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-task-layer {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.history-btn {
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #3e4a62;
|
||||
border-radius: 6px;
|
||||
background: #222;
|
||||
color: #c8d2e2;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-btn:hover {
|
||||
background: #2b3447;
|
||||
color: #f5f8fc;
|
||||
}
|
||||
|
||||
.history-body {
|
||||
padding: 4px 2px 12px;
|
||||
}
|
||||
|
||||
.history-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 4px 12px;
|
||||
border-bottom: 1px solid #2e3a52;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.select-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #c8d2e2;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
color: #a0acbe;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
margin-left: auto;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: rgba(231, 76, 60, .18);
|
||||
color: #ff8f8f;
|
||||
font-size: 12px;
|
||||
transition: background .2s;
|
||||
}
|
||||
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
background: rgba(231, 76, 60, .32);
|
||||
}
|
||||
|
||||
.btn-delete:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-tasks {
|
||||
color: #5e6878;
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.history-item-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-check {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-check input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.history-item-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-item-row.is-selected .history-item-card {
|
||||
border-radius: 8px;
|
||||
outline: 2px solid rgba(52, 152, 219, .35);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* el-drawer 深色主题(非 scoped:Element Plus 组件挂在 body 下) */
|
||||
.task-history-drawer .el-drawer__header {
|
||||
margin-bottom: 0;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #2e3a52;
|
||||
color: #f5f8fc;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-history-drawer .el-drawer__close-btn {
|
||||
color: #a0acbe;
|
||||
}
|
||||
|
||||
.task-history-drawer .el-drawer__body {
|
||||
background: #151a25;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.task-history-drawer .el-drawer {
|
||||
background: #151a25;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="task-center-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-header__title">{{ title }}</span>
|
||||
<HistoryTaskLayer
|
||||
:count="historyItems.length"
|
||||
:history-title="historyTitle"
|
||||
:items="historyItems"
|
||||
:empty-text="historyEmptyText"
|
||||
:on-batch-delete="onBatchDelete"
|
||||
>
|
||||
<template #history-item-extra="scope">
|
||||
<slot name="history-item-extra" v-bind="scope" />
|
||||
</template>
|
||||
<template #history-item-actions="scope">
|
||||
<slot name="history-item-actions" v-bind="scope" />
|
||||
</template>
|
||||
</HistoryTaskLayer>
|
||||
</div>
|
||||
|
||||
<div class="task-list-wrap">
|
||||
<TaskStatCards :cards="cards" />
|
||||
|
||||
<!-- 统计卡与当前任务列表之间的可选区块(如"匹配结果"表格),由各页面按需提供 -->
|
||||
<slot name="cards-extra" />
|
||||
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>{{ currentTitle }}</span>
|
||||
</div>
|
||||
<div v-if="!currentItems.length" class="empty-tasks">{{ currentEmptyText }}</div>
|
||||
<ul v-else class="task-list">
|
||||
<TaskItemCard v-for="item in currentItems" :key="item.key" :item="item">
|
||||
<template #extra="scope">
|
||||
<slot name="item-extra" v-bind="scope" />
|
||||
</template>
|
||||
<template #actions="scope">
|
||||
<slot name="item-actions" v-bind="scope" />
|
||||
</template>
|
||||
</TaskItemCard>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HistoryTaskLayer from './HistoryTaskLayer.vue'
|
||||
import TaskItemCard from './TaskItemCard.vue'
|
||||
import TaskStatCards from './TaskStatCards.vue'
|
||||
import type { TaskItemView, TaskStatCard } from './types'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
/** 面板标题(右侧 panel-header) */
|
||||
title: string
|
||||
/** 统计卡 */
|
||||
cards?: TaskStatCard[]
|
||||
/** 当前任务列表 */
|
||||
currentItems?: TaskItemView[]
|
||||
/** 历史任务列表(进入"历史任务"弹层) */
|
||||
historyItems?: TaskItemView[]
|
||||
/** 当前任务区块标题 */
|
||||
currentTitle?: string
|
||||
/** 历史弹层标题 */
|
||||
historyTitle?: string
|
||||
/** 空态文案 */
|
||||
currentEmptyText?: string
|
||||
historyEmptyText?: string
|
||||
/** 历史记录批量删除回调(传入后历史弹层支持勾选批量删除) */
|
||||
onBatchDelete?: (items: TaskItemView[]) => Promise<void> | void
|
||||
}>(), {
|
||||
cards: () => [],
|
||||
currentItems: () => [],
|
||||
historyItems: () => [],
|
||||
currentTitle: '当前任务',
|
||||
historyTitle: '历史任务',
|
||||
currentEmptyText: '暂无当前任务',
|
||||
historyEmptyText: '暂无历史任务',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-center-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #2e3a52;
|
||||
}
|
||||
|
||||
.panel-header__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #c8d2e2;
|
||||
}
|
||||
|
||||
.task-list-wrap {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.result-list-wrap {
|
||||
border: 1px solid #2e3a52;
|
||||
border-radius: 8px;
|
||||
background: #1c2333;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.result-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #2e3a52;
|
||||
color: #c8d2e2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-tasks {
|
||||
color: #5e6878;
|
||||
font-size: 13px;
|
||||
padding: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<li class="task-item">
|
||||
<div class="left">
|
||||
<span class="id" :title="item.title">{{ item.title || '-' }}</span>
|
||||
<div class="files">任务 ID:{{ item.taskId ?? '-' }}</div>
|
||||
<div class="files">开始时间:{{ item.startedAt || '-' }}</div>
|
||||
<div class="files">结束时间:{{ item.finishedAt || '-' }}</div>
|
||||
<div v-for="line in item.extraLines || []" :key="line" class="files">{{ line }}</div>
|
||||
<slot name="extra" :item="item" />
|
||||
<div v-if="item.progress" class="file-progress">
|
||||
<div v-if="item.progress.stage || item.progress.countLabel" class="file-progress-meta">
|
||||
<span>{{ item.progress.stage || '处理中' }}</span>
|
||||
<span v-if="item.progress.countLabel">{{ item.progress.countLabel }}</span>
|
||||
<span v-else>总进度 {{ item.progress.percent }}%</span>
|
||||
</div>
|
||||
<div class="file-progress-track">
|
||||
<div class="file-progress-bar" :style="{ width: `${item.progress.percent}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-right">
|
||||
<span class="status" :class="item.statusClass">{{ item.statusText }}</span>
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskItemView } from './types'
|
||||
|
||||
defineProps<{
|
||||
item: TaskItemView
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #2e3a52;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.id {
|
||||
color: #f5f8fc;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.files {
|
||||
color: #5e6878;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: rgba(46, 204, 113, .18);
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.status.failed {
|
||||
background: rgba(231, 76, 60, .18);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.status.running {
|
||||
background: rgba(52, 152, 219, .18);
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.status.pending {
|
||||
background: rgba(149, 165, 166, .18);
|
||||
color: #a0acbe;
|
||||
}
|
||||
|
||||
.file-progress {
|
||||
margin-top: 8px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.file-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #d8c278;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-progress-track {
|
||||
margin-top: 5px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: #333f55;
|
||||
border: 1px solid #3b3b3b;
|
||||
}
|
||||
|
||||
.file-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #4aa3ff, #f0c75e);
|
||||
transition: width .25s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="task-stat-cards">
|
||||
<div v-for="card in cards" :key="card.label" class="summary-card">
|
||||
<span class="summary-label">{{ card.label }}</span>
|
||||
<strong>{{ card.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatCard } from './types'
|
||||
|
||||
defineProps<{
|
||||
cards: TaskStatCard[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #2e3a52;
|
||||
border-radius: 8px;
|
||||
background: #1c2333;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #f5f8fc;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: #5e6878;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.task-stat-cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 工具详情页右侧任务面板的统一视图模型。
|
||||
*
|
||||
* 各页面(BrandXxxTab)把各自业务任务对象适配成该结构后,交给 TaskCenterPanel 渲染,
|
||||
* 保证所有菜单详情右侧的任务卡片样式一致(任务ID / 开始时间 / 结束时间 / 任务状态)。
|
||||
*/
|
||||
|
||||
export type TaskStatusClass = 'success' | 'failed' | 'running' | 'pending'
|
||||
|
||||
export interface TaskStatCard {
|
||||
/** 统计卡标签,如:运行中任务 */
|
||||
label: string
|
||||
/** 数值 */
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface TaskProgressView {
|
||||
/** 0-100 */
|
||||
percent: number
|
||||
/** 阶段文案,如:LLM 回流中 */
|
||||
stage?: string
|
||||
/** 计数文案,如:12/50 */
|
||||
countLabel?: string
|
||||
}
|
||||
|
||||
export interface TaskItemView {
|
||||
/** 列表 key(同任务的当前/历史用同一 key 即可) */
|
||||
key: string
|
||||
/** 主标题:文件名 / 店铺名 / 任务描述 */
|
||||
title: string
|
||||
/** 任务 ID(无则显示 -) */
|
||||
taskId: string | number | null | undefined
|
||||
/** 开始时间(已格式化),无则显示 - */
|
||||
startedAt: string
|
||||
/** 结束时间(已格式化),无则显示 - */
|
||||
finishedAt: string
|
||||
/** 状态文案:执行中 / 已完成 / 失败 / 等待中… */
|
||||
statusText: string
|
||||
statusClass: TaskStatusClass
|
||||
/** 附加信息行(行数、错误等),可空 */
|
||||
extraLines?: string[]
|
||||
/** 进度条(可空) */
|
||||
progress?: TaskProgressView | null
|
||||
/** 页面业务原始对象(插槽操作按钮联动下载/删除等使用) */
|
||||
source?: unknown
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
/**
|
||||
* 页面路径环境适配
|
||||
*
|
||||
* 生产环境(桌面客户端 Flask)功能页由 /new_web_source/ 路由提供;
|
||||
* vite dev 下页面直接挂在根路径。旧静态页(/brand、/web_source/*)仅生产存在。
|
||||
* SPA 化(2026-09-08)后所有页面统一为无 .html 后缀的路径(/home、/dedupe …),
|
||||
* 生产(https://api.aishufu.top)与 vite dev 前缀一致,无需再区分环境。
|
||||
* 旧 MPA 链接形式 /new_web_source/xxx.html 由本模块归一为 /xxx。
|
||||
*/
|
||||
|
||||
/** 当前是否运行在桌面客户端生产环境(页面路径以 /new_web_source/ 开头) */
|
||||
export function isDesktopClientPage(): boolean {
|
||||
return typeof window !== 'undefined' && window.location.pathname.startsWith('/new_web_source')
|
||||
}
|
||||
|
||||
/** 链接本地化:dev 预览去掉 /new_web_source 前缀;/brand、/web_source/* 等旧静态页仅桌面端可用 */
|
||||
/** 归一页面链接:/new_web_source/xxx.html → /xxx;其余原样返回 */
|
||||
export function resolvePageHref(rawHref?: string): string {
|
||||
if (!rawHref) return ''
|
||||
if (rawHref.startsWith('/new_web_source/')) {
|
||||
return `${isDesktopClientPage() ? '/new_web_source' : ''}${rawHref.slice('/new_web_source'.length)}`
|
||||
}
|
||||
return isDesktopClientPage() ? rawHref : ''
|
||||
return rawHref.replace(/^\/new_web_source\/(.+)\.html$/, '/$1')
|
||||
}
|
||||
|
||||
/** 兼容旧引用:SPA 下生产/开发路径一致,恒为 false(页面不再按路径区分桌面/Web) */
|
||||
export function isDesktopClientPage(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user