mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-22 16:11:38 +08:00
新增 matrix_group_label 与 jd_competitor_report 同源路径解析;API 使用 report_group 字符串;摘要返回 report_group_options;搜索行按 SKU 从合并表回填细类;导出含报告细类列;前端下拉展示饼干/米等名称。 Made-with: Cursor
314 lines
9.1 KiB
JavaScript
314 lines
9.1 KiB
JavaScript
import { ref, watch } from 'vue'
|
||
|
||
const jobs = ref([])
|
||
|
||
/** 终态 */
|
||
const TERMINAL_JOB_STATUSES = new Set(['success', 'failed', 'cancelled'])
|
||
|
||
function isActiveJobStatus(status) {
|
||
return status === 'pending' || status === 'running'
|
||
}
|
||
|
||
/** 单一定时器轮询列表(避免 N 个任务 → N 路 GET /api/jobs/:id/) */
|
||
let jobsListPollTimer = null
|
||
|
||
function stopJobsListPoll() {
|
||
if (jobsListPollTimer != null) {
|
||
clearInterval(jobsListPollTimer)
|
||
jobsListPollTimer = null
|
||
}
|
||
}
|
||
|
||
async function fetchJobsListQuietly() {
|
||
try {
|
||
const r = await api('/api/jobs/')
|
||
if (r.ok) {
|
||
jobs.value = await r.json()
|
||
}
|
||
} catch {
|
||
/* 忽略网络错误,下一轮再试 */
|
||
}
|
||
}
|
||
|
||
function syncJobsListPoll() {
|
||
const hasActive = jobs.value.some((j) => isActiveJobStatus(j.status))
|
||
if (!hasActive) {
|
||
stopJobsListPoll()
|
||
return
|
||
}
|
||
if (jobsListPollTimer != null) return
|
||
jobsListPollTimer = setInterval(fetchJobsListQuietly, 3000)
|
||
}
|
||
|
||
export function api(path, opts = {}) {
|
||
return fetch(path, {
|
||
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
||
...opts,
|
||
})
|
||
}
|
||
|
||
export async function refreshJobs() {
|
||
const r = await api('/api/jobs/')
|
||
if (!r.ok) throw new Error(await r.text())
|
||
jobs.value = await r.json()
|
||
}
|
||
|
||
export function jobCancelUrl(jobId) {
|
||
return `/api/jobs/${jobId}/cancel/`
|
||
}
|
||
|
||
export function downloadUrl(jobId, name) {
|
||
return `/api/jobs/${jobId}/download/?name=${name}`
|
||
}
|
||
|
||
export function previewUrl(jobId, name) {
|
||
return `/api/jobs/${jobId}/preview/?name=${name}`
|
||
}
|
||
|
||
export function jobDatasetSummaryUrl(jobId) {
|
||
return `/api/jobs/${jobId}/dataset/summary/`
|
||
}
|
||
|
||
export function jobCompetitorBriefUrl(jobId) {
|
||
return `/api/jobs/${jobId}/competitor-brief/`
|
||
}
|
||
|
||
export function jobCompetitorBriefPackUrl(jobId) {
|
||
return `/api/jobs/${jobId}/competitor-brief-pack/`
|
||
}
|
||
|
||
/** 竞品报告 Markdown → Word/PDF(服务端读 run_dir 下 competitor_analysis.md) */
|
||
export function jobExportReportDocumentUrl(jobId, fmt = 'docx') {
|
||
return `/api/jobs/${jobId}/export-document/?kind=report&fmt=${encodeURIComponent(fmt)}`
|
||
}
|
||
|
||
/** 竞品报告 GET 导出 Word/PDF(blob 下载,失败时解析服务端 JSON 提示) */
|
||
export async function exportReportDocument(jobId, fmt = 'docx') {
|
||
const url = jobExportReportDocumentUrl(jobId, fmt)
|
||
const r = await fetch(url)
|
||
const ct = r.headers.get('Content-Type') || ''
|
||
if (!r.ok) {
|
||
let msg = `HTTP ${r.status}`
|
||
try {
|
||
if (ct.includes('application/json')) {
|
||
const j = await r.json()
|
||
msg = typeof j?.detail === 'string' ? j.detail : JSON.stringify(j)
|
||
} else {
|
||
const t = await r.text()
|
||
if (t) msg = t.length > 500 ? `${t.slice(0, 500)}…` : t
|
||
}
|
||
} catch {
|
||
/* keep msg */
|
||
}
|
||
throw new Error(msg)
|
||
}
|
||
const blob = await r.blob()
|
||
const filename =
|
||
filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
|
||
`job_${jobId}_competitor_report.${fmt}`
|
||
const u = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = u
|
||
a.download = filename
|
||
a.rel = 'noopener'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(u)
|
||
}
|
||
|
||
/** 策略稿正文(浏览器 sessionStorage)→ Word/PDF */
|
||
export async function exportStrategyDocument(jobId, markdown, fmt = 'docx') {
|
||
const r = await api(`/api/jobs/${jobId}/export-document/`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ kind: 'strategy', fmt, markdown }),
|
||
})
|
||
const ct = r.headers.get('Content-Type') || ''
|
||
if (!r.ok) {
|
||
let msg = `HTTP ${r.status}`
|
||
try {
|
||
if (ct.includes('application/json')) {
|
||
const j = await r.json()
|
||
msg = typeof j?.detail === 'string' ? j.detail : JSON.stringify(j)
|
||
} else {
|
||
const t = await r.text()
|
||
if (t) msg = t.length > 500 ? `${t.slice(0, 500)}…` : t
|
||
}
|
||
} catch {
|
||
/* keep msg */
|
||
}
|
||
throw new Error(msg)
|
||
}
|
||
const blob = await r.blob()
|
||
const filename =
|
||
filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
|
||
`job_${jobId}_strategy_draft.${fmt}`
|
||
const u = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = u
|
||
a.download = filename
|
||
a.rel = 'noopener'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(u)
|
||
}
|
||
|
||
export async function downloadCompetitorBriefPack(jobId) {
|
||
const url = jobCompetitorBriefPackUrl(jobId)
|
||
const r = await fetch(url)
|
||
const ct = r.headers.get('Content-Type') || ''
|
||
if (!r.ok) {
|
||
let msg = `HTTP ${r.status}`
|
||
try {
|
||
if (ct.includes('application/json')) {
|
||
const j = await r.json()
|
||
msg = j.detail || JSON.stringify(j)
|
||
} else {
|
||
const t = await r.text()
|
||
if (t) msg = t.length > 500 ? `${t.slice(0, 500)}…` : t
|
||
}
|
||
} catch {
|
||
/* keep msg */
|
||
}
|
||
throw new Error(msg)
|
||
}
|
||
const blob = await r.blob()
|
||
let filename =
|
||
filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
|
||
`job_${jobId}_competitor_brief_pack.zip`
|
||
const u = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = u
|
||
a.download = filename
|
||
a.rel = 'noopener'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(u)
|
||
}
|
||
|
||
export function reportConfigDefaultsUrl() {
|
||
return '/api/report-config-defaults/'
|
||
}
|
||
|
||
/**
|
||
* @param {Record<string, string | number | undefined> | string} [opts] 筛选参数对象;兼容旧调用:传入字符串视为 comments 的 sku_id
|
||
*/
|
||
export function jobDatasetPageUrl(jobId, kind, page = 1, pageSize = 50, opts = {}) {
|
||
const o = typeof opts === 'string' ? { skuId: opts } : opts || {}
|
||
const p = new URLSearchParams({
|
||
page: String(page),
|
||
page_size: String(pageSize),
|
||
})
|
||
const sku = o.skuId ?? o.sku_id
|
||
if (sku) p.set('sku_id', String(sku))
|
||
if (o.sort) p.set('sort', String(o.sort))
|
||
if (o.order) p.set('order', String(o.order))
|
||
const rg =
|
||
o.reportGroup ?? o.report_group ?? o.categoryNormId ?? o.category_norm_id
|
||
if (rg !== undefined && rg !== null && String(rg).trim() !== '')
|
||
p.set('report_group', String(rg).trim())
|
||
const pmin = o.priceMin ?? o.price_min
|
||
if (pmin !== undefined && pmin !== null && String(pmin).trim() !== '')
|
||
p.set('price_min', String(pmin).trim())
|
||
const pmax = o.priceMax ?? o.price_max
|
||
if (pmax !== undefined && pmax !== null && String(pmax).trim() !== '')
|
||
p.set('price_max', String(pmax).trim())
|
||
const dcq = o.detailCategoryQ ?? o.detail_category_q
|
||
if (dcq !== undefined && dcq !== null && String(dcq).trim() !== '')
|
||
p.set('detail_category_q', String(dcq).trim())
|
||
return `/api/jobs/${jobId}/dataset/${kind}/?${p.toString()}`
|
||
}
|
||
|
||
export function jobExportUrl(jobId, kind, exportFmt) {
|
||
return `/api/jobs/${jobId}/export/?kind=${encodeURIComponent(kind)}&export_fmt=${encodeURIComponent(exportFmt)}`
|
||
}
|
||
|
||
function filenameFromContentDisposition(header) {
|
||
if (!header) return null
|
||
const star = /filename\*=UTF-8''([^;\s]+)/i.exec(header)
|
||
if (star) {
|
||
try {
|
||
return decodeURIComponent(star[1].trim())
|
||
} catch {
|
||
return star[1].trim()
|
||
}
|
||
}
|
||
const q = /filename="([^"]+)"/i.exec(header)
|
||
if (q) return q[1]
|
||
const plain = /filename=([^;\s]+)/i.exec(header)
|
||
return plain ? plain[1].replace(/^"|"$/g, '') : null
|
||
}
|
||
|
||
export async function downloadJobDatasetExport(jobId, kind, exportFmt) {
|
||
const url = jobExportUrl(jobId, kind, exportFmt)
|
||
const r = await fetch(url)
|
||
const ct = r.headers.get('Content-Type') || ''
|
||
if (!r.ok) {
|
||
let msg = `HTTP ${r.status}`
|
||
try {
|
||
if (ct.includes('application/json')) {
|
||
const j = await r.json()
|
||
msg = j.detail || JSON.stringify(j)
|
||
} else {
|
||
const t = await r.text()
|
||
if (t) msg = t.length > 500 ? `${t.slice(0, 500)}…` : t
|
||
}
|
||
} catch {
|
||
/* keep msg */
|
||
}
|
||
throw new Error(msg)
|
||
}
|
||
const blob = await r.blob()
|
||
let filename =
|
||
filenameFromContentDisposition(r.headers.get('Content-Disposition')) ||
|
||
`job_${jobId}_export.${exportFmt === 'xlsx' ? 'xlsx' : exportFmt === 'csv' ? 'csv' : 'json'}`
|
||
const u = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = u
|
||
a.download = filename
|
||
a.rel = 'noopener'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(u)
|
||
}
|
||
|
||
watch(
|
||
jobs,
|
||
() => {
|
||
syncJobsListPoll()
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
export function jobConfigHint(j) {
|
||
const parts = []
|
||
if (j.page_start != null || j.page_to != null) {
|
||
parts.push(`页 ${j.page_start ?? '—'}–${j.page_to ?? '—'}`)
|
||
}
|
||
if (j.max_skus != null) parts.push(`SKU≤${j.max_skus}`)
|
||
if (j.pipeline_run_dir) {
|
||
const s = j.pipeline_run_dir
|
||
parts.push(s.length > 24 ? `目录:${s.slice(0, 24)}…` : `目录:${s}`)
|
||
}
|
||
if (j.cookie_file_path) parts.push('Cookie:文件')
|
||
if (j.inline_cookie_used) parts.push('Cookie:粘贴')
|
||
if (j.request_delay) parts.push(`延迟:${j.request_delay}`)
|
||
if (j.list_pages) parts.push(`评页:${j.list_pages}`)
|
||
if (j.pvid) parts.push('pvid')
|
||
if (j.scenario_filter_enabled === true) parts.push('筛选:开')
|
||
if (j.scenario_filter_enabled === false) parts.push('筛选:关')
|
||
return parts.length ? parts.join(' · ') : '默认'
|
||
}
|
||
|
||
export function useJobs() {
|
||
return {
|
||
jobs,
|
||
refreshJobs,
|
||
downloadUrl,
|
||
jobConfigHint,
|
||
}
|
||
}
|