加载中…
{{ err }}
@@ -371,6 +537,20 @@ async function runExport(format) {
第 {{ list.page || page }} / {{ totalPages() }} 页 · 共 {{ list.total ?? 0 }} 条
+
@@ -563,6 +743,40 @@ async function runExport(format) {
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;
@@ -689,6 +903,27 @@ async function runExport(format) {
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;
}
diff --git a/frontend/src/components/ReportConfigFormFields.vue b/frontend/src/components/ReportConfigFormFields.vue
index e24557d..50d9ee8 100644
--- a/frontend/src/components/ReportConfigFormFields.vue
+++ b/frontend/src/components/ReportConfigFormFields.vue
@@ -1,55 +1,24 @@
-
1. 评价里要统计的「关注词」
-
报告会数这些词在评价里出现了多少次(适合看大家常提什么,例如口感、控糖、价格等)。
-
-
-
-
-
-
2. 用途 / 场景分组
+
1. 第八章评论分析
- 每一组有一个场景名称,和若干触发词。若一条评价里出现了其中任意一个词,这条评价就会算进该场景(一条评价可以同时属于多个场景)。触发词请用顿号、逗号或换行分开。
+ 报告不再使用「预设关注词 / 预设场景词组」子串统计。请在报告配置(高级 JSON 或接口)中维护
+ chapter8_text_mining_probe
+ 等开关,以生成开放词表、词频与共现等文本挖掘内容。可选
+ llm_comment_sentiment:按矩阵细类分别调用模型,在报告8.3生成「正/负向主题」归纳,与探针及「细类评论要点归纳」并列、互不替代。
-
-
-
-
-
-
-
-
-
3. 外部市场信息(可选)
+
2. 外部市场信息(可选)
若手边有第三方市场规模、增速等摘录,可填在表里,报告会多一节说明;不需要可整表留空。
@@ -107,17 +76,6 @@ defineEmits([
color: #6b7280;
line-height: 1.5;
}
-.rc-rows {
- display: flex;
- flex-direction: column;
- gap: 0.45rem;
-}
-.rc-inline {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
-}
.rc-input {
flex: 1;
min-width: 140px;
@@ -128,7 +86,6 @@ defineEmits([
font-size: 0.88rem;
box-sizing: border-box;
}
-/* 表内输入:不占 flex,宽度受列约束,避免撑进邻列 */
.rc-input.rc-td {
flex: none;
display: block;
@@ -137,28 +94,6 @@ defineEmits([
max-width: 100%;
font-size: 0.8rem;
}
-.rc-textarea {
- width: 100%;
- max-width: 100%;
- box-sizing: border-box;
- padding: 0.45rem 0.55rem;
- border: 1px solid #d1d5db;
- border-radius: 6px;
- font: inherit;
- font-size: 0.88rem;
- resize: vertical;
- margin: 0.35rem 0 0.5rem;
-}
-.rc-label {
- display: block;
- font-size: 0.8rem;
- font-weight: 500;
- color: #4b5563;
- margin-top: 0.35rem;
-}
-.rc-label:first-of-type {
- margin-top: 0;
-}
.rc-mini {
font-size: 0.8rem;
padding: 0.3rem 0.55rem;
@@ -168,13 +103,6 @@ defineEmits([
margin-top: 0.5rem;
font-size: 0.85rem;
}
-.rc-scenario-card {
- background: #fff;
- border: 1px solid #e5e7eb;
- border-radius: 8px;
- padding: 0.75rem 0.85rem;
- margin-bottom: 0.65rem;
-}
.rc-market-wrap {
overflow-x: auto;
margin-bottom: 0.35rem;
diff --git a/frontend/src/composables/useGenerationInFlight.js b/frontend/src/composables/useGenerationInFlight.js
index 2b62cfc..5182f70 100644
--- a/frontend/src/composables/useGenerationInFlight.js
+++ b/frontend/src/composables/useGenerationInFlight.js
@@ -1,13 +1,19 @@
-import { ref } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useTaskStore } from '../stores/task'
/**
- * 长耗时生成类 POST/GET 的「进行中」标记(模块级,路由切换不丢)。
- * key 示例:`strategy-draft:12`、`regenerate-report:12`、`preview-report:12`
+ * 与 Pinia `useTaskStore` 同步;进行中列表持久化在 localStorage,跨标签页可见。
*/
-const inFlightKey = ref(null)
-
export function generationInFlightKey() {
- return inFlightKey
+ return storeToRefs(useTaskStore()).inFlightKeys
+}
+
+export function clearGenerationInFlightState() {
+ useTaskStore().clearAll()
+}
+
+export function clearRegenerateReportInFlightOnly() {
+ useTaskStore().clearRegenerateReportOnly()
}
/**
@@ -16,12 +22,5 @@ export function generationInFlightKey() {
* @returns {Promise}
*/
export async function withGenerationInFlight(key, fn) {
- inFlightKey.value = key
- try {
- return await fn()
- } finally {
- if (inFlightKey.value === key) {
- inFlightKey.value = null
- }
- }
+ return useTaskStore().withInFlight(key, fn)
}
diff --git a/frontend/src/composables/useJobs.js b/frontend/src/composables/useJobs.js
index 850c628..d1bf6c4 100644
--- a/frontend/src/composables/useJobs.js
+++ b/frontend/src/composables/useJobs.js
@@ -1,44 +1,5 @@
-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)
-}
+import { storeToRefs } from 'pinia'
+import { useJobStore } from '../stores/jobs'
export function api(path, opts = {}) {
return fetch(path, {
@@ -48,9 +9,7 @@ export function api(path, 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()
+ return useJobStore().refreshJobs()
}
export function jobCancelUrl(jobId) {
@@ -82,24 +41,77 @@ export function jobExportReportDocumentUrl(jobId, fmt = 'docx') {
return `/api/jobs/${jobId}/export-document/?kind=report&fmt=${encodeURIComponent(fmt)}`
}
-/** 策略稿正文(浏览器 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 }),
- })
+/** 竞品报告 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) {
- const t = await r.text()
- throw new Error(t || `HTTP ${r.status}`)
+ 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 dispo = r.headers.get('Content-Disposition') || ''
- const m = dispo.match(/filename="([^"]+)"/)
- const name = m ? m[1] : `job_${jobId}_strategy_draft.${fmt}`
+ 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 = name
+ a.download = filename
+ a.rel = 'noopener'
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(u)
+}
+
+/**
+ * 策略稿或营销内容 Markdown → Word/PDF
+ * @param {'strategy' | 'marketing_detail'} [kind]
+ */
+export async function exportStrategyDocument(jobId, markdown, fmt = 'docx', kind = 'strategy') {
+ const r = await api(`/api/jobs/${jobId}/export-document/`, {
+ method: 'POST',
+ body: JSON.stringify({ kind, 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 fallback =
+ kind === 'marketing_detail'
+ ? `job_${jobId}_marketing_detail_pack.${fmt}`
+ : `job_${jobId}_strategy_draft.${fmt}`
+ const filename =
+ filenameFromContentDisposition(r.headers.get('Content-Disposition')) || fallback
+ 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()
@@ -145,12 +157,39 @@ export function reportConfigDefaultsUrl() {
return '/api/report-config-defaults/'
}
-export function jobDatasetPageUrl(jobId, kind, page = 1, pageSize = 50, skuId = '') {
+export function strategyConfigDefaultsUrl() {
+ return '/api/strategy-config-defaults/'
+}
+
+/**
+ * @param {Record | 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),
})
- if (skuId) p.set('sku_id', skuId)
+ 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 shop = o.shop ?? o.shop_name ?? o.shopQ ?? o.shop_q
+ if (shop !== undefined && shop !== null && String(shop).trim() !== '')
+ p.set('shop', String(shop).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()}`
}
@@ -208,14 +247,6 @@ export async function downloadJobDatasetExport(jobId, kind, exportFmt) {
URL.revokeObjectURL(u)
}
-watch(
- jobs,
- () => {
- syncJobsListPoll()
- },
- { deep: true },
-)
-
export function jobConfigHint(j) {
const parts = []
if (j.page_start != null || j.page_to != null) {
@@ -237,6 +268,8 @@ export function jobConfigHint(j) {
}
export function useJobs() {
+ const store = useJobStore()
+ const { jobs } = storeToRefs(store)
return {
jobs,
refreshJobs,
diff --git a/frontend/src/composables/useReportConfigForm.js b/frontend/src/composables/useReportConfigForm.js
index 1261865..750609c 100644
--- a/frontend/src/composables/useReportConfigForm.js
+++ b/frontend/src/composables/useReportConfigForm.js
@@ -11,6 +11,18 @@ function splitTriggers(text) {
.filter(Boolean)
}
+/**
+ * 表单未展示的大模型/细类归纳等布尔项:从任务读入后在「保存」时原样写回,避免误清空。
+ * (与 backend ``validate_report_config_body`` 允许的键一致。)
+ */
+const REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS = [
+ 'llm_comment_sentiment',
+ 'llm_matrix_group_summaries',
+ 'llm_price_group_summaries',
+ 'llm_comment_group_summaries',
+ 'llm_scenario_group_summaries',
+]
+
/**
* 报告调参表单(与后端 report_config 字段对应),面向非技术用户。
*/
@@ -20,11 +32,14 @@ export function useReportConfigForm() {
const marketRows = ref([
{ indicator: '', value_and_scope: '', source: '', year: '' },
])
+ /** 表单未编辑的布尔项,从任务配置读入后随保存写回 */
+ const passthroughBools = ref({})
function resetToEmpty() {
focusWordRows.value = [{ text: '' }]
scenarioGroups.value = [{ label: '', triggersText: '' }]
marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }]
+ passthroughBools.value = {}
}
/**
@@ -94,6 +109,12 @@ export function useReportConfigForm() {
} else {
marketRows.value = [{ indicator: '', value_and_scope: '', source: '', year: '' }]
}
+
+ const pass = {}
+ for (const k of REPORT_CONFIG_PASSTHROUGH_BOOL_KEYS) {
+ if (Object.prototype.hasOwnProperty.call(cfg, k)) pass[k] = Boolean(cfg[k])
+ }
+ passthroughBools.value = pass
}
/** @returns {Record} 可 PATCH 到后端的 report_config;全空则为 {} */
@@ -133,6 +154,7 @@ export function useReportConfigForm() {
}))
}
+ Object.assign(out, passthroughBools.value)
return out
}
@@ -178,6 +200,7 @@ export function useReportConfigForm() {
focusWordRows,
scenarioGroups,
marketRows,
+ passthroughBools,
resetToEmpty,
applyFromApiConfig,
buildPayload,
diff --git a/frontend/src/lib/marketingPackMarkdown.js b/frontend/src/lib/marketingPackMarkdown.js
new file mode 100644
index 0000000..d48d9be
--- /dev/null
+++ b/frontend/src/lib/marketingPackMarkdown.js
@@ -0,0 +1,154 @@
+/**
+ * 将营销内容 API 返回的 JSON 转为可导出 Word/PDF 的 Markdown(中文小标题)。
+ */
+
+const CORE_LABELS = {
+ what_we_sell: '卖的是什么(本品)',
+ one_liner_value: '一句话价值主张',
+ buyer_job_to_be_done: '购买者任务与情境',
+ key_pain_or_desire: '核心痛点或欲望',
+ why_this_product: '为何要选这一款',
+ proof_or_trust_angle: '信任或证明角度',
+ differentiation_vs_alternatives: '与替代方案的差异',
+ price_value_framing: '价位与价值感表述',
+ compliance_taboos: '表述禁区摘要',
+ open_points_for_business: '待业务补充',
+}
+
+function escLine(s) {
+ if (s == null || s === '') return '—'
+ return String(s).replace(/\r\n/g, '\n').trim() || '—'
+}
+
+function pushCoreCard(lines, card) {
+ if (!card || typeof card !== 'object') {
+ lines.push('(无核心信息卡数据)')
+ lines.push('')
+ return
+ }
+ for (const [en, zh] of Object.entries(CORE_LABELS)) {
+ lines.push(`### ${zh}`)
+ lines.push('')
+ lines.push(escLine(card[en]))
+ lines.push('')
+ }
+}
+
+function pushBullets(lines, title, arr) {
+ lines.push(`### ${title}`)
+ lines.push('')
+ if (!Array.isArray(arr) || !arr.length) {
+ lines.push('—')
+ lines.push('')
+ return
+ }
+ let any = false
+ for (const item of arr) {
+ const t = escLine(item)
+ if (t !== '—') {
+ lines.push(`- ${t}`)
+ any = true
+ }
+ }
+ if (!any) lines.push('—')
+ lines.push('')
+}
+
+function pushFaq(lines, faq) {
+ lines.push('### 买家问答')
+ lines.push('')
+ if (!Array.isArray(faq) || !faq.length) {
+ lines.push('—')
+ lines.push('')
+ return
+ }
+ let n = 0
+ for (const item of faq) {
+ if (!item || typeof item !== 'object') continue
+ const q = escLine(item.question)
+ const a = escLine(item.answer)
+ if (q === '—' && a === '—') continue
+ n += 1
+ lines.push(`#### 问 ${n}:${q === '—' ? '(未提供)' : q}`)
+ lines.push('')
+ lines.push(a)
+ lines.push('')
+ }
+ if (n === 0) {
+ lines.push('—')
+ lines.push('')
+ }
+}
+
+function pushDetailPack(lines, pack) {
+ if (!pack || typeof pack !== 'object') {
+ lines.push('(无多触点营销文案数据)')
+ lines.push('')
+ return
+ }
+ lines.push('### 依据与边界')
+ lines.push('')
+ lines.push(escLine(pack.traceability_note))
+ lines.push('')
+ pushBullets(lines, '商品短标题备选', pack.listing_titles)
+ lines.push('### 列表副文案')
+ lines.push('')
+ lines.push(escLine(pack.listing_subtitle))
+ lines.push('')
+ lines.push('### 商品详情页首屏引导')
+ lines.push('')
+ lines.push(escLine(pack.detail_headline))
+ lines.push('')
+ pushBullets(lines, '详情页中段叙事', pack.detail_mid_story_paragraphs)
+ pushBullets(lines, '卖点列表', pack.selling_bullets)
+ pushBullets(lines, '食用场景与搭配建议', pack.usage_and_pairing_tips)
+ pushBullets(lines, '参数区旁短句', pack.spec_sidebar_lines)
+ pushFaq(lines, pack.faq)
+ pushBullets(lines, '短图文/种草贴变体', pack.short_graphic_post_variants)
+ pushBullets(lines, '主图三要点', pack.main_image_three_points)
+ lines.push('### 文生图提示词(主图)')
+ lines.push('')
+ lines.push(escLine(pack.text_to_image_prompt_main))
+ lines.push('')
+ lines.push('### 文生图提示词(场景/备选)')
+ lines.push('')
+ lines.push(escLine(pack.text_to_image_prompt_scene))
+ lines.push('')
+ lines.push('### 文生视频提示词(短视频)')
+ lines.push('')
+ lines.push(escLine(pack.text_to_video_prompt))
+ lines.push('')
+ lines.push('### 直播/短视频钩句')
+ lines.push('')
+ lines.push(escLine(pack.live_or_short_hook))
+ lines.push('')
+ pushBullets(lines, '直播/短视频要点提纲', pack.live_script_bullets)
+ lines.push('### 客服首句建议')
+ lines.push('')
+ lines.push(escLine(pack.customer_service_opening))
+ lines.push('')
+}
+
+/**
+ * @param {Record} result marketing-detail-pack API 的 JSON 体
+ * @returns {string}
+ */
+export function marketingPackResultToMarkdown(result) {
+ if (!result || typeof result !== 'object') return ''
+ const lines = []
+ const jobId = result.job_id ?? ''
+ const kw = result.keyword ?? ''
+ const genAt = result.generated_at ?? ''
+ const src = result.source ?? ''
+ lines.push('# 营销内容')
+ lines.push('')
+ lines.push(`> 任务 #${jobId} · 关键词:${kw} · ${genAt}${src ? ` · ${src}` : ''}`)
+ lines.push('')
+ lines.push('## 核心信息卡')
+ lines.push('')
+ pushCoreCard(lines, result.core_info_card)
+ lines.push('## 多触点文案(列表/详情页/主图等)')
+ lines.push('')
+ pushDetailPack(lines, result.detail_page_pack)
+ return lines.join('\n').trim() + '\n'
+}
diff --git a/frontend/src/lib/strategyDraftStorage.js b/frontend/src/lib/strategyDraftStorage.js
new file mode 100644
index 0000000..2a500a0
--- /dev/null
+++ b/frontend/src/lib/strategyDraftStorage.js
@@ -0,0 +1,123 @@
+/**
+ * 策略稿与会话字段:localStorage 主存,便于跨标签;首次读取时从 sessionStorage 迁移旧数据。
+ */
+
+const DRAFT_PREFIX = 'ma_strategy_draft_'
+const SCOPE_PREFIX = 'ma_strategy_scope_'
+
+function draftKey(jobId) {
+ return `${DRAFT_PREFIX}${jobId}`
+}
+
+function scopeKey(jobId) {
+ return `${SCOPE_PREFIX}${jobId}`
+}
+
+/**
+ * @param {string} jobId
+ * @returns {Record|null}
+ */
+export function loadStrategyDraftRecord(jobId) {
+ if (!jobId) return null
+ const key = draftKey(jobId)
+ try {
+ let raw = localStorage.getItem(key)
+ if (!raw && typeof sessionStorage !== 'undefined') {
+ raw = sessionStorage.getItem(key)
+ if (raw) {
+ try {
+ localStorage.setItem(key, raw)
+ } catch {
+ /* 配额:保留 session 可读 */
+ }
+ }
+ }
+ if (!raw) return null
+ return JSON.parse(raw)
+ } catch {
+ return null
+ }
+}
+
+/**
+ * @param {string} jobId
+ * @param {Record} record
+ */
+export function saveStrategyDraftRecord(jobId, record) {
+ if (!jobId) return
+ const key = draftKey(jobId)
+ const payload = JSON.stringify(record)
+ try {
+ localStorage.setItem(key, payload)
+ } catch {
+ try {
+ sessionStorage.setItem(key, payload)
+ } catch {
+ /* ignore */
+ }
+ return
+ }
+ try {
+ sessionStorage.removeItem(key)
+ } catch {
+ /* ignore */
+ }
+}
+
+/**
+ * @param {string} jobId
+ * @returns {string}
+ */
+export function loadStrategyMatrixScope(jobId) {
+ if (!jobId) return ''
+ const key = scopeKey(jobId)
+ try {
+ let v = localStorage.getItem(key)
+ if (v == null && typeof sessionStorage !== 'undefined') {
+ v = sessionStorage.getItem(key)
+ if (v != null) {
+ try {
+ localStorage.setItem(key, v)
+ } catch {
+ /* */
+ }
+ }
+ }
+ return v || ''
+ } catch {
+ return ''
+ }
+}
+
+/**
+ * @param {string} jobId
+ * @param {string} groupLabel empty = clear
+ */
+export function saveStrategyMatrixScope(jobId, groupLabel) {
+ if (!jobId) return
+ const key = scopeKey(jobId)
+ try {
+ if (groupLabel) {
+ localStorage.setItem(key, groupLabel)
+ try {
+ sessionStorage.setItem(key, groupLabel)
+ } catch {
+ /* */
+ }
+ } else {
+ localStorage.removeItem(key)
+ try {
+ sessionStorage.removeItem(key)
+ } catch {
+ /* */
+ }
+ }
+ } catch {
+ try {
+ if (groupLabel) sessionStorage.setItem(key, groupLabel)
+ else sessionStorage.removeItem(key)
+ } catch {
+ /* */
+ }
+ }
+}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 99b9e5c..7590842 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -1,8 +1,23 @@
import { createApp } from 'vue'
+import { createPinia } from 'pinia'
import './style.css'
import 'github-markdown-css/github-markdown-light.css'
import './styles/ui.css'
import App from './App.vue'
import router from './router'
+import { useTaskStore } from './stores/task'
-createApp(App).use(router).mount('#app')
+const pinia = createPinia()
+const app = createApp(App)
+app.use(pinia)
+app.use(router)
+
+if (typeof window !== 'undefined') {
+ window.addEventListener('storage', (e) => {
+ if (e.key === 'ma_tasks_inflight' || e.key === 'ma_tasks_inflight_ts') {
+ useTaskStore().hydrateFromLocalStorage()
+ }
+ })
+}
+
+app.mount('#app')
diff --git a/frontend/src/stores/task.js b/frontend/src/stores/task.js
new file mode 100644
index 0000000..1ca844b
--- /dev/null
+++ b/frontend/src/stores/task.js
@@ -0,0 +1,133 @@
+/**
+ * 全局任务状态:耗时生成/导出/下载等「进行中」锁,跨标签页用 localStorage 同步。
+ */
+import { defineStore } from 'pinia'
+
+const LS_KEY = 'ma_tasks_inflight'
+const LS_TS = 'ma_tasks_inflight_ts'
+/** 含 LLM 的请求可能较久;超时后视为未进行,避免按钮永久禁用 */
+const TTL_MS = 45 * 60 * 1000
+const LEGACY_SS_KEY = 'ma_generation_inflight'
+const LEGACY_SS_TS = 'ma_generation_inflight_ts'
+
+function isAmbiguousClientFailure(err) {
+ if (err == null) return false
+ const name = err.name || ''
+ if (name === 'AbortError') return true
+ const msg = String(err.message || err)
+ return /Failed to fetch|NetworkError|Load failed|ERR_NETWORK|INTERNET_DISCONNECTED|aborted|cancel/i.test(
+ msg,
+ )
+}
+
+function migrateLegacySessionStorage() {
+ if (typeof sessionStorage === 'undefined' || typeof localStorage === 'undefined') return
+ try {
+ if (localStorage.getItem(LS_KEY)) return
+ const raw = sessionStorage.getItem(LEGACY_SS_KEY)
+ const ts = sessionStorage.getItem(LEGACY_SS_TS)
+ if (!raw) return
+ localStorage.setItem(LS_KEY, raw)
+ if (ts != null) localStorage.setItem(LS_TS, ts)
+ } catch {
+ /* ignore */
+ }
+}
+
+function readKeysFromLocalStorage() {
+ migrateLegacySessionStorage()
+ if (typeof localStorage === 'undefined') return []
+ try {
+ const raw = localStorage.getItem(LS_KEY)
+ const ts = localStorage.getItem(LS_TS)
+ if (!raw || ts == null) return []
+ const t = Number(ts)
+ if (!Number.isFinite(t) || Date.now() - t > TTL_MS) {
+ localStorage.removeItem(LS_KEY)
+ localStorage.removeItem(LS_TS)
+ return []
+ }
+ try {
+ const parsed = JSON.parse(raw)
+ if (Array.isArray(parsed)) return parsed.filter((x) => typeof x === 'string' && x)
+ if (typeof parsed === 'string') return [parsed]
+ return []
+ } catch {
+ return raw ? [raw] : []
+ }
+ } catch {
+ return []
+ }
+}
+
+function writeKeysToLocalStorage(keys) {
+ if (typeof localStorage === 'undefined') return
+ try {
+ if (keys.length) {
+ localStorage.setItem(LS_KEY, JSON.stringify(keys))
+ localStorage.setItem(LS_TS, String(Date.now()))
+ } else {
+ localStorage.removeItem(LS_KEY)
+ localStorage.removeItem(LS_TS)
+ }
+ } catch {
+ /* 隐私模式 / 配额 */
+ }
+}
+
+export const useTaskStore = defineStore('ma-tasks', {
+ state: () => ({
+ inFlightKeys: readKeysFromLocalStorage(),
+ }),
+
+ actions: {
+ hydrateFromLocalStorage() {
+ this.inFlightKeys = readKeysFromLocalStorage()
+ },
+
+ _persist() {
+ writeKeysToLocalStorage(this.inFlightKeys)
+ },
+
+ addKey(key) {
+ if (this.inFlightKeys.includes(key)) return
+ this.inFlightKeys = [...this.inFlightKeys, key]
+ this._persist()
+ },
+
+ removeKey(key) {
+ this.inFlightKeys = this.inFlightKeys.filter((k) => k !== key)
+ this._persist()
+ },
+
+ clearAll() {
+ this.inFlightKeys = []
+ this._persist()
+ },
+
+ clearRegenerateReportOnly() {
+ const next = this.inFlightKeys.filter((k) => !String(k).startsWith('regenerate-report:'))
+ this.inFlightKeys = next
+ this._persist()
+ },
+
+ /**
+ * @param {string} key
+ * @param {() => Promise} fn
+ * @returns {Promise}
+ */
+ async withInFlight(key, fn) {
+ this.addKey(key)
+ try {
+ const out = await fn()
+ this.removeKey(key)
+ return out
+ } catch (e) {
+ if (!isAmbiguousClientFailure(e)) {
+ this.removeKey(key)
+ }
+ throw e
+ }
+ },
+ },
+})
diff --git a/frontend/src/views/jd/JdAnalysisBuildView.vue b/frontend/src/views/jd/JdAnalysisBuildView.vue
index b853958..8aec125 100644
--- a/frontend/src/views/jd/JdAnalysisBuildView.vue
+++ b/frontend/src/views/jd/JdAnalysisBuildView.vue
@@ -1,6 +1,7 @@