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",
|
||||
"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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
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 { 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
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>
|
||||
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({
|
||||
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>
|
||||
|
||||
|
||||
@ -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,12 +113,13 @@ 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/`, {
|
||||
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,
|
||||
@ -126,23 +138,22 @@ async function generateMarketingDetailPack() {
|
||||
return
|
||||
}
|
||||
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>。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user