后端架构更新
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
<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 后点击解析,后端会提取 id、ASIN、国家,并保留整数 id 与子数据的第一条。</div>
|
||||
<div class="btns">
|
||||
<button type="button" class="opt-btn" @click="selectFiles">选择 Excel</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">AI 提示词</div>
|
||||
<textarea v-model="aiPrompt" class="prompt-input" rows="8" placeholder="可选,留空时使用下方默认提示词" />
|
||||
<div class="prompt-default-label">留空时默认使用以下提示词</div>
|
||||
<div class="prompt-preview">{{ defaultAiPrompt }}</div>
|
||||
</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 || !parsedRows.length" @click="pushToPythonQueue">
|
||||
{{ pushing ? '推送中...' : '推送到 Python 队列' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="loading-msg">Python 回传字段按 asin、国家、url、标题提交;后端接收分片后按 10 条一批调用 Coze。</p>
|
||||
|
||||
<div v-if="parseResult" class="parse-card">
|
||||
<div>任务 ID:{{ parseResult.taskId }}</div>
|
||||
<div>总行数:{{ parseResult.totalRows }},保留:{{ parseResult.acceptedRows }},过滤:{{ parseResult.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 preview-wrap">
|
||||
<div class="result-list-header">
|
||||
<span>解析结果</span>
|
||||
<span v-if="parsedRows.length" class="muted">显示 1 条样例 / 共 {{ parsedRows.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="!parsedRows.length" class="empty-tasks">暂无解析结果</div>
|
||||
<el-table v-else :data="previewRows" height="120" class="result-table">
|
||||
<el-table-column prop="displayId" label="ID" width="90" />
|
||||
<el-table-column prop="asin" label="ASIN" width="130" />
|
||||
<el-table-column prop="country" label="国家" width="100" />
|
||||
<el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="url" label="URL" min-width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</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>
|
||||
<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="item.error" class="files">错误:{{ item.error }}</div>
|
||||
</div>
|
||||
<div class="task-right">
|
||||
<span class="status" :class="item.success ? 'success' : 'failed'">{{ 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,
|
||||
getAppearancePatentResultDownloadUrl,
|
||||
getAppearancePatentTaskProgressBatch,
|
||||
parseAppearancePatent,
|
||||
type AppearancePatentDashboardVo,
|
||||
type AppearancePatentHistoryItem,
|
||||
type AppearancePatentParsedRow,
|
||||
type AppearancePatentParseVo,
|
||||
type UploadedFileRef,
|
||||
type UploadFileVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
const parseResult = ref<AppearancePatentParseVo | null>(null)
|
||||
const parsedRows = ref<AppearancePatentParsedRow[]>([])
|
||||
const defaultAiPrompt = `请帮我排查以下亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。
|
||||
请直接给出明确结论(有侵权风险 或 未发现明显侵权风险),并严格按照以下三个维度提供精简的排查理由:
|
||||
外观维度: 评估产品外形、图案设计是否与欧洲/英国常见外观专利雷同。
|
||||
专利维度: 评估产品的核心技术或物理结构是否存在侵权可能。
|
||||
标题维度: 排查标题中是否包含大牌商标、敏感词或版权保护词汇。
|
||||
结论:XX`
|
||||
const aiPrompt = ref('')
|
||||
const parsing = ref(false)
|
||||
const pushing = ref(false)
|
||||
const queuePayloadText = ref('')
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
|
||||
const dashboard = ref<AppearancePatentDashboardVo>({
|
||||
pendingTaskCount: 0,
|
||||
processedTaskCount: 0,
|
||||
successTaskCount: 0,
|
||||
failedTaskCount: 0,
|
||||
})
|
||||
const historyItems = ref<AppearancePatentHistoryItem[]>([])
|
||||
|
||||
const previewRows = computed(() => parsedRows.value.slice(0, 1))
|
||||
const currentItems = computed(() => historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId)))
|
||||
const historyOnlyItems = computed(() => historyItems.value.filter((i) => (i.taskStatus || '') !== 'RUNNING'))
|
||||
|
||||
function effectiveAiPrompt() {
|
||||
return aiPrompt.value.trim() || defaultAiPrompt
|
||||
}
|
||||
|
||||
function uidForStorage() {
|
||||
return typeof window !== 'undefined' ? window.localStorage.getItem('uid') || '0' : '0'
|
||||
}
|
||||
|
||||
function pollingKey() {
|
||||
return `appearance-patent:tasks:${uidForStorage()}`
|
||||
}
|
||||
|
||||
function savePollingIds() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!pollingTaskIds.value.length) window.localStorage.removeItem(pollingKey())
|
||||
else window.localStorage.setItem(pollingKey(), JSON.stringify(pollingTaskIds.value))
|
||||
}
|
||||
|
||||
function clearPollingIds() {
|
||||
pollingTaskIds.value = []
|
||||
savePollingIds()
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
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: UploadFileVo[] = []
|
||||
for (const path of paths) {
|
||||
const uploaded = await api.upload_file_to_java(path)
|
||||
if (!uploaded?.success || !uploaded.data) {
|
||||
throw new Error(uploaded?.error || `上传失败:${path}`)
|
||||
}
|
||||
files.push(uploaded.data)
|
||||
}
|
||||
uploadedFiles.value = files
|
||||
selectedFileNames.value = files.map((f) => f.originalFilename || f.fileKey)
|
||||
parseResult.value = null
|
||||
parsedRows.value = []
|
||||
queuePayloadText.value = ''
|
||||
}
|
||||
|
||||
async function parseFiles() {
|
||||
if (!uploadedFiles.value.length) {
|
||||
ElMessage.warning('请先选择 Excel')
|
||||
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())
|
||||
parseResult.value = res
|
||||
parsedRows.value = res.items || []
|
||||
queuePayloadText.value = ''
|
||||
await loadDashboard()
|
||||
await loadHistory()
|
||||
ElMessage.success(`解析完成,保留 ${res.acceptedRows} 条`)
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '解析失败')
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pushToPythonQueue() {
|
||||
const api = getPywebviewApi()
|
||||
const taskId = parseResult.value?.taskId
|
||||
if (!taskId || !parsedRows.value.length) {
|
||||
ElMessage.warning('请先解析文件')
|
||||
return
|
||||
}
|
||||
if (!api?.enqueue_json) {
|
||||
ElMessage.error('当前环境未启用 pywebview enqueue_json')
|
||||
return
|
||||
}
|
||||
pushing.value = true
|
||||
try {
|
||||
const payload = {
|
||||
type: 'appearance-patent-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
taskId,
|
||||
prompt: parseResult.value?.aiPrompt || effectiveAiPrompt(),
|
||||
rows: parsedRows.value.map((row) => ({
|
||||
id: row.displayId,
|
||||
asin: row.asin,
|
||||
country: row.country,
|
||||
url: row.url || '',
|
||||
title: row.title || '',
|
||||
})),
|
||||
},
|
||||
}
|
||||
queuePayloadText.value = JSON.stringify(payload, null, 2)
|
||||
const result = await api.enqueue_json(payload)
|
||||
if (!result?.success) {
|
||||
ElMessage.error(result?.error || '推送失败')
|
||||
return
|
||||
}
|
||||
await activateAppearancePatentTask(taskId)
|
||||
addPollingTask(taskId)
|
||||
await loadDashboard()
|
||||
await loadHistory()
|
||||
ElMessage.success('已推送到 Python 队列')
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
savePollingIds()
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (pollTimer.value != null) return
|
||||
scheduleNextPoll(true)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer.value != null) {
|
||||
window.clearTimeout(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextPoll(immediate = false) {
|
||||
if (pollTimer.value != null) {
|
||||
if (!immediate) return
|
||||
window.clearTimeout(pollTimer.value)
|
||||
pollTimer.value = null
|
||||
}
|
||||
const run = async () => {
|
||||
pollTimer.value = null
|
||||
if (!pollingTaskIds.value.length) return
|
||||
await refreshTaskProgress()
|
||||
if (pollingTaskIds.value.length) {
|
||||
pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs())
|
||||
}
|
||||
}
|
||||
if (immediate) void run()
|
||||
else pollTimer.value = window.setTimeout(run, getTaskPollIntervalMs())
|
||||
}
|
||||
|
||||
async function refreshTaskProgress() {
|
||||
if (pollingInFlight.value || !pollingTaskIds.value.length) {
|
||||
if (!pollingTaskIds.value.length) stopPolling()
|
||||
return
|
||||
}
|
||||
pollingInFlight.value = true
|
||||
try {
|
||||
const batch = await getAppearancePatentTaskProgressBatch(pollingTaskIds.value)
|
||||
let hasTerminalTask = false
|
||||
for (const detail of batch.items || []) {
|
||||
const task = detail.task
|
||||
if (!task?.id) continue
|
||||
if (task.status === 'SUCCESS' || task.status === 'FAILED') {
|
||||
removePollingTask(task.id)
|
||||
hasTerminalTask = true
|
||||
}
|
||||
}
|
||||
if (batch.missingTaskIds?.length) {
|
||||
batch.missingTaskIds.forEach((taskId) => removePollingTask(taskId))
|
||||
}
|
||||
if (hasTerminalTask) {
|
||||
await loadDashboard()
|
||||
await loadHistory()
|
||||
}
|
||||
} finally {
|
||||
pollingInFlight.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
dashboard.value = await getAppearancePatentDashboard()
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const res = await getAppearancePatentHistory()
|
||||
historyItems.value = res.items || []
|
||||
}
|
||||
|
||||
function statusText(item: AppearancePatentHistoryItem) {
|
||||
const status = item.taskStatus || ''
|
||||
if (status === 'RUNNING') return '执行中'
|
||||
if (status === 'SUCCESS' || item.success) return '已完成'
|
||||
if (status === 'FAILED') return '失败'
|
||||
return '等待中'
|
||||
}
|
||||
|
||||
function canDownload(item: AppearancePatentHistoryItem) {
|
||||
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
|
||||
}
|
||||
|
||||
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()
|
||||
ElMessage.success('已删除')
|
||||
} catch (e) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
clearPollingIds()
|
||||
await Promise.all([
|
||||
loadDashboard().catch(() => undefined),
|
||||
loadHistory().catch(() => undefined),
|
||||
])
|
||||
})
|
||||
|
||||
onUnmounted(() => stopPolling())
|
||||
</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 { 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 { margin-bottom: 18px; }
|
||||
.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; }
|
||||
.prompt-input:focus { border-color: #3498db; }
|
||||
.prompt-default-label { margin-top: 10px; color: #8d8d8d; font-size: 12px; }
|
||||
.prompt-preview { margin-top: 10px; padding: 12px; border: 1px solid #2a2a2a; border-radius: 8px; background: #202020; color: #9ea7b3; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
|
||||
.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; }
|
||||
.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>
|
||||
@@ -151,7 +151,7 @@
|
||||
<span class="status" :class="item.success ? 'success' : 'failed'">
|
||||
{{ item.success ? '已完成' : '失败' }}
|
||||
</span>
|
||||
<button v-if="item.success && item.downloadUrl" type="button" class="download"
|
||||
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
|
||||
@click="downloadConvertResult(item)">
|
||||
下载压缩包
|
||||
</button>
|
||||
@@ -179,6 +179,7 @@ import {
|
||||
deleteConvertHistory,
|
||||
deleteConvertTemplate,
|
||||
getConvertHistory,
|
||||
getConvertResultDownloadUrl,
|
||||
getConvertTemplates,
|
||||
importConvertTemplate,
|
||||
runConvert,
|
||||
@@ -472,14 +473,15 @@ async function deleteConvertHistoryRecord(resultId: number) {
|
||||
|
||||
async function downloadConvertResult(item: ConvertResultItem) {
|
||||
const api = getPywebviewApi()
|
||||
if (!item.downloadUrl) {
|
||||
const url = item.downloadUrl || (item.resultId ? getConvertResultDownloadUrl(item.resultId) : '')
|
||||
if (!url) {
|
||||
ElMessage.warning('当前结果没有下载地址')
|
||||
return
|
||||
}
|
||||
|
||||
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.txt`
|
||||
if (api?.save_file_from_url_new) {
|
||||
const result = await api.save_file_from_url_new(item.downloadUrl, filename)
|
||||
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 !== '用户取消') {
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
<span class="status" :class="item.success ? 'success' : 'failed'">
|
||||
{{ item.success ? '已完成' : '失败' }}
|
||||
</span>
|
||||
<button v-if="item.success && item.downloadUrl" type="button" class="download"
|
||||
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
|
||||
@click="downloadCleanResult(item)">
|
||||
下载文件
|
||||
</button>
|
||||
@@ -148,7 +148,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from './BrandTopBar.vue'
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { deleteDedupeHistory, getDedupeHistory, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunVo } from '@/shared/api/java-modules'
|
||||
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunVo } from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
|
||||
const cleanAvailableColumns = ref<string[]>([])
|
||||
@@ -329,14 +329,15 @@ async function deleteCleanHistoryRecord(resultId: number) {
|
||||
|
||||
async function downloadCleanResult(item: DedupeResultItem) {
|
||||
const api = getPywebviewApi()
|
||||
if (!item.downloadUrl) {
|
||||
const url = item.downloadUrl || (item.resultId ? getDedupeResultDownloadUrl(item.resultId) : '')
|
||||
if (!url) {
|
||||
ElMessage.warning('当前结果没有下载地址')
|
||||
return
|
||||
}
|
||||
|
||||
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}_cleaned.xlsx`
|
||||
if (api?.save_file_from_url_new) {
|
||||
const result = await api.save_file_from_url_new(item.downloadUrl, filename)
|
||||
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 !== '用户取消') {
|
||||
|
||||
@@ -518,7 +518,7 @@ function shouldShowProgress(item: DeleteBrandResultItem) {
|
||||
|
||||
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
|
||||
return items.map((item) => {
|
||||
if (item.matchStatus === 'MATCHED') {
|
||||
if (item.matched || item.matchStatus === 'MATCHED' || item.matchStatus === 'INDEX_STALE') {
|
||||
return {
|
||||
...item,
|
||||
_pushed: false,
|
||||
@@ -537,6 +537,9 @@ function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
|
||||
}
|
||||
|
||||
function formatMatchResult(item: DeleteBrandResultItem) {
|
||||
if (item.matched && item.matchStatus === 'INDEX_STALE') {
|
||||
return item.matchMessage || `已匹配 ${item.shopId || ''}(索引过期)`.trim()
|
||||
}
|
||||
if (item.matchStatus === 'MATCHED') {
|
||||
return `已匹配 ${item.shopId || ''}`.trim()
|
||||
}
|
||||
@@ -546,7 +549,7 @@ function formatMatchResult(item: DeleteBrandResultItem) {
|
||||
if (item.matchStatus === 'CONFLICT') {
|
||||
return '存在多个同名店铺,请人工确认'
|
||||
}
|
||||
if (item.matchStatus === 'PENDING' || item.matchStatus === 'INDEX_STALE') {
|
||||
if (item.matchStatus === 'PENDING') {
|
||||
return '店铺索引暂未就绪'
|
||||
}
|
||||
if (item.matchStatus) {
|
||||
@@ -781,11 +784,8 @@ function getQueueStatus(item: DeleteBrandResultItem) {
|
||||
|
||||
function canDownloadTaskResult(item: DeleteBrandResultItem) {
|
||||
if (item.downloadUrl) return true
|
||||
|
||||
const taskId = item.taskId
|
||||
if (!taskId) return { success: false, retryable: false }
|
||||
const status = taskDetails.value[taskId]?.task?.status
|
||||
return status === 'SUCCESS'
|
||||
if (item.fileReady) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function mergeTaskDetailItemsIntoSession(detail: DeleteBrandTaskDetailVo) {
|
||||
@@ -1048,12 +1048,17 @@ async function submitRun() {
|
||||
})),
|
||||
})
|
||||
const normalizedItems = normalizeDeleteBrandItems(result.items || [])
|
||||
const hasMatchedItems = normalizedItems.some((item) => item.matchStatus === 'MATCHED')
|
||||
const hasPendingItems = normalizedItems.some((item) => item.matchStatus && item.matchStatus !== 'MATCHED')
|
||||
const hasRunnableItems = normalizedItems.some((item) =>
|
||||
item.matchStatus === 'MATCHED' || item.matchStatus === 'INDEX_STALE' || item.matched
|
||||
)
|
||||
const hasStaleMatchedItems = normalizedItems.some((item) => item.matchStatus === 'INDEX_STALE' && item.matched)
|
||||
const hasBlockedItems = normalizedItems.some((item) =>
|
||||
!item.matched && item.matchStatus !== 'MATCHED' && item.matchStatus !== 'INDEX_STALE'
|
||||
)
|
||||
|
||||
queuePushResult.value = hasMatchedItems
|
||||
queuePushResult.value = hasRunnableItems
|
||||
? '解析完成,可在左侧点击“推送到 Python 队列”开始串行处理'
|
||||
: '解析完成,当前没有可推送的已匹配文件'
|
||||
: '解析完成,当前没有可推送的文件'
|
||||
queuePayloadText.value = ''
|
||||
|
||||
if (normalizedItems.length && normalizedItems[0].taskId) {
|
||||
@@ -1061,12 +1066,14 @@ async function submitRun() {
|
||||
}
|
||||
syncResultState()
|
||||
|
||||
if (hasPendingItems) {
|
||||
if (hasBlockedItems) {
|
||||
ElMessage.warning('部分文件尚未命中可用店铺索引,已保留状态信息,请等待后台刷新后重试。')
|
||||
} else if (hasMatchedItems) {
|
||||
} else if (hasStaleMatchedItems) {
|
||||
ElMessage.warning('部分文件命中的是过期索引,仍可推送到 Python 队列;后台刷新后会自动重新匹配。')
|
||||
} else if (hasRunnableItems) {
|
||||
ElMessage.success('删除品牌解析完成,请在左侧推送到 Python 队列')
|
||||
} else {
|
||||
ElMessage.warning('当前没有可推送的已匹配文件,请先等待店铺索引刷新。')
|
||||
ElMessage.warning('当前没有可推送的文件,请先等待店铺索引刷新。')
|
||||
}
|
||||
|
||||
// 更新历史记录,以展示那些未进入队列的失败项
|
||||
@@ -1207,7 +1214,7 @@ function getItemKey(item: DeleteBrandResultItem) {
|
||||
}
|
||||
|
||||
function canPushToQueue(item: DeleteBrandResultItem) {
|
||||
return Boolean(item.taskId && (item.matchStatus === 'MATCHED' || item.matched))
|
||||
return Boolean(item.taskId && (item.matchStatus === 'MATCHED' || item.matchStatus === 'INDEX_STALE' || item.matched))
|
||||
}
|
||||
|
||||
function findNextAutoRunnableItem() {
|
||||
@@ -1284,7 +1291,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
|
||||
async function pushToPythonQueue() {
|
||||
const nextItem = findNextAutoRunnableItem()
|
||||
if (!nextItem) {
|
||||
ElMessage.warning('当前没有可推送的已匹配文件')
|
||||
ElMessage.warning('当前没有可推送的文件')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -559,7 +559,7 @@ function statusClass(status?: string) {
|
||||
}
|
||||
|
||||
function canDownload(item: PatrolDeleteHistoryItem) {
|
||||
return Boolean(item.resultId && (item.outputFilename || item.downloadUrl));
|
||||
return Boolean(item.resultId && (item.fileReady || item.downloadUrl));
|
||||
}
|
||||
|
||||
function saveMatchedItems() {
|
||||
@@ -717,14 +717,20 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
|
||||
function startHistoryPolling() {
|
||||
stopHistoryPolling();
|
||||
if (!hasQueueWork.value) return;
|
||||
historyPollTimer = window.setInterval(() => {
|
||||
void refreshActiveTaskProgress();
|
||||
}, getTaskPollIntervalMs() * 2);
|
||||
const run = async () => {
|
||||
historyPollTimer = null;
|
||||
if (!hasQueueWork.value) return;
|
||||
await refreshActiveTaskProgress();
|
||||
if (hasQueueWork.value) {
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
};
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
|
||||
function stopHistoryPolling() {
|
||||
if (historyPollTimer) {
|
||||
window.clearInterval(historyPollTimer);
|
||||
window.clearTimeout(historyPollTimer);
|
||||
historyPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,31 +86,33 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 国家顺序 -->
|
||||
<div class="section-title">处理国家与顺序</div>
|
||||
<p class="hint country-pref-hint">
|
||||
勾选需要处理的站点;拖拽调整顺序。设置会按当前登录用户保存。
|
||||
</p>
|
||||
<div class="country-pref-checks">
|
||||
<label v-for="row in countryCheckboxRows" :key="row.code" class="country-check-row">
|
||||
<input type="checkbox" class="country-check-input" :checked="isCountrySelected(row.code)"
|
||||
:disabled="isCountrySelectionLocked(row.code)"
|
||||
@change="onCountryNativeChange(row.code, $event)" />
|
||||
<span class="country-check-text">{{ row.label }}({{ row.code }})</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="orderedCountryCodes.length" class="country-order-panel">
|
||||
<div class="country-order-caption">已选顺序(拖拽 ⋮⋮ 调整)</div>
|
||||
<div class="country-order-list">
|
||||
<div v-for="(code, idx) in orderedCountryCodes" :key="code" class="country-drag-row"
|
||||
:class="{ dragging: dragCountryIndex === idx }" draggable="true" @dragstart="onCountryDragStart(idx)"
|
||||
@dragend="onCountryDragEnd" @dragover.prevent @drop.prevent="onCountryDrop(idx)">
|
||||
<span class="drag-handle" title="拖动排序">⋮⋮</span>
|
||||
<span class="country-drag-label">{{ countryLabel(code) }}({{ code }})</span>
|
||||
<template v-if="!asinModeEnabled">
|
||||
<!-- 国家顺序 -->
|
||||
<div class="section-title">处理国家与顺序</div>
|
||||
<p class="hint country-pref-hint">
|
||||
勾选需要处理的站点;拖拽调整顺序。设置会按当前登录用户保存。
|
||||
</p>
|
||||
<div class="country-pref-checks">
|
||||
<label v-for="row in countryCheckboxRows" :key="row.code" class="country-check-row">
|
||||
<input type="checkbox" class="country-check-input" :checked="isCountrySelected(row.code)"
|
||||
:disabled="isCountrySelectionLocked(row.code)"
|
||||
@change="onCountryNativeChange(row.code, $event)" />
|
||||
<span class="country-check-text">{{ row.label }}({{ row.code }})</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="orderedCountryCodes.length" class="country-order-panel">
|
||||
<div class="country-order-caption">已选顺序(拖拽 ⋮⋮ 调整)</div>
|
||||
<div class="country-order-list">
|
||||
<div v-for="(code, idx) in orderedCountryCodes" :key="code" class="country-drag-row"
|
||||
:class="{ dragging: dragCountryIndex === idx }" draggable="true" @dragstart="onCountryDragStart(idx)"
|
||||
@dragend="onCountryDragEnd" @dragover.prevent @drop.prevent="onCountryDrop(idx)">
|
||||
<span class="drag-handle" title="拖动排序">⋮⋮</span>
|
||||
<span class="country-drag-label">{{ countryLabel(code) }}({{ code }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="countryPrefSaving" class="country-pref-status">保存中…</div>
|
||||
<div v-if="countryPrefSaving" class="country-pref-status">保存中…</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态指示 -->
|
||||
<div v-if="statusModeEnabled" class="mode-status-bar">
|
||||
@@ -569,6 +571,10 @@ function resolveAsinRequestPaths() {
|
||||
return asinFiles.value
|
||||
}
|
||||
|
||||
function resolveCountryCodesForRequest() {
|
||||
return asinModeEnabled.value ? COUNTRY_OPTIONS.map((item) => item.code) : [...orderedCountryCodes.value]
|
||||
}
|
||||
|
||||
function selectMode(mode: 'status' | 'asin') {
|
||||
statusModeEnabled.value = mode === 'status'
|
||||
asinModeEnabled.value = mode === 'asin'
|
||||
@@ -777,7 +783,7 @@ async function runMatch() {
|
||||
try {
|
||||
const res = await matchPriceTrackShops(names, {
|
||||
asinFiles: asinModeEnabled.value ? resolveAsinRequestPaths() : [],
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
countryCodes: resolveCountryCodesForRequest(),
|
||||
})
|
||||
const batch = res.items || []
|
||||
matchAsinRowsByCountry.value = res.asinRowsByCountry || {}
|
||||
@@ -915,7 +921,7 @@ async function pushToPythonQueueLegacy() {
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: matchedRows as unknown as Record<string, unknown>[],
|
||||
asinFiles: resolveAsinRequestPaths(),
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
countryCodes: resolveCountryCodesForRequest(),
|
||||
}
|
||||
const taskVo = await createPriceTrackTask(taskReq)
|
||||
taskSnapshots.value = {
|
||||
@@ -940,7 +946,7 @@ async function pushToPythonQueueLegacy() {
|
||||
task_id: taskVo.taskId,
|
||||
// 店铺简要信息(用于展示)
|
||||
shop_names: matchedRows.map((i) => i.shopName).filter(Boolean),
|
||||
country_codes: [...orderedCountryCodes.value],
|
||||
country_codes: resolveCountryCodesForRequest(),
|
||||
skip_asin_delete_policy: buildSkipAsinDeletePolicy(),
|
||||
delete_skip_asin_when_price_below_minimum: statusModeEnabled.value && !asinModeEnabled.value,
|
||||
deleteSkipAsinWhenPriceBelowMinimum: statusModeEnabled.value && !asinModeEnabled.value,
|
||||
@@ -1003,7 +1009,7 @@ function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrackShopQu
|
||||
task_status: taskStatus,
|
||||
loop_run_id: loopRunId,
|
||||
round_index: roundIndex,
|
||||
country_codes: [...orderedCountryCodes.value],
|
||||
country_codes: resolveCountryCodesForRequest(),
|
||||
mode: statusModeEnabled.value ? 'status' : 'asin',
|
||||
skip_asins: skipAsinsByCountry,
|
||||
skip_asins_by_country: skipAsinsByCountry,
|
||||
@@ -1142,7 +1148,7 @@ async function processMatchedQueue() {
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: [row] as unknown as Record<string, unknown>[],
|
||||
asinFiles: resolveAsinRequestPaths(),
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
countryCodes: resolveCountryCodesForRequest(),
|
||||
})
|
||||
} catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error || '')).toLowerCase()
|
||||
@@ -1416,7 +1422,7 @@ async function pushToPythonLoopQueue() {
|
||||
asinMode: asinModeEnabled.value,
|
||||
items: matchedRows,
|
||||
asinFiles: resolveAsinRequestPaths(),
|
||||
countryCodes: [...orderedCountryCodes.value],
|
||||
countryCodes: resolveCountryCodesForRequest(),
|
||||
executionMode: executionMode.value,
|
||||
targetRounds: executionMode.value === 'FINITE' ? Math.max(1, Number(roundCount.value) || 1) : undefined,
|
||||
})
|
||||
@@ -1568,7 +1574,7 @@ function statusClass(item: PriceTrackHistoryItem) {
|
||||
}
|
||||
|
||||
function canDownload(item: PriceTrackHistoryItem) {
|
||||
if (!item.resultId) return false
|
||||
if (!item.resultId || (!item.fileReady && !item.downloadUrl)) return false
|
||||
const st = resolvedTaskStatus(item)
|
||||
return st === 'SUCCESS' || item.success === true
|
||||
}
|
||||
|
||||
@@ -968,7 +968,7 @@ function statusClass(item: ProductRiskHistoryItem) {
|
||||
}
|
||||
|
||||
function canDownload(item: ProductRiskHistoryItem) {
|
||||
if (!item.resultId || !item.downloadUrl) return false
|
||||
if (!item.resultId || (!item.fileReady && !item.downloadUrl)) return false
|
||||
const st = resolvedTaskStatus(item)
|
||||
return st === 'SUCCESS' || item.success === true
|
||||
}
|
||||
|
||||
@@ -557,7 +557,7 @@ function statusClass(status?: string) {
|
||||
}
|
||||
|
||||
function canDownload(item: QueryAsinHistoryItem) {
|
||||
return Boolean(item.resultId && (item.outputFilename || item.downloadUrl));
|
||||
return Boolean(item.resultId && (item.fileReady || item.downloadUrl));
|
||||
}
|
||||
|
||||
function saveMatchedItems() {
|
||||
@@ -748,14 +748,20 @@ async function refreshActiveTaskProgress(taskIds?: number[]) {
|
||||
function startHistoryPolling() {
|
||||
stopHistoryPolling();
|
||||
if (!hasQueueWork.value) return;
|
||||
historyPollTimer = window.setInterval(() => {
|
||||
void refreshActiveTaskProgress();
|
||||
}, getTaskPollIntervalMs() * 2);
|
||||
const run = async () => {
|
||||
historyPollTimer = null;
|
||||
if (!hasQueueWork.value) return;
|
||||
await refreshActiveTaskProgress();
|
||||
if (hasQueueWork.value) {
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
};
|
||||
historyPollTimer = window.setTimeout(run, getTaskPollIntervalMs() * 2);
|
||||
}
|
||||
|
||||
function stopHistoryPolling() {
|
||||
if (historyPollTimer) {
|
||||
window.clearInterval(historyPollTimer);
|
||||
window.clearTimeout(historyPollTimer);
|
||||
historyPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +293,33 @@ function nextScheduleStage(taskId: number) { const task = taskSnapshots.value[ta
|
||||
async function dispatchScheduledTask(taskId: number, item: ShopMatchHistoryItem) { const api = getPywebviewApi(); if (!api?.enqueue_json) throw new Error('当前客户端未提供 enqueue_json'); const stage = nextScheduleStage(taskId); const snapshot = taskSnapshots.value[taskId]; const countryCodes = snapshot?.task?.countryCodes || orderedCountryCodes.value; if (!stage) throw new Error(`任务 ${taskId} 缺少待执行阶段`); clearDispatchTimer(taskId); await withTransientRetry(() => activateShopMatchTask(taskId, stage.stageIndex), (attempt, maxAttempts) => { queuePushResult.value = `任务 ${taskId} 等待后端恢复后再激活执行(${attempt}/${maxAttempts})...` }); taskDetails.value = { ...taskDetails.value, [taskId]: 'RUNNING' }; if (snapshot?.task) taskSnapshots.value = { ...taskSnapshots.value, [taskId]: { ...snapshot, task: { ...snapshot.task, status: 'RUNNING', activeStageIndex: stage.stageIndex } } }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const payload = buildQueuePayload(taskId, item, countryCodes, stage.stageIndex, stage.finalStage); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) throw new Error(result?.error || `任务 ${taskId} 推送失败`); addPollingTask(taskId); queuePushResult.value = `任务 ${taskId} 已推送第 ${stage.stageIndex + 1} 轮`; ensurePolling(true) }
|
||||
function scheduledTimestamp(value?: string) { const date = parseScheduleDateTime(value); return date ? date.getTime() : Number.NaN }
|
||||
function findReadyScheduledTasks() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } const ready = Array.from(taskIds).map((taskId) => { const snapshot = taskSnapshots.value[taskId]; const taskStatus = taskDetails.value[taskId] || snapshot?.task?.status; const item = snapshot?.items?.[0] || historyItems.value.find((row) => normalizeTaskId(row.taskId) === taskId); const stage = nextScheduleStage(taskId); if (taskStatus !== 'SCHEDULED' || !item || !stage?.scheduledAt) return null; const scheduledTime = scheduledTimestamp(stage.scheduledAt); if (!Number.isFinite(scheduledTime) || scheduledTime > Date.now()) return null; return { taskId, item, scheduledTime } }).filter((item): item is { taskId: number; item: ShopMatchHistoryItem; scheduledTime: number } => !!item); ready.sort((a, b) => a.scheduledTime - b.scheduledTime || a.taskId - b.taskId); return ready }
|
||||
async function pumpScheduledQueue() { if (scheduledDispatchInFlight.value) return; const readyTasks = findReadyScheduledTasks(); if (!readyTasks.length) return; scheduledDispatchInFlight.value = true; try { for (const readyTask of readyTasks) { try { await dispatchScheduledTask(readyTask.taskId, readyTask.item) } catch (error) { const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`; const key = `${readyTask.taskId}:${message}`; const now = Date.now(); queuePushResult.value = message; if (lastScheduledErrorKey !== key || now - lastScheduledErrorAt > 10000) { lastScheduledErrorKey = key; lastScheduledErrorAt = now; ElMessage.error(message) } await Promise.allSettled([loadHistory(), refreshTaskBatch()]) } } } finally { scheduledDispatchInFlight.value = false } }
|
||||
async function pumpScheduledQueue() {
|
||||
if (scheduledDispatchInFlight.value) return
|
||||
const readyTasks = findReadyScheduledTasks()
|
||||
if (!readyTasks.length) return
|
||||
scheduledDispatchInFlight.value = true
|
||||
try {
|
||||
const readyTask = readyTasks[0]
|
||||
try {
|
||||
await dispatchScheduledTask(readyTask.taskId, readyTask.item)
|
||||
await waitForScheduledTaskStageExit(readyTask.taskId)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : `任务 ${readyTask.taskId} 调度失败`
|
||||
const key = `${readyTask.taskId}:${message}`
|
||||
const now = Date.now()
|
||||
queuePushResult.value = message
|
||||
if (lastScheduledErrorKey !== key || now - lastScheduledErrorAt > 10000) {
|
||||
lastScheduledErrorKey = key
|
||||
lastScheduledErrorAt = now
|
||||
ElMessage.error(message)
|
||||
}
|
||||
await Promise.allSettled([loadHistory(), refreshTaskBatch()])
|
||||
}
|
||||
} finally {
|
||||
scheduledDispatchInFlight.value = false
|
||||
restoreScheduledDispatches()
|
||||
}
|
||||
}
|
||||
function scheduleDispatch(taskId: number, scheduledAt?: string) { if (!scheduledAt) return; clearDispatchTimer(taskId); const time = scheduledTimestamp(scheduledAt); if (!Number.isFinite(time)) return; const delay = Math.max(0, time - Date.now()); const timer = window.setTimeout(() => { dispatchTimers.delete(taskId); void pumpScheduledQueue() }, delay); dispatchTimers.set(taskId, timer) }
|
||||
function restoreScheduledDispatches() { const taskIds = new Set<number>(pollingTaskIds.value); for (const key of Object.keys(taskSnapshots.value)) { const id = normalizeTaskId(key); if (id > 0) taskIds.add(id) } for (const item of historyItems.value) { const id = normalizeTaskId(item.taskId); if (id > 0) taskIds.add(id) } for (const taskId of taskIds) { const snapshot = taskSnapshots.value[taskId]; const taskStatus = snapshot?.task?.status || taskDetails.value[taskId]; const stage = nextScheduleStage(taskId); if (taskStatus === 'SCHEDULED' && stage?.scheduledAt) scheduleDispatch(taskId, stage.scheduledAt); else clearDispatchTimer(taskId) } void pumpScheduledQueue() }
|
||||
function startScheduleHeartbeat() { if (scheduleHeartbeatTimer) return; scheduleHeartbeatTimer = window.setInterval(() => { void pumpScheduledQueue() }, 1000) }
|
||||
@@ -309,13 +335,64 @@ function scheduleNextPoll(immediate = false) { if (pollTimer.value) { if (!immed
|
||||
function ensurePolling(immediate = false) { scheduleNextPoll(immediate) }
|
||||
function stopPolling() { if (pollTimer.value) { window.clearTimeout(pollTimer.value); pollTimer.value = null } }
|
||||
async function waitForTaskTerminal(taskId: number) { let transientErrorCount = 0; const maxTransientErrors = 30; while (true) { try { const batch = await getShopMatchTaskProgressBatch([taskId]); if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待执行结果...`; transientErrorCount = 0; if ((batch.missingTaskIds || []).includes(taskId)) { removeTaskLocally(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return 'FAILED' } const detail = (batch.items || []).find((item) => item.task?.id === taskId); const status = detail?.task?.status || ''; if (detail) { const prev = taskSnapshots.value[taskId]; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }; saveTaskSnapshotsToStorage() } if (status) { taskDetails.value[taskId] = status; saveTaskDetailsToStorage() } if (isTaskTerminalStatus(status)) { removePollingTask(taskId); await refreshTaskViewsBestEffort(); restoreScheduledDispatches(); return status } } catch (error) { if (!isTransientBackendError(error)) throw error; transientErrorCount += 1; if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待后端恢复超时,请稍后手动刷新查看状态`); queuePushResult.value = `任务 ${taskId} 运行中,正在等待后端服务恢复(${transientErrorCount}/${maxTransientErrors})...`; if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`); await sleep(getPollIntervalMs()); continue } await sleep(getPollIntervalMs()) } }
|
||||
async function waitForScheduledTaskStageExit(taskId: number) {
|
||||
let transientErrorCount = 0
|
||||
const maxTransientErrors = 30
|
||||
while (true) {
|
||||
try {
|
||||
const batch = await getShopMatchTaskProgressBatch([taskId])
|
||||
if (transientErrorCount > 0) queuePushResult.value = `任务 ${taskId} 后端已恢复,继续等待当前轮次结束...`
|
||||
transientErrorCount = 0
|
||||
if ((batch.missingTaskIds || []).includes(taskId)) {
|
||||
removeTaskLocally(taskId)
|
||||
await refreshTaskViewsBestEffort()
|
||||
restoreScheduledDispatches()
|
||||
return 'FAILED'
|
||||
}
|
||||
const detail = (batch.items || []).find((item) => item.task?.id === taskId)
|
||||
const status = detail?.task?.status || ''
|
||||
if (detail) {
|
||||
const prev = taskSnapshots.value[taskId]
|
||||
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: prev ? { ...prev, task: { ...(prev.task || {}), ...(detail.task || {}) } } : detail }
|
||||
saveTaskSnapshotsToStorage()
|
||||
}
|
||||
if (status) {
|
||||
taskDetails.value[taskId] = status
|
||||
saveTaskDetailsToStorage()
|
||||
}
|
||||
if (!status || status === 'RUNNING') {
|
||||
await sleep(getPollIntervalMs())
|
||||
continue
|
||||
}
|
||||
if (isTaskTerminalStatus(status)) {
|
||||
removePollingTask(taskId)
|
||||
await refreshTaskViewsBestEffort()
|
||||
restoreScheduledDispatches()
|
||||
return status
|
||||
}
|
||||
if (status === 'SCHEDULED') {
|
||||
stopPollingTask(taskId)
|
||||
restoreScheduledDispatches()
|
||||
return status
|
||||
}
|
||||
return status
|
||||
} catch (error) {
|
||||
if (!isTransientBackendError(error)) throw error
|
||||
transientErrorCount += 1
|
||||
if (transientErrorCount >= maxTransientErrors) throw new Error(`任务 ${taskId} 等待当前轮次结束超时,请稍后手动刷新查看状态`)
|
||||
queuePushResult.value = `任务 ${taskId} 运行中,正在等待当前轮次结束(${transientErrorCount}/${maxTransientErrors})...`
|
||||
if (transientErrorCount === 1 || transientErrorCount % 5 === 0) ElMessage.warning(`任务 ${taskId} 运行中,后端服务暂时不可用,正在自动重试`)
|
||||
await sleep(getPollIntervalMs())
|
||||
}
|
||||
}
|
||||
}
|
||||
function resolvedTaskStatus(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); if (!taskId) return item.taskStatus || ''; return taskDetails.value[taskId] || taskSnapshots.value[taskId]?.task?.status || item.taskStatus || '' }
|
||||
function taskSnapshotOf(item: ShopMatchHistoryItem) { const taskId = normalizeTaskId(item.taskId); return taskId ? taskSnapshots.value[taskId] : undefined }
|
||||
function currentTaskStageText(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (!stages.length) return ''; const total = stages.length; const activeIndex = typeof task?.activeStageIndex === 'number' ? task.activeStageIndex : undefined; const currentIndex = typeof task?.currentStageIndex === 'number' ? task.currentStageIndex : undefined; if (typeof activeIndex === 'number') return `执行进度: 第 ${activeIndex + 1}/${total} 次执行中`; if (typeof currentIndex === 'number') return `执行进度: 等待第 ${currentIndex + 1}/${total} 次`; return `执行进度: 共 ${total} 次`}
|
||||
function nextScheduledDisplay(item: ShopMatchHistoryItem) { const snapshot = taskSnapshotOf(item); const task = snapshot?.task; const stages = task?.scheduleStages || []; if (typeof task?.currentStageIndex === 'number') { const stage = stages.find((entry) => entry.stageIndex === task.currentStageIndex); if (stage?.scheduledAt) return formatMonthDayTime(stage.scheduledAt) } if (item.scheduledAt) return formatMonthDayTime(item.scheduledAt); return '' }
|
||||
function statusText(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); if (status === 'SCHEDULED') return '待执行'; if (status === 'RUNNING') return '执行中'; if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成'; if (status === 'FAILED') return '失败'; return item.success ? '已完成' : '未知' }
|
||||
function statusClass(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
|
||||
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && !!item.downloadUrl && (status === 'SUCCESS' || status === 'COMPLETED') }
|
||||
function canDownload(item: ShopMatchHistoryItem) { const status = resolvedTaskStatus(item); return !!item.resultId && (!!item.fileReady || !!item.downloadUrl) && (status === 'SUCCESS' || status === 'COMPLETED') }
|
||||
async function downloadResult(item: ShopMatchHistoryItem) { if (!item.resultId) return; const api = getPywebviewApi(); if (!api?.save_file_from_url_new) { ElMessage.error('当前客户端未提供下载能力'); return } const url = getShopMatchResultDownloadUrl(item.resultId); const filename = item.outputFilename || `${item.shopName || 'result'}.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: ShopMatchHistoryItem) {
|
||||
const taskId = normalizeTaskId(item.taskId)
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
<span class="status" :class="item.success ? 'success' : 'failed'">
|
||||
{{ item.success ? '已完成' : '失败' }}
|
||||
</span>
|
||||
<button v-if="item.success && item.downloadUrl" type="button" class="download"
|
||||
<button v-if="item.success && (item.downloadUrl || item.resultId)" type="button" class="download"
|
||||
@click="downloadSplitResult(item)">
|
||||
下载压缩包
|
||||
</button>
|
||||
@@ -167,7 +167,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from './BrandTopBar.vue'
|
||||
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
|
||||
import { deleteSplitHistory, getExcelInfo, getSplitHistory, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules'
|
||||
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem, type SplitRunVo } from '@/shared/api/java-modules'
|
||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||
|
||||
const splitSelectedPaths = ref<string[]>([])
|
||||
@@ -348,14 +348,15 @@ function formatSplitEntries(entries: NonNullable<SplitResultItem['entries']>) {
|
||||
|
||||
async function downloadSplitResult(item: SplitResultItem) {
|
||||
const api = getPywebviewApi()
|
||||
if (!item.downloadUrl) {
|
||||
const url = item.downloadUrl || (item.resultId ? getSplitResultDownloadUrl(item.resultId) : '')
|
||||
if (!url) {
|
||||
ElMessage.warning('当前结果没有下载地址')
|
||||
return
|
||||
}
|
||||
|
||||
const filename = item.outputFilename || `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}.zip`
|
||||
if (api?.save_file_from_url_new) {
|
||||
const result = await api.save_file_from_url_new(item.downloadUrl, filename)
|
||||
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 !== '用户取消') {
|
||||
|
||||
@@ -45,6 +45,7 @@ import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
|
||||
type ActiveNavKey =
|
||||
| 'brand'
|
||||
| 'appearance-patent'
|
||||
| 'dedupe'
|
||||
| 'convert'
|
||||
| 'split'
|
||||
@@ -82,6 +83,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{ key: 'collect', label: '采集数据' },
|
||||
{ key: 'variant', label: '变体分析' },
|
||||
{ key: 'brand', label: '品牌检测', href: '/brand' },
|
||||
{ key: 'appearance-patent', label: '外观专利检测', href: '/new_web_source/appearance-patent.html' },
|
||||
{ key: 'dedupe', label: '数据去重', href: '/new_web_source/dedupe.html' },
|
||||
{ key: 'split', label: '数据拆分', href: '/new_web_source/split.html' },
|
||||
{ key: 'convert', label: '格式转换', href: '/new_web_source/convert.html' },
|
||||
|
||||
Reference in New Issue
Block a user