market-assistant/frontend/src/components/JobDatasetModal.vue
hub-gif f915653104 feat(dataset): 库内浏览支持跳转页、排序与类目价格筛选
新增 JdLeafCategoryNorm 与 price 解析字段;数据集 API 支持 sort/order、category_norm_id、price 区间与类目路径模糊;摘要返回类目选项;前端 JobDatasetModal 增加筛选与分页跳转。

Made-with: Cursor
2026-04-16 09:51:26 +08:00

939 lines
23 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.

<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 SORT_LABELS = {
row_index: '入库顺序',
price: '价格',
sku_id: 'SKU',
title: '标题',
leaf_category: '叶类目',
detail_category_path: '类目路径',
detail_brand: '品牌',
}
const tab = ref('search')
const page = ref(1)
const pageSize = ref(30)
const pageJumpDraft = ref(1)
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 sortField = ref('row_index')
const sortOrder = ref('asc')
const categoryNormId = ref('')
const priceMin = ref('')
const priceMax = ref('')
const detailCategoryQ = 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 sortOptions = computed(() => {
const h = summary.value?.dataset_sort_help
let keys = ['row_index']
if (h) {
if (tab.value === 'search') keys = h.search?.length ? h.search : keys
else if (tab.value === 'detail') keys = h.detail?.length ? h.detail : keys
else if (tab.value === 'merged') keys = h.merged?.length ? h.merged : keys
else keys = h.comments?.length ? h.comments : ['row_index']
}
return keys.map((k) => ({ value: k, label: SORT_LABELS[k] || k }))
})
const categoryNormOptions = computed(() => {
if (!summary.value) return []
if (tab.value === 'search') return summary.value.search_category_options || []
if (tab.value === 'merged') return summary.value.merged_category_options || []
return []
})
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 opts =
tab.value === 'comments'
? { skuId: commentSkuFilter.value.trim() }
: {
sort: sortField.value,
order: sortOrder.value,
categoryNormId: categoryNormId.value.trim(),
priceMin: priceMin.value,
priceMax: priceMax.value,
detailCategoryQ: detailCategoryQ.value.trim(),
}
const url = jobDatasetPageUrl(
props.job.id,
tab.value,
page.value,
pageSize.value,
opts,
)
const r = await api(url)
if (!r.ok) {
err.value = await r.text()
return
}
list.value = await r.json()
pageJumpDraft.value = list.value.page || page.value
} 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
sortField.value = 'row_index'
sortOrder.value = 'asc'
categoryNormId.value = ''
priceMin.value = ''
priceMax.value = ''
detailCategoryQ.value = ''
commentSkuFilter.value = ''
err.value = ''
summary.value = null
exportPanelOpen.value = false
exportErr.value = ''
}
},
)
watch(tab, () => {
page.value = 1
exportPanelOpen.value = false
sortField.value = 'row_index'
sortOrder.value = 'asc'
categoryNormId.value = ''
priceMin.value = ''
priceMax.value = ''
detailCategoryQ.value = ''
})
watch(
[
sortField,
sortOrder,
categoryNormId,
priceMin,
priceMax,
detailCategoryQ,
],
() => {
if (paneActive.value && props.job && tab.value !== 'comments') page.value = 1
},
)
watch(
[
paneActive,
() => props.job?.id,
tab,
page,
commentSkuFilter,
sortField,
sortOrder,
categoryNormId,
priceMin,
priceMax,
detailCategoryQ,
],
() => {
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
}
function goToPage() {
const tp = totalPages()
let n = Math.round(Number(pageJumpDraft.value))
if (!Number.isFinite(n)) n = 1
page.value = Math.min(Math.max(1, n), tp)
pageJumpDraft.value = page.value
}
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 v-if="tab !== 'comments'" class="toolbar-filters">
<label class="filter-item">
排序
<select v-model="sortField" class="filter-select">
<option v-for="o in sortOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</label>
<label class="filter-item">
顺序
<select v-model="sortOrder" class="filter-select">
<option value="asc">升序</option>
<option value="desc">降序</option>
</select>
</label>
<template v-if="tab === 'search' || tab === 'merged'">
<label class="filter-item">
叶类目
<select v-model="categoryNormId" class="filter-select wide">
<option value="">全部</option>
<option v-for="c in categoryNormOptions" :key="c.id" :value="String(c.id)">
{{ c.label }}
</option>
</select>
</label>
</template>
<template v-if="tab === 'detail' || tab === 'merged'">
<label class="filter-item">
类目路径包含
<input
v-model="detailCategoryQ"
type="search"
class="filter-input wide"
placeholder="模糊匹配商详类目路径"
list="detail-cat-dl"
/>
<datalist id="detail-cat-dl">
<option
v-for="p in summary?.detail_category_path_options || []"
:key="p"
:value="p"
/>
</datalist>
</label>
</template>
<label class="filter-item">
价格
<input v-model="priceMin" type="number" step="any" class="filter-input narrow" placeholder="最低" />
</label>
<label class="filter-item">
价格
<input v-model="priceMax" type="number" step="any" class="filter-input narrow" placeholder="最高" />
</label>
</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
>
<span class="pager-jump">
<label class="jump-label"
>跳转
<input
v-model.number="pageJumpDraft"
type="number"
:min="1"
:max="totalPages()"
class="jump-input"
/>
</label
>
<button type="button" class="ma-btn ma-btn-secondary" @click="goToPage">确定</button>
</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;
}
.toolbar-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.65rem 0.85rem;
padding: 0.55rem 1rem 0.65rem;
border-bottom: 1px solid #f1f5f9;
background: #fafafa;
flex-shrink: 0;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.72rem;
color: #475569;
}
.filter-select,
.filter-input {
font: inherit;
font-size: 0.8rem;
padding: 0.3rem 0.45rem;
border: 1px solid #d1d5db;
border-radius: 6px;
min-width: 0;
}
.filter-select.wide,
.filter-input.wide {
min-width: 12rem;
max-width: 22rem;
}
.filter-input.narrow {
width: 5.5rem;
}
/* 高度封顶:数据再长也在表格内滚动,不把整块卡片无限撑高 */
.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;
}
.pager-jump {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.jump-label {
font-size: 0.78rem;
color: #475569;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.jump-input {
width: 3.5rem;
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.35rem;
border: 1px solid #d1d5db;
border-radius: 6px;
}
.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>