mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
feat(frontend): Pinia 全局任务锁与策略稿 localStorage 跨标签同步
Made-with: Cursor
This commit is contained in:
parent
7b9009bd90
commit
220517f111
53
frontend/package-lock.json
generated
53
frontend/package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"marked": "^15.0.7",
|
"marked": "^15.0.7",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
|
"pinia": "^2.2.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.4.5"
|
"vue-router": "^4.4.5"
|
||||||
},
|
},
|
||||||
@ -1100,6 +1101,58 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.9",
|
"version": "8.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"marked": "^15.0.7",
|
"marked": "^15.0.7",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
|
"pinia": "^2.2.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.4.5"
|
"vue-router": "^4.4.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,118 +1,19 @@
|
|||||||
import { ref } from 'vue'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useTaskStore } from '../stores/task'
|
||||||
const STORAGE_KEY = 'ma_generation_inflight'
|
|
||||||
const STORAGE_TS = 'ma_generation_inflight_ts'
|
|
||||||
/** 含 LLM 的重新生成可能较久;超时后视为未进行,避免按钮永久禁用 */
|
|
||||||
const TTL_MS = 45 * 60 * 1000
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string[]}
|
* 与 Pinia `useTaskStore` 同步;进行中列表持久化在 localStorage,跨标签页可见。
|
||||||
*/
|
|
||||||
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`
|
|
||||||
*/
|
*/
|
||||||
export function generationInFlightKey() {
|
export function generationInFlightKey() {
|
||||||
return inFlightKeys
|
return storeToRefs(useTaskStore()).inFlightKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空本地「进行中」标记(sessionStorage + 内存)。
|
|
||||||
* 用于断网/关页后误锁导致按钮长期灰显时手动恢复;若服务端仍在跑请勿点。
|
|
||||||
*/
|
|
||||||
export function clearGenerationInFlightState() {
|
export function clearGenerationInFlightState() {
|
||||||
inFlightKeys.value = []
|
useTaskStore().clearAll()
|
||||||
writePersistedKeys([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const REGEN_REPORT_PREFIX = 'regenerate-report:'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 仅移除「重新生成报告」相关的进行中 key,不影响策略生成、报告预览等其它并行锁。
|
|
||||||
*/
|
|
||||||
export function clearRegenerateReportInFlightOnly() {
|
export function clearRegenerateReportInFlightOnly() {
|
||||||
const next = inFlightKeys.value.filter((k) => !String(k).startsWith(REGEN_REPORT_PREFIX))
|
useTaskStore().clearRegenerateReportOnly()
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -121,15 +22,5 @@ function isAmbiguousClientFailure(err) {
|
|||||||
* @returns {Promise<T>}
|
* @returns {Promise<T>}
|
||||||
*/
|
*/
|
||||||
export async function withGenerationInFlight(key, fn) {
|
export async function withGenerationInFlight(key, fn) {
|
||||||
addInFlightKey(key)
|
return useTaskStore().withInFlight(key, fn)
|
||||||
try {
|
|
||||||
const out = await fn()
|
|
||||||
removeInFlightKey(key)
|
|
||||||
return out
|
|
||||||
} catch (e) {
|
|
||||||
if (!isAmbiguousClientFailure(e)) {
|
|
||||||
removeInFlightKey(key)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
123
frontend/src/lib/strategyDraftStorage.js
Normal file
123
frontend/src/lib/strategyDraftStorage.js
Normal 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 {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,23 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import 'github-markdown-css/github-markdown-light.css'
|
import 'github-markdown-css/github-markdown-light.css'
|
||||||
import './styles/ui.css'
|
import './styles/ui.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
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
133
frontend/src/stores/task.js
Normal 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,11 +1,16 @@
|
|||||||
<script setup>
|
<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 { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import { refreshJobs, useJobs, api } from '../../composables/useJobs'
|
import { refreshJobs, useJobs, api } from '../../composables/useJobs'
|
||||||
import {
|
import {
|
||||||
generationInFlightKey,
|
generationInFlightKey,
|
||||||
withGenerationInFlight,
|
withGenerationInFlight,
|
||||||
} from '../../composables/useGenerationInFlight'
|
} from '../../composables/useGenerationInFlight'
|
||||||
|
import {
|
||||||
|
loadStrategyMatrixScope,
|
||||||
|
saveStrategyDraftRecord,
|
||||||
|
saveStrategyMatrixScope,
|
||||||
|
} from '../../lib/strategyDraftStorage'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -115,8 +120,6 @@ function buildPayload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = (id) => `ma_strategy_draft_${id}`
|
|
||||||
|
|
||||||
function formatJobOption(j) {
|
function formatJobOption(j) {
|
||||||
const t = j.created_at
|
const t = j.created_at
|
||||||
const tail = t ? String(t).replace('T', ' ').slice(0, 16) : ''
|
const tail = t ? String(t).replace('T', ' ').slice(0, 16) : ''
|
||||||
@ -151,7 +154,7 @@ async function loadMatrixGroupsForJob(id) {
|
|||||||
const data = JSON.parse(text)
|
const data = JSON.parse(text)
|
||||||
const mg = data.matrix_groups
|
const mg = data.matrix_groups
|
||||||
matrixGroups.value = Array.isArray(mg) ? mg : []
|
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)) {
|
if (saved && matrixGroups.value.some((g) => g.group === saved)) {
|
||||||
strategyMatrixScope.value = saved
|
strategyMatrixScope.value = saved
|
||||||
}
|
}
|
||||||
@ -184,15 +187,12 @@ async function generateAndGoPreview() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const j = JSON.parse(text)
|
const j = JSON.parse(text)
|
||||||
sessionStorage.setItem(
|
saveStrategyDraftRecord(id, {
|
||||||
STORAGE_KEY(id),
|
markdown: j.markdown || '',
|
||||||
JSON.stringify({
|
keyword: j.keyword || '',
|
||||||
markdown: j.markdown || '',
|
generated_at: j.generated_at || '',
|
||||||
keyword: j.keyword || '',
|
last_request: buildPayload(),
|
||||||
generated_at: j.generated_at || '',
|
})
|
||||||
last_request: buildPayload(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
router.push({ path: '/jd/strategy-view', query: { job: id } })
|
router.push({ path: '/jd/strategy-view', query: { job: id } })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.value = String(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) => {
|
watch(selectedId, (id) => {
|
||||||
loadMatrixGroupsForJob(id)
|
loadMatrixGroupsForJob(id)
|
||||||
@ -209,8 +233,7 @@ watch(selectedId, (id) => {
|
|||||||
watch(strategyMatrixScope, (v) => {
|
watch(strategyMatrixScope, (v) => {
|
||||||
const jid = selectedId.value
|
const jid = selectedId.value
|
||||||
if (!jid) return
|
if (!jid) return
|
||||||
if (v) sessionStorage.setItem(`ma_strategy_scope_${jid}`, v)
|
saveStrategyMatrixScope(jid, v)
|
||||||
else sessionStorage.removeItem(`ma_strategy_scope_${jid}`)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -237,7 +260,7 @@ watch(
|
|||||||
<section class="ma-card">
|
<section class="ma-card">
|
||||||
<h2>策略生成</h2>
|
<h2>策略生成</h2>
|
||||||
<p class="hint-top">
|
<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>可由模型结合数据推断。
|
<RouterLink to="/jd/strategy-view">策略稿预览</RouterLink>。<strong>已填项</strong>进入底稿并由大模型落实;<strong>未填项</strong>可由模型结合数据推断。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<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 { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import MarkdownPreview from '../../components/MarkdownPreview.vue'
|
import MarkdownPreview from '../../components/MarkdownPreview.vue'
|
||||||
import {
|
import {
|
||||||
@ -8,21 +8,35 @@ import {
|
|||||||
useJobs,
|
useJobs,
|
||||||
exportStrategyDocument,
|
exportStrategyDocument,
|
||||||
} from '../../composables/useJobs'
|
} from '../../composables/useJobs'
|
||||||
|
import { generationInFlightKey, withGenerationInFlight } from '../../composables/useGenerationInFlight'
|
||||||
|
import { loadStrategyDraftRecord } from '../../lib/strategyDraftStorage'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { jobs } = useJobs()
|
const { jobs } = useJobs()
|
||||||
|
|
||||||
|
const genInFlight = generationInFlightKey()
|
||||||
|
|
||||||
const selectedId = ref('')
|
const selectedId = ref('')
|
||||||
const draftMd = ref('')
|
const draftMd = ref('')
|
||||||
const draftMeta = ref(null)
|
const draftMeta = ref(null)
|
||||||
const viewMode = ref('render')
|
const viewMode = ref('render')
|
||||||
const exportErr = ref('')
|
const exportErr = ref('')
|
||||||
const exportBusy = ref(false)
|
|
||||||
const marketingBusy = ref(false)
|
|
||||||
const marketingErr = ref('')
|
const marketingErr = ref('')
|
||||||
const marketingResult = ref(null)
|
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) {
|
function payloadForMarketing(lastRequest) {
|
||||||
if (!lastRequest || typeof lastRequest !== 'object') {
|
if (!lastRequest || typeof lastRequest !== 'object') {
|
||||||
return { business_notes: '', strategy_decisions: {} }
|
return { business_notes: '', strategy_decisions: {} }
|
||||||
@ -39,8 +53,6 @@ function payloadForMarketing(lastRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = (id) => `ma_strategy_draft_${id}`
|
|
||||||
|
|
||||||
const successJobs = computed(() =>
|
const successJobs = computed(() =>
|
||||||
[...jobs.value].filter((j) => j.status === 'success').sort((a, b) => b.id - a.id),
|
[...jobs.value].filter((j) => j.status === 'success').sort((a, b) => b.id - a.id),
|
||||||
)
|
)
|
||||||
@ -57,13 +69,12 @@ function loadDraft() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(STORAGE_KEY(id))
|
const o = loadStrategyDraftRecord(id)
|
||||||
if (!raw) {
|
if (!o) {
|
||||||
draftMd.value = ''
|
draftMd.value = ''
|
||||||
draftMeta.value = null
|
draftMeta.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const o = JSON.parse(raw)
|
|
||||||
draftMd.value = o.markdown || ''
|
draftMd.value = o.markdown || ''
|
||||||
draftMeta.value = {
|
draftMeta.value = {
|
||||||
keyword: o.keyword || '',
|
keyword: o.keyword || '',
|
||||||
@ -102,47 +113,47 @@ async function generateMarketingDetailPack() {
|
|||||||
if (!draftMd.value || !selectedId.value) return
|
if (!draftMd.value || !selectedId.value) return
|
||||||
marketingErr.value = ''
|
marketingErr.value = ''
|
||||||
marketingResult.value = null
|
marketingResult.value = null
|
||||||
marketingBusy.value = true
|
const id = selectedId.value
|
||||||
const { business_notes, strategy_decisions } = payloadForMarketing(
|
const { business_notes, strategy_decisions } = payloadForMarketing(
|
||||||
draftMeta.value?.last_request,
|
draftMeta.value?.last_request,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const r = await api(`/api/jobs/${selectedId.value}/marketing-detail-pack/`, {
|
await withGenerationInFlight(`marketing-detail-pack:${id}`, async () => {
|
||||||
method: 'POST',
|
const r = await api(`/api/jobs/${id}/marketing-detail-pack/`, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
strategy_markdown: draftMd.value,
|
body: JSON.stringify({
|
||||||
business_notes,
|
strategy_markdown: draftMd.value,
|
||||||
strategy_decisions,
|
business_notes,
|
||||||
}),
|
strategy_decisions,
|
||||||
})
|
}),
|
||||||
const text = await r.text()
|
})
|
||||||
if (!r.ok) {
|
const text = await r.text()
|
||||||
try {
|
if (!r.ok) {
|
||||||
const j = JSON.parse(text)
|
try {
|
||||||
marketingErr.value = j.detail || text
|
const j = JSON.parse(text)
|
||||||
} catch {
|
marketingErr.value = j.detail || text
|
||||||
marketingErr.value = text || `HTTP ${r.status}`
|
} catch {
|
||||||
|
marketingErr.value = text || `HTTP ${r.status}`
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
marketingResult.value = JSON.parse(text)
|
||||||
}
|
})
|
||||||
marketingResult.value = JSON.parse(text)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
marketingErr.value = String(e)
|
marketingErr.value = String(e)
|
||||||
} finally {
|
|
||||||
marketingBusy.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportStrategyFmt(fmt) {
|
async function exportStrategyFmt(fmt) {
|
||||||
if (!draftMd.value || !selectedId.value) return
|
if (!draftMd.value || !selectedId.value) return
|
||||||
exportErr.value = ''
|
exportErr.value = ''
|
||||||
exportBusy.value = true
|
const id = selectedId.value
|
||||||
try {
|
try {
|
||||||
await exportStrategyDocument(selectedId.value, draftMd.value, fmt)
|
await withGenerationInFlight(`export-strategy:${id}:${fmt}`, async () => {
|
||||||
|
await exportStrategyDocument(id, draftMd.value, fmt)
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
exportErr.value = String(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 () => {
|
onMounted(async () => {
|
||||||
await loadList()
|
await loadList()
|
||||||
syncSelectionFromRouteAndJobs()
|
syncSelectionFromRouteAndJobs()
|
||||||
loadDraft()
|
loadDraft()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('storage', onStorageDraftSync)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('storage', onStorageDraftSync)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -206,7 +233,7 @@ watch(successJobs, (list) => {
|
|||||||
<section class="ma-card">
|
<section class="ma-card">
|
||||||
<h2>策略稿预览</h2>
|
<h2>策略稿预览</h2>
|
||||||
<p class="hint-top">
|
<p class="hint-top">
|
||||||
选择在<strong>策略生成</strong>页已生成过的任务查看文稿(保存在本浏览器会话内)。可点击<strong>生成商详包</strong>:先抽核心信息卡,再派生标题与卖点等(两次大模型调用)。需要改决策请回到
|
选择在<strong>策略生成</strong>页已生成过的任务查看文稿(保存在本机浏览器 <strong>localStorage</strong>,同域名下可跨标签查看)。生成/导出/商详包等耗时操作状态在全局任务锁中同步,跨标签页可看到进行中。需要改决策请回到
|
||||||
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
|
<RouterLink to="/jd/strategy-build">策略生成</RouterLink>
|
||||||
重新提交。分析数据见
|
重新提交。分析数据见
|
||||||
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>。
|
<RouterLink to="/jd/analysis-view">报告查看</RouterLink>。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user