mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-22 16:11:38 +08:00
714 lines
17 KiB
Vue
714 lines
17 KiB
Vue
<script setup>
|
||
import { computed, ref, watch } from 'vue'
|
||
import {
|
||
api,
|
||
downloadJobDatasetExport,
|
||
jobDatasetSummaryUrl,
|
||
jobDatasetPageUrl,
|
||
} from '../composables/useJobs'
|
||
|
||
const props = defineProps({
|
||
open: { type: Boolean, default: false },
|
||
job: { type: Object, default: null },
|
||
/** 为 true 时嵌入页面(无遮罩、无关闭),用于「库内数据浏览」独立菜单 */
|
||
embedded: { type: Boolean, default: false },
|
||
})
|
||
|
||
const emit = defineEmits(['close'])
|
||
|
||
const paneActive = computed(() => !!(props.job && (props.embedded || props.open)))
|
||
|
||
const tab = ref('search')
|
||
const page = ref(1)
|
||
const pageSize = ref(30)
|
||
const summary = ref(null)
|
||
const list = ref({ results: [], total: 0, page: 1, page_size: 30 })
|
||
const loading = ref(false)
|
||
const err = ref('')
|
||
const commentSkuFilter = ref('')
|
||
const exportPanelOpen = ref(false)
|
||
const exportLoading = ref(false)
|
||
const exportErr = ref('')
|
||
|
||
function onBackdrop(e) {
|
||
if (e.target === e.currentTarget) emit('close')
|
||
}
|
||
|
||
function handleBackdrop(e) {
|
||
if (props.embedded) return
|
||
onBackdrop(e)
|
||
}
|
||
|
||
const displayColumns = computed(() => {
|
||
const s = summary.value
|
||
let cols = []
|
||
if (s) {
|
||
if (tab.value === 'search') cols = s.search_columns || []
|
||
else if (tab.value === 'detail') cols = s.detail_columns || []
|
||
else if (tab.value === 'comments') cols = s.comment_columns || []
|
||
else if (tab.value === 'merged') cols = s.merged_columns || []
|
||
}
|
||
if (cols.length > 0) return cols
|
||
const rows = list.value.results
|
||
if (!rows?.length) return []
|
||
const row = rows[0]
|
||
if (!row || typeof row !== 'object') return []
|
||
const skip = new Set(['id', 'row_index'])
|
||
return Object.keys(row)
|
||
.filter((k) => !skip.has(k))
|
||
.map((key) => ({ key, label: key }))
|
||
})
|
||
|
||
function cellText(row, key) {
|
||
const v = row[key]
|
||
if (v == null || v === '') return '—'
|
||
return String(v)
|
||
}
|
||
|
||
/** 与入库字段名一致:这些列存的是可展示的图片 URL(可多段、分号分隔等) */
|
||
const IMAGE_FIELD_KEYS = new Set([
|
||
'image',
|
||
'large_pic_urls',
|
||
'detail_main_image',
|
||
'shop_logo',
|
||
])
|
||
|
||
function normalizePossibleUrl(s) {
|
||
const t = String(s).trim()
|
||
if (!t) return ''
|
||
if (t.startsWith('//')) return `https:${t}`
|
||
return t
|
||
}
|
||
|
||
/** 从单元格原文中抽出 http(s) 或 // 图片链接(评论图、多图等) */
|
||
function extractImageUrlsFromRaw(raw) {
|
||
if (raw == null || raw === '') return []
|
||
const s = String(raw)
|
||
const out = []
|
||
const seen = new Set()
|
||
for (const m of s.matchAll(/(?:https?:)?\/\/[^\s;|'"<>()[\]\\]+/gi)) {
|
||
let u = m[0].replace(/[,;.)'"\]]+$/g, '')
|
||
u = normalizePossibleUrl(u)
|
||
if (u.length > 14 && !seen.has(u)) {
|
||
seen.add(u)
|
||
out.push(u)
|
||
}
|
||
}
|
||
return out.slice(0, 6)
|
||
}
|
||
|
||
function cellImageUrls(row, key) {
|
||
if (!IMAGE_FIELD_KEYS.has(key)) return []
|
||
return extractImageUrlsFromRaw(row[key])
|
||
}
|
||
|
||
function onThumbError(e) {
|
||
const el = e.target
|
||
if (el && el instanceof HTMLImageElement) {
|
||
el.style.display = 'none'
|
||
}
|
||
}
|
||
|
||
async function loadSummary() {
|
||
if (!props.job?.id) return
|
||
const r = await api(jobDatasetSummaryUrl(props.job.id))
|
||
if (r.ok) summary.value = await r.json()
|
||
else summary.value = null
|
||
}
|
||
|
||
async function refreshList() {
|
||
if (!props.job?.id || !paneActive.value) return
|
||
loading.value = true
|
||
err.value = ''
|
||
try {
|
||
await loadSummary()
|
||
const sku = tab.value === 'comments' ? commentSkuFilter.value.trim() : ''
|
||
const url = jobDatasetPageUrl(props.job.id, tab.value, page.value, pageSize.value, sku)
|
||
const r = await api(url)
|
||
if (!r.ok) {
|
||
err.value = await r.text()
|
||
return
|
||
}
|
||
list.value = await r.json()
|
||
} catch (e) {
|
||
err.value = String(e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => [props.embedded, props.open, props.job?.id],
|
||
() => {
|
||
if (paneActive.value) {
|
||
tab.value = 'search'
|
||
page.value = 1
|
||
commentSkuFilter.value = ''
|
||
err.value = ''
|
||
summary.value = null
|
||
exportPanelOpen.value = false
|
||
exportErr.value = ''
|
||
}
|
||
},
|
||
)
|
||
|
||
watch(tab, () => {
|
||
page.value = 1
|
||
exportPanelOpen.value = false
|
||
})
|
||
|
||
watch([paneActive, () => props.job?.id, tab, page, commentSkuFilter], () => {
|
||
if (paneActive.value && props.job) refreshList()
|
||
})
|
||
|
||
const totalPages = () => {
|
||
const t = list.value.total || 0
|
||
const ps = list.value.page_size || pageSize.value
|
||
return Math.max(1, Math.ceil(t / ps) || 1)
|
||
}
|
||
|
||
function prevPage() {
|
||
if (page.value > 1) page.value -= 1
|
||
}
|
||
|
||
function nextPage() {
|
||
if (page.value < totalPages()) page.value += 1
|
||
}
|
||
|
||
const exportPanelTitle = computed(() => {
|
||
const m = { search: '搜索', detail: '商详', comments: '评论', merged: '整合宽表' }
|
||
return `当前表(${m[tab.value] || tab.value})`
|
||
})
|
||
|
||
function toggleExportPanel() {
|
||
exportErr.value = ''
|
||
exportPanelOpen.value = !exportPanelOpen.value
|
||
}
|
||
|
||
function cancelExport() {
|
||
exportPanelOpen.value = false
|
||
exportErr.value = ''
|
||
}
|
||
|
||
async function runExport(format) {
|
||
if (!props.job?.id || !exportPanelOpen.value) return
|
||
const kind = tab.value
|
||
exportLoading.value = true
|
||
exportErr.value = ''
|
||
try {
|
||
await downloadJobDatasetExport(props.job.id, kind, format)
|
||
exportPanelOpen.value = false
|
||
} catch (e) {
|
||
exportErr.value = String(e?.message || e)
|
||
} finally {
|
||
exportLoading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport to="body" :disabled="embedded">
|
||
<div
|
||
v-if="job && (embedded || open)"
|
||
:class="embedded ? 'embedded-root' : 'overlay'"
|
||
@click="handleBackdrop"
|
||
>
|
||
<div
|
||
:class="embedded ? 'modal modal-embedded' : 'modal'"
|
||
role="dialog"
|
||
:aria-modal="!embedded"
|
||
@click.stop
|
||
>
|
||
<header class="head">
|
||
<div>
|
||
<h3>库内数据 · 任务 #{{ job.id }} · {{ job.keyword }}</h3>
|
||
<p v-if="summary" class="sub">
|
||
搜索 {{ summary.search_rows }} 行 · 商详 {{ summary.detail_rows }} 行 · 评价
|
||
{{ summary.comment_rows }} 条 · 整合 {{ summary.merged_rows ?? 0 }} 行 ·
|
||
仅展示全表至少有一格有值的列(与导出一致)
|
||
</p>
|
||
</div>
|
||
<button
|
||
v-if="!embedded"
|
||
type="button"
|
||
class="close"
|
||
aria-label="关闭"
|
||
@click="emit('close')"
|
||
>
|
||
×
|
||
</button>
|
||
</header>
|
||
|
||
<div class="toolbar">
|
||
<div class="tabs">
|
||
<button type="button" :class="{ on: tab === 'search' }" @click="tab = 'search'">搜索结果</button>
|
||
<button type="button" :class="{ on: tab === 'detail' }" @click="tab = 'detail'">商详结果</button>
|
||
<button type="button" :class="{ on: tab === 'comments' }" @click="tab = 'comments'">评论结果</button>
|
||
<button type="button" :class="{ on: tab === 'merged' }" @click="tab = 'merged'">整合宽表</button>
|
||
</div>
|
||
<div class="exports">
|
||
<button
|
||
type="button"
|
||
class="exp-btn"
|
||
:class="{ on: exportPanelOpen }"
|
||
:disabled="exportLoading"
|
||
@click="toggleExportPanel"
|
||
>
|
||
导出当前表
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="exportPanelOpen" class="export-panel">
|
||
<p class="export-panel-title">
|
||
{{ exportPanelTitle }} — 选择导出类型
|
||
</p>
|
||
<div class="export-formats">
|
||
<button
|
||
type="button"
|
||
class="ma-btn ma-btn-secondary exp-fmt"
|
||
:disabled="exportLoading"
|
||
@click="runExport('json')"
|
||
>
|
||
JSON
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="ma-btn ma-btn-secondary exp-fmt"
|
||
:disabled="exportLoading"
|
||
@click="runExport('csv')"
|
||
>
|
||
CSV
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="ma-btn ma-btn-secondary exp-fmt"
|
||
:disabled="exportLoading"
|
||
@click="runExport('xlsx')"
|
||
>
|
||
Excel
|
||
</button>
|
||
<button type="button" class="ma-btn ma-btn-secondary exp-cancel" :disabled="exportLoading" @click="cancelExport">
|
||
取消
|
||
</button>
|
||
</div>
|
||
<p v-if="exportLoading" class="export-status">正在生成文件…</p>
|
||
<p v-if="exportErr" class="export-err">{{ exportErr }}</p>
|
||
</div>
|
||
|
||
<div class="toolbar2">
|
||
<button
|
||
type="button"
|
||
class="ma-btn ma-btn-secondary"
|
||
title="重新拉取摘要与当前页(仅刷新界面数据)"
|
||
:disabled="loading || exportLoading"
|
||
@click="refreshList"
|
||
>
|
||
{{ loading ? '刷新中…' : '刷新' }}
|
||
</button>
|
||
<template v-if="tab === 'comments'">
|
||
<label class="sku-filter">
|
||
按 SKU 筛选
|
||
<input v-model="commentSkuFilter" type="text" placeholder="可选" class="sku-input" />
|
||
</label>
|
||
</template>
|
||
</div>
|
||
|
||
<div class="table-block">
|
||
<div v-if="loading" class="state state-fill">加载中…</div>
|
||
<p v-else-if="err" class="state err state-fill">{{ err }}</p>
|
||
<div v-else class="table-wrap">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="col-narrow">id</th>
|
||
<th class="col-narrow">row</th>
|
||
<th v-for="col in displayColumns" :key="col.key" class="col-dyn" :title="col.label">
|
||
{{ col.label }}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in list.results" :key="row.id">
|
||
<td class="num col-narrow">{{ row.id }}</td>
|
||
<td class="num col-narrow">{{ row.row_index }}</td>
|
||
<td
|
||
v-for="col in displayColumns"
|
||
:key="col.key"
|
||
class="cell-dyn"
|
||
:class="{ 'cell-dyn-media': cellImageUrls(row, col.key).length > 0 }"
|
||
>
|
||
<div v-if="cellImageUrls(row, col.key).length" class="cell-media">
|
||
<a
|
||
v-for="(u, i) in cellImageUrls(row, col.key)"
|
||
:key="i"
|
||
:href="u"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="cell-thumb-link"
|
||
:title="u"
|
||
>
|
||
<img
|
||
:src="u"
|
||
class="cell-thumb"
|
||
loading="lazy"
|
||
referrerpolicy="no-referrer"
|
||
alt=""
|
||
@error="onThumbError"
|
||
/>
|
||
</a>
|
||
</div>
|
||
<template v-else>{{ cellText(row, col.key) }}</template>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-if="!list.results?.length" class="ma-muted empty">本页无数据(可点「刷新」或切换分页 / 表)</p>
|
||
</div>
|
||
</div>
|
||
|
||
<footer class="pager">
|
||
<span class="ma-muted"
|
||
>第 {{ list.page || page }} / {{ totalPages() }} 页 · 共 {{ list.total ?? 0 }} 条</span
|
||
>
|
||
<button type="button" class="ma-btn ma-btn-secondary" :disabled="page <= 1" @click="prevPage">
|
||
上一页
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="ma-btn ma-btn-secondary"
|
||
:disabled="page >= totalPages()"
|
||
@click="nextPage"
|
||
>
|
||
下一页
|
||
</button>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgb(15 23 42 / 0.45);
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 1rem;
|
||
box-sizing: border-box;
|
||
}
|
||
.modal {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
width: min(98vw, 1400px);
|
||
max-height: min(92vh, 900px);
|
||
min-height: 0;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||
overflow: hidden;
|
||
}
|
||
.head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
padding: 0.85rem 1.1rem;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
flex-shrink: 0;
|
||
}
|
||
.head h3 {
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
}
|
||
.sub {
|
||
margin: 0.35rem 0 0;
|
||
font-size: 0.78rem;
|
||
color: #64748b;
|
||
}
|
||
.close {
|
||
border: none;
|
||
background: #f3f4f6;
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border-radius: 8px;
|
||
font-size: 1.35rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
color: #374151;
|
||
flex-shrink: 0;
|
||
}
|
||
.toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
padding: 0.5rem 1rem;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
flex-shrink: 0;
|
||
}
|
||
.tabs {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.tabs button {
|
||
border: 1px solid #e5e7eb;
|
||
background: #f9fafb;
|
||
padding: 0.35rem 0.75rem;
|
||
border-radius: 6px;
|
||
font-size: 0.82rem;
|
||
cursor: pointer;
|
||
color: #4b5563;
|
||
}
|
||
.tabs button.on {
|
||
background: #2563eb;
|
||
border-color: #2563eb;
|
||
color: #fff;
|
||
}
|
||
.exports {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.exp-btn {
|
||
border: 1px solid #cbd5e1;
|
||
background: #fff;
|
||
padding: 0.4rem 0.75rem;
|
||
border-radius: 8px;
|
||
font-size: 0.8rem;
|
||
cursor: pointer;
|
||
color: #334155;
|
||
}
|
||
.exp-btn:hover:not(:disabled) {
|
||
border-color: #2563eb;
|
||
color: #1d4ed8;
|
||
}
|
||
.exp-btn.on {
|
||
border-color: #2563eb;
|
||
background: #eff6ff;
|
||
color: #1d4ed8;
|
||
}
|
||
.exp-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
.export-panel {
|
||
padding: 0.65rem 1rem 0.85rem;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
background: #f8fafc;
|
||
flex-shrink: 0;
|
||
}
|
||
.export-panel-title {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
color: #1e293b;
|
||
}
|
||
.export-formats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
}
|
||
.exp-fmt {
|
||
font-size: 0.8rem;
|
||
}
|
||
.exp-cancel {
|
||
font-size: 0.8rem;
|
||
margin-left: 0.25rem;
|
||
}
|
||
.export-status {
|
||
margin: 0.5rem 0 0;
|
||
font-size: 0.78rem;
|
||
color: #64748b;
|
||
}
|
||
.export-err {
|
||
margin: 0.5rem 0 0;
|
||
font-size: 0.78rem;
|
||
color: #b91c1c;
|
||
white-space: pre-wrap;
|
||
}
|
||
.export-hint {
|
||
margin: 0.45rem 0 0;
|
||
font-size: 0.72rem;
|
||
line-height: 1.45;
|
||
}
|
||
.toolbar2 {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.5rem 1rem;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
flex-shrink: 0;
|
||
}
|
||
.sku-filter {
|
||
font-size: 0.8rem;
|
||
color: #475569;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
}
|
||
.sku-input {
|
||
padding: 0.35rem 0.5rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 6px;
|
||
width: 160px;
|
||
font: inherit;
|
||
}
|
||
/* 高度封顶:数据再长也在表格内滚动,不把整块卡片无限撑高 */
|
||
.table-block {
|
||
flex: 1 1 auto;
|
||
min-height: 12rem;
|
||
max-height: min(58vh, 40rem);
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.state {
|
||
padding: 1.5rem;
|
||
text-align: center;
|
||
color: #6b7280;
|
||
}
|
||
.state.err {
|
||
color: #b91c1c;
|
||
white-space: pre-wrap;
|
||
text-align: left;
|
||
}
|
||
.state-fill {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
.table-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
min-width: 0;
|
||
overflow: auto;
|
||
overflow-x: auto;
|
||
overflow-y: auto;
|
||
padding: 0.5rem 1rem;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.data-table {
|
||
width: max-content;
|
||
border-collapse: collapse;
|
||
font-size: 0.7rem;
|
||
table-layout: auto;
|
||
}
|
||
.data-table th,
|
||
.data-table td {
|
||
border: 1px solid #e5e7eb;
|
||
padding: 0.3rem 0.4rem;
|
||
vertical-align: top;
|
||
text-align: left;
|
||
}
|
||
.data-table th {
|
||
background: #f8fafc;
|
||
font-weight: 600;
|
||
color: #334155;
|
||
}
|
||
.data-table th.col-narrow {
|
||
width: 3rem;
|
||
max-width: 3.25rem;
|
||
min-width: 2.5rem;
|
||
left: auto;
|
||
}
|
||
.data-table th.col-dyn {
|
||
max-width: 9.5rem;
|
||
min-width: 3.5rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.col-narrow {
|
||
width: 3rem;
|
||
max-width: 3.25rem;
|
||
min-width: 2.5rem;
|
||
box-sizing: border-box;
|
||
}
|
||
.col-dyn {
|
||
min-width: 3.5rem;
|
||
max-width: 9.5rem;
|
||
}
|
||
.cell-dyn {
|
||
font-family: ui-monospace, monospace;
|
||
max-width: 9.5rem;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
color: #1e293b;
|
||
}
|
||
.cell-dyn-media {
|
||
max-width: 11rem;
|
||
vertical-align: middle;
|
||
}
|
||
.cell-media {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.35rem;
|
||
align-items: center;
|
||
}
|
||
.cell-thumb-link {
|
||
display: inline-block;
|
||
line-height: 0;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
border: 1px solid #e5e7eb;
|
||
background: #f8fafc;
|
||
flex-shrink: 0;
|
||
}
|
||
.cell-thumb {
|
||
display: block;
|
||
width: 4.5rem;
|
||
height: 4.5rem;
|
||
object-fit: contain;
|
||
}
|
||
.num {
|
||
white-space: nowrap;
|
||
color: #64748b;
|
||
}
|
||
.empty {
|
||
margin: 1rem 0;
|
||
text-align: center;
|
||
}
|
||
.pager {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 0.65rem;
|
||
padding: 0.65rem 1rem;
|
||
border-top: 1px solid #e5e7eb;
|
||
flex-shrink: 0;
|
||
}
|
||
.ma-muted {
|
||
color: #64748b;
|
||
}
|
||
.embedded-root {
|
||
width: 100%;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.modal-embedded {
|
||
width: 100%;
|
||
max-width: none;
|
||
min-width: 0;
|
||
box-shadow: none;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
</style>
|