完成前后端紫鸟部分开发

This commit is contained in:
super
2026-03-28 15:42:18 +08:00
parent d3c7938627
commit 9c6232bc85
52 changed files with 2406 additions and 209 deletions

View File

@@ -29,7 +29,7 @@
<input type="checkbox" checked disabled />
<div>
<span class="label">文件名作为店铺名</span>
<div class="desc">例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明当前先不联动紫鸟</div>
<div class="desc">例如 鲍丽明.xlsx 会解析为店铺名 鲍丽明并自动联动紫鸟进行店铺匹配</div>
</div>
</label>
@@ -58,6 +58,14 @@
{{ running ? '正在读取删除品牌数据,请稍候…' : '解析后会在右侧展示按国家分组的去重结果' }}
</span>
</div>
<div v-if="queuePushResult || queuePayloadText" class="queue-debug-card">
<div class="section-title queue-debug-title">Python 队列推送结果</div>
<div v-if="queuePushResult" class="queue-debug-line">
{{ queuePushResult }}
</div>
<pre v-if="queuePayloadText" class="queue-debug-payload">{{ queuePayloadText }}</pre>
</div>
</aside>
<section class="right-panel">
@@ -83,52 +91,139 @@
<span>删除品牌结果列表</span>
</div>
<div v-if="resultItems.length === 0" class="empty-tasks">
<div v-if="!hasVisibleItems" class="empty-tasks">
暂无删除品牌结果完成解析后会在这里展示按国家分组的去重结果
</div>
<ul v-else class="task-list clean-result-list">
<li
v-for="item in resultItems"
:key="`${item.resultId || item.taskId || item.sourceFilename}`"
class="task-item split-result-item"
>
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.countryCount !== undefined" class="files">国家数{{ item.countryCount }}</div>
<div v-if="item.totalRows !== undefined" class="time">去重后 {{ item.totalRows }} </div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }}
</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<template v-else>
<div v-if="currentSectionItems.length" class="result-subsection">
<div class="result-subsection-title">当前任务</div>
<ul class="task-list clean-result-list">
<li
v-for="item in currentSectionItems"
:key="`current-${item.resultId || item.taskId || item.sourceFilename}`"
class="task-item split-result-item"
>
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount }}</div>
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} </div>
<div v-if="item.taskId && item.matched" class="delete-brand-progress-block">
<div class="delete-brand-progress-header">
<span>任务进度</span>
<span>{{ formatProgressPercent(item.taskId) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill" :style="{ width: `${formatProgressPercent(item.taskId)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item.taskId) }}</div>
</div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }}
</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
<div class="task-right split-result-actions">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
>
打开紫鸟
</button>
<button
v-if="canDownloadTaskResult(item)"
type="button"
class="download"
@click="downloadTaskResult(item)"
>
下载
</button>
<button
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteHistoryRecord(item.resultId)"
>
删除
</button>
</div>
</li>
</ul>
</div>
<div v-if="historySectionItems.length" class="result-subsection">
<div class="result-subsection-title">历史记录</div>
<ul class="task-list clean-result-list">
<li
v-for="item in historySectionItems"
:key="`history-${item.resultId || item.taskId || item.sourceFilename}`"
class="task-item split-result-item"
>
打开紫鸟
</button>
<button
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteHistoryRecord(item.resultId)"
>
删除
</button>
</div>
</li>
</ul>
<div class="left split-result-main">
<span class="id" :title="item.sourceFilename">{{ item.sourceFilename || '-' }}</span>
<div class="files">店铺名{{ item.shopName || '-' }}</div>
<div class="files">匹配结果{{ item.matched ? `已匹配 ${item.shopId || ''}` : '未匹配到紫鸟店铺' }}</div>
<div v-if="item.platform" class="files">平台{{ item.platform }}</div>
<div v-if="item.countryCount !== undefined && item.matched" class="files">国家数{{ item.countryCount }}</div>
<div v-if="item.totalRows !== undefined && item.matched" class="time">去重后 {{ item.totalRows }} </div>
<div v-if="item.taskId && item.matched" class="delete-brand-progress-block">
<div class="delete-brand-progress-header">
<span>任务进度</span>
<span>{{ formatProgressPercent(item.taskId) }}%</span>
</div>
<div class="delete-brand-progress-bar">
<div class="delete-brand-progress-bar-fill" :style="{ width: `${formatProgressPercent(item.taskId)}%` }"></div>
</div>
<div class="files delete-brand-progress">{{ formatProgress(item.taskId) }}</div>
</div>
<div v-if="item.previewRows?.length" class="files split-entry-list delete-brand-preview">
{{ formatPreview(item.previewRows) }}
</div>
<div v-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
<div class="task-right split-result-actions">
<span class="status" :class="item.success ? 'success' : 'failed'">
{{ item.success ? '已完成' : '失败' }}
</span>
<button
v-if="item.openStoreUrl"
type="button"
class="download"
@click="openShop(item.openStoreUrl)"
>
打开紫鸟
</button>
<button
v-if="canDownloadTaskResult(item)"
type="button"
class="download"
@click="downloadTaskResult(item)"
>
下载
</button>
<button
v-if="item.resultId"
type="button"
class="btn-delete"
@click="deleteHistoryRecord(item.resultId)"
>
删除
</button>
</div>
</li>
</ul>
</div>
</template>
</div>
</div>
</section>
@@ -137,17 +232,20 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import {
deleteDeleteBrandHistory,
getDeleteBrandHistory,
getDeleteBrandTaskDetail,
getDeleteBrandTaskDownloadUrl,
runDeleteBrand,
type DeleteBrandPreviewRow,
type DeleteBrandResultItem,
type DeleteBrandRunVo,
type DeleteBrandTaskDetailVo,
} from '@/shared/api/java-modules'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
@@ -155,8 +253,50 @@ const selectedPaths = ref<string[]>([])
const uploadedFiles = ref<UploadedJavaFile[]>([])
const running = ref(false)
const resultItems = ref<DeleteBrandResultItem[]>([])
const currentRunItems = ref<DeleteBrandResultItem[]>([])
const historyItems = ref<DeleteBrandResultItem[]>([])
const summary = ref<DeleteBrandRunVo>({ total: 0, successCount: 0, failedCount: 0, items: [] })
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
const queuePushResult = ref('')
const queuePayloadText = ref('')
const taskDetails = ref<Record<number, DeleteBrandTaskDetailVo>>({})
const pollTimer = ref<number | null>(null)
const displayPaths = computed(() => selectedPaths.value.slice(0, 10))
const currentSectionItems = computed(() => currentRunItems.value)
const historySectionItems = computed(() =>
historyItems.value.filter(
(item) =>
!currentRunItems.value.some(
(current) =>
(current.resultId || current.taskId || current.fileKey || current.sourceFilename) ===
(item.resultId || item.taskId || item.fileKey || item.sourceFilename),
),
),
)
const hasVisibleItems = computed(() => currentSectionItems.value.length > 0 || historySectionItems.value.length > 0)
function normalizeDeleteBrandItems(items: DeleteBrandResultItem[]) {
return items.map((item) => {
if (!item.matched) {
return {
...item,
success: false,
error: item.error || '未匹配到紫鸟店铺',
taskId: undefined,
openStoreUrl: undefined,
}
}
return item
})
}
function mergeVisibleItems() {
resultItems.value = [...currentSectionItems.value, ...historySectionItems.value]
}
function syncResultState() {
mergeVisibleItems()
updateSummary(resultItems.value)
}
function updateSummary(items: DeleteBrandResultItem[]) {
summary.value = {
@@ -167,6 +307,87 @@ function updateSummary(items: DeleteBrandResultItem[]) {
}
}
function getItemTaskId(item: DeleteBrandResultItem) {
return item.taskId || 0
}
function formatProgress(taskId: number) {
if (!taskId) return ''
const detail = taskDetails.value[taskId]
const info = detail?.line_progress?.info
if (!detail?.line_progress?.has_progress || !info) {
const status = detail?.task?.status
return status ? `任务状态:${status}` : ''
}
const parts: string[] = []
if (info.phase) parts.push(`阶段:${info.phase}`)
if (info.file_index && info.file_total) parts.push(`文件:${info.file_index}/${info.file_total}`)
if (info.file_name) parts.push(`当前文件:${info.file_name}`)
if (info.current_line !== undefined && info.total_lines !== undefined) parts.push(`进度:${info.current_line}/${info.total_lines}`)
if (info.current_country) parts.push(`国家:${info.current_country}`)
if (info.current_asin) parts.push(`ASIN${info.current_asin}`)
if (info.finished_files !== undefined) parts.push(`已完成文件:${info.finished_files}`)
return parts.join('')
}
function formatProgressPercent(taskId: number) {
if (!taskId) return 0
const info = taskDetails.value[taskId]?.line_progress?.info
if (!info) return 0
if (info.current_line !== undefined && info.total_lines && info.total_lines > 0) {
return Math.max(0, Math.min(100, Math.round((info.current_line / info.total_lines) * 100)))
}
if (info.finished_files !== undefined && info.file_total && info.file_total > 0) {
return Math.max(0, Math.min(100, Math.round((info.finished_files / info.file_total) * 100)))
}
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS' ? 100 : 0
}
function isTaskRunning(taskId: number) {
const status = taskDetails.value[taskId]?.task?.status
return status === 'RUNNING'
}
function canDownloadTaskResult(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId || !item.matched) return false
const status = taskDetails.value[taskId]?.task?.status
return status === 'SUCCESS'
}
async function refreshTaskDetails() {
const taskIds = Array.from(new Set(resultItems.value.map(getItemTaskId).filter((id) => id > 0)))
for (const id of taskIds) {
try {
const detail = await getDeleteBrandTaskDetail(id)
taskDetails.value[id] = detail
} catch {
// ignore polling failures
}
}
}
function ensurePolling() {
if (pollTimer.value) return
pollTimer.value = window.setInterval(async () => {
await refreshTaskDetails()
const stillRunning = Object.keys(taskDetails.value).some((id) => isTaskRunning(Number(id)))
if (!stillRunning) {
stopPolling()
}
}, 1500)
}
function stopPolling() {
if (pollTimer.value) {
window.clearInterval(pollTimer.value)
pollTimer.value = null
}
}
async function uploadPathsToJava(paths: Array<string | BrandExpandFolderItem>) {
const api = getPywebviewApi()
if (!api?.upload_file_to_java) {
@@ -239,9 +460,40 @@ async function submitRun() {
relativePath: item.relativePath,
})),
})
summary.value = result
resultItems.value = result.items || []
ElMessage.success('删除品牌解析完成')
const normalizedItems = normalizeDeleteBrandItems(result.items || [])
currentRunItems.value = normalizedItems
syncResultState()
await refreshTaskDetails()
ensurePolling()
const api = getPywebviewApi()
const hasUnmatched = normalizedItems.some((item) => !item.matched)
if (!hasUnmatched && api?.enqueue_json) {
const payload = {
type: 'delete-brand-run',
ts: Date.now(),
data: { ...result, items: normalizedItems },
}
queuePayloadText.value = JSON.stringify(payload, null, 2)
const pushResult = await api.enqueue_json(payload)
if (pushResult?.success) {
queuePushResult.value = `已推送到 Python 队列,当前队列长度:${pushResult.queue_size ?? '-'}`
} else {
queuePushResult.value = `推送到 Python 队列失败:${pushResult?.error || '未知错误'}`
}
} else {
queuePushResult.value = hasUnmatched
? '存在未匹配到紫鸟店铺的文件,已跳过推送到 Python 队列'
: '当前环境未启用 pywebview enqueue_json浏览器环境不会推送'
queuePayloadText.value = ''
}
if (hasUnmatched) {
ElMessage.error('存在未匹配到紫鸟店铺的文件,已标记为失败')
} else {
ElMessage.success('删除品牌解析完成')
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '执行失败')
} finally {
@@ -249,11 +501,37 @@ async function submitRun() {
}
}
async function downloadTaskResult(item: DeleteBrandResultItem) {
const taskId = item.taskId
if (!taskId) {
ElMessage.warning('未找到任务ID')
return
}
const api = getPywebviewApi()
const url = getDeleteBrandTaskDownloadUrl(taskId)
const filename =
item.outputFilename ||
item.sourceFilename ||
`delete-brand_${taskId}.xlsx`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(url, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
window.open(url, '_blank')
}
async function loadHistory() {
try {
const response = await getDeleteBrandHistory()
resultItems.value = response.items || []
updateSummary(resultItems.value)
historyItems.value = normalizeDeleteBrandItems(response.items || [])
syncResultState()
} catch {
// ignore history load errors
}
@@ -282,9 +560,18 @@ function openShop(url: string) {
onMounted(() => {
loadHistory().catch(() => undefined)
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.queue-debug-card { margin-top: 18px; padding: 14px; border: 1px solid #2a2a2a; border-radius: 10px; background: #202020; }
.queue-debug-title { margin-bottom: 8px; }
.queue-debug-line { color: #b8c1cc; font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
.queue-debug-payload { margin: 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 8px; background: #141414; color: #8fd3ff; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
.module-page { min-height: 100vh; background: #1a1a1a; }
.main-content { display: flex; min-height: calc(100vh - 56px); height: calc(100vh - 56px); }
.left-panel { width: 380px; background: #1e1e1e; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; }
@@ -324,6 +611,11 @@ onMounted(() => {
.time { font-size: 12px; color: #666; margin-top: 2px; }
.split-entry-list { line-height: 1.6; word-break: break-all; max-width: 100%; }
.delete-brand-preview { white-space: normal; }
.delete-brand-progress-block { margin-top: 8px; }
.delete-brand-progress-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12px; color: #8fbfff; }
.delete-brand-progress-bar { width: 100%; height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); overflow: hidden; }
.delete-brand-progress-bar-fill { height: 100%; border-radius: inherit; background: linear-gradient(90deg, #2d8cf0 0%, #4db3ff 100%); transition: width 0.3s ease; }
.delete-brand-progress { color: #69b6ff; line-height: 1.6; white-space: normal; margin-top: 6px; }
.task-right { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: flex-end; }
.split-result-actions { flex-shrink: 0; min-width: 180px; justify-content: flex-end; align-self: center; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; }

View File

@@ -150,22 +150,72 @@ export interface DeleteBrandProcessedCountry {
}
export interface DeleteBrandResultFile {
fileKey?: string
sourceFilename: string
fileIndex?: number
fileTotal?: number
chunkIndex?: number
chunkTotal?: number
processedRows?: number
totalRows?: number
currentCountry?: string
currentAsin?: string
countries: DeleteBrandProcessedCountry[]
}
export interface DeleteBrandSubmitResultRequest {
submissionId?: string
files: DeleteBrandResultFile[]
}
export interface DeleteBrandLineProgressInfo {
file_index?: number
file_total?: number
file_name?: string
current_line?: number
total_lines?: number
current_country?: string
current_asin?: string
finished_files?: number
phase?: string
}
export interface DeleteBrandLineProgress {
has_progress: boolean
info?: DeleteBrandLineProgressInfo
}
export interface DeleteBrandTaskItem {
id: number
taskNo?: string
status?: string
sourceFileCount?: number
successFileCount?: number
failedFileCount?: number
errorMessage?: string
createdAt?: string
updatedAt?: string
finishedAt?: string
downloadUrl?: string
downloadFilename?: string
}
export interface DeleteBrandTaskDetailVo {
task: DeleteBrandTaskItem
line_progress: DeleteBrandLineProgress
}
export interface DeleteBrandResultItem {
resultId?: number
fileKey?: string
sourceFilename: string
shopName?: string
matched: boolean
shopId?: string
platform?: string
openStoreUrl?: string
outputFilename?: string
downloadUrl?: string
taskId?: number
countryCount?: number
countries?: DeleteBrandCountryGroup[]
@@ -311,6 +361,14 @@ export function deleteDeleteBrandHistory(resultId: number) {
}))
}
export function getDeleteBrandTaskDetail(taskId: number) {
return unwrapJavaResponse(get<JavaApiResponse<DeleteBrandTaskDetailVo>>(`${JAVA_API_PREFIX}/delete-brand/tasks/${taskId}`))
}
export function getDeleteBrandTaskDownloadUrl(taskId: number) {
return getJavaDownloadUrl(`/delete-brand/tasks/${taskId}/download`)
}
export function submitDeleteBrandResult(taskId: number, request: DeleteBrandSubmitResultRequest) {
return unwrapJavaResponse(post<JavaApiResponse<null>, DeleteBrandSubmitResultRequest>(`${JAVA_API_PREFIX}/delete-brand/tasks/${taskId}/result`, request))
}

View File

@@ -20,6 +20,7 @@ export interface PywebviewApi {
save_file_from_url?: (url: string, filename: string) => Promise<{ success: boolean; path?: string; error?: string }>
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }>
save_template_zip?: () => Promise<{ success: boolean; path?: string; error?: string }>
enqueue_json?: (data: unknown) => Promise<{ success: boolean; queue_size?: number; error?: string }>
}
declare global {