菜单修改优化
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>店铺数据抓取 - 数富AI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/shop-data-crawl-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -128,8 +128,7 @@
|
||||
>
|
||||
<div class="task-section-header">
|
||||
<div class="task-title-wrap">
|
||||
<strong>{{ detail.task.taskNo || `任务 ${detail.task.id}` }}</strong>
|
||||
<span class="task-meta">任务 ID:{{ detail.task.id }}</span>
|
||||
<strong>上架任务 #{{ detail.task.id }}</strong>
|
||||
<span class="task-meta">创建时间:{{ formatDateTime(detail.task.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
@@ -144,6 +143,14 @@
|
||||
>
|
||||
下载结果
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
:disabled="isDeletingTask(detail.task.id)"
|
||||
@click="deleteTaskRecord(detail)"
|
||||
>
|
||||
{{ isDeletingTask(detail.task.id) ? '删除中...' : '删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,17 +170,19 @@
|
||||
<span v-if="file.platform">平台:{{ file.platform }}</span>
|
||||
<span>数据:{{ fileProgressCurrent(file) }}/{{ fileProgressTotal(file) }}</span>
|
||||
</div>
|
||||
<div class="file-progress-header">
|
||||
<span>{{ file.progressMessage || statusText(file.status) }}</span>
|
||||
<span>{{ fileProgressPercent(file) }}%</span>
|
||||
</div>
|
||||
<div class="file-progress-track">
|
||||
<div
|
||||
class="file-progress-fill"
|
||||
:class="statusClass(file.status)"
|
||||
:style="{ width: `${fileProgressPercent(file)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="shouldShowFileProgress(file)">
|
||||
<div class="file-progress-header">
|
||||
<span>{{ file.progressMessage || statusText(file.status) }}</span>
|
||||
<span>{{ fileProgressPercent(file) }}%</span>
|
||||
</div>
|
||||
<div class="file-progress-track">
|
||||
<div
|
||||
class="file-progress-fill"
|
||||
:class="statusClass(file.status)"
|
||||
:style="{ width: `${fileProgressPercent(file)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="fileErrorText(file)" class="file-error">{{ fileErrorText(file) }}</div>
|
||||
</div>
|
||||
<span class="status file-status" :class="statusClass(file.status)">
|
||||
@@ -191,7 +200,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import BrandTopBar from './BrandTopBar.vue'
|
||||
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
||||
@@ -199,6 +208,7 @@ import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared
|
||||
import {
|
||||
activatePublishFile,
|
||||
activatePublishTask,
|
||||
deletePublishTask,
|
||||
getPublishDashboard,
|
||||
getPublishHistory,
|
||||
getPublishItemsPageUrl,
|
||||
@@ -261,6 +271,7 @@ const queuedBatches = ref<PublishQueueBatch[]>([])
|
||||
const taskSnapshots = ref<Record<number, PublishTaskDetailVo>>({})
|
||||
const historyItems = ref<PublishTaskDetailVo[]>([])
|
||||
const missingTaskIds = ref<number[]>([])
|
||||
const deletingTaskIds = ref<number[]>([])
|
||||
const dashboard = ref<PublishDashboardVo>({
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
@@ -990,6 +1001,10 @@ function fileErrorText(file: PublishFileItem) {
|
||||
return file.errorMessage || file.error || ''
|
||||
}
|
||||
|
||||
function shouldShowFileProgress(file: PublishFileItem) {
|
||||
return ['RUNNING', 'SUCCESS', 'COMPLETED', 'FAILED', 'CANCELLED'].includes(normalizeStatus(file.status))
|
||||
}
|
||||
|
||||
function fileProgressCurrent(file: PublishFileItem) {
|
||||
return Math.max(0, Number(file.progressCurrent ?? file.processedRows ?? 0))
|
||||
}
|
||||
@@ -1038,6 +1053,58 @@ async function downloadResult(detail: PublishTaskDetailVo) {
|
||||
else if (saved.error && saved.error !== '用户取消') ElMessage.error(saved.error)
|
||||
}
|
||||
|
||||
function isDeletingTask(taskId: number) {
|
||||
return deletingTaskIds.value.includes(taskId)
|
||||
}
|
||||
|
||||
function removeTaskLocally(taskId: number) {
|
||||
progressLoop.remove(taskId)
|
||||
historyItems.value = historyItems.value.filter((detail) => detail.task?.id !== taskId)
|
||||
const snapshots = { ...taskSnapshots.value }
|
||||
delete snapshots[taskId]
|
||||
taskSnapshots.value = snapshots
|
||||
missingTaskIds.value = Array.from(new Set([...missingTaskIds.value, taskId]))
|
||||
queuedBatches.value = queuedBatches.value.filter((batch) => batch.taskId !== taskId)
|
||||
if (currentTaskId.value === taskId) {
|
||||
currentTaskId.value = null
|
||||
currentFiles.value = []
|
||||
pendingFileIds.value = []
|
||||
activeFileId.value = null
|
||||
dispatchOptions.value = null
|
||||
}
|
||||
saveQueueState()
|
||||
}
|
||||
|
||||
async function deleteTaskRecord(detail: PublishTaskDetailVo) {
|
||||
const taskId = detail.task?.id
|
||||
if (!taskId || isDeletingTask(taskId)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除上架任务 #${taskId}?任务明细和结果文件也会一并删除。`,
|
||||
'删除任务',
|
||||
{
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
deletingTaskIds.value = [...deletingTaskIds.value, taskId]
|
||||
try {
|
||||
await deletePublishTask(taskId)
|
||||
removeTaskLocally(taskId)
|
||||
ElMessage.success('任务已删除')
|
||||
await Promise.all([loadDashboard(), loadHistory()])
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除任务失败')
|
||||
} finally {
|
||||
deletingTaskIds.value = deletingTaskIds.value.filter((id) => id !== taskId)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadQueueState()
|
||||
if (currentTaskId.value) progressLoop.add(currentTaskId.value)
|
||||
@@ -1069,7 +1136,7 @@ onBeforeUnmount(() => {
|
||||
.hint, .loading-msg, .task-meta, .file-info, .history-count { color: #888; font-size: 12px; line-height: 1.5; }
|
||||
.btns, .run-row, .task-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.btns { justify-content: center; }
|
||||
.opt-btn, .btn-run, .download { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
|
||||
.opt-btn, .btn-run, .download, .btn-delete { border: 0; border-radius: 6px; cursor: pointer; transition: background-color .15s ease, color .15s ease, opacity .15s ease; }
|
||||
.opt-btn { padding: 8px 16px; border: 1px solid #3a3a3a; background: #2a2a2a; color: #ccc; font-size: 13px; }
|
||||
.opt-btn:hover:not(:disabled) { border-color: #3498db; color: #67b7ef; }
|
||||
.opt-btn:disabled, .btn-run:disabled { opacity: .55; cursor: not-allowed; }
|
||||
@@ -1105,6 +1172,9 @@ onBeforeUnmount(() => {
|
||||
.task-error { padding: 10px 16px 0; }
|
||||
.download { padding: 7px 12px; background: #2d6b46; color: #dff7e8; font-size: 12px; white-space: nowrap; }
|
||||
.download:hover { background: #367d54; }
|
||||
.btn-delete { padding: 7px 12px; background: #472929; color: #efaaaa; font-size: 12px; white-space: nowrap; }
|
||||
.btn-delete:hover:not(:disabled) { background: #5a3030; color: #ffd0d0; }
|
||||
.btn-delete:disabled { cursor: wait; opacity: .55; }
|
||||
.status { display: inline-flex; align-items: center; justify-content: center; min-width: 58px; min-height: 26px; padding: 0 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
|
||||
.status.pending { background: #3a3424; color: #e4c56a; }
|
||||
.status.running { background: #24394a; color: #75bff1; }
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
<template>
|
||||
<div class="page-shell module-page">
|
||||
<BrandTopBar active="shop-data-crawl" />
|
||||
|
||||
<div class="main-content">
|
||||
<aside class="left-panel">
|
||||
<div class="section-title">店铺输入</div>
|
||||
<div class="input-zone">
|
||||
<div class="input-row">
|
||||
<el-input v-model="shopInput" clearable placeholder="请输入店铺名" @keyup.enter="confirmAdd" />
|
||||
<button type="button" class="opt-btn" :disabled="adding" @click="confirmAdd">
|
||||
{{ adding ? '添加中...' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title">备选区</div>
|
||||
<div v-if="!candidates.length" class="empty-candidates">暂无备选店铺</div>
|
||||
<div v-else class="candidate-table-scroll">
|
||||
<el-table :data="candidates" row-key="id" height="250" class="candidate-table"
|
||||
@selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="42" />
|
||||
<el-table-column prop="shop_name" label="店铺名" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="64" align="center">
|
||||
<template #default="{ row }">
|
||||
<button type="button" class="link-danger" @click="removeCandidate(row.id)">删除</button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="section-title">抓取国家与顺序</div>
|
||||
<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>{{ row.label }}({{ row.code }})</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="country-order-panel">
|
||||
<div class="country-order-caption">已选顺序</div>
|
||||
<div class="country-order-list">
|
||||
<div v-for="(code, index) in orderedCountryCodes" :key="code" class="country-drag-row"
|
||||
:class="{ dragging: dragCountryIndex === index }" draggable="true"
|
||||
@dragstart="dragCountryIndex = index" @dragend="dragCountryIndex = null"
|
||||
@dragover.prevent @drop.prevent="onCountryDrop(index)">
|
||||
<span class="drag-handle" title="拖动排序">⋮⋮</span>
|
||||
<span>{{ countryLabel(code) }}({{ code }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="countryPrefSaving" class="country-pref-status">保存中...</div>
|
||||
|
||||
<ZiniaoVersionSetting v-model="ziniaoVersion" />
|
||||
|
||||
<div class="run-row">
|
||||
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">
|
||||
{{ matching ? '匹配中...' : '匹配店铺' }}
|
||||
</button>
|
||||
<button type="button" class="btn-run btn-queue" :disabled="isQueueBusy || !matchedRunnableItems.length"
|
||||
@click="startQueue">
|
||||
{{ isQueueBusy ? '串行抓取中...' : '开始串行抓取' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="queueStatus" class="queue-status">{{ queueStatus }}</p>
|
||||
</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.candidateCount }}</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 v-if="!matchedItems.length" class="empty-tasks narrow">匹配后将在这里显示结果</div>
|
||||
<el-table v-else :data="matchedItems" :row-key="rowKey" :highlight-current-row="false"
|
||||
class="result-table match-table">
|
||||
<el-table-column prop="shopName" label="店铺名" min-width="120" />
|
||||
<el-table-column label="匹配" width="64" align="center">
|
||||
<template #default="{ row }"><span :class="row.matched ? 'ok' : 'fail'">{{ row.matched ? '是' : '否' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="shopId" label="店铺 ID" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="platform" label="平台" width="88" show-overflow-tooltip />
|
||||
<el-table-column prop="companyName" label="公司" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }">{{ formatMatchStatus(row.matchStatus) }}</template></el-table-column>
|
||||
<el-table-column label="说明" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.matchMessage || '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="64" align="center"><template #default="{ row }">
|
||||
<button type="button" class="link-danger" @click="removeMatchedRow(row)">删除</button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="result-list-wrap">
|
||||
<div class="result-list-header"><span>任务记录</span></div>
|
||||
<div v-if="!currentItems.length && !historySectionItems.length" class="empty-tasks">暂无任务记录</div>
|
||||
<div v-if="currentItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">当前任务</div>
|
||||
<ul class="task-list">
|
||||
<TaskRow v-for="item in currentItems" :key="historyKey(item)" :item="item"
|
||||
@download="downloadResult" @delete="deleteTaskRecord" />
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="historySectionItems.length" class="result-subsection">
|
||||
<div class="result-subsection-title">历史记录</div>
|
||||
<ul class="task-list">
|
||||
<TaskRow v-for="item in historySectionItems" :key="historyKey(item)" :item="item"
|
||||
@download="downloadResult" @delete="deleteTaskRecord" />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
|
||||
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
import {
|
||||
addShopDataCrawlCandidate,
|
||||
createShopDataCrawlTask,
|
||||
deleteShopDataCrawlCandidate,
|
||||
deleteShopDataCrawlHistory,
|
||||
deleteShopDataCrawlTask,
|
||||
getShopDataCrawlCountryPreference,
|
||||
getShopDataCrawlDashboard,
|
||||
getShopDataCrawlHistory,
|
||||
getShopDataCrawlResultDownloadUrl,
|
||||
getShopDataCrawlTaskProgressBatch,
|
||||
listShopDataCrawlCandidates,
|
||||
matchShopDataCrawlShops,
|
||||
putShopDataCrawlCountryPreference,
|
||||
type ShopDataCrawlCandidateVo,
|
||||
type ShopDataCrawlDashboardVo,
|
||||
type ShopDataCrawlHistoryItem,
|
||||
type ShopDataCrawlShopItem,
|
||||
type ShopDataCrawlTaskDetailVo,
|
||||
} from '@/shared/api/java-modules'
|
||||
|
||||
const COUNTRY_OPTIONS = [
|
||||
{ code: 'UK', label: '英国' },
|
||||
{ code: 'DE', label: '德国' },
|
||||
{ code: 'FR', label: '法国' },
|
||||
{ code: 'ES', label: '西班牙' },
|
||||
{ code: 'IT', label: '意大利' },
|
||||
] as const
|
||||
|
||||
const TaskRow = defineComponent({
|
||||
props: { item: { type: Object as () => ShopDataCrawlHistoryItem, required: true } },
|
||||
emits: ['download', 'delete'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('li', { class: 'task-item' }, [
|
||||
h('div', { class: 'left split-result-main' }, [
|
||||
h('span', { class: 'id', title: props.item.shopName || '' }, props.item.shopName || '-'),
|
||||
h('div', { class: 'files' }, `任务 ID:${props.item.taskId ?? '-'}`),
|
||||
props.item.platform ? h('div', { class: 'files' }, `平台:${props.item.platform}`) : null,
|
||||
props.item.outputFilename ? h('div', { class: 'files' }, `文件:${props.item.outputFilename}`) : null,
|
||||
props.item.createdAt ? h('div', { class: 'files' }, `创建时间:${formatDateTime(props.item.createdAt)}`) : null,
|
||||
props.item.error ? h('div', { class: 'files error-text' }, `错误:${props.item.error}`) : null,
|
||||
]),
|
||||
h('div', { class: 'task-right' }, [
|
||||
h('span', { class: ['status', statusClass(props.item.taskStatus)] }, statusText(props.item.taskStatus)),
|
||||
canDownload(props.item) ? h('button', { type: 'button', class: 'download', onClick: () => emit('download', props.item) }, '下载') : null,
|
||||
h('button', { type: 'button', class: 'btn-delete', onClick: () => emit('delete', props.item) }, '删除'),
|
||||
]),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const timers = createCategorizedTimers('shop-data-crawl')
|
||||
const ziniaoVersion = useZiniaoVersion()
|
||||
const shopInput = ref('')
|
||||
const candidates = ref<ShopDataCrawlCandidateVo[]>([])
|
||||
const selectedCandidates = ref<ShopDataCrawlCandidateVo[]>([])
|
||||
const matchedItems = ref<ShopDataCrawlShopItem[]>([])
|
||||
const historyItems = ref<ShopDataCrawlHistoryItem[]>([])
|
||||
const dashboard = ref<ShopDataCrawlDashboardVo>({ candidateCount: 0, processedTaskCount: 0, successTaskCount: 0, failedTaskCount: 0 })
|
||||
const orderedCountryCodes = ref<string[]>(COUNTRY_OPTIONS.map((row) => row.code))
|
||||
const dragCountryIndex = ref<number | null>(null)
|
||||
const countryPrefSaving = ref(false)
|
||||
const countryPrefUserTouched = ref(false)
|
||||
const adding = ref(false)
|
||||
const matching = ref(false)
|
||||
const queueWorkerRunning = ref(false)
|
||||
const queueStatus = ref('')
|
||||
const pendingQueue = ref<ShopDataCrawlShopItem[]>([])
|
||||
const activeTaskId = ref<number | null>(null)
|
||||
const activeQueueItem = ref<ShopDataCrawlShopItem | null>(null)
|
||||
const activeDispatched = ref(false)
|
||||
const activeCountryCodes = ref<string[]>([])
|
||||
const activeZiniaoVersion = ref<'new' | 'old'>('new')
|
||||
const pollingTaskIds = ref<number[]>([])
|
||||
const taskSnapshots = ref<Record<number, ShopDataCrawlTaskDetailVo>>({})
|
||||
const autoQueueEnabled = ref(false)
|
||||
let countryPrefSaveTimer: number | null = null
|
||||
let disposed = false
|
||||
|
||||
const matchedRunnableItems = computed(() => matchedItems.value.filter((item) => item.matched))
|
||||
const currentItems = computed(() => historyItems.value.filter((item) => !isTerminal(item.taskStatus)))
|
||||
const historySectionItems = computed(() => historyItems.value.filter((item) => isTerminal(item.taskStatus)))
|
||||
const isQueueBusy = computed(() => queueWorkerRunning.value || !!activeTaskId.value || pendingQueue.value.length > 0)
|
||||
const countryCheckboxRows = computed(() => {
|
||||
const selected = new Set(orderedCountryCodes.value)
|
||||
return [
|
||||
...orderedCountryCodes.value.map((code) => ({ code, label: countryLabel(code) })),
|
||||
...COUNTRY_OPTIONS.filter((row) => !selected.has(row.code)),
|
||||
]
|
||||
})
|
||||
|
||||
function uid() { return typeof window === 'undefined' ? '0' : window.localStorage.getItem('uid') || '0' }
|
||||
function storageKey(name: string) { return `shop-data-crawl:${name}:${uid()}` }
|
||||
function rowKey(item: ShopDataCrawlShopItem) { return `${(item.shopName || '').trim()}::${item.shopId || ''}` }
|
||||
function historyKey(item: ShopDataCrawlHistoryItem) { return `${item.taskId || 0}:${item.resultId || 0}:${rowKey(item)}` }
|
||||
function countryLabel(code: string) { return COUNTRY_OPTIONS.find((row) => row.code === code)?.label || code }
|
||||
function isCountrySelected(code: string) { return orderedCountryCodes.value.includes(code) }
|
||||
function isCountrySelectionLocked(code: string) { return orderedCountryCodes.value.length === 1 && orderedCountryCodes.value[0] === code }
|
||||
function isTerminal(status?: string) { return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED' }
|
||||
function statusText(status?: string) { return status === 'SUCCESS' || status === 'COMPLETED' ? '已完成' : status === 'FAILED' ? '失败' : '执行中' }
|
||||
function statusClass(status?: string) { return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' }
|
||||
function canDownload(item: ShopDataCrawlHistoryItem) { return Boolean(item.resultId && (item.fileReady || item.downloadUrl)) }
|
||||
function formatDateTime(value?: string) { if (!value) return '-'; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) }
|
||||
function formatMatchStatus(status?: string) { return ({ MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需确认', INDEX_STALE: '索引过期' } as Record<string, string>)[status || ''] || status || '-' }
|
||||
|
||||
function setStorage(name: string, value: unknown) {
|
||||
if (typeof window !== 'undefined') window.localStorage.setItem(storageKey(name), JSON.stringify(value))
|
||||
}
|
||||
|
||||
function loadLocalState() {
|
||||
try { matchedItems.value = JSON.parse(window.localStorage.getItem(storageKey('matched')) || '[]') } catch { matchedItems.value = [] }
|
||||
try {
|
||||
const state = JSON.parse(window.localStorage.getItem(storageKey('queue')) || '{}') as { pendingQueue?: ShopDataCrawlShopItem[]; activeTaskId?: number | null; activeQueueItem?: ShopDataCrawlShopItem | null; activeDispatched?: boolean; activeCountryCodes?: string[]; activeZiniaoVersion?: 'new' | 'old'; autoQueueEnabled?: boolean }
|
||||
pendingQueue.value = state.pendingQueue || []
|
||||
activeTaskId.value = state.activeTaskId || null
|
||||
activeQueueItem.value = state.activeQueueItem || null
|
||||
activeDispatched.value = state.activeDispatched || false
|
||||
activeCountryCodes.value = state.activeCountryCodes || []
|
||||
activeZiniaoVersion.value = state.activeZiniaoVersion || 'new'
|
||||
autoQueueEnabled.value = state.autoQueueEnabled || false
|
||||
} catch { pendingQueue.value = []; activeTaskId.value = null; activeQueueItem.value = null }
|
||||
try { pollingTaskIds.value = JSON.parse(window.localStorage.getItem(storageKey('polling-task-ids')) || '[]') } catch { pollingTaskIds.value = [] }
|
||||
try { taskSnapshots.value = JSON.parse(window.localStorage.getItem(storageKey('task-snapshots')) || '{}') } catch { taskSnapshots.value = {} }
|
||||
if (activeDispatched.value && activeQueueItem.value) removeMatchedRow(activeQueueItem.value)
|
||||
}
|
||||
|
||||
function saveQueueState() {
|
||||
setStorage('queue', { pendingQueue: pendingQueue.value, activeTaskId: activeTaskId.value, activeQueueItem: activeQueueItem.value, activeDispatched: activeDispatched.value, activeCountryCodes: activeCountryCodes.value, activeZiniaoVersion: activeZiniaoVersion.value, autoQueueEnabled: autoQueueEnabled.value })
|
||||
setStorage('polling-task-ids', pollingTaskIds.value)
|
||||
setStorage('task-snapshots', taskSnapshots.value)
|
||||
}
|
||||
|
||||
function mergeItems(base: ShopDataCrawlShopItem[], incoming: ShopDataCrawlShopItem[]) {
|
||||
const map = new Map(base.map((item) => [rowKey(item), item]))
|
||||
for (const item of incoming) map.set(rowKey(item), item)
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function saveMatched() { setStorage('matched', matchedItems.value) }
|
||||
|
||||
function onSelectionChange(rows: ShopDataCrawlCandidateVo[]) { selectedCandidates.value = rows || [] }
|
||||
function onCountryNativeChange(code: string, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.checked && isCountrySelectionLocked(code)) { input.checked = true; ElMessage.warning('至少保留 1 个国家'); return }
|
||||
countryPrefUserTouched.value = true
|
||||
orderedCountryCodes.value = input.checked ? [...orderedCountryCodes.value, code] : orderedCountryCodes.value.filter((item) => item !== code)
|
||||
scheduleCountryPreferenceSave()
|
||||
}
|
||||
function onCountryDrop(toIndex: number) {
|
||||
const from = dragCountryIndex.value
|
||||
dragCountryIndex.value = null
|
||||
if (from == null || from === toIndex) return
|
||||
const next = [...orderedCountryCodes.value]
|
||||
const [item] = next.splice(from, 1)
|
||||
next.splice(toIndex, 0, item)
|
||||
orderedCountryCodes.value = next
|
||||
countryPrefUserTouched.value = true
|
||||
scheduleCountryPreferenceSave()
|
||||
}
|
||||
function scheduleCountryPreferenceSave() {
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
countryPrefSaveTimer = timers.setTimeout('preference-save', () => { countryPrefSaveTimer = null; void persistCountryPreference() }, 450)
|
||||
}
|
||||
async function persistCountryPreference() {
|
||||
countryPrefSaving.value = true
|
||||
try { await putShopDataCrawlCountryPreference(orderedCountryCodes.value) }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存国家顺序失败') }
|
||||
finally { countryPrefSaving.value = false }
|
||||
}
|
||||
|
||||
async function loadCandidates() { candidates.value = await listShopDataCrawlCandidates() }
|
||||
async function loadDashboard() { dashboard.value = await getShopDataCrawlDashboard() }
|
||||
async function loadHistory() {
|
||||
const localFailures = historyItems.value.filter((item) => !item.taskId && !item.resultId && item.taskStatus === 'FAILED')
|
||||
historyItems.value = [...localFailures, ...((await getShopDataCrawlHistory()).items || [])]
|
||||
}
|
||||
async function refreshViews() { await Promise.all([loadDashboard(), loadHistory()]) }
|
||||
|
||||
async function confirmAdd() {
|
||||
const name = shopInput.value.trim()
|
||||
if (!name) { ElMessage.warning('请输入店铺名'); return }
|
||||
adding.value = true
|
||||
try { await addShopDataCrawlCandidate(name); shopInput.value = ''; await Promise.all([loadCandidates(), loadDashboard()]); ElMessage.success('已加入备选区') }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : '添加失败') }
|
||||
finally { adding.value = false }
|
||||
}
|
||||
async function removeCandidate(id: number) {
|
||||
try { await deleteShopDataCrawlCandidate(id); selectedCandidates.value = selectedCandidates.value.filter((item) => item.id !== id); await Promise.all([loadCandidates(), loadDashboard()]); ElMessage.success('已删除') }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : '删除失败') }
|
||||
}
|
||||
async function runMatch() {
|
||||
const names = selectedCandidates.value.map((item) => item.shop_name).filter(Boolean)
|
||||
if (!names.length) { ElMessage.warning('请先勾选备选店铺'); return }
|
||||
matching.value = true
|
||||
try {
|
||||
const incoming = (await matchShopDataCrawlShops(names)).items || []
|
||||
matchedItems.value = mergeItems(matchedItems.value, incoming)
|
||||
saveMatched()
|
||||
if (autoQueueEnabled.value) {
|
||||
const activeKey = activeQueueItem.value ? rowKey(activeQueueItem.value) : ''
|
||||
pendingQueue.value = mergeItems(
|
||||
pendingQueue.value,
|
||||
incoming.filter((item) => item.matched && rowKey(item) !== activeKey),
|
||||
)
|
||||
saveQueueState()
|
||||
void processQueue()
|
||||
}
|
||||
ElMessage.success(`匹配完成,共 ${incoming.length} 条`)
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '匹配失败') }
|
||||
finally { matching.value = false }
|
||||
}
|
||||
function removeMatchedRow(item: ShopDataCrawlShopItem) {
|
||||
const key = rowKey(item)
|
||||
matchedItems.value = matchedItems.value.filter((row) => rowKey(row) !== key)
|
||||
pendingQueue.value = pendingQueue.value.filter((row) => rowKey(row) !== key)
|
||||
saveMatched(); saveQueueState()
|
||||
}
|
||||
|
||||
function buildTaskItem(item: ShopDataCrawlShopItem): ShopDataCrawlShopItem { return { ...item } }
|
||||
function mergeProgress(detail: ShopDataCrawlTaskDetailVo) {
|
||||
const taskId = detail.task?.id || detail.items?.[0]?.taskId
|
||||
if (!taskId) return
|
||||
taskSnapshots.value = { ...taskSnapshots.value, [taskId]: detail }
|
||||
const incoming = (detail.items || []).map((item) => ({ ...item, taskId: item.taskId || taskId, taskStatus: item.taskStatus || detail.task?.status, error: item.error || detail.task?.errorMessage }))
|
||||
const map = new Map(historyItems.value.map((item) => [historyKey(item), item]))
|
||||
for (const item of incoming) {
|
||||
const existing = [...map.values()].find((row) => row.taskId === item.taskId && (row.resultId === item.resultId || !item.resultId))
|
||||
if (existing) map.set(historyKey(existing), { ...existing, ...item })
|
||||
else map.set(historyKey(item), item)
|
||||
}
|
||||
historyItems.value = [...map.values()]
|
||||
saveQueueState()
|
||||
}
|
||||
function isTaskDetail(row: ShopDataCrawlTaskDetailVo | ShopDataCrawlHistoryItem): row is ShopDataCrawlTaskDetailVo {
|
||||
return 'task' in row || 'items' in row
|
||||
}
|
||||
async function refreshTaskProgress(taskId: number) {
|
||||
const batch = await getShopDataCrawlTaskProgressBatch([taskId])
|
||||
for (const row of batch.items || []) {
|
||||
if (isTaskDetail(row)) {
|
||||
mergeProgress(row)
|
||||
} else {
|
||||
mergeProgress({ task: { id: row.taskId, status: row.taskStatus }, items: [row] })
|
||||
}
|
||||
}
|
||||
return !(batch.missingTaskIds || []).includes(taskId)
|
||||
}
|
||||
function activeTaskStatus(taskId: number) {
|
||||
return taskSnapshots.value[taskId]?.task?.status || historyItems.value.find((item) => item.taskId === taskId)?.taskStatus || ''
|
||||
}
|
||||
function sleep(ms: number) { return disposed ? Promise.resolve() : timers.sleep('queue-wait', ms) }
|
||||
function isTransientError(error: unknown) { return /network|fetch|timeout|50[0234]|load failed|connection refused|econnrefused|无法连接.*后端|后端服务.*(?:不可用|连接)|网络错误/i.test(error instanceof Error ? error.message : String(error || '')) }
|
||||
async function retry<T>(action: () => Promise<T>) {
|
||||
let attempt = 0
|
||||
while (!disposed) {
|
||||
try { return await action() } catch (error) {
|
||||
if (!isTransientError(error)) throw error
|
||||
attempt += 1; queueStatus.value = `服务暂时不可用,正在重试(${attempt})`; await sleep(getTaskPollIntervalMs())
|
||||
}
|
||||
}
|
||||
throw new Error('页面已关闭')
|
||||
}
|
||||
async function waitForTerminal(taskId: number) {
|
||||
while (!disposed) {
|
||||
try {
|
||||
const exists = await refreshTaskProgress(taskId)
|
||||
if (!exists) return 'FAILED'
|
||||
const status = activeTaskStatus(taskId)
|
||||
if (isTerminal(status)) return status
|
||||
} catch (error) {
|
||||
if (!isTransientError(error)) throw error
|
||||
queueStatus.value = `任务 ${taskId} 执行中,等待服务恢复...`
|
||||
}
|
||||
await sleep(getTaskPollIntervalMs())
|
||||
}
|
||||
return 'STOPPED'
|
||||
}
|
||||
function clearActiveTask() {
|
||||
const taskId = activeTaskId.value
|
||||
activeTaskId.value = null; activeQueueItem.value = null; activeDispatched.value = false
|
||||
activeCountryCodes.value = []; activeZiniaoVersion.value = 'new'
|
||||
if (taskId) pollingTaskIds.value = pollingTaskIds.value.filter((id) => id !== taskId)
|
||||
saveQueueState()
|
||||
}
|
||||
function recordLocalFailure(item: ShopDataCrawlShopItem, error: unknown) {
|
||||
historyItems.value = [{ ...item, taskStatus: 'FAILED', success: false, error: error instanceof Error ? error.message : String(error || '任务失败'), createdAt: new Date().toISOString() }, ...historyItems.value]
|
||||
}
|
||||
async function dispatchActiveTask(api: NonNullable<ReturnType<typeof getPywebviewApi>>) {
|
||||
if (!activeTaskId.value || !activeQueueItem.value || !api.enqueue_json) return false
|
||||
const payload = {
|
||||
type: 'shop-data-crawl-run',
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
taskId: activeTaskId.value,
|
||||
ziniao_version: activeZiniaoVersion.value,
|
||||
items: [buildTaskItem(activeQueueItem.value)],
|
||||
country_codes: [...activeCountryCodes.value],
|
||||
},
|
||||
}
|
||||
const pushed = await api.enqueue_json(payload)
|
||||
if (!pushed?.success) throw new Error(pushed?.error || `任务 ${activeTaskId.value} 入队失败`)
|
||||
activeDispatched.value = true
|
||||
saveQueueState()
|
||||
return true
|
||||
}
|
||||
async function processQueue() {
|
||||
if (disposed || queueWorkerRunning.value) return
|
||||
queueWorkerRunning.value = true
|
||||
try {
|
||||
const api = getPywebviewApi()
|
||||
if (!api?.enqueue_json) throw new Error('当前客户端不支持任务队列')
|
||||
while (!disposed && (activeTaskId.value || pendingQueue.value.length)) {
|
||||
if (activeTaskId.value) {
|
||||
if (!activeDispatched.value) {
|
||||
const taskId = activeTaskId.value
|
||||
const item = activeQueueItem.value
|
||||
try {
|
||||
await dispatchActiveTask(api)
|
||||
if (item) removeMatchedRow(item)
|
||||
queueStatus.value = `任务 ${taskId} 执行中,剩余 ${pendingQueue.value.length} 条`
|
||||
} catch (error) {
|
||||
await deleteShopDataCrawlTask(taskId).catch(() => undefined)
|
||||
if (item) recordLocalFailure(item, error)
|
||||
clearActiveTask()
|
||||
queueStatus.value = `任务 ${taskId} 入队失败,继续下一店`
|
||||
continue
|
||||
}
|
||||
}
|
||||
const taskId = activeTaskId.value
|
||||
const status = await waitForTerminal(taskId)
|
||||
clearActiveTask()
|
||||
queueStatus.value = `任务 ${taskId} ${status === 'SUCCESS' || status === 'COMPLETED' ? '已完成' : '失败'},剩余 ${pendingQueue.value.length} 条`
|
||||
continue
|
||||
}
|
||||
const next = pendingQueue.value.shift()
|
||||
saveQueueState()
|
||||
if (!next) break
|
||||
const countries = [...orderedCountryCodes.value]
|
||||
let created
|
||||
try {
|
||||
created = await retry(() => createShopDataCrawlTask([buildTaskItem(next)], countries))
|
||||
if (!created.taskId) throw new Error('后端未返回任务 ID')
|
||||
} catch (error) {
|
||||
if (disposed) return
|
||||
recordLocalFailure(next, error)
|
||||
queueStatus.value = `${next.shopName} 创建任务失败,继续下一店`
|
||||
continue
|
||||
}
|
||||
activeTaskId.value = created.taskId
|
||||
activeQueueItem.value = next
|
||||
activeDispatched.value = false
|
||||
activeCountryCodes.value = countries
|
||||
activeZiniaoVersion.value = ziniaoVersion.value
|
||||
pollingTaskIds.value = [...new Set([...pollingTaskIds.value, created.taskId])]
|
||||
const createdItems = (created.items || []).map((item) => ({ ...item, taskId: item.taskId || created.taskId }))
|
||||
taskSnapshots.value = { ...taskSnapshots.value, [created.taskId]: { task: { id: created.taskId, status: createdItems[0]?.taskStatus }, items: createdItems } }
|
||||
historyItems.value = [...createdItems, ...historyItems.value.filter((item) => item.taskId !== created.taskId)]
|
||||
saveQueueState()
|
||||
continue
|
||||
}
|
||||
if (!disposed) {
|
||||
autoQueueEnabled.value = false
|
||||
saveQueueState()
|
||||
queueStatus.value = '串行抓取已完成'
|
||||
await refreshViews()
|
||||
ElMessage.success('店铺数据抓取队列已完成')
|
||||
}
|
||||
} catch (error) {
|
||||
if (!disposed) { queueStatus.value = error instanceof Error ? error.message : '队列执行失败'; ElMessage.error(queueStatus.value) }
|
||||
} finally { queueWorkerRunning.value = false; saveQueueState() }
|
||||
}
|
||||
function startQueue() {
|
||||
const runnable = matchedRunnableItems.value
|
||||
if (!runnable.length) { ElMessage.warning('请先匹配可用店铺'); return }
|
||||
autoQueueEnabled.value = true
|
||||
pendingQueue.value = mergeItems(pendingQueue.value, runnable)
|
||||
saveQueueState()
|
||||
queueStatus.value = `已加入 ${runnable.length} 条店铺`
|
||||
void processQueue()
|
||||
}
|
||||
|
||||
async function downloadResult(item: ShopDataCrawlHistoryItem) {
|
||||
if (!item.resultId) return
|
||||
const filename = item.outputFilename || `${item.shopName || 'shop-data'}.xlsx`
|
||||
const result = await saveUrlWithProgress(getShopDataCrawlResultDownloadUrl(item.resultId), filename, `shop-data-crawl:${item.resultId}`)
|
||||
if (result.success) ElMessage.success(`已保存:${result.path || filename}`)
|
||||
else if (result.error && result.error !== '用户取消') ElMessage.error(result.error)
|
||||
}
|
||||
async function deleteTaskRecord(item: ShopDataCrawlHistoryItem) {
|
||||
try {
|
||||
if (!item.taskId && !item.resultId) {
|
||||
historyItems.value = historyItems.value.filter((row) => row !== item)
|
||||
ElMessage.success('已删除')
|
||||
return
|
||||
}
|
||||
if (item.taskId && !isTerminal(item.taskStatus)) await deleteShopDataCrawlTask(item.taskId)
|
||||
else if (item.resultId) await deleteShopDataCrawlHistory(item.resultId)
|
||||
else if (item.taskId) await deleteShopDataCrawlTask(item.taskId)
|
||||
else throw new Error('缺少记录标识')
|
||||
if (item.taskId === activeTaskId.value) clearActiveTask()
|
||||
historyItems.value = historyItems.value.filter((row) => historyKey(row) !== historyKey(item))
|
||||
await loadDashboard(); ElMessage.success('已删除')
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadLocalState()
|
||||
await Promise.allSettled([
|
||||
loadCandidates(), loadDashboard(), loadHistory(),
|
||||
getShopDataCrawlCountryPreference().then((preference) => {
|
||||
if (!countryPrefUserTouched.value && preference.country_codes?.length) orderedCountryCodes.value = preference.country_codes
|
||||
}).catch(() => undefined),
|
||||
])
|
||||
if (activeTaskId.value || pendingQueue.value.length) {
|
||||
autoQueueEnabled.value = true
|
||||
queueStatus.value = '检测到未完成队列,正在恢复...'
|
||||
void processQueue()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
if (countryPrefSaveTimer) timers.clearTimer('preference-save', countryPrefSaveTimer)
|
||||
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; padding: 20px; overflow-y: auto; border-right: 1px solid #2a2a2a; background: #1e1e1e; }
|
||||
.right-panel { flex: 1; min-width: 0; background: #1a1a1a; }
|
||||
.section-title { margin-bottom: 10px; color: #bbb; font-size: 13px; }
|
||||
.input-zone { margin-bottom: 18px; padding: 14px; border: 1px dashed #3a3a3a; border-radius: 8px; background: #252525; }
|
||||
.input-row { display: flex; gap: 10px; }
|
||||
.opt-btn, .btn-run { min-height: 36px; padding: 0 16px; border: 0; border-radius: 5px; background: #409eff; color: #fff; white-space: nowrap; cursor: pointer; }
|
||||
.opt-btn { flex: 0 0 auto; }
|
||||
.opt-btn:disabled, .btn-run:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.empty-candidates, .empty-tasks { padding: 16px; border: 1px dashed #333; border-radius: 6px; color: #777; font-size: 13px; }
|
||||
.candidate-table-scroll { margin-bottom: 18px; border: 1px solid #2a2a2a; border-radius: 6px; overflow: hidden; }
|
||||
.candidate-table { --el-table-bg-color: #252525; --el-table-tr-bg-color: #252525; --el-table-header-bg-color: #2a2a2a; --el-table-text-color: #ccc; --el-table-border-color: #333; }
|
||||
.link-danger { border: 0; background: transparent; color: #f56c6c; cursor: pointer; }
|
||||
.country-pref-checks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 10px; }
|
||||
.country-check-row { display: flex; align-items: center; gap: 7px; min-height: 32px; color: #ccc; font-size: 13px; }
|
||||
.country-check-input { width: 15px; height: 15px; }
|
||||
.country-order-panel { margin-bottom: 8px; padding: 10px; border: 1px solid #333; border-radius: 6px; background: #242424; }
|
||||
.country-order-caption { margin-bottom: 8px; color: #888; font-size: 12px; }
|
||||
.country-order-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.country-drag-row { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 0 9px; border: 1px solid #383838; border-radius: 4px; color: #ccc; font-size: 13px; cursor: grab; }
|
||||
.country-drag-row.dragging { opacity: .5; }
|
||||
.drag-handle { color: #777; }
|
||||
.country-pref-status, .queue-status { color: #8dc4ff; font-size: 12px; line-height: 1.5; }
|
||||
.run-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
||||
.btn-queue { background: #67c23a; }
|
||||
.panel-header { height: 52px; padding: 16px 22px; border-bottom: 1px solid #2a2a2a; color: #eee; font-size: 15px; font-weight: 700; }
|
||||
.task-list-wrap { height: calc(100% - 52px); padding: 18px 22px 28px; overflow-y: auto; }
|
||||
.clean-result-summary { display: grid; grid-template-columns: repeat(4, minmax(100px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
||||
.summary-card { display: flex; min-height: 70px; flex-direction: column; justify-content: center; padding: 12px 16px; border: 1px solid #303030; border-radius: 6px; background: #222; }
|
||||
.summary-label { margin-bottom: 4px; color: #888; font-size: 12px; }
|
||||
.summary-card strong { color: #fff; font-size: 22px; }
|
||||
.subsection-title, .result-subsection-title { margin: 18px 0 10px; color: #bbb; font-size: 13px; font-weight: 700; }
|
||||
.match-table { margin-bottom: 20px; --el-table-bg-color: #222; --el-table-tr-bg-color: #222; --el-table-header-bg-color: #292929; --el-table-text-color: #ccc; --el-table-border-color: #333; }
|
||||
.ok { color: #67c23a; }.fail, .error-text { color: #f56c6c; }
|
||||
.result-list-header { padding: 13px 0; border-bottom: 1px solid #303030; color: #eee; font-weight: 700; }
|
||||
.task-list { margin: 0; padding: 0; list-style: none; }
|
||||
:deep(.task-item) { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-height: 84px; padding: 14px 0; border-bottom: 1px solid #2b2b2b; }
|
||||
:deep(.split-result-main) { min-width: 0; }
|
||||
:deep(.id) { display: block; margin-bottom: 5px; color: #eee; font-weight: 700; }
|
||||
:deep(.files) { margin-top: 3px; color: #888; font-size: 12px; }
|
||||
:deep(.task-right) { display: flex; align-items: center; gap: 10px; }
|
||||
:deep(.status) { min-width: 52px; font-size: 12px; text-align: center; }
|
||||
:deep(.status.success) { color: #67c23a; }:deep(.status.failed) { color: #f56c6c; }:deep(.status.running) { color: #e6a23c; }
|
||||
:deep(.download), :deep(.btn-delete) { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #ccc; cursor: pointer; }
|
||||
:deep(.download) { border-color: #409eff; color: #8dc4ff; }:deep(.btn-delete) { color: #f56c6c; }
|
||||
@media (max-width: 900px) { .main-content { height: auto; flex-direction: column; }.left-panel { width: 100%; border-right: 0; }.clean-result-summary { grid-template-columns: repeat(2, 1fr); } }
|
||||
</style>
|
||||
@@ -792,7 +792,9 @@ function isResultPreparing(item: SimilarAsinHistoryItem) {
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(item: SimilarAsinHistoryItem) {
|
||||
return (item.taskStatus || '').toUpperCase()
|
||||
const taskStatus = (item.taskStatus || '').toUpperCase()
|
||||
if (taskStatus !== 'FAILED' && isResultFileComplete(item)) return 'SUCCESS'
|
||||
return taskStatus
|
||||
}
|
||||
|
||||
function isResultBuildFailed(item: SimilarAsinHistoryItem) {
|
||||
@@ -821,7 +823,16 @@ function canDownload(item: SimilarAsinHistoryItem) {
|
||||
return Boolean(item.resultId && (item.fileReady || item.downloadUrl))
|
||||
}
|
||||
|
||||
function isResultFileComplete(item: SimilarAsinHistoryItem) {
|
||||
return Boolean(
|
||||
item.fileReady
|
||||
|| item.downloadUrl
|
||||
|| (item.fileStatus || '').toUpperCase() === 'SUCCESS',
|
||||
)
|
||||
}
|
||||
|
||||
function fileProgressPercent(item: SimilarAsinHistoryItem) {
|
||||
if (isResultFileComplete(item)) return 100
|
||||
const percent = Number(item.fileProgressPercent || 0)
|
||||
if (Number.isFinite(percent) && percent > 0) {
|
||||
return Math.max(0, Math.min(100, Math.round(percent)))
|
||||
|
||||
@@ -52,6 +52,7 @@ type ActiveNavKey =
|
||||
| 'pricing'
|
||||
| 'patrol-delete'
|
||||
| 'query-asin'
|
||||
| 'shop-data-crawl'
|
||||
| 'withdraw'
|
||||
| 'collect-data'
|
||||
| 'image-video'
|
||||
@@ -85,7 +86,7 @@ const active = props.active
|
||||
const showNav = props.showNav
|
||||
const showHomeLink = props.showHomeLink
|
||||
const showActions = props.showActions
|
||||
const allowedColumnKeys = ref<string[] | null>(null)
|
||||
const allowedColumnKeys = ref<string[]>([])
|
||||
|
||||
const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{
|
||||
@@ -114,6 +115,7 @@ const navGroups: ReadonlyArray<NavGroup> = [
|
||||
{ key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html', aliases: ['price-track'] },
|
||||
{ key: 'patrol-delete', label: '巡店删除', href: '/new_web_source/patrol-delete.html' },
|
||||
{ key: 'query-asin', label: '查询ASIN', href: '/new_web_source/query-asin.html' },
|
||||
{ key: 'shop-data-crawl', label: '店铺数据抓取', href: '/new_web_source/shop-data-crawl.html', columnKey: 'shop_data_crawl', aliases: ['shop-data-crawl'] },
|
||||
{ key: 'withdraw', label: '取款', href: '/new_web_source/withdraw.html' },
|
||||
{ key: 'shop-status', label: '店铺状态查询' },
|
||||
],
|
||||
@@ -135,9 +137,6 @@ function getItemPermissionKeys(item: NavItem) {
|
||||
}
|
||||
|
||||
const visibleNavGroups = computed(() => {
|
||||
if (allowedColumnKeys.value === null) {
|
||||
return navGroups
|
||||
}
|
||||
const allowedSet = new Set(allowedColumnKeys.value)
|
||||
|
||||
const groups = navGroups.flatMap((group) => {
|
||||
@@ -150,14 +149,14 @@ const visibleNavGroups = computed(() => {
|
||||
return items.length ? [{ ...group, items }] : []
|
||||
})
|
||||
|
||||
return groups.length ? groups : navGroups
|
||||
return groups
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
allowedColumnKeys.value = await getCurrentUserAppColumnKeys()
|
||||
} catch (_error) {
|
||||
allowedColumnKeys.value = null
|
||||
allowedColumnKeys.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
<a class="back-link" href="/home">返回首页</a>
|
||||
</header>
|
||||
|
||||
<main class="entrances" aria-label="视频入口">
|
||||
<main class="entrances" :class="{ 'is-loading': permissionsLoading }" aria-label="视频与图片入口">
|
||||
<button
|
||||
v-if="hasMenuPermission('digital-human')"
|
||||
type="button"
|
||||
class="entrance-btn"
|
||||
:disabled="launching"
|
||||
@@ -14,12 +15,15 @@
|
||||
>
|
||||
数字人
|
||||
</button>
|
||||
<button type="button" class="entrance-btn" @click="openDeliveryWorkspace">
|
||||
<button v-if="hasMenuPermission('delivery-video')" type="button" class="entrance-btn" @click="openDeliveryWorkspace">
|
||||
带货视频
|
||||
</button>
|
||||
<button type="button" class="entrance-btn" @click="showSoon('混剪')">
|
||||
<button v-if="hasMenuPermission('mix-video')" type="button" class="entrance-btn" @click="showSoon('混剪')">
|
||||
混剪
|
||||
</button>
|
||||
<a v-if="hasMenuPermission('image')" class="entrance-btn" href="/image">
|
||||
图片
|
||||
</a>
|
||||
</main>
|
||||
|
||||
<div class="toast" :class="{ show: Boolean(statusText), error: statusType === 'error' }">
|
||||
@@ -64,17 +68,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import PageShell from '@/components/layout/PageShell.vue'
|
||||
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
|
||||
import DeliveryVideoWorkspace from '@/pages/image-video/components/DeliveryVideoWorkspace.vue'
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
|
||||
|
||||
type ViewMode = 'menu' | 'delivery'
|
||||
|
||||
const currentView = ref<ViewMode>('menu')
|
||||
const allowedColumnKeys = ref(new Set<string>())
|
||||
const permissionsLoading = ref(true)
|
||||
const launching = ref(false)
|
||||
const statusText = ref('')
|
||||
const statusType = ref<'normal' | 'error'>('normal')
|
||||
@@ -103,6 +110,20 @@ function showSoon(name: string) {
|
||||
showStatus(`${name}暂未开通,敬请期待`)
|
||||
}
|
||||
|
||||
function hasMenuPermission(columnKey: string) {
|
||||
return allowedColumnKeys.value.has('wb') || allowedColumnKeys.value.has(columnKey)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
allowedColumnKeys.value = new Set(await getCurrentUserAppColumnKeys())
|
||||
} catch (_error) {
|
||||
showStatus('菜单权限加载失败', 'error')
|
||||
} finally {
|
||||
permissionsLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function openDeliveryWorkspace() {
|
||||
currentView.value = 'delivery'
|
||||
}
|
||||
@@ -265,6 +286,10 @@ async function launchDesktop() {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.entrances.is-loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.entrance-btn {
|
||||
width: 160px;
|
||||
height: 100px;
|
||||
|
||||
@@ -1569,6 +1569,191 @@ export function deleteQueryAsinHistory(resultId: number) {
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 店铺数据抓取 ==========
|
||||
|
||||
export interface ShopDataCrawlCandidateVo {
|
||||
id: number;
|
||||
shop_name: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlCountryPreferenceVo {
|
||||
country_codes: string[];
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlShopItem {
|
||||
shopName: string;
|
||||
matched: boolean;
|
||||
shopId?: string;
|
||||
platform?: string;
|
||||
companyName?: string;
|
||||
openStoreUrl?: string;
|
||||
matchedUserId?: number;
|
||||
matchStatus?: string;
|
||||
matchMessage?: string;
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlMatchVo {
|
||||
items: ShopDataCrawlShopItem[];
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlDashboardVo {
|
||||
candidateCount: number;
|
||||
processedTaskCount: number;
|
||||
successTaskCount: number;
|
||||
failedTaskCount: number;
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlHistoryItem extends ShopDataCrawlShopItem {
|
||||
resultId?: number;
|
||||
taskId?: number;
|
||||
taskStatus?: string;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
fileReady?: boolean;
|
||||
fileStatus?: string;
|
||||
downloadUrl?: string;
|
||||
outputFilename?: string;
|
||||
createdAt?: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlHistoryVo {
|
||||
items: ShopDataCrawlHistoryItem[];
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlTaskSummary {
|
||||
id?: number;
|
||||
status?: string;
|
||||
errorMessage?: string;
|
||||
createdAt?: string;
|
||||
finishedAt?: string;
|
||||
countryCodes?: string[];
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlTaskDetailVo {
|
||||
task?: ShopDataCrawlTaskSummary;
|
||||
items?: ShopDataCrawlHistoryItem[];
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlTaskBatchVo {
|
||||
items: Array<ShopDataCrawlTaskDetailVo | ShopDataCrawlHistoryItem>;
|
||||
missingTaskIds?: number[];
|
||||
}
|
||||
|
||||
export interface ShopDataCrawlCreateTaskVo {
|
||||
taskId: number;
|
||||
items: ShopDataCrawlHistoryItem[];
|
||||
}
|
||||
|
||||
export function listShopDataCrawlCandidates() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ShopDataCrawlCandidateVo[]>>(`${JAVA_API_PREFIX}/shop-data-crawl/candidates`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function addShopDataCrawlCandidate(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ShopDataCrawlCandidateVo>, { user_id: number; shop_name: string }>(
|
||||
`${JAVA_API_PREFIX}/shop-data-crawl/candidates`,
|
||||
{ user_id: getCurrentUserId(), shop_name: shopName },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteShopDataCrawlCandidate(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/shop-data-crawl/candidates/${id}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getShopDataCrawlCountryPreference() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ShopDataCrawlCountryPreferenceVo>>(
|
||||
`${JAVA_API_PREFIX}/shop-data-crawl/country-preference`,
|
||||
{ params: { user_id: getCurrentUserId() } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function putShopDataCrawlCountryPreference(countryCodes: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
put<JavaApiResponse<ShopDataCrawlCountryPreferenceVo>, { user_id: number; country_codes: string[] }>(
|
||||
`${JAVA_API_PREFIX}/shop-data-crawl/country-preference`,
|
||||
{ user_id: getCurrentUserId(), country_codes: countryCodes },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchShopDataCrawlShops(shopNames: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ShopDataCrawlMatchVo>, { user_id: number; shop_names: string[] }>(
|
||||
`${JAVA_API_PREFIX}/shop-data-crawl/match-shops`,
|
||||
{ user_id: getCurrentUserId(), shop_names: shopNames },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getShopDataCrawlDashboard() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ShopDataCrawlDashboardVo>>(`${JAVA_API_PREFIX}/shop-data-crawl/dashboard`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getShopDataCrawlHistory() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<ShopDataCrawlHistoryVo>>(`${JAVA_API_PREFIX}/shop-data-crawl/history`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function createShopDataCrawlTask(items: ShopDataCrawlShopItem[], countryCodes: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<
|
||||
JavaApiResponse<ShopDataCrawlCreateTaskVo>,
|
||||
{ user_id: number; items: ShopDataCrawlShopItem[]; country_codes: string[] }
|
||||
>(`${JAVA_API_PREFIX}/shop-data-crawl/tasks`, {
|
||||
user_id: getCurrentUserId(),
|
||||
items,
|
||||
country_codes: countryCodes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getShopDataCrawlTaskProgressBatch(taskIds: number[]) {
|
||||
return postTaskProgressBatch<ShopDataCrawlTaskBatchVo>(
|
||||
`${JAVA_API_PREFIX}/shop-data-crawl/tasks/progress/batch`,
|
||||
taskIds,
|
||||
);
|
||||
}
|
||||
|
||||
export function getShopDataCrawlResultDownloadUrl(resultId: number) {
|
||||
return getJavaDownloadUrl(`/shop-data-crawl/results/${resultId}/download`);
|
||||
}
|
||||
|
||||
export function deleteShopDataCrawlTask(taskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/shop-data-crawl/tasks/${taskId}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteShopDataCrawlHistory(resultId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/shop-data-crawl/history/${resultId}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 取款 ==========
|
||||
|
||||
export type WithdrawCandidateVo = QueryAsinCandidateVo;
|
||||
@@ -3324,6 +3509,14 @@ export function getPublishHistory() {
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePublishTask(taskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(`${JAVA_API_PREFIX}/publish/tasks/${taskId}`, {
|
||||
params: { user_id: getCurrentUserId() },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getJavaDownloadUrl(path: string) {
|
||||
let raw =
|
||||
path.startsWith("http://") || path.startsWith("https://")
|
||||
|
||||
@@ -8,6 +8,10 @@ export interface PermissionMenuItem {
|
||||
route_path?: string
|
||||
routePath?: string
|
||||
menu_type?: string
|
||||
parent_id?: number | string | null
|
||||
parentId?: number | string | null
|
||||
root_column_key?: string
|
||||
rootColumnKey?: string
|
||||
sort_order?: number
|
||||
created_at?: string
|
||||
}
|
||||
@@ -54,16 +58,6 @@ export async function getCurrentUserAppColumnKeys() {
|
||||
const uid = getCurrentUserId()
|
||||
const cacheKey = getAppPermissionCacheKey(uid)
|
||||
|
||||
try {
|
||||
const cachedItems = JSON.parse(window.localStorage.getItem(cacheKey) || 'null') as PermissionMenuItem[] | null
|
||||
if (Array.isArray(cachedItems)) {
|
||||
const cachedKeys = normalizeColumnKeys(cachedItems)
|
||||
if (cachedKeys.length) {
|
||||
return cachedKeys
|
||||
}
|
||||
}
|
||||
} catch (_error) {}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
const token = getAuthToken()
|
||||
if (token) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import BrandShopDataCrawlTab from '@/pages/brand/components/BrandShopDataCrawlTab.vue'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
createApp(BrandShopDataCrawlTab).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
@@ -54,6 +54,7 @@ export default defineConfig({
|
||||
'price-track': resolve(__dirname, 'price-track.html'),
|
||||
'patrol-delete': resolve(__dirname, 'patrol-delete.html'),
|
||||
'query-asin': resolve(__dirname, 'query-asin.html'),
|
||||
'shop-data-crawl': resolve(__dirname, 'shop-data-crawl.html'),
|
||||
withdraw: resolve(__dirname, 'withdraw.html'),
|
||||
'collect-data': resolve(__dirname, 'collect-data.html'),
|
||||
'image-video': resolve(__dirname, 'image-video.html'),
|
||||
|
||||
Reference in New Issue
Block a user