Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue
2026-05-15 16:06:12 +08:00

975 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<BrandTopBar active="appearance-patent" />
<div class="main-content">
<aside class="left-panel">
<div class="section-title">上传文件</div>
<div class="upload-zone">
<div class="hint">选择 Excel 后点击解析后端会提取 idASIN国家并保留整数 id 与子数据的第一条</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="prompt-card">
<div class="section-title">新增条件要求</div>
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,填写后会追加到默认检测提示词中" />
</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 ? '推送中...' : '推送到 Python 队列' }}
</button>
</div>
<p class="loading-msg">Python 回传字段按 asin国家url标题提交后端接收分片后累计 50 条调用 Coze当前任务区展示 Coze 回流和结果组装进度</p>
<div v-if="visibleTaskSummary" class="parse-card">
<div>任务 ID{{ visibleTaskSummary.taskId }}</div>
<div>总行数{{ visibleTaskSummary.totalRows }}有效{{ visibleTaskSummary.acceptedRows }}分组{{ visibleTaskSummary.groupCount || 0 }}过滤{{ visibleTaskSummary.droppedRows }}</div>
</div>
<pre v-if="queuePayloadText" class="queue-payload">{{ queuePayloadText }}</pre>
</aside>
<section class="right-panel">
<div class="panel-header">外观专利检测</div>
<div class="task-list-wrap">
<div class="clean-result-summary">
<div class="summary-card">
<span class="summary-label">运行中任务</span>
<strong>{{ dashboard.pendingTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">已结束任务</span>
<strong>{{ dashboard.processedTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">成功任务</span>
<strong>{{ dashboard.successTaskCount }}</strong>
</div>
<div class="summary-card">
<span class="summary-label">失败任务</span>
<strong>{{ dashboard.failedTaskCount }}</strong>
</div>
</div>
<div class="subsection-title">匹配任务</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>当前任务</span>
</div>
<div v-if="!currentItems.length" class="empty-tasks">暂无当前任务</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in currentItems" :key="`cur-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId }}</div>
<div class="files">行数{{ item.rowCount ?? '-' }}</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressMessage(item, '处理中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
</div>
</div>
<div class="task-right">
<span class="status running">{{ statusText(item) }}</span>
<button type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
<div class="result-list-wrap">
<div class="result-list-header">
<span>历史记录</span>
</div>
<div v-if="!historyOnlyItems.length" class="empty-tasks">暂无历史记录</div>
<ul v-else class="task-list clean-result-list">
<li v-for="item in historyOnlyItems" :key="`his-${item.resultId}-${item.taskId}`" class="task-item">
<div class="left">
<span class="id">{{ item.sourceFilename || '外观专利检测' }}</span>
<div class="files">任务 ID{{ item.taskId ?? '-' }}</div>
<div v-if="item.resultFilename" class="files">
{{ item.resultFilename || '下载结果' }}
</div>
<div v-if="pendingResultHint(item)" class="files result-hint">{{ pendingResultHint(item) }}</div>
<div v-if="showFileProgress(item)" class="file-progress">
<div class="file-progress-meta">
<span>{{ displayFileProgressMessage(item, '结果生成中') }}</span>
<span>{{ fileProgressPercent(item) }}%</span>
</div>
<div class="file-progress-track">
<div class="file-progress-bar" :style="{ width: `${fileProgressPercent(item)}%` }"></div>
</div>
</div>
<div v-if="item.error" class="files">错误{{ item.error }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item)">{{ statusText(item) }}</span>
<button v-if="canDownload(item)" type="button" class="download" @click="downloadResult(item)">下载</button>
<button v-if="item.resultId" type="button" class="btn-delete" @click="deleteTaskRecord(item)">删除</button>
</div>
</li>
</ul>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import {
activateAppearancePatentTask,
deleteAppearancePatentHistory,
deleteAppearancePatentTask,
getAppearancePatentDashboard,
getAppearancePatentHistory,
getAppearancePatentQueuePayload,
getAppearancePatentResultDownloadUrl,
getAppearancePatentTaskProgressBatch,
parseAppearancePatent,
type AppearancePatentParsedPayloadDto,
type AppearancePatentDashboardVo,
type AppearancePatentHistoryItem,
type AppearancePatentParseVo,
type UploadedFileRef,
type UploadFileVo,
} from '@/shared/api/java-modules'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { getPywebviewApi } 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'
const selectedFileNames = ref<string[]>([])
const uploadedFiles = ref<UploadFileVo[]>([])
const parseResult = ref<AppearancePatentParseVo | null>(null)
type TaskSummary = Pick<AppearancePatentParseVo, 'taskId' | 'totalRows' | 'acceptedRows' | 'groupCount' | 'droppedRows'>
const queuedTaskSummary = ref<TaskSummary | null>(null)
const defaultAiPrompt = `请帮我排查以下亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。
请直接给出明确结论(有侵权风险 或 未发现明显侵权风险),并严格按照以下三个维度提供精简的排查理由:
外观维度: 评估产品外形、图案设计是否与欧洲/英国常见外观专利雷同。
专利维度: 评估产品的核心技术或物理结构是否存在侵权可能。
标题维度: 排查标题中是否包含大牌商标、敏感词或版权保护词汇。
结论XX`
const aiPrompt = ref('')
const parsing = ref(false)
const pushing = ref(false)
const queuePayloadText = ref('')
const pollingTaskIds = ref<number[]>([])
const pendingFileTaskIds = ref<number[]>([])
const pollTimer = ref<number | null>(null)
const pollingInFlight = ref(false)
const HISTORY_CACHE_TTL_MS = 3000
let historyInFlight: Promise<void> | null = null
let lastHistoryLoadedAt = 0
let disposed = false
const timers = createCategorizedTimers('appearance-patent')
const dashboard = ref<AppearancePatentDashboardVo>({
pendingTaskCount: 0,
processedTaskCount: 0,
successTaskCount: 0,
failedTaskCount: 0,
})
const historyItems = ref<AppearancePatentHistoryItem[]>([])
const liveProgressItems = ref<Record<number, AppearancePatentHistoryItem>>({})
const visibleTaskSummary = computed(() => parseResult.value || queuedTaskSummary.value)
const pendingParseItem = computed<AppearancePatentHistoryItem | 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))
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'
}),
)
function mergeCurrentTaskItem(item: AppearancePatentHistoryItem) {
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 effectiveAiPrompt() {
const extraPrompt = aiPrompt.value.trim()
if (!extraPrompt) return defaultAiPrompt
return `${defaultAiPrompt}\n\n新增条件要求\n${extraPrompt}`
}
function effectiveCozeApiKey() {
return getStoredApiSecret().trim()
}
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 || '')),
}
: payload.data,
}
}
function uidForStorage() {
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
}
function pollingKey() {
return `appearance-patent:tasks:${uidForStorage()}`
}
function toTaskSummary(result: AppearancePatentParseVo): 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
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 = ''
}
async function selectFolder() {
const api = getPywebviewApi()
if (!api?.select_brand_folder) {
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
}
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) {
ElMessage.error(error instanceof Error ? error.message : '选择失败')
}
}
async function parseFiles() {
if (!uploadedFiles.value.length) {
ElMessage.warning('请先选择 Excel')
return
}
if (!effectiveCozeApiKey()) {
ElMessage.warning('请先在左上角设置中填写外观专利密钥')
return
}
parsing.value = true
try {
const files: UploadedFileRef[] = uploadedFiles.value.map((f) => ({
fileKey: f.fileKey,
originalFilename: f.originalFilename,
relativePath: f.relativePath,
}))
const res = await parseAppearancePatent(files, effectiveAiPrompt(), effectiveCozeApiKey())
parseResult.value = res
queuedTaskSummary.value = null
queuePayloadText.value = ''
ElMessage.success(`解析完成,共 ${res.groupCount || 0} 组 / ${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 (!effectiveCozeApiKey()) {
ElMessage.warning('请先在左上角设置中填写外观专利密钥')
return
}
pushing.value = true
try {
const parsedPayload: AppearancePatentParsedPayloadDto = await getAppearancePatentQueuePayload(taskId)
const groups = parsedPayload.groups || []
const payload = {
type: 'appearance-patent-run',
ts: Date.now(),
data: {
taskId,
prompt: parsedPayload.aiPrompt || currentParseResult.aiPrompt || effectiveAiPrompt(),
api_key: effectiveCozeApiKey(),
sourceFileCount: currentParseResult.sourceFileCount || 0,
totalRows: currentParseResult.totalRows || 0,
acceptedRows: currentParseResult.acceptedRows || 0,
groupCount: currentParseResult.groupCount || 0,
groups: groups.map((group) => ({
sourceFileKey: group.sourceFileKey || '',
sourceFilename: group.sourceFilename || '',
groupKey: group.groupKey || '',
baseId: group.baseId || '',
displayId: group.displayId || '',
items: (group.items || []).map((row) => ({
sourceFileKey: row.sourceFileKey || '',
sourceFilename: row.sourceFilename || '',
rowToken: row.rowToken || '',
groupKey: row.groupKey || '',
id: row.displayId,
asin: row.asin,
country: row.country,
url: row.url || '',
title: row.title || '',
})),
})),
},
}
queuePayloadText.value = `任务 ${taskId} 已准备推送,共 ${groups.length}`
await activateAppearancePatentTask(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('已推送到 Python 队列')
} finally {
pushing.value = false
}
}
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)
if (!pollingTaskIds.value.includes(taskId)) {
const next = { ...liveProgressItems.value }
delete next[taskId]
liveProgressItems.value = next
}
savePollingIds()
}
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
await refreshTaskProgress()
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 batch = await getAppearancePatentTaskProgressBatch(taskIds, { force: pendingFileTaskIds.value.length > 0 })
for (const detail of batch.items || []) {
const task = detail.task
if (!task?.id) continue
const item = detail.items?.[0]
if (item) {
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: AppearancePatentHistoryItem) {
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 getAppearancePatentDashboard()
}
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 = getAppearancePatentHistory()
.then((res) => {
historyItems.value = mergeHistoryItemsPreservingLiveProgress(historyItems.value, res.items || [])
lastHistoryLoadedAt = Date.now()
})
.finally(() => {
historyInFlight = null
})
return historyInFlight
}
function mergeHistoryItemsPreservingLiveProgress(
previousItems: AppearancePatentHistoryItem[],
incomingItems: AppearancePatentHistoryItem[],
) {
const previousByTaskId = new Map<number, AppearancePatentHistoryItem>()
const previousByResultId = new Map<number, AppearancePatentHistoryItem>()
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: AppearancePatentHistoryItem | undefined,
incoming: AppearancePatentHistoryItem,
) {
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: AppearancePatentHistoryItem) {
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: AppearancePatentHistoryItem) {
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: AppearancePatentHistoryItem) {
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: AppearancePatentHistoryItem) {
return (item.taskStatus || '').toUpperCase()
}
function isResultBuildFailed(item: AppearancePatentHistoryItem) {
return (item.fileStatus || '').toUpperCase() === 'FAILED'
}
function pendingResultHint(item: AppearancePatentHistoryItem) {
if (isResultBuildFailed(item)) {
return item.fileError || '结果文件生成失败,请稍后重试或检查后端日志'
}
if (showFileProgress(item)) {
return ''
}
if (!isResultPreparing(item)) return ''
const fileStatus = (item.fileStatus || '').toUpperCase()
if (fileStatus === 'RUNNING') {
return '任务已完成,正在等待 Coze 回流并组装结果文件,下载按钮稍后出现'
}
if (fileStatus === 'PENDING') {
return '任务已完成,结果文件已入队,正在等待后端开始处理'
}
return '任务已完成,正在生成结果文件,下载按钮稍后出现'
}
function canDownload(item: AppearancePatentHistoryItem) {
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
}
function fileProgressPercent(item: AppearancePatentHistoryItem) {
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 displayFileProgressMessage(item: AppearancePatentHistoryItem, fallback: string) {
const message = (item.fileProgressMessage || '').trim()
if (!message) return fallback
return translateFileProgressMessage(message)
}
function translateFileProgressMessage(message: string) {
const submittedMatch = message.match(/^Coze submitted (\d+) batches, completed (\d+), waiting for results$/)
if (submittedMatch) {
return `已提交 Coze ${submittedMatch[1]} 批,已完成 ${submittedMatch[2]} 批,等待结果回流`
}
const completedMatch = message.match(/^Coze completed (\d+) batches, waiting for remaining Python data$/)
if (completedMatch) {
return `Coze 已完成 ${completedMatch[1]} 批,等待 Python 继续回传数据`
}
const translations: Record<string, string> = {
'Receiving Python data, waiting for 50 rows before Coze': '正在接收 Python 数据,累计 50 条后提交 Coze',
'Submitting Coze': '正在提交 Coze',
'Coze submitted, waiting for result': 'Coze 已提交,等待结果回流',
'Waiting for Python upload': '等待 Python 继续回传数据',
'Coze results ready, assembling xlsx': 'Coze 结果已回流,正在组装 xlsx',
'Assembling xlsx': '正在组装 xlsx',
'Uploading result file': '正在上传结果文件',
'Result file generated': '结果文件已生成',
}
return translations[message] || message
}
function showFileProgress(item: AppearancePatentHistoryItem) {
const status = normalizeTaskStatus(item)
return (status === 'RUNNING' || isResultPreparing(item)) && (hasExplicitFileProgress(item) || shouldShowFallbackJobProgress(item))
}
function hasExplicitFileProgress(item: AppearancePatentHistoryItem) {
return hasFileProgressCount(item)
|| Number(item.fileProgressPercent || 0) > 0
|| Boolean((item.fileProgressMessage || '').trim())
}
function hasFileProgressCount(item: AppearancePatentHistoryItem) {
return (item.fileProgressTotal || 0) > 0
}
function activeFileJobStatus(item: AppearancePatentHistoryItem) {
return (item.fileStatus || '').toUpperCase()
}
function hasActiveFileJob(item: AppearancePatentHistoryItem) {
const fileStatus = activeFileJobStatus(item)
return item.fileJobId != null || fileStatus === 'PENDING' || fileStatus === 'RUNNING'
}
function shouldShowFallbackJobProgress(item: AppearancePatentHistoryItem) {
return hasActiveFileJob(item) && !hasExplicitFileProgress(item)
}
async function downloadResult(item: AppearancePatentHistoryItem) {
if (!item.resultId) return
const api = getPywebviewApi()
if (!api?.save_file_from_url_new) {
ElMessage.error('当前客户端未提供下载能力')
return
}
const url = item.downloadUrl || getAppearancePatentResultDownloadUrl(item.resultId)
const filename = item.resultFilename || `${item.sourceFilename || 'appearance-patent'}.xlsx`
const result = await api.save_file_from_url_new(url, filename)
if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
}
async function deleteTaskRecord(item: AppearancePatentHistoryItem) {
try {
if (item.taskStatus === 'RUNNING' && item.taskId) {
await deleteAppearancePatentTask(item.taskId)
removePollingTask(item.taskId)
} else if (item.resultId) {
await deleteAppearancePatentHistory(item.resultId)
}
await loadDashboard()
await loadHistory({ force: true })
ElMessage.success('已删除')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '删除失败')
}
}
onMounted(async () => {
loadPollingIds()
await Promise.all([
loadDashboard().catch(() => undefined),
loadHistory().catch(() => undefined),
])
seedRunningTasksFromHistory()
seedPendingFileTasksFromHistory()
if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling()
})
onUnmounted(() => {
disposed = true
stopPolling()
timers.clearScope()
})
</script>
<style scoped>
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 400px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
.right-panel { flex: 1; min-width: 0; background: #1a1a1a; display: flex; flex-direction: column; }
.section-title, .subsection-title { font-size: 13px; color: #bbb; margin-bottom: 10px; }
.upload-zone { border: 1px dashed #3a3a3a; border-radius: 10px; padding: 18px; background: #252525; margin-bottom: 18px; }
.hint, .loading-msg, .files, .muted { color: #888; 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: #ccc; background: #2a2a2a; border: 1px solid #3a3a3a; }
.btn-run { padding: 10px 18px; color: #fff; background: #3498db; font-weight: 600; }
.btn-queue { background: #27ae60; }
.btn-run:disabled { opacity: .55; cursor: not-allowed; }
.selected-files { margin-top: 14px; color: #999; font-size: 12px; word-break: break-all; }
.selected-files span { display: block; margin: 4px 0; }
.prompt-card, .secret-card { margin-bottom: 18px; }
.secret-card { display: none; }
.prompt-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 180px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; line-height: 1.6; outline: none; }
.secret-input { width: 100%; box-sizing: border-box; height: 38px; padding: 0 12px; border: 1px solid #333; border-radius: 8px; background: #202020; color: #d8d8d8; font-size: 12px; outline: none; }
.prompt-input:focus, .secret-input:focus { border-color: #3498db; }
.parse-card, .queue-payload { margin-top: 14px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #b8c1cc; font-size: 12px; }
.queue-payload { max-height: 220px; overflow: auto; color: #8fd3ff; white-space: pre-wrap; }
.panel-header { padding: 16px 20px; border-bottom: 1px solid #2a2a2a; font-size: 15px; font-weight: 600; color: #ddd; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
.summary-card { padding: 14px 16px; border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; }
.summary-card strong { display: block; margin-top: 8px; color: #eaf4ff; font-size: 22px; }
.summary-label { color: #8d8d8d; font-size: 12px; }
.result-list-wrap { border: 1px solid #2a2a2a; border-radius: 8px; background: #1e1e1e; min-height: 180px; margin: 0 0 16px; }
.result-list-header { display: flex; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #2a2a2a; color: #ddd; font-size: 14px; }
.empty-tasks { color: #666; font-size: 13px; padding: 18px; text-align: center; }
.result-table { --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #2a2a2a; --el-table-text-color: #ccc; --el-table-border-color: #333; }
.task-list { list-style: none; margin: 0; padding: 12px; }
.task-item { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 12px 14px; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; background: #222; }
.left { flex: 1; min-width: 0; }
.id { color: #e0e0e0; font-size: 13px; font-weight: 600; }
.task-right { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.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: #bdc3c7; }
.result-hint { margin-top: 6px; color: #e0b96d; }
.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: #303030; border: 1px solid #3b3b3b; }
.file-progress-bar { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #4aa3ff, #f0c75e); transition: width .25s ease; }
.download { padding: 6px 10px; color: #d6ecff; 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 #2a2a2a; }
.clean-result-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>