From 220517f1115e6e363905790e8625d7ec44a13c73 Mon Sep 17 00:00:00 2001 From: hub-gif <2487812171@qq.com> Date: Wed, 22 Apr 2026 10:31:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Pinia=20=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E9=94=81=E4=B8=8E=E7=AD=96=E7=95=A5=E7=A8=BF?= =?UTF-8?q?=20localStorage=20=E8=B7=A8=E6=A0=87=E7=AD=BE=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- frontend/package-lock.json | 53 +++++++ frontend/package.json | 1 + .../src/composables/useGenerationInFlight.js | 123 +--------------- frontend/src/lib/strategyDraftStorage.js | 123 ++++++++++++++++ frontend/src/main.js | 17 ++- frontend/src/stores/task.js | 133 ++++++++++++++++++ frontend/src/views/jd/JdStrategyBuildView.vue | 57 +++++--- frontend/src/views/jd/JdStrategyView.vue | 95 ++++++++----- 8 files changed, 434 insertions(+), 168 deletions(-) create mode 100644 frontend/src/lib/strategyDraftStorage.js create mode 100644 frontend/src/stores/task.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ee93f9d..f16c220 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 8dfcafd..e605045 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" }, diff --git a/frontend/src/composables/useGenerationInFlight.js b/frontend/src/composables/useGenerationInFlight.js index 28c12d4..5182f70 100644 --- a/frontend/src/composables/useGenerationInFlight.js +++ b/frontend/src/composables/useGenerationInFlight.js @@ -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} */ 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) } 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/JdStrategyBuildView.vue b/frontend/src/views/jd/JdStrategyBuildView.vue index 84f237c..7936eff 100644 --- a/frontend/src/views/jd/JdStrategyBuildView.vue +++ b/frontend/src/views/jd/JdStrategyBuildView.vue @@ -1,11 +1,16 @@