14a723ab1c
16 个品牌页逐字相同的 batchDeleteHistory 循环(逐条删除 + 容忍个别失败计数) 收敛为 shared/utils/batch-delete.ts 的 runBatchDelete;各页仅保留自己的删除调用 与提示文案。补单测 4 例(空列表 / 全成功 / 部分失败继续 / 非 Error 异常)。 净减约 120 行;vue-tsc 构建与 699 个前端单测通过。
588 lines
24 KiB
Vue
588 lines
24 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<AmazonToolPageShell tool-id="brand">
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel">
|
||
<div class="section-title">待检品牌文件</div>
|
||
<div class="upload-zone">
|
||
<div class="hint">
|
||
Excel 每行含「品牌」列即会加入检测。可多选文件或选择整个文件夹(.xlsx)。检测由本机客户端执行,请在桌面客户端中打开本页面。
|
||
</div>
|
||
<div class="btns">
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFiles">选择 Excel</button>
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFolder">选择文件夹</button>
|
||
</div>
|
||
<div class="selected-files">
|
||
<template v-if="selectedPaths.length">
|
||
<span v-for="name in displayPaths" :key="name" :title="name">{{ baseName(name) }}</span>
|
||
<span v-if="selectedPaths.length > displayPaths.length" class="more-line">
|
||
还有 {{ selectedPaths.length - displayPaths.length }} 个文件未展开显示
|
||
</span>
|
||
</template>
|
||
<span v-else>暂未选择文件</span>
|
||
</div>
|
||
<div v-if="!hasBridge" class="bridge-tip">当前浏览器环境无桌面客户端桥接,仅可预览页面;请用本机客户端打开。</div>
|
||
</div>
|
||
|
||
<div class="section-title">匹配策略</div>
|
||
<div class="radio-cards">
|
||
<label class="radio-card" :class="{ selected: strategy === 'Simple' }">
|
||
<input type="radio" name="brand-strategy" value="Simple" v-model="strategy" />
|
||
<span class="radio-card-name">精确匹配(Simple)</span>
|
||
<span class="radio-card-desc">仅精确命中才判为疑似,误杀最低</span>
|
||
</label>
|
||
<label class="radio-card" :class="{ selected: strategy === 'Terms' }">
|
||
<input type="radio" name="brand-strategy" value="Terms" v-model="strategy" />
|
||
<span class="radio-card-name">词条 / 嵌入匹配(Terms)</span>
|
||
<span class="radio-card-desc">按品牌词条做宽范围比对,误杀率低,推荐第一遍使用</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="section-title">运行方式</div>
|
||
<p class="loading-msg">提交后立即开始检测,可在右侧任务列表查看进度或取消。</p>
|
||
|
||
<div class="run-row">
|
||
<button
|
||
type="button"
|
||
class="btn-run"
|
||
:disabled="!selectedPaths.length || submitting || !hasBridge || !hasUid"
|
||
@click="submitRun"
|
||
>
|
||
{{ submitting ? '提交中...' : '立即运行品牌检测' }}
|
||
</button>
|
||
</div>
|
||
<p v-if="!hasBridge || !hasUid" class="env-tip">
|
||
品牌检测在桌面客户端内执行:请在本机客户端登录后打开本页面使用(浏览器仅可预览界面)。
|
||
</p>
|
||
<p v-else class="loading-msg">
|
||
检测完成后自动生成结果 Excel(含原始数据、不符合品牌、查询失败品牌),到右侧任务列表下载。
|
||
</p>
|
||
|
||
<div class="section-title">文档模板</div>
|
||
<div class="btns">
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="downloadTemplate('xlsx')">下载 Excel 模板</button>
|
||
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="downloadTemplate('zip')">下载文件夹模板</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<div class="panel-actions-row">
|
||
<button type="button" class="btn-refresh" @click="syncTasksAndResumePolling">刷新任务状态</button>
|
||
</div>
|
||
<TaskCenterPanel
|
||
:on-batch-delete="batchDeleteHistory"
|
||
title="品牌检测任务"
|
||
:cards="brandCards"
|
||
:current-items="currentTaskViews"
|
||
:history-items="historyTaskViews"
|
||
current-title="当前任务"
|
||
current-empty-text="暂无当前任务,提交检测后运行中任务会显示在这里"
|
||
history-empty-text="暂无历史记录"
|
||
>
|
||
<template #item-actions="{ item }">
|
||
<template v-if="itemSource(item)">
|
||
<button v-if="isRunning(itemSource(item))" type="button" class="btn-delete"
|
||
@click="cancelTask(itemSource(item))">取消</button>
|
||
<button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
|
||
@click="deleteTask(itemSource(item))">删除</button>
|
||
</template>
|
||
</template>
|
||
<template #history-item-actions="{ item }">
|
||
<template v-if="itemSource(item)">
|
||
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||
@click="downloadResult(itemSource(item))">下载结果</button>
|
||
<button v-if="canDelete(itemSource(item))" type="button" class="btn-delete"
|
||
@click="deleteTask(itemSource(item))">删除</button>
|
||
</template>
|
||
</template>
|
||
</TaskCenterPanel>
|
||
</section>
|
||
</div>
|
||
</AmazonToolPageShell>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
|
||
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||
import {
|
||
cancelBrandTask,
|
||
deleteBrandTask,
|
||
getBrandTask,
|
||
getBrandTaskDownloadUrl,
|
||
getBrandTasks,
|
||
runBrandNow,
|
||
type BrandTaskItem,
|
||
} from '@/shared/api/brand'
|
||
import { checkSelectedFiles, EXCEL_EXTENSIONS } from '@/shared/dispatch-guard.ts'
|
||
import { passGuard } from '@/shared/dispatch-guard-ui'
|
||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||
import type { UploadFileVo } from '@/shared/api/upload.ts'
|
||
import type { UploadedFileRef } from '@/shared/api/types/upload.ts'
|
||
import { formatDateTime } from '@/shared/utils/datetime'
|
||
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
||
import { runBatchDelete } from '@/shared/utils/batch-delete'
|
||
|
||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||
const strategy = ref<'Terms' | 'Simple'>('Simple')
|
||
const submitting = ref(false)
|
||
const tasks = ref<BrandTaskItem[]>([])
|
||
|
||
// 品牌检测已服务端化(/api/brand/* 走域名):浏览器降级桥提供文件选择,Web 端可用;
|
||
// 文件夹选择/模板桥等降级桥未实现的方法在点击时提示"仅桌面端"
|
||
const hasBridge = computed(() => Boolean(getPywebviewApi()))
|
||
const hasUid = computed(() => {
|
||
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
|
||
const numeric = Number(raw.trim())
|
||
return Number.isFinite(numeric) && numeric > 0
|
||
})
|
||
// 展示用:已上传文件列表的展示名(优先相对路径,其次原文件名)
|
||
const selectedPaths = computed(() =>
|
||
uploadedFiles.value.map((f) => f.relativePath || f.originalFilename || f.fileKey),
|
||
)
|
||
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
|
||
|
||
function baseName(path: string) {
|
||
return path.split(/[\\/]/).pop() || path
|
||
}
|
||
|
||
const timers = createCategorizedTimers('brand-brand-task')
|
||
let pollTimer: number | null = null
|
||
/** taskId -> 行级进度快照(按“已处理品牌行 / 总品牌行”推进,成功或查询失败都计入完成数) */
|
||
const lineProgressMap = ref<Record<string, { current: number; total: number; phase?: string }>>({})
|
||
|
||
const BRAND_PHASE_TEXT: Record<string, string> = {
|
||
crawling: '品牌查询中',
|
||
assembling: '结果生成中',
|
||
uploading: '结果上传中',
|
||
failed: '处理失败',
|
||
}
|
||
|
||
function hasBusyBrandTask() {
|
||
return tasks.value.some(isBusyBrandTask)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer != null) {
|
||
timers.clearTimer('task-poll', pollTimer)
|
||
pollTimer = null
|
||
}
|
||
}
|
||
|
||
function scheduleNextPoll() {
|
||
if (pollTimer != null) return
|
||
pollTimer = timers.setTimeout('task-poll', () => {
|
||
pollTimer = null
|
||
void pollOnce()
|
||
}, getTaskPollIntervalMs())
|
||
}
|
||
|
||
async function pollOnce() {
|
||
await loadTasks()
|
||
const busy = tasks.value.filter(isBusyBrandTask)
|
||
if (!busy.length) {
|
||
stopPolling()
|
||
return
|
||
}
|
||
// 清理已不在运行集合的任务旧快照,并拉取运行中任务的实时行级进度
|
||
for (const key of Object.keys(lineProgressMap.value)) {
|
||
if (!busy.some((b) => String(b.id) === key)) delete lineProgressMap.value[key]
|
||
}
|
||
for (const item of busy) {
|
||
const id = Number(item.id)
|
||
if (!id) continue
|
||
try {
|
||
const res = await getBrandTask(id)
|
||
const lp = res?.line_progress
|
||
if (lp?.has_progress && lp.info) {
|
||
lineProgressMap.value[String(id)] = {
|
||
current: Number(lp.info.current_line) || 0,
|
||
total: Number(lp.info.total_lines) || 0,
|
||
phase: lp.info.phase,
|
||
}
|
||
} else {
|
||
delete lineProgressMap.value[String(id)]
|
||
}
|
||
} catch {
|
||
// 单条详情失败不中断整轮轮询
|
||
}
|
||
}
|
||
scheduleNextPoll()
|
||
}
|
||
|
||
/** 拉一次列表;若仍有运行/排队任务则保持自动轮询,否则停表 */
|
||
async function syncTasksAndResumePolling() {
|
||
await loadTasks()
|
||
if (hasBusyBrandTask()) {
|
||
scheduleNextPoll()
|
||
} else {
|
||
stopPolling()
|
||
}
|
||
}
|
||
|
||
function isRunning(item: BrandTaskItem) {
|
||
return (item.status || '').toLowerCase() === 'running'
|
||
}
|
||
|
||
function isBusyBrandTask(item: BrandTaskItem) {
|
||
const status = (item.status || '').toLowerCase()
|
||
return status === 'pending' || status === 'running'
|
||
}
|
||
|
||
function isTerminal(item: BrandTaskItem) {
|
||
const status = (item.status || '').toLowerCase()
|
||
return status === 'success' || status === 'failed' || status === 'cancelled'
|
||
}
|
||
|
||
function canDelete(item: BrandTaskItem) {
|
||
return !isRunning(item)
|
||
}
|
||
|
||
function canDownload(item: BrandTaskItem) {
|
||
const status = (item.status || '').toLowerCase()
|
||
return status === 'success' && Boolean(item.result_paths?.zip_url)
|
||
}
|
||
|
||
function taskProgress(item: BrandTaskItem) {
|
||
const total = Number(item.progress_total) || 0
|
||
const current = Number(item.progress_current) || 0
|
||
if (!total) return 0
|
||
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
|
||
}
|
||
|
||
function errorMessage(item: BrandTaskItem) {
|
||
return (item.error_message || '').trim()
|
||
}
|
||
|
||
function statusText(status?: BrandTaskItem['status']) {
|
||
const value = (status || '').toLowerCase()
|
||
const map: Record<string, string> = {
|
||
pending: '排队中',
|
||
running: '执行中',
|
||
success: '已完成',
|
||
failed: '失败',
|
||
cancelled: '已取消',
|
||
}
|
||
return map[value] || String(status ?? '未知')
|
||
}
|
||
|
||
function statusClass(status?: BrandTaskItem['status']) {
|
||
const value = (status || '').toLowerCase()
|
||
if (value === 'running') return 'running'
|
||
if (value === 'success') return 'success'
|
||
if (value === 'failed') return 'failed'
|
||
return 'pending'
|
||
}
|
||
|
||
// ---- 右侧任务面板统一视图(TaskCenterPanel)----
|
||
/** 当前任务:排队中 / 执行中的任务 */
|
||
const currentTaskViews = computed<TaskItemView[]>(() =>
|
||
tasks.value.filter(isBusyBrandTask).map(toBrandTaskView),
|
||
)
|
||
|
||
/** 历史任务:终态(成功 / 失败 / 已取消 / 其他状态)的任务 */
|
||
const historyTaskViews = computed<TaskItemView[]>(() =>
|
||
tasks.value.filter((item) => !isBusyBrandTask(item)).map(toBrandTaskView),
|
||
)
|
||
|
||
const brandCards = computed<TaskStatCard[]>(() => [
|
||
{ label: '运行中任务', value: currentTaskViews.value.length },
|
||
{ label: '已结束任务', value: historyTaskViews.value.length },
|
||
{ label: '成功任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'success').length },
|
||
{ label: '失败任务', value: historyTaskViews.value.filter((view) => view.statusClass === 'failed').length },
|
||
])
|
||
|
||
function itemSource(item: TaskItemView): BrandTaskItem {
|
||
return item.source as BrandTaskItem
|
||
}
|
||
|
||
function toBrandTaskView(item: BrandTaskItem): TaskItemView {
|
||
const totalFiles = Number(item.progress_total) || 0
|
||
const doneFiles = Number(item.progress_current) || 0
|
||
const lp = isRunning(item) ? lineProgressMap.value[String(item.id)] : undefined
|
||
const lineTotal = lp?.total || 0
|
||
const lineCurrent = lp?.current || 0
|
||
const extraLines = [
|
||
...(item.file_paths?.length ? [`文件:${item.file_paths.length} 个`] : []),
|
||
...(lp && lineTotal > 0 ? [`已处理 ${lineCurrent} 行 / 共 ${lineTotal} 行(含查询失败)`] : []),
|
||
...(errorMessage(item) ? [`错误:${errorMessage(item)}`] : []),
|
||
]
|
||
// 行级进度优先:完成数 = 已处理品牌行(成功或查询失败都计入),进度 = 完成数 / 总数 * 100
|
||
let progress: TaskItemView['progress'] = null
|
||
if (isRunning(item)) {
|
||
if (lp && lineTotal > 0) {
|
||
progress = {
|
||
percent: Math.max(0, Math.min(100, Math.round((lineCurrent / lineTotal) * 100))),
|
||
stage: (lp.phase && BRAND_PHASE_TEXT[lp.phase]) || '品牌检测中',
|
||
countLabel: `${lineCurrent}/${lineTotal}`,
|
||
}
|
||
} else {
|
||
progress = {
|
||
percent: taskProgress(item),
|
||
stage: '任务进度',
|
||
countLabel: totalFiles > 0 ? `${doneFiles}/${totalFiles}` : undefined,
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
key: `brand-${item.id}`,
|
||
title: (item.desc || '').trim() || '品牌检测',
|
||
taskId: item.id,
|
||
startedAt: formatDateTime(item.created_at),
|
||
finishedAt: isTerminal(item) ? formatDateTime(item.updated_at) : '',
|
||
statusText: statusText(item.status),
|
||
statusClass: statusClass(item.status),
|
||
extraLines,
|
||
progress,
|
||
source: item,
|
||
}
|
||
}
|
||
|
||
/** 上传本地 xlsx 到 Java 临时目录,成功后回填 fileUrl(服务器本地路径,Java 直接读取) */
|
||
async function selectFiles() {
|
||
const bridge = getPywebviewApi()
|
||
if (!bridge?.select_brand_xlsx_files) {
|
||
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
|
||
return
|
||
}
|
||
const paths = await bridge.select_brand_xlsx_files()
|
||
if (!paths?.length) return
|
||
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
|
||
try {
|
||
const files = await uploadPathsToJava(getPywebviewApi(), paths, { uploadOss: true })
|
||
uploadedFiles.value = files
|
||
ElMessage.success(`已选择并上传 ${files.length} 个 Excel 文件`)
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '文件上传失败')
|
||
}
|
||
}
|
||
|
||
async function selectFolder() {
|
||
const bridge = getPywebviewApi()
|
||
if (!bridge?.select_brand_folder) {
|
||
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
|
||
return
|
||
}
|
||
const folder = await bridge.select_brand_folder()
|
||
if (!folder) return
|
||
// 新版桌面客户端桥在本机展开文件夹(服务器无法访问用户本机目录)
|
||
if (!bridge.expand_brand_folder) {
|
||
ElMessage.warning('当前桌面客户端版本不支持文件夹展开,请使用"选择文件"(可多选)')
|
||
return
|
||
}
|
||
try {
|
||
const res = await bridge.expand_brand_folder(folder)
|
||
if (!res.success || !res.items?.length) {
|
||
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
|
||
return
|
||
}
|
||
if (!(await passGuard(checkSelectedFiles(res.items.map((i) => i.absolutePath), { allowedExtensions: EXCEL_EXTENSIONS })))) return
|
||
const files = await uploadPathsToJava(getPywebviewApi(), res.items, { uploadOss: true })
|
||
uploadedFiles.value = files
|
||
ElMessage.success(`已选择并上传文件夹内 ${files.length} 个 Excel 文件`)
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '文件夹选择失败')
|
||
}
|
||
}
|
||
|
||
async function submitRun() {
|
||
if (!hasBridge.value) {
|
||
ElMessage.warning('请在桌面客户端中打开本页面执行品牌检测')
|
||
return
|
||
}
|
||
if (!hasUid.value) {
|
||
ElMessage.warning('未获取到登录用户,请先在本机客户端登录后再试')
|
||
return
|
||
}
|
||
if (!uploadedFiles.value.length) {
|
||
ElMessage.warning('请先选择待检 Excel 文件')
|
||
return
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
// 上传后以可跨节点下载的源地址作为 fileUrl:优先 OSS url(uploadToOss=true 的桌面桥),
|
||
// 旧桌面桥只回 localPath(Java 容器临时路径,双活组装可能读不到),作为兜底仍透传。
|
||
const files: UploadedFileRef[] = uploadedFiles.value.map((f) => ({
|
||
fileKey: f.fileKey,
|
||
originalFilename: f.originalFilename,
|
||
relativePath: f.relativePath,
|
||
fileUrl: f.url || f.localPath,
|
||
}))
|
||
const res = await runBrandNow(files, strategy.value)
|
||
if (res.success && res.task_id) {
|
||
ElMessage.success(`已创建任务 ${res.task_id},开始检测`)
|
||
} else {
|
||
throw new Error((res as { error?: string }).error || '运行失败')
|
||
}
|
||
uploadedFiles.value = []
|
||
await syncTasksAndResumePolling()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '提交失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function loadTasks() {
|
||
try {
|
||
const res = await getBrandTasks()
|
||
if (res.success && Array.isArray(res.items)) {
|
||
tasks.value = res.items
|
||
}
|
||
} catch (error) {
|
||
// 浏览器预览(无 uid / 无桌面后端)时静默,避免误报
|
||
if (hasBridge.value && hasUid.value) {
|
||
ElMessage.error(error instanceof Error ? error.message : '任务列表加载失败')
|
||
}
|
||
}
|
||
}
|
||
|
||
async function cancelTask(item: BrandTaskItem) {
|
||
try {
|
||
await ElMessageBox.confirm(`确认取消任务 ${item.id}?`, '取消任务', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
try {
|
||
const res = await cancelBrandTask(item.id)
|
||
if (!res.success) throw new Error((res as { error?: string }).error || '取消失败')
|
||
ElMessage.success('已取消')
|
||
await syncTasksAndResumePolling()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '取消失败')
|
||
}
|
||
}
|
||
|
||
async function deleteTask(item: BrandTaskItem) {
|
||
try {
|
||
await ElMessageBox.confirm(`确认删除任务 ${item.id}?删除后不可恢复。`, '删除任务', { type: 'warning' })
|
||
} catch {
|
||
return
|
||
}
|
||
try {
|
||
const res = await deleteBrandTask(item.id)
|
||
if (!res.success) throw new Error((res as { error?: string }).error || '删除失败')
|
||
ElMessage.success('已删除')
|
||
await syncTasksAndResumePolling()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
async function downloadResult(item: BrandTaskItem) {
|
||
const zipUrl = item.result_paths?.zip_url
|
||
const filename = `brand_task_${item.id}.zip`
|
||
const bridge = getPywebviewApi()
|
||
if (bridge?.save_file_from_url && zipUrl) {
|
||
try {
|
||
const res = await bridge.save_file_from_url(zipUrl, filename)
|
||
if (res.success) {
|
||
ElMessage.success(`已保存:${res.path || filename}`)
|
||
} else if (res.error && res.error !== '用户取消') {
|
||
ElMessage.error(res.error)
|
||
}
|
||
return
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '下载失败')
|
||
return
|
||
}
|
||
}
|
||
if (zipUrl) {
|
||
window.open(zipUrl, '_blank')
|
||
return
|
||
}
|
||
window.open(getBrandTaskDownloadUrl(item.id), '_blank')
|
||
}
|
||
|
||
async function downloadTemplate(kind: 'xlsx' | 'zip') {
|
||
const bridge = getPywebviewApi()
|
||
try {
|
||
let res: { success: boolean; path?: string; error?: string } | undefined
|
||
if (kind === 'xlsx') {
|
||
res = bridge?.save_template_xlsx ? await bridge.save_template_xlsx() : undefined
|
||
} else {
|
||
res = bridge?.save_template_zip ? await bridge.save_template_zip() : undefined
|
||
}
|
||
if (res?.success) {
|
||
ElMessage.success(`模板已保存至:${res.path}`)
|
||
} else if (res?.error) {
|
||
ElMessage.warning(res.error)
|
||
} else {
|
||
ElMessage.warning('当前环境不支持模板下载,请在本机客户端中打开')
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '模板下载失败')
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void syncTasksAndResumePolling()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
stopPolling()
|
||
})
|
||
|
||
/**
|
||
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||
*/
|
||
async function batchDeleteHistory(views: TaskItemView[]) {
|
||
const { total, failed } = await runBatchDelete(views, (view) => deleteTask(itemSource(view)))
|
||
if (!total) return
|
||
if (failed > 0) {
|
||
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||
} else {
|
||
ElMessage.success(`已删除 ${total} 条历史记录`)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.module-page { min-height: 100vh; background: #242424; }
|
||
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
|
||
.left-panel { width: 420px; background: #242424; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
|
||
.right-panel { flex: 1; min-width: 0; background: #242424; display: flex; flex-direction: column; }
|
||
.section-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
|
||
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
|
||
.hint { margin-bottom: 12px; }
|
||
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #242424; margin-bottom: 6px; }
|
||
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.run-row { margin-top: 14px; }
|
||
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 7px; cursor: pointer; font-size: 12px; }
|
||
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
|
||
.opt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.selected-files { margin-top: 12px; color: #a0acbe; font-size: 12px; word-break: break-all; }
|
||
.selected-files span { display: block; margin: 3px 0; }
|
||
.selected-files .more-line { color: #5e6878; }
|
||
.bridge-tip { margin-top: 10px; color: #e6a23c; font-size: 11px; }
|
||
.env-tip { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(230, 162, 60, 0.1); color: #e6a23c; font-size: 12px; line-height: 1.6; }
|
||
.radio-cards { display: flex; flex-direction: column; gap: 8px; margin-bottom: 6px; }
|
||
.radio-card { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #242424; cursor: pointer; }
|
||
.radio-card.selected { border-color: #409eff; background: rgba(64, 158, 255, 0.1); }
|
||
.radio-card input { position: absolute; opacity: 0; pointer-events: none; }
|
||
.radio-card-name { color: #c8d2e2; font-size: 13px; }
|
||
.radio-card-desc { color: #5e6878; font-size: 11px; }
|
||
.run-mode-zone { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #242424; }
|
||
.run-mode-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: #c8d2e2; font-size: 12px; }
|
||
.run-mode-row input { width: 15px; height: 15px; accent-color: #409eff; }
|
||
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.loading-msg { margin-top: 10px; }
|
||
.panel-actions-row { display: flex; justify-content: flex-end; padding: 10px 20px 0; }
|
||
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #242424; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
||
.download { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(52, 152, 219, 0.18); color: #69b6ff; border: none; cursor: pointer; }
|
||
.download:hover { background: rgba(52, 152, 219, 0.28); }
|
||
.btn-delete { padding: 6px 10px; border-radius: 6px; font-size: 12px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; border: none; cursor: pointer; }
|
||
.btn-delete:hover { background: rgba(231, 76, 60, 0.22); }
|
||
@media (max-width: 1100px) {
|
||
.main-content { flex-direction: column; height: auto; }
|
||
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
|
||
}
|
||
</style>
|