feat(frontend): Pinia 全局任务锁与策略稿 localStorage 跨标签同步

Made-with: Cursor
This commit is contained in:
hub-gif 2026-04-22 10:31:47 +08:00
parent 7b9009bd90
commit 220517f111
8 changed files with 434 additions and 168 deletions

View File

@ -12,6 +12,7 @@
"github-markdown-css": "^5.8.1",
"marked": "^15.0.7",
"papaparse": "^5.5.2",
"pinia": "^2.2.0",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},
@ -1100,6 +1101,58 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/pinia": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.2.0.tgz",
"integrity": "sha512-iPrIh26GMqfpUlMOGyxuDowGmYousTecbTHFwT0xZ1zJvh23oQ+Cj99ZoPQA1TnUPhU6AuRPv6/drkTCJ0VHQA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@vue/composition-api": "^1.4.0",
"typescript": ">=4.4.4",
"vue": "^2.6.14 || ^3.3.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",

View File

@ -13,6 +13,7 @@
"github-markdown-css": "^5.8.1",
"marked": "^15.0.7",
"papaparse": "^5.5.2",
"pinia": "^2.2.0",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},

View File

@ -1,118 +1,19 @@
import { ref } from 'vue'
const STORAGE_KEY = 'ma_generation_inflight'
const STORAGE_TS = 'ma_generation_inflight_ts'
/** 含 LLM 的重新生成可能较久;超时后视为未进行,避免按钮永久禁用 */
const TTL_MS = 45 * 60 * 1000
import { storeToRefs } from 'pinia'
import { useTaskStore } from '../stores/task'
/**
* @returns {string[]}
*/
function readPersistedKeys() {
if (typeof sessionStorage === 'undefined') return []
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
const ts = sessionStorage.getItem(STORAGE_TS)
if (!raw || ts == null) return []
const t = Number(ts)
if (!Number.isFinite(t) || Date.now() - t > TTL_MS) {
sessionStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem(STORAGE_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 {
/* 旧版:存的是 JSON 字符串化的单个 key或非法 JSON 时按原字符串当作一个 key */
return raw ? [raw] : []
}
} catch {
return []
}
}
/**
* @param {string[]} keys
*/
function writePersistedKeys(keys) {
if (typeof sessionStorage === 'undefined') return
try {
if (keys.length) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
sessionStorage.setItem(STORAGE_TS, String(Date.now()))
} else {
sessionStorage.removeItem(STORAGE_KEY)
sessionStorage.removeItem(STORAGE_TS)
}
} catch {
/* 隐私模式 / 配额 */
}
}
/** 多个耗时请求可并行:例如「重新生成报告」未完成时又点了「报告预览」,不得互相覆盖。 */
const inFlightKeys = ref(readPersistedKeys())
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY && e.key !== STORAGE_TS) return
inFlightKeys.value = readPersistedKeys()
})
}
/**
* 当前进行中的请求 key 列表同一 key 不会重复
* key 示例`strategy-draft:12``regenerate-report:12``preview-report:12`
* Pinia `useTaskStore` 同步进行中列表持久化在 localStorage跨标签页可见
*/
export function generationInFlightKey() {
return inFlightKeys
return storeToRefs(useTaskStore()).inFlightKeys
}
/**
* 清空本地进行中标记sessionStorage + 内存
* 用于断网/关页后误锁导致按钮长期灰显时手动恢复若服务端仍在跑请勿点
*/
export function clearGenerationInFlightState() {
inFlightKeys.value = []
writePersistedKeys([])
useTaskStore().clearAll()
}
const REGEN_REPORT_PREFIX = 'regenerate-report:'
/**
* 仅移除重新生成报告相关的进行中 key不影响策略生成报告预览等其它并行锁
*/
export function clearRegenerateReportInFlightOnly() {
const next = inFlightKeys.value.filter((k) => !String(k).startsWith(REGEN_REPORT_PREFIX))
inFlightKeys.value = next
writePersistedKeys(next)
}
function addInFlightKey(key) {
const cur = inFlightKeys.value
if (cur.includes(key)) return
inFlightKeys.value = [...cur, key]
writePersistedKeys(inFlightKeys.value)
}
function removeInFlightKey(key) {
inFlightKeys.value = inFlightKeys.value.filter((k) => k !== key)
writePersistedKeys(inFlightKeys.value)
}
/**
* 切换页签/隐藏标签时浏览器可能中止 fetch服务端可能仍在执行不应移除该 key
*/
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,
)
useTaskStore().clearRegenerateReportOnly()
}
/**
@ -121,15 +22,5 @@ function isAmbiguousClientFailure(err) {
* @returns {Promise<T>}
*/
export async function withGenerationInFlight(key, fn) {
addInFlightKey(key)
try {
const out = await fn()
removeInFlightKey(key)
return out
} catch (e) {
if (!isAmbiguousClientFailure(e)) {
removeInFlightKey(key)
}
throw e
}
return useTaskStore().withInFlight(key, fn)
}

View File

@ -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<string, unknown>|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<string, unknown>} 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 {
/* */
}
}
}

View File

@ -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')

133
frontend/src/stores/task.js Normal file
View File

@ -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<T>} fn
* @returns {Promise<T>}
*/
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
}
},
},
})

View File

@ -1,11 +1,16 @@
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { refreshJobs, useJobs, api } from '../../composables/useJobs'
import {
generationInFlightKey,
withGenerationInFlight,
} from '../../composables/useGenerationInFlight'
import {
loadStrategyMatrixScope,
saveStrategyDraftRecord,
saveStrategyMatrixScope,
} from '../../lib/strategyDraftStorage'
const route = useRoute()
const router = useRouter()
@ -115,8 +120,6 @@ function buildPayload() {
}
}
const STORAGE_KEY = (id) => `ma_strategy_draft_${id}`
function formatJobOption(j) {
const t = j.created_at
const tail = t ? String(t).replace('T', ' ').slice(0, 16) : ''
@ -151,7 +154,7 @@ async function loadMatrixGroupsForJob(id) {
const data = JSON.parse(text)
const mg = data.matrix_groups
matrixGroups.value = Array.isArray(mg) ? mg : []
const saved = sessionStorage.getItem(`ma_strategy_scope_${id}`)
const saved = loadStrategyMatrixScope(id)
if (saved && matrixGroups.value.some((g) => g.group === saved)) {
strategyMatrixScope.value = saved
}
@ -184,15 +187,12 @@ async function generateAndGoPreview() {
return
}
const j = JSON.parse(text)
sessionStorage.setItem(
STORAGE_KEY(id),
JSON.stringify({
markdown: j.markdown || '',
keyword: j.keyword || '',
generated_at: j.generated_at || '',
last_request: buildPayload(),
}),
)
saveStrategyDraftRecord(id, {
markdown: j.markdown || '',
keyword: j.keyword || '',
generated_at: j.generated_at || '',
last_request: buildPayload(),
})
router.push({ path: '/jd/strategy-view', query: { job: id } })
} catch (e) {
err.value = String(e)
@ -200,7 +200,31 @@ async function generateAndGoPreview() {
})
}
onMounted(loadList)
function onStorageScopeSync(ev) {
const prefix = 'ma_strategy_scope_'
if (!ev.key || !ev.key.startsWith(prefix)) return
const jid = ev.key.slice(prefix.length)
if (jid !== String(selectedId.value)) return
const v = loadStrategyMatrixScope(jid)
if (v && matrixGroups.value.some((g) => g.group === v)) {
strategyMatrixScope.value = v
} else if (!v) {
strategyMatrixScope.value = ''
}
}
onMounted(() => {
loadList()
if (typeof window !== 'undefined') {
window.addEventListener('storage', onStorageScopeSync)
}
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', onStorageScopeSync)
}
})
watch(selectedId, (id) => {
loadMatrixGroupsForJob(id)
@ -209,8 +233,7 @@ watch(selectedId, (id) => {
watch(strategyMatrixScope, (v) => {
const jid = selectedId.value
if (!jid) return
if (v) sessionStorage.setItem(`ma_strategy_scope_${jid}`, v)
else sessionStorage.removeItem(`ma_strategy_scope_${jid}`)
saveStrategyMatrixScope(jid, v)
})
watch(
@ -237,7 +260,7 @@ watch(
<section class="ma-card">
<h2>策略生成</h2>
<p class="hint-top">
选择<strong>已成功</strong>任务先选顶部<strong>矩阵细类</strong>主推类目与报告矩阵一致下方字段按策略文档常见顺序排列成稿里的小节标题与编号由系统自动对应无需在此对照章节号有关痛点购买理由品牌承诺等段落由监测与模型撰写本页主要收集<strong>业务决策与战术要点</strong>生成结果见
选择<strong>已成功</strong>任务先选顶部<strong>矩阵细类</strong>主推类目与报告矩阵一致策略稿与矩阵选择保存在本机 <strong>localStorage</strong>同域名下可跨标签查看与其它页面的耗时任务通过全局任务锁同步下方字段按策略文档常见顺序排列成稿里的小节标题与编号由系统自动对应有关痛点购买理由品牌承诺等由监测与模型撰写本页主要收集<strong>业务决策与战术要点</strong>生成结果见
<RouterLink to="/jd/strategy-view">策略稿预览</RouterLink><strong>已填项</strong>进入底稿并由大模型落实<strong>未填项</strong>可由模型结合数据推断
</p>

View File

@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import MarkdownPreview from '../../components/MarkdownPreview.vue'
import {
@ -8,21 +8,35 @@ import {
useJobs,
exportStrategyDocument,
} from '../../composables/useJobs'
import { generationInFlightKey, withGenerationInFlight } from '../../composables/useGenerationInFlight'
import { loadStrategyDraftRecord } from '../../lib/strategyDraftStorage'
const route = useRoute()
const router = useRouter()
const { jobs } = useJobs()
const genInFlight = generationInFlightKey()
const selectedId = ref('')
const draftMd = ref('')
const draftMeta = ref(null)
const viewMode = ref('render')
const exportErr = ref('')
const exportBusy = ref(false)
const marketingBusy = ref(false)
const marketingErr = ref('')
const marketingResult = ref(null)
const exportBusy = computed(() => {
const id = selectedId.value
if (!id) return false
return genInFlight.value.some((k) => String(k).startsWith(`export-strategy:${id}:`))
})
const marketingBusy = computed(() => {
const id = selectedId.value
if (!id) return false
return genInFlight.value.includes(`marketing-detail-pack:${id}`)
})
function payloadForMarketing(lastRequest) {
if (!lastRequest || typeof lastRequest !== 'object') {
return { business_notes: '', strategy_decisions: {} }
@ -39,8 +53,6 @@ function payloadForMarketing(lastRequest) {
}
}
const STORAGE_KEY = (id) => `ma_strategy_draft_${id}`
const successJobs = computed(() =>
[...jobs.value].filter((j) => j.status === 'success').sort((a, b) => b.id - a.id),
)
@ -57,13 +69,12 @@ function loadDraft() {
return
}
try {
const raw = sessionStorage.getItem(STORAGE_KEY(id))
if (!raw) {
const o = loadStrategyDraftRecord(id)
if (!o) {
draftMd.value = ''
draftMeta.value = null
return
}
const o = JSON.parse(raw)
draftMd.value = o.markdown || ''
draftMeta.value = {
keyword: o.keyword || '',
@ -102,47 +113,47 @@ async function generateMarketingDetailPack() {
if (!draftMd.value || !selectedId.value) return
marketingErr.value = ''
marketingResult.value = null
marketingBusy.value = true
const id = selectedId.value
const { business_notes, strategy_decisions } = payloadForMarketing(
draftMeta.value?.last_request,
)
try {
const r = await api(`/api/jobs/${selectedId.value}/marketing-detail-pack/`, {
method: 'POST',
body: JSON.stringify({
strategy_markdown: draftMd.value,
business_notes,
strategy_decisions,
}),
})
const text = await r.text()
if (!r.ok) {
try {
const j = JSON.parse(text)
marketingErr.value = j.detail || text
} catch {
marketingErr.value = text || `HTTP ${r.status}`
await withGenerationInFlight(`marketing-detail-pack:${id}`, async () => {
const r = await api(`/api/jobs/${id}/marketing-detail-pack/`, {
method: 'POST',
body: JSON.stringify({
strategy_markdown: draftMd.value,
business_notes,
strategy_decisions,
}),
})
const text = await r.text()
if (!r.ok) {
try {
const j = JSON.parse(text)
marketingErr.value = j.detail || text
} catch {
marketingErr.value = text || `HTTP ${r.status}`
}
return
}
return
}
marketingResult.value = JSON.parse(text)
marketingResult.value = JSON.parse(text)
})
} catch (e) {
marketingErr.value = String(e)
} finally {
marketingBusy.value = false
}
}
async function exportStrategyFmt(fmt) {
if (!draftMd.value || !selectedId.value) return
exportErr.value = ''
exportBusy.value = true
const id = selectedId.value
try {
await exportStrategyDocument(selectedId.value, draftMd.value, fmt)
await withGenerationInFlight(`export-strategy:${id}:${fmt}`, async () => {
await exportStrategyDocument(id, draftMd.value, fmt)
})
} catch (e) {
exportErr.value = String(e)
} finally {
exportBusy.value = false
}
}
@ -165,10 +176,26 @@ function syncSelectionFromRouteAndJobs() {
}
}
function onStorageDraftSync(ev) {
const prefix = 'ma_strategy_draft_'
if (!ev.key || !ev.key.startsWith(prefix)) return
const jid = ev.key.slice(prefix.length)
if (jid === String(selectedId.value)) loadDraft()
}
onMounted(async () => {
await loadList()
syncSelectionFromRouteAndJobs()
loadDraft()
if (typeof window !== 'undefined') {
window.addEventListener('storage', onStorageDraftSync)
}
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', onStorageDraftSync)
}
})
watch(
@ -206,7 +233,7 @@ watch(successJobs, (list) => {
<section class="ma-card">
<h2>策略稿预览</h2>
<p class="hint-top">
选择在<strong>策略生成</strong>页已生成过的任务查看文稿保存在本浏览器会话内可点击<strong>生成商详包</strong>先抽核心信息卡再派生标题与卖点等两次大模型调用需要改决策请回到
选择在<strong>策略生成</strong>页已生成过的任务查看文稿保存在本浏览器 <strong>localStorage</strong>同域名下可跨标签查看生成/导出/商详包等耗时操作状态在全局任务锁中同步跨标签页可看到进行中需要改决策请回到
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
重新提交分析数据见
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>