82a782550e
- 新增 usersecret 模块:外观专利/货源查询密钥从本地 localStorage 迁移至 biz_user_api_secret(AES 加密、按 uid 绑定) - 后台「密钥管理」页:脱敏展示、立即检测、清空;每日 04:30 分布式锁定时巡检 - 桌面端:密钥设置面板走服务端、未配齐引导 /setup-secrets、代理余量展示 - 删除专利汇令牌全链路与密钥保留时长选择器
1391 lines
44 KiB
Vue
1391 lines
44 KiB
Vue
<template>
|
||
<div class="page-shell module-page">
|
||
<AmazonToolPageShell tool-id="source">
|
||
|
||
<div class="main-content">
|
||
<aside class="left-panel left-panel--with-template">
|
||
<div class="section-title">上传文件</div>
|
||
<div class="upload-zone">
|
||
<div class="hint">选择 Excel 后点击解析,系统会识别表格里的 ID、ASIN、国家、价格,每一行都会自动检测。</div>
|
||
<div class="btns">
|
||
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</button>
|
||
<button type="button" class="opt-btn" @click="selectFolder">选择文件夹</button>
|
||
</div>
|
||
<div class="selected-files">
|
||
<span v-if="!selectedFileNames.length">暂未选择文件</span>
|
||
<span v-for="name in selectedFileNames" v-else :key="name">{{ name }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="condition-card">
|
||
<div class="section-title">检测条件</div>
|
||
<label class="switch-row">
|
||
<span>
|
||
<strong>图片检测</strong>
|
||
</span>
|
||
<input v-model="imgSwitch" type="checkbox" />
|
||
<i></i>
|
||
</label>
|
||
<label class="switch-row">
|
||
<span>
|
||
<strong>类目检测</strong>
|
||
</span>
|
||
<input v-model="categorySwitch" type="checkbox" />
|
||
<i></i>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="aliprice-card">
|
||
<div class="section-title">货源账号</div>
|
||
<label class="aliprice-field">
|
||
<span>账号</span>
|
||
<input
|
||
v-model="alipriceUsername"
|
||
type="text"
|
||
autocomplete="username"
|
||
placeholder="请输入 Aliprice 账号"
|
||
required
|
||
/>
|
||
</label>
|
||
<label class="aliprice-field">
|
||
<span>密码</span>
|
||
<input
|
||
v-model="alipricePassword"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
placeholder="请输入 Aliprice 密码"
|
||
required
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="run-row">
|
||
<button type="button" class="btn-run" :disabled="parsing || !uploadedFiles.length" @click="parseFiles">
|
||
{{ parsing ? '解析中...' : '解析并创建任务' }}
|
||
</button>
|
||
<button type="button" class="btn-run btn-queue" :disabled="pushing || !parseResult?.taskId"
|
||
@click="pushToPythonQueue">
|
||
{{ pushing ? '启动中...' : '启动任务' }}
|
||
</button>
|
||
</div>
|
||
<p class="loading-msg">提交后系统自动分批检测,进度显示在右侧任务区,完成后可下载结果。</p>
|
||
|
||
<div v-if="visibleTaskSummary" class="parse-card">
|
||
<div>任务 ID:{{ visibleTaskSummary.taskId }}</div>
|
||
<div>总行数:{{ visibleTaskSummary.totalRows }},有效:{{ visibleTaskSummary.acceptedRows }},过滤:{{
|
||
visibleTaskSummary.droppedRows }}</div>
|
||
</div>
|
||
<pre v-if="queuePayloadText" class="queue-payload">{{ queuePayloadText }}</pre>
|
||
<ModuleTemplateDownload module-code="similar-asin" filename="货源查询 文档格式.xlsx" />
|
||
</aside>
|
||
|
||
<section class="right-panel">
|
||
<TaskCenterPanel
|
||
:on-batch-delete="batchDeleteHistory"
|
||
title="货源查询"
|
||
:cards="asinCards"
|
||
:current-items="currentTaskViews"
|
||
:history-items="historyTaskViews"
|
||
current-title="当前任务"
|
||
current-empty-text="暂无当前任务"
|
||
history-empty-text="暂无历史记录"
|
||
>
|
||
<template #item-extra="{ item }">
|
||
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
|
||
</template>
|
||
<template #history-item-extra="{ item }">
|
||
<div v-if="pendingResultHint(itemSource(item))" class="files result-hint">{{ pendingResultHint(itemSource(item)) }}</div>
|
||
</template>
|
||
<template #item-actions="{ item }">
|
||
<button type="button" class="btn-delete" @click="deleteTaskRecord(itemSource(item))">删除</button>
|
||
</template>
|
||
<template #history-item-actions="{ item }">
|
||
<button v-if="canDownload(itemSource(item))" type="button" class="download"
|
||
@click="downloadResult(itemSource(item))">下载</button>
|
||
<button v-if="itemSource(item).resultId" type="button" class="btn-delete"
|
||
@click="deleteTaskRecord(itemSource(item))">删除</button>
|
||
</template>
|
||
</TaskCenterPanel>
|
||
</section>
|
||
</div>
|
||
|
||
</AmazonToolPageShell>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue';
|
||
import ModuleTemplateDownload from '@/shared/components/ModuleTemplateDownload.vue'
|
||
import TaskCenterPanel from '@/shared/components/tasks/TaskCenterPanel.vue'
|
||
import type { TaskItemView, TaskStatCard } from '@/shared/components/tasks/types'
|
||
import {
|
||
activateSimilarAsinTask,
|
||
deleteSimilarAsinHistory,
|
||
deleteSimilarAsinTask,
|
||
getSimilarAsinDashboard,
|
||
getSimilarAsinHistory,
|
||
getSimilarAsinResultDownloadUrl,
|
||
getSimilarAsinTaskProgressBatch,
|
||
parseSimilarAsin,
|
||
type SimilarAsinDashboardVo,
|
||
type SimilarAsinHistoryItem,
|
||
type SimilarAsinParseVo,
|
||
type UploadedFileRef,
|
||
type UploadFileVo,
|
||
} from '@/shared/api/java-modules'
|
||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||
import { getPywebviewApi, type ProxyMode } from '@/shared/bridges/pywebview'
|
||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
|
||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||
import { createAsinForceThrottle } from '@/shared/asin-force-throttle'
|
||
import { toParsePreview, type ParsePreviewOptions } from '@/shared/parse-preview'
|
||
import {
|
||
EXCEL_EXTENSIONS,
|
||
checkParseResult,
|
||
checkQueuePayload,
|
||
checkSelectedFiles,
|
||
} from '@/shared/dispatch-guard'
|
||
import { passGuard } from '@/shared/dispatch-guard-ui'
|
||
|
||
const selectedFileNames = ref<string[]>([])
|
||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||
const parseResult = ref<SimilarAsinParseVo | null>(null)
|
||
type TaskSummary = Pick<SimilarAsinParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
|
||
const queuedTaskSummary = ref<TaskSummary | null>(null)
|
||
const parsing = ref(false)
|
||
const pushing = ref(false)
|
||
const imgSwitch = ref(false)
|
||
const categorySwitch = ref(false)
|
||
const alipriceUsername = ref('')
|
||
const alipricePassword = ref('')
|
||
const queuePayloadText = ref('')
|
||
const pollingTaskIds = ref<number[]>([])
|
||
const pendingFileTaskIds = ref<number[]>([])
|
||
const pollTimer = ref<number | null>(null)
|
||
const pollingInFlight = ref(false)
|
||
// force 请求节流:文件生成中不重复 force(TTL 窗口内只发一次),终态后 clear 释放
|
||
const forceThrottle = createAsinForceThrottle({ ttlMs: 30_000 })
|
||
const HISTORY_CACHE_TTL_MS = 3000
|
||
let historyInFlight: Promise<void> | null = null
|
||
let lastHistoryLoadedAt = 0
|
||
let disposed = false
|
||
let pywebviewReadyHandler: (() => void) | null = null
|
||
const timers = createCategorizedTimers('similar-asin')
|
||
|
||
const dashboard = ref<SimilarAsinDashboardVo>({
|
||
pendingTaskCount: 0,
|
||
processedTaskCount: 0,
|
||
successTaskCount: 0,
|
||
failedTaskCount: 0,
|
||
})
|
||
const historyItems = ref<SimilarAsinHistoryItem[]>([])
|
||
// 前端实时进度态:保存最近一次 progress/batch 接口拿到的进度,作为 history 接口的兜底,
|
||
// 避免历史接口暂时返回不到该任务、或字段不全时,当前任务区出现进度回退/丢失。
|
||
const liveProgressItems = ref<Record<number, SimilarAsinHistoryItem>>({})
|
||
|
||
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
|
||
const pendingParseItem = computed<SimilarAsinHistoryItem | null>(() => {
|
||
const result = parseResult.value
|
||
if (!result?.taskId) return null
|
||
const exists = historyItems.value.some((item) => item.taskId === result.taskId)
|
||
if (exists) return null
|
||
return {
|
||
taskId: result.taskId,
|
||
sourceFilename: result.sourceFilename,
|
||
rowCount: result.acceptedRows,
|
||
taskStatus: 'PENDING',
|
||
success: false,
|
||
}
|
||
})
|
||
const currentItems = computed(() => {
|
||
const activeIds = new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])
|
||
const activeItems = historyItems.value
|
||
.filter((item) => {
|
||
const status = normalizeTaskStatus(item)
|
||
return item.taskId != null && (activeIds.has(item.taskId) || status === 'RUNNING' || status === 'PENDING')
|
||
})
|
||
.map((item) => mergeCurrentTaskItem(item))
|
||
// 兜底:history 暂未返回该任务,但实时缓存里仍在跑,保留它在当前任务区
|
||
for (const [taskIdText, liveItem] of Object.entries(liveProgressItems.value)) {
|
||
const taskId = Number(taskIdText)
|
||
if (!Number.isFinite(taskId) || taskId <= 0) continue
|
||
if (!activeIds.has(taskId) && normalizeTaskStatus(liveItem) !== 'RUNNING' && normalizeTaskStatus(liveItem) !== 'PENDING') {
|
||
continue
|
||
}
|
||
if (activeItems.some((item) => item.taskId === taskId)) continue
|
||
activeItems.push(mergeCurrentTaskItem(liveItem))
|
||
}
|
||
if (!pendingParseItem.value) return activeItems
|
||
const exists = activeItems.some((item) => item.taskId === pendingParseItem.value?.taskId)
|
||
return exists ? activeItems : [pendingParseItem.value, ...activeItems]
|
||
})
|
||
const historyOnlyItems = computed(() =>
|
||
historyItems.value.filter((i) => {
|
||
const status = normalizeTaskStatus(i)
|
||
return status !== 'RUNNING' && status !== 'PENDING'
|
||
}),
|
||
)
|
||
|
||
const asinCards = computed<TaskStatCard[]>(() => [
|
||
{ label: '运行中任务', value: dashboard.value.pendingTaskCount },
|
||
{ label: '已结束任务', value: dashboard.value.processedTaskCount },
|
||
{ label: '成功任务', value: dashboard.value.successTaskCount },
|
||
{ label: '失败任务', value: dashboard.value.failedTaskCount },
|
||
])
|
||
|
||
const currentTaskViews = computed<TaskItemView[]>(() => currentItems.value.map(toAsinTaskView))
|
||
|
||
const historyTaskViews = computed<TaskItemView[]>(() => historyOnlyItems.value.map(toAsinTaskView))
|
||
|
||
function itemSource(item: TaskItemView): SimilarAsinHistoryItem {
|
||
return item.source as SimilarAsinHistoryItem
|
||
}
|
||
|
||
function toAsinTaskView(item: SimilarAsinHistoryItem): TaskItemView {
|
||
const showProgress = showFileProgress(item)
|
||
const isRunning = normalizeTaskStatus(item) === 'RUNNING'
|
||
return {
|
||
key: `asin-${item.taskId ?? item.resultId ?? item.sourceFilename}`,
|
||
title: item.sourceFilename || '货源查询',
|
||
taskId: item.taskId ?? '-',
|
||
startedAt: formatDateTime(item.startedAt || item.createdAt),
|
||
finishedAt: formatDateTime(item.finishedAt),
|
||
statusText: statusText(item),
|
||
statusClass: statusClass(item),
|
||
extraLines: [
|
||
...(item.rowCount != null ? [`行数:${item.rowCount}`] : []),
|
||
...(item.resultFilename && !showProgress ? [item.resultFilename] : []),
|
||
...(item.error ? [`错误:${item.error}`] : []),
|
||
],
|
||
progress: showProgress
|
||
? {
|
||
percent: fileProgressPercent(item),
|
||
stage: displayFileProgressStage(item, isRunning ? '处理中' : '结果生成中'),
|
||
countLabel: hasFileProgressCount(item) ? fileProgressCountLabel(item) : undefined,
|
||
}
|
||
: null,
|
||
source: item,
|
||
}
|
||
}
|
||
|
||
function mergeCurrentTaskItem(item: SimilarAsinHistoryItem) {
|
||
if (item.taskId == null) return item
|
||
const liveItem = liveProgressItems.value[item.taskId]
|
||
if (!liveItem) return item
|
||
return {
|
||
...item,
|
||
...liveItem,
|
||
sourceFilename: liveItem.sourceFilename || item.sourceFilename,
|
||
resultFilename: liveItem.resultFilename || item.resultFilename,
|
||
rowCount: liveItem.rowCount ?? item.rowCount,
|
||
createdAt: liveItem.createdAt || item.createdAt,
|
||
}
|
||
}
|
||
|
||
function effectiveLlmApiKey() {
|
||
return getStoredApiSecret('similar-asin').trim()
|
||
}
|
||
|
||
function getRequiredAlipriceCredentials() {
|
||
const username = alipriceUsername.value.trim()
|
||
const password = alipricePassword.value
|
||
if (!username || !password.trim()) {
|
||
ElMessage.warning('请填写 Aliprice 账号和密码')
|
||
return null
|
||
}
|
||
return { username, password }
|
||
}
|
||
|
||
function formatDateTime(value?: string) {
|
||
if (!value) return '-'
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return value
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
const hours = String(date.getHours()).padStart(2, '0')
|
||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||
}
|
||
|
||
function maskSecret(secret: string) {
|
||
if (!secret) return ''
|
||
if (secret.length <= 10) return '***'
|
||
return `${secret.slice(0, 6)}***${secret.slice(-4)}`
|
||
}
|
||
|
||
function payloadForDisplay<T extends { data?: Record<string, unknown> }>(payload: T) {
|
||
return {
|
||
...payload,
|
||
data: payload.data
|
||
? {
|
||
...payload.data,
|
||
api_key: maskSecret(String(payload.data.api_key || '')),
|
||
aliprice_pwd: maskSecret(String(payload.data.aliprice_pwd || '')),
|
||
}
|
||
: payload.data,
|
||
}
|
||
}
|
||
|
||
function uidForStorage() {
|
||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||
}
|
||
|
||
function currentUserId() {
|
||
return Number(uidForStorage()) || 0
|
||
}
|
||
|
||
function pollingKey() {
|
||
return `similar-asin:tasks:${uidForStorage()}`
|
||
}
|
||
|
||
function toTaskSummary(result: SimilarAsinParseVo): TaskSummary {
|
||
return {
|
||
taskId: result.taskId,
|
||
totalRows: result.totalRows,
|
||
acceptedRows: result.acceptedRows,
|
||
groupCount: result.groupCount,
|
||
droppedRows: result.droppedRows,
|
||
}
|
||
}
|
||
|
||
function savePollingIds() {
|
||
if (typeof window === 'undefined') return
|
||
const ids = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
|
||
if (!ids.length) window.localStorage.removeItem(pollingKey())
|
||
else window.localStorage.setItem(pollingKey(), JSON.stringify(ids))
|
||
}
|
||
|
||
function loadPollingIds() {
|
||
if (typeof window === 'undefined') return
|
||
try {
|
||
const raw = window.localStorage.getItem(pollingKey())
|
||
const ids = raw ? JSON.parse(raw) : []
|
||
pollingTaskIds.value = Array.isArray(ids)
|
||
? ids.filter((id): id is number => typeof id === 'number' && id > 0)
|
||
: []
|
||
} catch {
|
||
pollingTaskIds.value = []
|
||
}
|
||
}
|
||
|
||
async function uploadAppearancePathsToJava(paths: Array<string | BrandExpandFolderItem>) {
|
||
const api = getPywebviewApi()
|
||
if (!api?.upload_file_to_java) {
|
||
throw new Error('当前桌面端未提供文件上传能力')
|
||
}
|
||
const files: UploadFileVo[] = []
|
||
for (const item of paths) {
|
||
const filePath = typeof item === 'string' ? item : item.absolutePath
|
||
const relativePath = typeof item === 'string' ? undefined : item.relativePath
|
||
const uploaded = await api.upload_file_to_java(filePath, relativePath)
|
||
if (!uploaded?.success || !uploaded.data) {
|
||
throw new Error(uploaded?.error || uploaded?.message || `上传失败:${filePath}`)
|
||
}
|
||
files.push(uploaded.data)
|
||
}
|
||
return files
|
||
}
|
||
|
||
async function selectFiles() {
|
||
const api = getPywebviewApi()
|
||
if (!api?.select_brand_xlsx_files || !api.upload_file_to_java) {
|
||
ElMessage.warning('当前桌面端未提供文件选择或上传能力')
|
||
return
|
||
}
|
||
const paths = await api.select_brand_xlsx_files()
|
||
if (!paths?.length) return
|
||
if (!(await passGuard(checkSelectedFiles(paths, { allowedExtensions: EXCEL_EXTENSIONS })))) return
|
||
try {
|
||
const files = await uploadAppearancePathsToJava(paths)
|
||
uploadedFiles.value = files
|
||
selectedFileNames.value = files.map((f) => f.relativePath || f.originalFilename || f.fileKey)
|
||
parseResult.value = null
|
||
queuedTaskSummary.value = null
|
||
queuePayloadText.value = ''
|
||
} catch (error) {
|
||
// 上传失败时清空选择,避免残留上一批文件让用户误以为新文件已就绪
|
||
uploadedFiles.value = []
|
||
selectedFileNames.value = []
|
||
parseResult.value = null
|
||
queuedTaskSummary.value = null
|
||
queuePayloadText.value = ''
|
||
ElMessage.error(error instanceof Error ? error.message : '文件上传失败')
|
||
}
|
||
}
|
||
|
||
async function selectFolder() {
|
||
const api = getPywebviewApi()
|
||
if (!api?.select_brand_folder || !api.upload_file_to_java) {
|
||
ElMessage.warning('当前桌面端未提供文件夹选择或上传能力')
|
||
return
|
||
}
|
||
try {
|
||
const folder = await api.select_brand_folder()
|
||
if (!folder) return
|
||
const result = await expandBrandFolderRecursive(folder)
|
||
if (!result.success || !result.items?.length) {
|
||
ElMessage.warning(result.error || '该文件夹下没有可用的 xlsx 文件')
|
||
return
|
||
}
|
||
if (!(await passGuard(checkSelectedFiles(result.items, { allowedExtensions: EXCEL_EXTENSIONS })))) return
|
||
const files = await uploadAppearancePathsToJava(result.items)
|
||
uploadedFiles.value = files
|
||
selectedFileNames.value = result.items.map((item) => item.relativePath || item.absolutePath)
|
||
parseResult.value = null
|
||
queuedTaskSummary.value = null
|
||
queuePayloadText.value = ''
|
||
ElMessage.success(`已选择文件夹内 ${result.items.length} 个 Excel 文件`)
|
||
} catch (error) {
|
||
uploadedFiles.value = []
|
||
selectedFileNames.value = []
|
||
parseResult.value = null
|
||
ElMessage.error(error instanceof Error ? error.message : '选择失败')
|
||
}
|
||
}
|
||
|
||
async function parseFiles() {
|
||
if (!uploadedFiles.value.length) {
|
||
ElMessage.warning('请先选择 Excel')
|
||
return
|
||
}
|
||
if (!effectiveLlmApiKey()) {
|
||
// 本地无明文不再阻断:密钥已服务端化,任务执行按用户 uid 兜底读取
|
||
console.log('[similar-asin] 本地无密钥明文,提交时由服务端按用户密钥兜底')
|
||
}
|
||
if (!getRequiredAlipriceCredentials()) return
|
||
parsing.value = true
|
||
try {
|
||
const files: UploadedFileRef[] = uploadedFiles.value.map((f) => ({
|
||
fileKey: f.fileKey,
|
||
originalFilename: f.originalFilename,
|
||
relativePath: f.relativePath,
|
||
}))
|
||
const res = await parseSimilarAsin(files, effectiveLlmApiKey(), imgSwitch.value, categorySwitch.value)
|
||
// 0 有效行的任务推给 Python 只会一直停在 RUNNING,这里拦下来不写入 parseResult
|
||
const guard = checkParseResult(res, { requiredColumnsHint: 'id / ASIN / 国家' })
|
||
if (!(await passGuard(guard))) {
|
||
parseResult.value = null
|
||
queuedTaskSummary.value = null
|
||
queuePayloadText.value = ''
|
||
return
|
||
}
|
||
// 只保留摘要字段与有界预览行,避免数千行 items/groups 进入响应式对象
|
||
const previewOptions: ParsePreviewOptions = { previewRowLimit: 200 }
|
||
parseResult.value = toParsePreview(res, previewOptions) as unknown as SimilarAsinParseVo
|
||
categorySwitch.value = Boolean(res.categorySwitch)
|
||
queuedTaskSummary.value = null
|
||
queuePayloadText.value = ''
|
||
ElMessage.success(`解析完成,共 ${res.acceptedRows} 条`)
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '解析失败')
|
||
} finally {
|
||
parsing.value = false
|
||
}
|
||
}
|
||
|
||
async function pushToPythonQueue() {
|
||
const api = getPywebviewApi()
|
||
const currentParseResult = parseResult.value
|
||
const taskId = currentParseResult?.taskId
|
||
if (!taskId) {
|
||
ElMessage.warning('请先解析文件')
|
||
return
|
||
}
|
||
if (!api?.enqueue_json) {
|
||
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||
return
|
||
}
|
||
if (!effectiveLlmApiKey()) {
|
||
// 本地无明文不再阻断:服务端已保存该用户密钥,任务执行时按 uid 兜底读取
|
||
console.log('[similar-asin] 本地无密钥明文,启动任务由服务端兜底')
|
||
}
|
||
const alipriceCredentials = getRequiredAlipriceCredentials()
|
||
if (!alipriceCredentials) return
|
||
const alipriceUsename = alipriceCredentials.username
|
||
const alipricePwd = alipriceCredentials.password
|
||
pushing.value = true
|
||
try {
|
||
let proxyData: { proxy_url: string; proxy_mode: ProxyMode } | undefined
|
||
if (api.read_config) {
|
||
try {
|
||
const config = await api.read_config()
|
||
const proxyUrl = typeof config?.proxy_url === 'string' ? config.proxy_url.trim() : ''
|
||
if (proxyUrl) {
|
||
proxyData = {
|
||
proxy_url: proxyUrl,
|
||
proxy_mode: Number(config?.proxy_mode) === 2 ? 2 : 1,
|
||
}
|
||
}
|
||
} catch {
|
||
// Keep the existing queue behavior when proxy configuration is unavailable.
|
||
}
|
||
}
|
||
if (api.save_config) {
|
||
try {
|
||
await api.save_config({
|
||
aliprice_usename: alipriceUsename,
|
||
aliprice_pwd: alipricePwd,
|
||
})
|
||
} catch {
|
||
ElMessage.warning('Aliprice 账号配置保存失败,本次任务仍会继续')
|
||
}
|
||
}
|
||
const payload = {
|
||
type: 'similar-asin-run',
|
||
ts: Date.now(),
|
||
data: {
|
||
taskId,
|
||
user_id: currentUserId(),
|
||
api_key: effectiveLlmApiKey(),
|
||
sourceFileCount: currentParseResult.sourceFileCount || 0,
|
||
totalRows: currentParseResult.totalRows || 0,
|
||
acceptedRows: currentParseResult.acceptedRows || 0,
|
||
groupCount: currentParseResult.groupCount || 0,
|
||
aliprice_usename: alipriceUsename,
|
||
aliprice_pwd: alipricePwd,
|
||
...proxyData,
|
||
},
|
||
}
|
||
queuePayloadText.value = JSON.stringify(payloadForDisplay(payload), null, 2)
|
||
// 入队前最后一道闸:缺字段或含 NaN 的 payload 到 Python 侧会变成 None,任务卡住
|
||
const guard = checkQueuePayload(payload, {
|
||
expectedType: 'similar-asin-run',
|
||
requiredDataKeys: ['taskId', 'api_key', 'aliprice_usename', 'aliprice_pwd'],
|
||
})
|
||
if (!(await passGuard(guard))) return
|
||
await activateSimilarAsinTask(taskId)
|
||
const result = await api.enqueue_json(payload)
|
||
if (!result?.success) {
|
||
ElMessage.error(result?.error || '启动失败')
|
||
return
|
||
}
|
||
queuedTaskSummary.value = toTaskSummary(currentParseResult)
|
||
addPollingTask(taskId)
|
||
clearParsedTask()
|
||
await loadDashboard()
|
||
await loadHistory({ force: true })
|
||
ElMessage.success('已启动任务')
|
||
} finally {
|
||
pushing.value = false
|
||
}
|
||
}
|
||
|
||
async function loadAlipriceConfig() {
|
||
const api = getPywebviewApi()
|
||
if (!api?.read_config) return
|
||
try {
|
||
const config = await api.read_config()
|
||
if (disposed) return
|
||
alipriceUsername.value = typeof config?.aliprice_usename === 'string' ? config.aliprice_usename : ''
|
||
alipricePassword.value = typeof config?.aliprice_pwd === 'string' ? config.aliprice_pwd : ''
|
||
} catch {
|
||
ElMessage.warning('Aliprice 账号配置读取失败')
|
||
}
|
||
}
|
||
|
||
function clearParsedTask() {
|
||
parseResult.value = null
|
||
queuePayloadText.value = ''
|
||
}
|
||
|
||
function addPollingTask(taskId: number) {
|
||
if (!pollingTaskIds.value.includes(taskId)) {
|
||
pollingTaskIds.value = [...pollingTaskIds.value, taskId]
|
||
savePollingIds()
|
||
}
|
||
ensurePolling()
|
||
}
|
||
|
||
function removePollingTask(taskId: number) {
|
||
pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
||
if (!pendingFileTaskIds.value.includes(taskId)) {
|
||
const next = { ...liveProgressItems.value }
|
||
delete next[taskId]
|
||
liveProgressItems.value = next
|
||
}
|
||
savePollingIds()
|
||
}
|
||
|
||
function addPendingFileTask(taskId: number) {
|
||
if (!pendingFileTaskIds.value.includes(taskId)) {
|
||
pendingFileTaskIds.value = [...pendingFileTaskIds.value, taskId]
|
||
savePollingIds()
|
||
}
|
||
ensurePolling()
|
||
}
|
||
|
||
function removePendingFileTask(taskId: number) {
|
||
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
|
||
forceThrottle.clear(taskId)
|
||
if (!pollingTaskIds.value.includes(taskId)) {
|
||
const next = { ...liveProgressItems.value }
|
||
delete next[taskId]
|
||
liveProgressItems.value = next
|
||
}
|
||
savePollingIds()
|
||
}
|
||
|
||
function removeTaskFromLocalState(taskId?: number) {
|
||
if (!taskId) return
|
||
removePollingTask(taskId)
|
||
removePendingFileTask(taskId)
|
||
if (parseResult.value?.taskId === taskId) {
|
||
clearParsedTask()
|
||
}
|
||
if (queuedTaskSummary.value?.taskId === taskId) {
|
||
queuedTaskSummary.value = null
|
||
}
|
||
historyItems.value = historyItems.value.filter((row) => row.taskId !== taskId)
|
||
if (liveProgressItems.value[taskId]) {
|
||
const next = { ...liveProgressItems.value }
|
||
delete next[taskId]
|
||
liveProgressItems.value = next
|
||
}
|
||
}
|
||
|
||
function ensurePolling() {
|
||
if (disposed) return
|
||
if (pollTimer.value != null) return
|
||
scheduleNextPoll(true)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer.value != null) {
|
||
timers.clearTimer('task-poll', pollTimer.value)
|
||
pollTimer.value = null
|
||
}
|
||
}
|
||
|
||
function scheduleNextPoll(immediate = false) {
|
||
if (disposed) return
|
||
if (pollTimer.value != null) {
|
||
if (!immediate) return
|
||
timers.clearTimer('task-poll', pollTimer.value)
|
||
pollTimer.value = null
|
||
}
|
||
const run = async () => {
|
||
pollTimer.value = null
|
||
if (disposed) return
|
||
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) return
|
||
try {
|
||
await refreshTaskProgress()
|
||
} catch {
|
||
// A transient request failure must not stop progress polling permanently.
|
||
} finally {
|
||
if (!disposed && pollTimer.value == null && (pollingTaskIds.value.length || pendingFileTaskIds.value.length)) {
|
||
pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||
}
|
||
}
|
||
}
|
||
if (immediate) void run()
|
||
else pollTimer.value = timers.setTimeout('task-poll', run, getTaskPollIntervalMs())
|
||
}
|
||
|
||
async function refreshTaskProgress() {
|
||
if (pollingInFlight.value || (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length)) {
|
||
if (!pollingTaskIds.value.length && !pendingFileTaskIds.value.length) stopPolling()
|
||
return
|
||
}
|
||
pollingInFlight.value = true
|
||
try {
|
||
let shouldRefreshDashboard = false
|
||
let shouldRefreshHistory = false
|
||
const taskIds = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
|
||
if (taskIds.length) {
|
||
const force = pendingFileTaskIds.value.some((taskId) => forceThrottle.shouldForce(taskId))
|
||
const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force })
|
||
if (force) {
|
||
// force 请求已发出,进入冷却窗口;TTL 内不再重复 force,避免重复触发文件生成
|
||
pendingFileTaskIds.value.forEach((taskId) => forceThrottle.markForce(taskId))
|
||
}
|
||
for (const detail of batch.items || []) {
|
||
const task = detail.task
|
||
if (!task?.id) continue
|
||
const item = detail.items?.[0]
|
||
if (item) {
|
||
// 实时缓存:以最新一次 progress 为准,避免 history 接口字段缺失/延迟时丢失进度
|
||
liveProgressItems.value = {
|
||
...liveProgressItems.value,
|
||
[task.id]: item,
|
||
}
|
||
mergeHistoryItem(item)
|
||
}
|
||
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
|
||
removePollingTask(task.id)
|
||
shouldRefreshDashboard = true
|
||
if (task.status === 'SUCCESS') addPendingFileTask(task.id)
|
||
else removePendingFileTask(task.id)
|
||
}
|
||
if (item && task.status === 'SUCCESS' && pendingFileTaskIds.value.includes(task.id)) {
|
||
const fileStatus = (item.fileStatus || '').toUpperCase()
|
||
if (item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') {
|
||
removePendingFileTask(task.id)
|
||
shouldRefreshDashboard = true
|
||
shouldRefreshHistory = true
|
||
}
|
||
}
|
||
}
|
||
if (batch.missingTaskIds?.length) {
|
||
batch.missingTaskIds.forEach((taskId) => {
|
||
removePollingTask(taskId)
|
||
removePendingFileTask(taskId)
|
||
})
|
||
shouldRefreshHistory = true
|
||
shouldRefreshDashboard = true
|
||
}
|
||
}
|
||
if (shouldRefreshDashboard) {
|
||
await loadDashboard()
|
||
}
|
||
if (shouldRefreshHistory) {
|
||
await loadHistory()
|
||
}
|
||
settlePendingFileTasks()
|
||
} finally {
|
||
pollingInFlight.value = false
|
||
}
|
||
}
|
||
|
||
function mergeHistoryItem(item: SimilarAsinHistoryItem) {
|
||
if (!item.taskId && !item.resultId) return
|
||
const index = historyItems.value.findIndex((row) =>
|
||
(item.resultId != null && row.resultId === item.resultId)
|
||
|| (item.taskId != null && row.taskId === item.taskId),
|
||
)
|
||
if (index >= 0) {
|
||
historyItems.value[index] = { ...historyItems.value[index], ...item }
|
||
} else {
|
||
historyItems.value = [item, ...historyItems.value]
|
||
}
|
||
}
|
||
|
||
function settlePendingFileTasks() {
|
||
if (!pendingFileTaskIds.value.length) return
|
||
const remaining: number[] = []
|
||
for (const taskId of pendingFileTaskIds.value) {
|
||
const item = historyItems.value.find((row) => row.taskId === taskId)
|
||
if (!item) {
|
||
remaining.push(taskId)
|
||
continue
|
||
}
|
||
const taskStatus = (item.taskStatus || '').toUpperCase()
|
||
const fileStatus = (item.fileStatus || '').toUpperCase()
|
||
if (taskStatus === 'FAILED' || item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') {
|
||
continue
|
||
}
|
||
if (taskStatus === 'SUCCESS') {
|
||
remaining.push(taskId)
|
||
}
|
||
}
|
||
pendingFileTaskIds.value = remaining
|
||
savePollingIds()
|
||
if (pendingFileTaskIds.value.length) ensurePolling()
|
||
}
|
||
|
||
function seedPendingFileTasksFromHistory() {
|
||
const ids = historyItems.value
|
||
.filter((item) => item.taskId != null && isResultPreparing(item))
|
||
.map((item) => item.taskId as number)
|
||
pendingFileTaskIds.value = Array.from(new Set(ids))
|
||
savePollingIds()
|
||
if (pendingFileTaskIds.value.length) ensurePolling()
|
||
}
|
||
|
||
function seedRunningTasksFromHistory() {
|
||
const ids = historyItems.value
|
||
.filter((item) => item.taskId != null && normalizeTaskStatus(item) === 'RUNNING')
|
||
.map((item) => item.taskId as number)
|
||
if (!ids.length) return
|
||
pollingTaskIds.value = Array.from(new Set([...pollingTaskIds.value, ...ids]))
|
||
savePollingIds()
|
||
ensurePolling()
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
dashboard.value = await getSimilarAsinDashboard()
|
||
}
|
||
|
||
async function loadHistory(options: { force?: boolean } = {}) {
|
||
const now = Date.now()
|
||
if (!options.force && historyItems.value.length && now - lastHistoryLoadedAt < HISTORY_CACHE_TTL_MS) return
|
||
if (historyInFlight) return historyInFlight
|
||
historyInFlight = getSimilarAsinHistory()
|
||
.then((res) => {
|
||
historyItems.value = mergeHistoryItemsPreservingLiveProgress(historyItems.value, res.items || [])
|
||
lastHistoryLoadedAt = Date.now()
|
||
})
|
||
.finally(() => {
|
||
historyInFlight = null
|
||
})
|
||
return historyInFlight
|
||
}
|
||
|
||
function mergeHistoryItemsPreservingLiveProgress(
|
||
previousItems: SimilarAsinHistoryItem[],
|
||
incomingItems: SimilarAsinHistoryItem[],
|
||
) {
|
||
const previousByTaskId = new Map<number, SimilarAsinHistoryItem>()
|
||
const previousByResultId = new Map<number, SimilarAsinHistoryItem>()
|
||
for (const item of previousItems || []) {
|
||
if (item.taskId != null) previousByTaskId.set(item.taskId, item)
|
||
if (item.resultId != null) previousByResultId.set(item.resultId, item)
|
||
}
|
||
return (incomingItems || []).map((incoming) => {
|
||
const previous = incoming.resultId != null
|
||
? previousByResultId.get(incoming.resultId)
|
||
: incoming.taskId != null
|
||
? previousByTaskId.get(incoming.taskId)
|
||
: undefined
|
||
return mergeHistoryItemPreservingLiveProgress(previous, incoming)
|
||
})
|
||
}
|
||
|
||
function mergeHistoryItemPreservingLiveProgress(
|
||
previous: SimilarAsinHistoryItem | undefined,
|
||
incoming: SimilarAsinHistoryItem,
|
||
) {
|
||
if (!previous) return incoming
|
||
const shouldPreserveLiveProgress =
|
||
(normalizeTaskStatus(previous) === 'RUNNING' || isResultPreparing(previous) || showFileProgress(previous))
|
||
&& !showFileProgress(incoming)
|
||
&& !canDownload(incoming)
|
||
|
||
if (!shouldPreserveLiveProgress) {
|
||
return incoming
|
||
}
|
||
|
||
return {
|
||
...incoming,
|
||
fileJobId: incoming.fileJobId ?? previous.fileJobId,
|
||
fileStatus: incoming.fileStatus || previous.fileStatus,
|
||
fileError: incoming.fileError || previous.fileError,
|
||
fileReady: incoming.fileReady || previous.fileReady,
|
||
fileProgressPercent: hasMeaningfulProgressValue(incoming.fileProgressPercent)
|
||
? incoming.fileProgressPercent
|
||
: previous.fileProgressPercent,
|
||
fileProgressCurrent: hasMeaningfulProgressValue(incoming.fileProgressCurrent)
|
||
? incoming.fileProgressCurrent
|
||
: previous.fileProgressCurrent,
|
||
fileProgressTotal: hasMeaningfulProgressValue(incoming.fileProgressTotal)
|
||
? incoming.fileProgressTotal
|
||
: previous.fileProgressTotal,
|
||
fileProgressMessage: (incoming.fileProgressMessage || '').trim()
|
||
? incoming.fileProgressMessage
|
||
: previous.fileProgressMessage,
|
||
}
|
||
}
|
||
|
||
function hasMeaningfulProgressValue(value: unknown) {
|
||
const num = Number(value)
|
||
return Number.isFinite(num) && num > 0
|
||
}
|
||
|
||
function statusText(item: SimilarAsinHistoryItem) {
|
||
const status = normalizeTaskStatus(item)
|
||
if (status === 'RUNNING') return '执行中'
|
||
if (status === 'PENDING') return '已解析待启动'
|
||
if (isResultPreparing(item)) return '结果生成中'
|
||
if (isResultBuildFailed(item)) return '结果生成失败'
|
||
if (status === 'SUCCESS' || item.success) return '已完成'
|
||
if (status === 'FAILED') return '失败'
|
||
return '等待中'
|
||
}
|
||
|
||
function statusClass(item: SimilarAsinHistoryItem) {
|
||
const status = normalizeTaskStatus(item)
|
||
if (status === 'RUNNING' || isResultPreparing(item)) return 'running'
|
||
if (isResultBuildFailed(item) || status === 'FAILED') return 'failed'
|
||
if (status === 'SUCCESS' || item.success) return 'success'
|
||
return 'pending'
|
||
}
|
||
|
||
function isResultPreparing(item: SimilarAsinHistoryItem) {
|
||
const status = normalizeTaskStatus(item)
|
||
const fileStatus = (item.fileStatus || '').toUpperCase()
|
||
if (status !== 'SUCCESS') return false
|
||
if (canDownload(item)) return false
|
||
if (fileStatus === 'FAILED' || fileStatus === 'SUCCESS') return false
|
||
return true
|
||
}
|
||
|
||
function normalizeTaskStatus(item: SimilarAsinHistoryItem) {
|
||
const taskStatus = (item.taskStatus || '').toUpperCase()
|
||
if (taskStatus !== 'FAILED' && isResultFileComplete(item)) return 'SUCCESS'
|
||
return taskStatus
|
||
}
|
||
|
||
function isResultBuildFailed(item: SimilarAsinHistoryItem) {
|
||
return (item.fileStatus || '').toUpperCase() === 'FAILED'
|
||
}
|
||
|
||
function pendingResultHint(item: SimilarAsinHistoryItem) {
|
||
if (isResultBuildFailed(item)) {
|
||
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
|
||
}
|
||
if (showFileProgress(item)) {
|
||
return ''
|
||
}
|
||
if (!isResultPreparing(item)) return ''
|
||
const fileStatus = (item.fileStatus || '').toUpperCase()
|
||
if (fileStatus === 'RUNNING') {
|
||
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
|
||
}
|
||
if (fileStatus === 'PENDING') {
|
||
return '任务已完成,结果文件排队中,请稍候'
|
||
}
|
||
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
|
||
}
|
||
|
||
function canDownload(item: SimilarAsinHistoryItem) {
|
||
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
|
||
}
|
||
|
||
function isResultFileComplete(item: SimilarAsinHistoryItem) {
|
||
return Boolean(
|
||
item.fileReady
|
||
|| item.downloadUrl
|
||
|| (item.fileStatus || '').toUpperCase() === 'SUCCESS',
|
||
)
|
||
}
|
||
|
||
function fileProgressPercent(item: SimilarAsinHistoryItem) {
|
||
if (isResultFileComplete(item)) return 100
|
||
const percent = Number(item.fileProgressPercent || 0)
|
||
if (Number.isFinite(percent) && percent > 0) {
|
||
return Math.max(0, Math.min(100, Math.round(percent)))
|
||
}
|
||
if (shouldShowFallbackJobProgress(item)) {
|
||
return activeFileJobStatus(item) === 'PENDING' ? 6 : 10
|
||
}
|
||
return 0
|
||
}
|
||
|
||
function displayFileProgressStage(item: SimilarAsinHistoryItem, fallback: string) {
|
||
const message = (item.fileProgressMessage || '').trim()
|
||
if (!message) return fallback
|
||
if (/llm/i.test(message) || /LLM|回流/.test(message)) {
|
||
if (/submitting/i.test(message) || /提交/.test(message)) return '正在提交检测批次'
|
||
if (/生成结果|结果文件|组装/i.test(message)) return '检测结果已返回,正在生成结果文件'
|
||
if (/waiting/i.test(message) || /等待/.test(message) || /\d+\s*\/\s*\d+/.test(message)) {
|
||
return '检测中,等待结果返回'
|
||
}
|
||
return '检测中'
|
||
}
|
||
if (/assembling|xlsx|组装|生成结果/i.test(message)) return '正在生成结果文件'
|
||
return message
|
||
}
|
||
|
||
function fileProgressCountLabel(item: SimilarAsinHistoryItem) {
|
||
const message = (item.fileProgressMessage || '').trim()
|
||
const parsed = parseProgressCountFromMessage(message)
|
||
const current = parsed?.current ?? item.fileProgressCurrent ?? 0
|
||
const total = parsed?.total ?? item.fileProgressTotal ?? 0
|
||
if (/llm/i.test(message) || /LLM|回流/.test(message)) {
|
||
if (/submitting/i.test(message) || /提交/.test(message)) {
|
||
return `检测批次 ${current}/${total}`
|
||
}
|
||
return `检测完成批次 ${current}/${total}`
|
||
}
|
||
return `当前阶段 ${current}/${total}`
|
||
}
|
||
|
||
function parseProgressCountFromMessage(message: string) {
|
||
const match = message.match(/(\d+)\s*\/\s*(\d+)/)
|
||
if (!match) return null
|
||
const current = Number(match[1])
|
||
const total = Number(match[2])
|
||
if (!Number.isFinite(current) || !Number.isFinite(total)) return null
|
||
return { current, total }
|
||
}
|
||
|
||
function showFileProgress(item: SimilarAsinHistoryItem) {
|
||
const status = normalizeTaskStatus(item)
|
||
return (status === 'RUNNING' || isResultPreparing(item)) && (hasExplicitFileProgress(item) || shouldShowFallbackJobProgress(item))
|
||
}
|
||
|
||
function hasExplicitFileProgress(item: SimilarAsinHistoryItem) {
|
||
return hasFileProgressCount(item)
|
||
|| Number(item.fileProgressPercent || 0) > 0
|
||
|| Boolean((item.fileProgressMessage || '').trim())
|
||
}
|
||
|
||
function hasFileProgressCount(item: SimilarAsinHistoryItem) {
|
||
return (item.fileProgressTotal || 0) > 0
|
||
}
|
||
|
||
function activeFileJobStatus(item: SimilarAsinHistoryItem) {
|
||
return (item.fileStatus || '').toUpperCase()
|
||
}
|
||
|
||
function hasActiveFileJob(item: SimilarAsinHistoryItem) {
|
||
const fileStatus = activeFileJobStatus(item)
|
||
return item.fileJobId != null || fileStatus === 'PENDING' || fileStatus === 'RUNNING'
|
||
}
|
||
|
||
function shouldShowFallbackJobProgress(item: SimilarAsinHistoryItem) {
|
||
return hasActiveFileJob(item) && !hasExplicitFileProgress(item)
|
||
}
|
||
|
||
async function downloadResult(item: SimilarAsinHistoryItem) {
|
||
if (!item.resultId) return
|
||
const url = item.downloadUrl || getSimilarAsinResultDownloadUrl(item.resultId)
|
||
const filename = item.resultFilename || `${item.sourceFilename || 'similar-asin'}.xlsx`
|
||
const result = await saveUrlWithProgress(url, filename, `similar-asin:${item.resultId}`)
|
||
if (result.success) {
|
||
ElMessage.success(`已保存: ${result.path || filename}`)
|
||
} else if (result.error && result.error !== '用户取消') {
|
||
ElMessage.error(result.error)
|
||
}
|
||
}
|
||
|
||
async function deleteTaskRecord(item: SimilarAsinHistoryItem) {
|
||
try {
|
||
const status = normalizeTaskStatus(item)
|
||
const taskId = item.taskId
|
||
const shouldDeleteTask = taskId != null && (status === 'RUNNING' || status === 'PENDING' || !item.resultId)
|
||
if (shouldDeleteTask) {
|
||
await deleteSimilarAsinTask(taskId)
|
||
removeTaskFromLocalState(taskId)
|
||
} else if (item.resultId) {
|
||
await deleteSimilarAsinHistory(item.resultId)
|
||
if (item.taskId) {
|
||
historyItems.value = historyItems.value.filter((row) => row.taskId !== item.taskId)
|
||
}
|
||
}
|
||
await loadDashboard()
|
||
await loadHistory({ force: true })
|
||
ElMessage.success('已删除')
|
||
} catch (e) {
|
||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
loadPollingIds()
|
||
if (typeof window !== 'undefined') {
|
||
pywebviewReadyHandler = () => {
|
||
void loadAlipriceConfig()
|
||
}
|
||
window.addEventListener('pywebviewready', pywebviewReadyHandler)
|
||
}
|
||
await Promise.all([
|
||
loadDashboard().catch(() => undefined),
|
||
loadHistory().catch(() => undefined),
|
||
loadAlipriceConfig(),
|
||
])
|
||
seedPendingFileTasksFromHistory()
|
||
seedRunningTasksFromHistory()
|
||
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
disposed = true
|
||
if (typeof window !== 'undefined' && pywebviewReadyHandler) {
|
||
window.removeEventListener('pywebviewready', pywebviewReadyHandler)
|
||
pywebviewReadyHandler = null
|
||
}
|
||
stopPolling()
|
||
timers.clearScope()
|
||
})
|
||
|
||
/**
|
||
* 历史记录批量删除:复用单条删除逻辑(删除后原函数各自刷新列表)
|
||
*/
|
||
async function batchDeleteHistory(views: TaskItemView[]) {
|
||
if (!views.length) return
|
||
let failed = 0
|
||
for (const view of views) {
|
||
try {
|
||
await deleteTaskRecord(itemSource(view))
|
||
} catch {
|
||
failed += 1
|
||
}
|
||
}
|
||
if (failed > 0) {
|
||
ElMessage.warning(`删除完成,${failed} 条失败`)
|
||
} else {
|
||
ElMessage.success(`已删除 ${views.length} 条历史记录`)
|
||
}
|
||
}
|
||
</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: 400px;
|
||
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-bottom: 10px;
|
||
}
|
||
|
||
.upload-zone {
|
||
border: 1px dashed #3e4a62;
|
||
border-radius: 10px;
|
||
padding: 18px;
|
||
background: #242424;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.condition-card {
|
||
margin-bottom: 18px;
|
||
padding: 14px 16px;
|
||
border: 1px solid #2f3a34;
|
||
border-radius: 10px;
|
||
background: linear-gradient(135deg, #222a25, #222b3d);
|
||
}
|
||
|
||
.aliprice-card {
|
||
margin-bottom: 18px;
|
||
padding: 14px 16px;
|
||
border: 1px solid #343434;
|
||
border-radius: 8px;
|
||
background: #232323;
|
||
}
|
||
|
||
.aliprice-field {
|
||
display: grid;
|
||
grid-template-columns: 44px minmax(0, 1fr);
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: #a0acbe;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.aliprice-field + .aliprice-field {
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.aliprice-field input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
height: 34px;
|
||
padding: 0 10px;
|
||
border: 1px solid #3b3b3b;
|
||
border-radius: 6px;
|
||
outline: none;
|
||
background: #1b1b1b;
|
||
color: #c8d2e2;
|
||
font: inherit;
|
||
}
|
||
|
||
.aliprice-field input:focus {
|
||
border-color: #4f91c7;
|
||
}
|
||
|
||
.switch-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 14px;
|
||
color: #d8e4dc;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.switch-row span {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.switch-row strong {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.switch-row em {
|
||
color: #8d9a91;
|
||
font-size: 12px;
|
||
font-style: normal;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.switch-row input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.switch-row i {
|
||
position: relative;
|
||
width: 46px;
|
||
height: 24px;
|
||
flex: 0 0 auto;
|
||
border-radius: 999px;
|
||
background: #3e4a62;
|
||
box-shadow: inset 0 0 0 1px #4b4b4b;
|
||
transition: background .2s ease, box-shadow .2s ease;
|
||
}
|
||
|
||
.switch-row i::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 3px;
|
||
left: 3px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: #c7c7c7;
|
||
transition: transform .2s ease, background .2s ease;
|
||
}
|
||
|
||
.switch-row input:checked+i {
|
||
background: #27ae60;
|
||
box-shadow: inset 0 0 0 1px #42d17a;
|
||
}
|
||
|
||
.switch-row input:checked+i::after {
|
||
transform: translateX(22px);
|
||
background: #fff;
|
||
}
|
||
|
||
.hint,
|
||
.loading-msg,
|
||
.files,
|
||
.muted {
|
||
color: #5e6878;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.link {
|
||
color: #6ea8fe;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.link:hover {
|
||
color: #9fc5ff;
|
||
}
|
||
|
||
.btns,
|
||
.run-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.opt-btn,
|
||
.btn-run,
|
||
.btn-delete,
|
||
.download {
|
||
border: none;
|
||
cursor: pointer;
|
||
border-radius: 7px;
|
||
}
|
||
|
||
.opt-btn {
|
||
padding: 8px 14px;
|
||
color: #c8d2e2;
|
||
background: #242424;
|
||
border: 1px solid #3e4a62;
|
||
}
|
||
|
||
.btn-run {
|
||
padding: 10px 18px;
|
||
color: #f5f8fc;
|
||
background: #3498db;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.btn-queue {
|
||
background: #27ae60;
|
||
}
|
||
|
||
.btn-run:disabled {
|
||
opacity: .55;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.selected-files {
|
||
margin-top: 14px;
|
||
color: #a0acbe;
|
||
font-size: 12px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.selected-files span {
|
||
display: block;
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.parse-card,
|
||
.queue-payload {
|
||
margin-top: 14px;
|
||
padding: 12px;
|
||
border: 1px solid #2e3a52;
|
||
border-radius: 8px;
|
||
background: #222b3d;
|
||
color: #a0acbe;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.queue-payload {
|
||
max-height: 220px;
|
||
overflow: auto;
|
||
color: #8fd3ff;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.result-hint {
|
||
margin-top: 6px;
|
||
color: #e0b96d;
|
||
}
|
||
|
||
.download {
|
||
padding: 6px 10px;
|
||
color: #c8d2e2;
|
||
background: rgba(52, 152, 219, .18);
|
||
}
|
||
|
||
.btn-delete {
|
||
padding: 6px 10px;
|
||
color: #ff8f8f;
|
||
background: rgba(231, 76, 60, .12);
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.main-content {
|
||
flex-direction: column;
|
||
height: auto;
|
||
}
|
||
|
||
.left-panel {
|
||
width: 100%;
|
||
border-right: none;
|
||
border-bottom: 1px solid #2e3a52;
|
||
}
|
||
}
|
||
</style>
|