Files
crawler-plugin/frontend-vue/src/pages/brand/components/BrandBrandTab.vue
T
huangzd1997 30b8c910e8 feat(web): 桌面端前端 Web 独立部署适配——浏览器降级桥+登录态引导+页面动态修正
- 新增 web-fallback.ts:无 pywebview 时以浏览器能力降级(文件选择/上传/模板下载/配置),
  其余桌面能力(enqueue_json/vc_*/folder 选择等)保持 undefined 交由页面既有请在本地客户端中打开提示
- 新增 ensure-auth.ts:无 uid 时经 /newApi/check_login 恢复登录态,无 token/失效跳登录页;
  21 个入口 main.ts 挂载前先引导(桌面端已注入 uid 时零开销)
- pywebview.ts:getPywebviewApi() 在纯浏览器环境返回降级桥;新增 isDesktopRuntime 标志
  (首页更新面板/退出链接据此区分桌面与 Web);hasPywebview 语义修正为真实桥判定
- 登录页跳转、首页登出链接、品牌检测页 hasBridge 判定(select_brand_folder 存在性)适配 Web
2026-09-07 16:35:19 +08:00

496 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-shell module-page">
<AmazonToolPageShell tool-id="brand">
<div class="main-content">
<aside class="left-panel">
<div class="section-title">待检品牌文件</div>
<div class="upload-zone">
<div class="hint">
Excel 每行含品牌列即会加入检测可多选文件或选择整个文件夹.xlsx)。检测由本机客户端执行请在桌面客户端中打开本页面
</div>
<div class="btns">
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFiles">选择 Excel</button>
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="selectFolder">选择文件夹</button>
</div>
<div class="selected-files">
<template v-if="selectedPaths.length">
<span v-for="name in displayPaths" :key="name" :title="name">{{ baseName(name) }}</span>
<span v-if="selectedPaths.length > displayPaths.length" class="more-line">
还有 {{ selectedPaths.length - displayPaths.length }} 个文件未展开显示
</span>
</template>
<span v-else>暂未选择文件</span>
</div>
<div v-if="!hasBridge" class="bridge-tip">当前浏览器环境无桌面客户端桥接仅可预览页面请用本机客户端打开</div>
</div>
<div class="section-title">匹配策略</div>
<div class="radio-cards">
<label class="radio-card" :class="{ selected: strategy === 'Terms' }">
<input type="radio" name="brand-strategy" value="Terms" v-model="strategy" />
<span class="radio-card-name">词条 / 嵌入匹配Terms</span>
<span class="radio-card-desc">按品牌词条做宽范围比对误杀率低推荐第一遍使用</span>
</label>
<label class="radio-card" :class="{ selected: strategy === 'Simple' }">
<input type="radio" name="brand-strategy" value="Simple" v-model="strategy" />
<span class="radio-card-name">精确匹配Simple</span>
<span class="radio-card-desc">仅精确命中才判为疑似误杀最低</span>
</label>
</div>
<div class="section-title">运行方式</div>
<div class="run-mode-zone">
<label class="run-mode-row">
<input type="radio" name="brand-run-mode" value="immediate" v-model="runMode" />
<span class="run-mode-text">立即执行前台运行可实时取消</span>
</label>
<label class="run-mode-row">
<input type="radio" name="brand-run-mode" value="queue" v-model="runMode" />
<span class="run-mode-text">添加到任务队列后台排队执行</span>
</label>
</div>
<div class="run-row">
<button
type="button"
class="btn-run"
:disabled="!selectedPaths.length || submitting || !hasBridge || !hasUid"
@click="submitRun"
>
{{ submitting ? '提交中...' : runMode === 'immediate' ? '立即运行品牌检测' : '添加到任务队列' }}
</button>
</div>
<p v-if="!hasBridge || !hasUid" class="env-tip">
品牌检测在桌面客户端内执行请在本机客户端登录后打开本页面使用浏览器仅可预览界面)。
</p>
<p v-else class="loading-msg">
检测完成后自动生成结果 Excel含原始数据不符合品牌查询失败品牌),到右侧任务列表下载
</p>
<div class="section-title">文档模板</div>
<div class="btns">
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="downloadTemplate('xlsx')">下载 Excel 模板</button>
<button type="button" class="opt-btn" :disabled="!hasBridge" @click="downloadTemplate('zip')">下载文件夹模板</button>
</div>
</aside>
<section class="right-panel">
<div class="panel-header">
<span>品牌检测任务</span>
<button type="button" class="btn-refresh" @click="loadTasks">刷新任务状态</button>
</div>
<div class="task-list-wrap">
<div v-if="!tasks.length" class="empty-tasks">
暂无品牌检测任务选择待检文件后点击运行添加到任务队列」。
</div>
<ul v-else class="task-list">
<li v-for="item in tasks" :key="`brand-${item.id}`" class="task-item">
<div class="left">
<span class="id" :title="taskDesc(item)">{{ taskDesc(item) }}</span>
<div class="files">任务 ID{{ item.id }}</div>
<div v-if="item.file_paths?.length" class="files">文件{{ item.file_paths.length }} </div>
<div class="files">创建时间{{ formatDateTime(item.created_at) }}</div>
<template v-if="isRunning(item)">
<div class="task-progress">
<div class="task-progress-meta">
<span>任务进度</span>
<span>{{ taskProgress(item) }}%</span>
</div>
<div class="task-progress-track">
<div class="task-progress-fill" :style="{ width: `${taskProgress(item)}%` }"></div>
</div>
</div>
<div v-if="errorMessage(item)" class="files error">{{ errorMessage(item) }}</div>
</template>
<div v-else-if="errorMessage(item)" class="files error">错误{{ errorMessage(item) }}</div>
</div>
<div class="task-right">
<span class="status" :class="statusClass(item.status)">{{ statusText(item.status) }}</span>
<button v-if="isRunning(item)" type="button" class="act-btn danger" @click="cancelTask(item)">取消</button>
<button v-if="canDownload(item)" type="button" class="act-btn ok" @click="downloadResult(item)">下载结果</button>
<button v-if="canDelete(item)" type="button" class="act-btn" @click="deleteTask(item)">删除</button>
</div>
</li>
</ul>
</div>
</section>
</div>
</AmazonToolPageShell>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AmazonToolPageShell from '@/pages/amazon/components/AmazonToolPageShell.vue'
import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { requestPostJson } from '@/shared/api/http'
import {
cancelBrandTask,
createBrandTask,
deleteBrandTask,
getBrandTaskDownloadUrl,
getBrandTasks,
runBrandNow,
type BrandTaskItem,
} from '@/shared/api/brand'
interface BrandExpandFolderPathsResponse {
success: boolean
paths?: string[]
error?: string
}
const selectedPaths = ref<string[]>([])
const strategy = ref<'Terms' | 'Simple'>('Terms')
const runMode = ref<'immediate' | 'queue'>('immediate')
const submitting = ref(false)
const tasks = ref<BrandTaskItem[]>([])
// 品牌检测由桌面客户端本地执行:以"文件夹选择"桥(Web 降级桥不提供)判定桌面运行时,浏览器下仅可预览
const hasBridge = computed(() => Boolean(getPywebviewApi()?.select_brand_folder))
const hasUid = computed(() => {
const raw = typeof window === 'undefined' ? '' : window.localStorage.getItem('uid') || ''
const numeric = Number(raw.trim())
return Number.isFinite(numeric) && numeric > 0
})
const displayPaths = computed(() => selectedPaths.value.slice(0, 8))
function baseName(path: string) {
return path.split(/[\\/]/).pop() || path
}
let pollTimer: number | null = null
function hasRunning() {
return tasks.value.some((item) => isRunning(item))
}
function isRunning(item: BrandTaskItem) {
return (item.status || '').toLowerCase() === 'running'
}
function isTerminal(item: BrandTaskItem) {
const status = (item.status || '').toLowerCase()
return status === 'success' || status === 'failed' || status === 'cancelled'
}
function canDelete(item: BrandTaskItem) {
return !isRunning(item)
}
function canDownload(item: BrandTaskItem) {
const status = (item.status || '').toLowerCase()
return status === 'success' && Boolean(item.result_paths?.zip_url)
}
function taskProgress(item: BrandTaskItem) {
const total = Number(item.progress_total) || 0
const current = Number(item.progress_current) || 0
if (!total) return 0
return Math.max(0, Math.min(100, Math.round((current / total) * 100)))
}
function taskDesc(item: BrandTaskItem) {
const desc = (item.desc || '').trim()
return desc ? (desc.length > 60 ? `${desc.slice(0, 60)}…` : desc) : `任务 #${item.id}`
}
function errorMessage(item: BrandTaskItem) {
return (item.error_message || '').trim()
}
function statusText(status?: BrandTaskItem['status']) {
const value = (status || '').toLowerCase()
const map: Record<string, string> = {
pending: '等待中',
running: '执行中',
success: '已完成',
failed: '失败',
cancelled: '已取消',
}
return map[value] || String(status ?? '未知')
}
function statusClass(status?: BrandTaskItem['status']) {
const value = (status || '').toLowerCase()
if (value === 'running') return 'running'
if (value === 'success') return 'success'
if (value === 'failed') return 'failed'
if (value === 'cancelled') return 'cancelled'
return 'pending'
}
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function normalizeSelected(paths: string[]) {
selectedPaths.value = paths
.filter((path) => /\.xlsx$/i.test(path))
.filter((path, index, array) => array.indexOf(path) === index)
}
async function selectFiles() {
const bridge = getPywebviewApi()
if (!bridge?.select_brand_xlsx_files) {
ElMessage.warning('当前环境不支持文件选择,请在本机客户端中打开')
return
}
try {
const paths = await bridge.select_brand_xlsx_files()
if (!paths?.length) return
normalizeSelected(paths)
ElMessage.success(`已选择 ${selectedPaths.value.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文件选择失败')
}
}
async function selectFolder() {
const bridge = getPywebviewApi()
if (!bridge?.select_brand_folder) {
ElMessage.warning('当前环境不支持文件夹选择,请在本机客户端中打开')
return
}
try {
const folder = await bridge.select_brand_folder()
if (!folder) return
const res = await requestPostJson<BrandExpandFolderPathsResponse>('/api/brand/expand-folder', { folder })
if (!res.success || !res.paths?.length) {
ElMessage.warning(res.error || '该文件夹下没有 xlsx 文件')
return
}
normalizeSelected(res.paths)
ElMessage.success(`已选择文件夹内 ${selectedPaths.value.length} 个 Excel 文件`)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文件夹选择失败')
}
}
async function submitRun() {
if (!hasBridge.value) {
ElMessage.warning('请在桌面客户端中打开本页面执行品牌检测')
return
}
if (!hasUid.value) {
ElMessage.warning('未获取到登录用户,请先在本机客户端登录后再试')
return
}
if (!selectedPaths.value.length) {
ElMessage.warning('请先选择待检 Excel 文件')
return
}
submitting.value = true
try {
if (runMode.value === 'immediate') {
const res = await runBrandNow(selectedPaths.value, strategy.value)
if (res.success && res.task_id) {
ElMessage.success(`已创建任务 ${res.task_id},客户端开始检测`)
} else {
throw new Error((res as { error?: string }).error || '运行失败')
}
} else {
const res = await createBrandTask(selectedPaths.value, strategy.value)
if (res.success && res.task_id) {
ElMessage.success(`任务 ${res.task_id} 已添加到队列`)
} else {
throw new Error((res as { error?: string }).error || '添加失败')
}
}
selectedPaths.value = []
await loadTasks()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败')
} finally {
submitting.value = false
}
}
async function loadTasks() {
try {
const res = await getBrandTasks()
if (res.success && Array.isArray(res.items)) {
tasks.value = res.items
}
} catch (error) {
// 浏览器预览(无 uid / 无桌面后端)时静默,避免误报
if (hasBridge.value && hasUid.value) {
ElMessage.error(error instanceof Error ? error.message : '任务列表加载失败')
}
}
}
async function cancelTask(item: BrandTaskItem) {
try {
await ElMessageBox.confirm(`确认取消任务 ${item.id}`, '取消任务', { type: 'warning' })
} catch {
return
}
try {
const res = await cancelBrandTask(item.id)
if (!res.success) throw new Error((res as { error?: string }).error || '取消失败')
ElMessage.success('已取消')
await loadTasks()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '取消失败')
}
}
async function deleteTask(item: BrandTaskItem) {
try {
await ElMessageBox.confirm(`确认删除任务 ${item.id}?删除后不可恢复。`, '删除任务', { type: 'warning' })
} catch {
return
}
try {
const res = await deleteBrandTask(item.id)
if (!res.success) throw new Error((res as { error?: string }).error || '删除失败')
ElMessage.success('已删除')
await loadTasks()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '删除失败')
}
}
async function downloadResult(item: BrandTaskItem) {
const zipUrl = item.result_paths?.zip_url
const filename = `brand_task_${item.id}.zip`
const bridge = getPywebviewApi()
if (bridge?.save_file_from_url && zipUrl) {
try {
const res = await bridge.save_file_from_url(zipUrl, filename)
if (res.success) {
ElMessage.success(`已保存:${res.path || filename}`)
} else if (res.error && res.error !== '用户取消') {
ElMessage.error(res.error)
}
return
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '下载失败')
return
}
}
if (zipUrl) {
window.open(zipUrl, '_blank')
return
}
window.open(getBrandTaskDownloadUrl(item.id), '_blank')
}
async function downloadTemplate(kind: 'xlsx' | 'zip') {
const bridge = getPywebviewApi()
try {
let res: { success: boolean; path?: string; error?: string } | undefined
if (kind === 'xlsx') {
res = bridge?.save_template_xlsx ? await bridge.save_template_xlsx() : undefined
} else {
res = bridge?.save_template_zip ? await bridge.save_template_zip() : undefined
}
if (res?.success) {
ElMessage.success(`模板已保存至:${res.path}`)
} else if (res?.error) {
ElMessage.warning(res.error)
} else {
ElMessage.warning('当前环境不支持模板下载,请在本机客户端中打开')
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '模板下载失败')
}
}
function schedulePoll() {
if (pollTimer) {
window.clearTimeout(pollTimer)
pollTimer = null
}
pollTimer = window.setTimeout(async () => {
await loadTasks()
if (hasRunning()) {
schedulePoll()
}
}, 3000)
}
onMounted(() => {
void loadTasks()
})
onBeforeUnmount(() => {
if (pollTimer) {
window.clearTimeout(pollTimer)
pollTimer = null
}
})
</script>
<style scoped>
.module-page { min-height: 100vh; background: #151a25; }
.main-content { display: flex; height: calc(100vh - 56px); min-height: calc(100vh - 56px); }
.left-panel { width: 420px; background: #1c2333; padding: 20px; overflow-y: auto; border-right: 1px solid #2e3a52; }
.right-panel { flex: 1; min-width: 0; background: #151a25; display: flex; flex-direction: column; }
.section-title { font-size: 13px; color: #a0acbe; margin: 12px 0 10px; }
.hint, .loading-msg, .files, .muted { color: #5e6878; font-size: 12px; line-height: 1.5; }
.hint { margin-bottom: 12px; }
.upload-zone { border: 1px dashed #3e4a62; border-radius: 10px; padding: 16px; background: #2e3a52; margin-bottom: 6px; }
.btns, .run-row { display: flex; gap: 10px; flex-wrap: wrap; }
.run-row { margin-top: 14px; }
.opt-btn { padding: 8px 14px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 7px; cursor: pointer; font-size: 12px; }
.opt-btn:hover:not(:disabled) { color: #3498db; border-color: #3498db; }
.opt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.selected-files { margin-top: 12px; color: #a0acbe; font-size: 12px; word-break: break-all; }
.selected-files span { display: block; margin: 3px 0; }
.selected-files .more-line { color: #5e6878; }
.bridge-tip { margin-top: 10px; color: #e6a23c; font-size: 11px; }
.env-tip { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: rgba(230, 162, 60, 0.1); color: #e6a23c; font-size: 12px; line-height: 1.6; }
.radio-cards { display: flex; flex-direction: column; gap: 8px; margin-bottom: 6px; }
.radio-card { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; cursor: pointer; }
.radio-card.selected { border-color: #409eff; background: rgba(64, 158, 255, 0.1); }
.radio-card input { position: absolute; opacity: 0; pointer-events: none; }
.radio-card-name { color: #c8d2e2; font-size: 13px; }
.radio-card-desc { color: #5e6878; font-size: 11px; }
.run-mode-zone { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid #333; border-radius: 8px; background: #2e3a52; }
.run-mode-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: #c8d2e2; font-size: 12px; }
.run-mode-row input { width: 15px; height: 15px; accent-color: #409eff; }
.btn-run { padding: 10px 18px; color: #f5f8fc; background: #3498db; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
.btn-run:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-msg { margin-top: 10px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid #2e3a52; color: #f5f8fc; font-size: 15px; font-weight: 600; }
.btn-refresh { padding: 6px 12px; color: #c8d2e2; background: #2e3a52; border: 1px solid #3e4a62; border-radius: 6px; cursor: pointer; font-size: 12px; }
.task-list-wrap { flex: 1; padding: 16px 20px; overflow: auto; }
.empty-tasks { color: #5e6878; font-size: 13px; padding: 28px; text-align: center; }
.task-list { list-style: none; margin: 0; padding: 0; }
.task-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; margin-bottom: 8px; border: 1px solid #2e3a52; border-radius: 8px; background: #1c2333; }
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.id { color: #f5f8fc; font-size: 13px; font-weight: 600; }
.files.error, .error { color: #ff6b6b; }
.task-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.status { padding: 4px 10px; border-radius: 6px; font-size: 12px; }
.status.pending { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.status.running { background: rgba(52, 152, 219, 0.18); color: #3498db; }
.status.success { background: rgba(46, 204, 113, 0.18); color: #2ecc71; }
.status.failed { background: rgba(231, 76, 60, 0.18); color: #ff6b6b; }
.status.cancelled { background: rgba(149, 165, 166, 0.18); color: #a0acbe; }
.act-btn { padding: 5px 10px; border-radius: 6px; font-size: 12px; background: #2e3a52; color: #c8d2e2; border: 1px solid #3e4a62; cursor: pointer; }
.act-btn.ok { background: rgba(52, 152, 219, 0.18); color: #69b6ff; border-color: transparent; }
.act-btn.danger { background: rgba(231, 76, 60, 0.15); color: #ff8f8f; border-color: transparent; }
.task-progress { margin-top: 6px; max-width: 460px; }
.task-progress-meta { display: flex; justify-content: space-between; color: #5e6878; font-size: 11px; margin-bottom: 4px; }
.task-progress-track { height: 5px; border-radius: 3px; background: #333; overflow: hidden; }
.task-progress-fill { height: 100%; background: #3498db; transition: width 0.25s ease; }
@media (max-width: 1100px) {
.main-content { flex-direction: column; height: auto; }
.left-panel { width: 100%; border-right: none; border-bottom: 1px solid #2e3a52; }
}
</style>