后端架构更新

This commit is contained in:
super
2026-04-27 09:18:10 +08:00
parent 03e697f5d3
commit 225b525ba1
150 changed files with 7013 additions and 804 deletions

View File

@@ -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 后点击解析后端会提取 idASIN国家并保留整数 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>