diff --git a/docs/project-list-logic-audit.md b/docs/project-list-logic-audit.md new file mode 100644 index 0000000..03e013a --- /dev/null +++ b/docs/project-list-logic-audit.md @@ -0,0 +1,427 @@ +# 项目清单与项目切换逻辑审计 + +## 1. 审计目标 + +本文件用于梳理“项目清单 / 首页进入项目 / 删除当前项目 / 重复项目检测”相关代码,并检查需求逻辑是否存在冲突。 + +当前问题已经表现为一组相互影响的回归: + +1. 首页打开已有项目时,可能进入了错误项目 +2. 删除当前项目后,回首页可能误触发“检测到项目重复打开” +3. 两个相同项目 tab 的重复检测一度失效 + +这些问题不是孤立 bug,而是同一组状态源和时序设计在互相冲突。 + +--- + +## 2. 相关代码清单 + +### 2.1 项目注册表 + +文件: + +- `src/lib/projectRegistry.ts` + +职责: + +- 维护项目元数据列表 +- 提供 `listProjects / createProject / upsertProject / deleteProject` +- 首页项目列表、项目菜单、冲突页项目列表都依赖这里 + +关键事实: + +- `listProjects()` 按 `lastOpenedAt` 排序 +- 注册表只存元信息,不负责切换实际项目数据上下文 + +--- + +### 2.2 URL 与项目上下文 + +文件: + +- `src/lib/workspace.ts` + +职责: + +- `readCurrentProjectId()` +- `ensureProjectIdInUrl()` +- `buildProjectUrl()` +- `DEFAULT_PROJECT_ID = 'default'` +- `QUICK_PROJECT_ID = 'quick'` + +关键事实: + +- `projectId` 主要通过 URL 传递 +- `default` 同时承担“首页占位项目”语义 + +--- + +### 2.3 应用启动时绑定项目库 + +文件: + +- `src/main.ts` +- `src/pinia/Plugin/indexdb.ts` + +职责: + +- 启动时根据当前 `projectId` 绑定 Pinia 持久化数据库 + +关键代码逻辑: + +```ts +const currentProjectId = pickBootstrapProjectId() +pinia.use( + piniaPersistedstate({ + name: getProjectDbName(currentProjectId), + storeName: 'pinia', + mode: 'multiple' + }) +) +``` + +结论: + +- 应用实例一旦启动,Pinia 的持久化库已经绑定到某个项目 +- 后续如果只改 URL、不整页重载,就不会真正切到目标项目库 + +这是一条核心约束。 + +--- + +### 2.4 首页项目列表与进入已有项目 + +文件: + +- `src/features/workbench/components/HomeEntryView.vue` + +职责: + +- 展示首页已有项目列表 +- 打开已有项目 +- 新建项目 +- 首页导入 + +当前关键逻辑: + +```ts +const refreshExistingProjects = async () => { + const projects = listProjects() + existingProjects.value = projects.map(project => ({ + id: project.id, + name: project.name, + updatedAt: project.updatedAt + })) +} +``` + +```ts +const navigateToWorkspace = (projectIdRaw: string, mode: 'project' | 'quick') => { + const projectId = String(projectIdRaw || '').trim() + writeWorkspaceMode(mode) + const currentProjectId = getActiveProjectId() + const shouldReloadApp = currentProjectId !== projectId + if (shouldReloadApp) { + window.location.href = buildProjectUrl(projectId, { forceHome: false, newProject: false }) + return false + } + writeCleanProjectUrl(projectId) + return true +} +``` + +```ts +const enterExistingProject = (projectIdRaw: string) => { + const projectId = String(projectIdRaw || '').trim() + upsertProject(projectId, resolveProjectRegistryName(projectId)) + if (!navigateToWorkspace(projectId, 'project')) return + tabStore.enterWorkspace(...) + tabStore.hasCompletedSetup = true +} +``` + +结论: + +- 当前首页进入已有项目,已经开始依赖“必要时整页重载”来切项目库 +- 这条方向是对的,因为它符合 `main.ts` 的启动约束 + +--- + +### 2.5 App 级首页/工作区/冲突锁 + +文件: + +- `src/App.vue` +- `src/lib/projectSessionLock.ts` + +职责: + +- 决定显示首页还是工作区 +- 初始化重复项目检测 +- 删除项目后回首页 + +当前关键逻辑: + +```ts +if (!isForceHomeRequest.value && currentProjectId.value !== QUICK_PROJECT_ID && currentProjectId.value !== DEFAULT_PROJECT_ID) { + initCurrentProjectLock() +} +``` + +```ts +const shouldLockDefaultProject = + !isForceHomeRequest.value + && currentProjectId.value === DEFAULT_PROJECT_ID + && tabStore.hasCompletedSetup +``` + +```ts +watch( + () => tabStore.hasCompletedSetup, + (next) => { + if (!next) return + if (isForceHomeRequest.value) return + if (currentProjectId.value !== DEFAULT_PROJECT_ID) return + initCurrentProjectLock() + } +) +``` + +结论: + +- `App.vue` 现在在尝试区分三种状态: + - 真实项目 + - 首页占位态 + - `default` 进入工作区后的实际项目态 +- 这套设计复杂,而且和 `default` 复用语义强耦合 + +--- + +### 2.6 删除当前项目 + +文件: + +- `src/layout/tab.vue` +- `src/App.vue` + +关键逻辑: + +```ts +if (isCurrentProject) { + window.dispatchEvent(new CustomEvent('jgjs-release-project-lock')) + await clearProjectPersistence() + await deleteIndexedDBByName(getProjectDbName(project.id)) + const removed = deleteProject(project.id) + emitProjectDeleted(project.id) + tabStore.resetTabs() + tabStore.hasCompletedSetup = false + window.location.href = buildProjectUrl('default', { forceHome: true }) + return +} +``` + +```ts +const handleProjectDeleted = (deletedProjectId: string) => { + handleReleaseProjectLock() + tabStore.resetTabs() + tabStore.hasCompletedSetup = false + const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true }) + window.setTimeout(() => { + window.location.href = href + }, 120) +} +``` + +结论: + +- 删除当前项目时,既有 tab 内直接重定向,又有 `App.vue` 监听删除事件后再次重定向 +- 这两条链路在行为上相近,但不是单一出口 + +--- + +## 3. 需求逻辑清单 + +下面是业务上应该同时成立的需求: + +1. 首页只是项目入口,不应触发“重复项目打开”拦截 +2. 打开已有项目时,必须进入用户点选的那个项目,不能串到别的项目 +3. 删除当前项目后,必须稳定回首页,不能落到冲突页 +4. 两个浏览器 tab 打开同一个真实项目时,必须触发重复项目拦截 +5. `quick` 项目与普通项目互不干扰 +6. `default` 不能既当“首页占位态”,又在多条链路里被当成“真实项目” + +--- + +## 4. 逻辑冲突检查 + +## 冲突 A:`default` 同时承担两种身份 + +现状: + +- `default` 既是首页占位项目 +- 又可能进入实际工作区 +- 锁逻辑还试图区分“default 首页态”和“default 工作区态” + +冲突: + +- 首页不该被锁 +- 工作区里的真实项目应该被锁 +- 但二者共用同一个 `projectId = default` + +结果: + +- 只要某条链路判断稍有偏差,就会出现: + - 首页误触发冲突 + - 或重复项目检测失效 + +结论: + +- 这是当前最核心的设计冲突 + +--- + +## 冲突 B:项目切换依赖 URL,但持久化库绑定依赖应用启动 + +现状: + +- `projectId` 在 URL 中切换 +- Pinia 持久化数据库在 `main.ts` 启动时绑定 + +冲突: + +- SPA 式改 URL,不会重建持久化上下文 +- 整页刷新才能真正切项目库 + +结果: + +- 首页打开已有项目,如果只是 `replaceState`,会出现“地址栏变了,项目数据没变” + +结论: + +- 项目切换要么强制 reload,要么重构持久化层支持运行时切库 +- 两者不能混用 + +--- + +## 冲突 C:删除当前项目存在双重跳转出口 + +现状: + +- `tab.vue` 删除当前项目后会直接 `window.location.href = buildProjectUrl('default', { forceHome: true })` +- `App.vue` 的 `handleProjectDeleted()` 监听到删除事件后也会再次重定向 + +冲突: + +- 同一个业务动作存在两个页面跳转出口 +- 两处都在改 `tabStore.hasCompletedSetup` +- 两处都依赖锁释放时序 + +结果: + +- 更容易和 `forceHome`、水合、默认项目锁判断形成竞态 + +结论: + +- 删除当前项目应该收敛成单一出口 + +--- + +## 冲突 D:首页项目列表读的是注册表,工作区项目实际名读的是项目库 + +现状: + +- 首页 `refreshExistingProjects()` 直接吃 `listProjects()` +- `App.vue` 冲突页会尝试再去项目库里补 `xm-base-info-v1.projectName` + +冲突: + +- 同一个“项目名称”有两套来源: + - 注册表 + - 项目库 + +结果: + +- 容易出现列表展示名、实际项目名、最近编辑项目排序不同步 + +结论: + +- 首页项目列表和冲突页项目列表的名称来源应该统一 + +--- + +## 冲突 E:首页导入链路仍然保留了“只改 URL 不 reload”的旧思路 + +现状: + +```ts +const confirmHomeImport = () => { + ... + const targetProjectId = ... + writeCleanProjectUrl(targetProjectId) + writeWorkspaceMode('project') + tabStore.enterWorkspace(...) + tabStore.hasCompletedSetup = true +} +``` + +冲突: + +- 这条链路与“打开已有项目时必须整页切库”的新规则不一致 + +结果: + +- 同类问题可能在首页导入上继续复现 + +结论: + +- 这条链路与 `navigateToWorkspace()` 的规则不一致,属于明确冲突 + +--- + +## 5. 结论 + +当前项目清单与项目切换逻辑的核心冲突不是单点 bug,而是以下三条设计同时存在: + +1. `default` 被复用为“首页占位项目 + 真实工作区项目” +2. 项目切换有时 reload,有时只改 URL,规则不统一 +3. 删除/回首页/重复项目检测有多处出口,状态源不唯一 + +这三条不拆开,任何局部修复都会互相打架。 + +--- + +## 6. 建议的需求基线 + +建议先对齐以下基线,再继续改代码: + +1. 首页不是项目,不参与项目锁 +2. 真实项目必须有真实 `projectId`,不要再让 `default` 进入工作区承担真实项目角色 +3. 项目切换一律走“整页重启切库”,除非后续重构 Pinia 持久化支持运行时切库 +4. 删除当前项目后的回首页只保留一个跳转出口 +5. 首页项目列表、冲突页项目列表统一同一套项目名称读取逻辑 + +--- + +## 7. 当前最值得先改的一条 + +如果只允许先做一条,我建议优先处理: + +**不要再让 `default` 作为真实项目进入工作区。** + +原因: + +- 这是首页误触发冲突和重复项目检测失灵的共同根因之一 +- 也是删除当前项目回首页后最容易炸的那条链路 + +--- + +## 8. 下一步执行建议 + +下一步不建议继续在现有条件分支上微调。建议按以下顺序重构: + +1. 把“首页态”和“真实项目态”彻底分离 +2. 把首页进入已有项目、首页导入、工作区打开项目统一成“切项目必须重载应用” +3. 把删除当前项目后的跳转收敛成单一出口 +4. 最后再回头收敛重复项目检测 + +否则就是继续在同一组冲突需求上打补丁,回归会反复发生。 diff --git a/public/logo.jpg b/public/logo.jpg new file mode 100644 index 0000000..0b5169c Binary files /dev/null and b/public/logo.jpg differ diff --git a/src/App.vue b/src/App.vue index 2395a65..c97b5f1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -29,6 +29,8 @@ const currentProjectName = ref('') const conflictProjectList = ref([]) const openedProjectIds = ref([]) const closeCountdown = ref(10) +const isNewProjectRequest = ref(false) +const isForceHomeRequest = ref(false) let closeCountdownTimer: ReturnType | null = null let releaseLock: (() => void) | null = null let stopProjectDeletedListener: (() => void) | null = null @@ -42,6 +44,28 @@ const handleImportComplete = () => { tabStore.hasCompletedSetup = true } +const initCurrentProjectLock = () => { + if (releaseLock) return + const projectId = String(currentProjectId.value || '').trim() + if (!projectId || projectId === QUICK_PROJECT_ID) { + lockConflict.value = false + return + } + const lock = initProjectSessionLock({ + projectId, + onConflict: (next) => { + lockConflict.value = next + if (next) { + refreshConflictProjectList() + startCloseCountdown() + } else { + clearCloseCountdown() + } + } + }) + releaseLock = lock.release +} + const refreshConflictProjectList = () => { void (async () => { const projects = listProjects() @@ -106,11 +130,22 @@ const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean } const createProjectAndOpen = () => { - const project = createProject() + const project = createProject(t('xmInfo.defaultProjectName')) refreshConflictProjectList() openProjectInNewTab(project.id, { newProject: true }) } +const syncRouteRequestFlags = () => { + try { + const url = new URL(window.location.href) + isNewProjectRequest.value = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1' + isForceHomeRequest.value = url.searchParams.get(FORCE_HOME_QUERY_KEY) === '1' + } catch { + isNewProjectRequest.value = false + isForceHomeRequest.value = false + } +} + const formatProjectEditedTime = (value: string) => { const date = new Date(value) if (Number.isNaN(date.getTime())) return '-' @@ -162,33 +197,10 @@ const handleResetAll = () => { onMounted(() => { currentProjectId.value = ensureProjectIdInUrl() + syncRouteRequestFlags() refreshConflictProjectList() - let isNewProjectRequest = false - let forceHomeRequest = false - try { - const url = new URL(window.location.href) - isNewProjectRequest = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1' - forceHomeRequest = url.searchParams.get(FORCE_HOME_QUERY_KEY) === '1' - } catch { - isNewProjectRequest = false - forceHomeRequest = false - } - if (currentProjectId.value !== QUICK_PROJECT_ID) { - const lock = initProjectSessionLock({ - projectId: currentProjectId.value, - onConflict: (next) => { - lockConflict.value = next - if (next) { - refreshConflictProjectList() - startCloseCountdown() - } else { - clearCloseCountdown() - } - } - }) - releaseLock = lock.release - } else { - lockConflict.value = false + if (!isForceHomeRequest.value && currentProjectId.value !== QUICK_PROJECT_ID && currentProjectId.value !== DEFAULT_PROJECT_ID) { + initCurrentProjectLock() } window.addEventListener('home-import-selected', handleImportComplete) @@ -196,13 +208,13 @@ onMounted(() => { stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted) stopResetAllListener = listenResetAll(handleResetAll) waitForHydration('tabs').then(() => { - if (forceHomeRequest) { + if (isForceHomeRequest.value) { tabStore.resetTabs() tabStore.hasCompletedSetup = false } - if (!tabStore.hasCompletedSetup && !isNewProjectRequest && !forceHomeRequest) { + if (!tabStore.hasCompletedSetup && !isNewProjectRequest.value && !isForceHomeRequest.value) { const hasProjects = listProjects().length > 0 - if (hasProjects) { + if (hasProjects && currentProjectId.value !== DEFAULT_PROJECT_ID) { if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) { tabStore.hasCompletedSetup = true } else { @@ -222,6 +234,10 @@ onMounted(() => { tabStore.activeTabId = tabStore.tabs[0]?.id } } + if (!releaseLock) { + lockConflict.value = false + clearCloseCountdown() + } isReady.value = true }) }) diff --git a/src/features/workbench/components/HomeEntryView.vue b/src/features/workbench/components/HomeEntryView.vue index f4f063f..2b78aa4 100644 --- a/src/features/workbench/components/HomeEntryView.vue +++ b/src/features/workbench/components/HomeEntryView.vue @@ -27,6 +27,7 @@ import { SelectViewport } from 'reka-ui' import { + buildProjectUrl, DEFAULT_PROJECT_ID, FORCE_HOME_QUERY_KEY, NEW_PROJECT_QUERY_KEY, @@ -45,6 +46,7 @@ import { writeWorkspaceMode } from '@/lib/workspace' import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry' +import { createProjectKvAdapter } from '@/lib/projectKvStore' interface QuickProjectInfoState { projectIndustry?: string @@ -104,6 +106,34 @@ const toggleLocale = () => { const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US' uiPrefsStore.setLocale(next as 'zh-CN' | 'en-US') } +const resolveProjectRegistryName = (projectIdRaw: string) => { + const projectId = String(projectIdRaw || '').trim() + if (projectId !== DEFAULT_PROJECT_ID) return undefined + return t('xmInfo.defaultProjectName') +} + +const writeCleanProjectUrl = (projectIdRaw: string) => { + try { + const href = buildProjectUrl(projectIdRaw, { forceHome: false, newProject: false }) + window.history.replaceState({}, '', href) + } catch { + writeProjectIdToUrl(projectIdRaw) + } +} + +const navigateToWorkspace = (projectIdRaw: string, mode: 'project' | 'quick') => { + const projectId = String(projectIdRaw || '').trim() + if (!projectId) return false + writeWorkspaceMode(mode) + const currentProjectId = getActiveProjectId() + const shouldReloadApp = mode === 'project' && currentProjectId !== projectId + if (shouldReloadApp) { + window.location.href = buildProjectUrl(projectId, { forceHome: false, newProject: false }) + return false + } + writeCleanProjectUrl(projectId) + return true +} const getTodayDateString = () => { const now = new Date() @@ -122,9 +152,8 @@ const formatProjectEditedTime = (value: string) => { const enterProjectCalc = () => { const projectId = getActiveProjectId() - upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? t('tab.messages.defaultProjectLabel') : undefined) - writeProjectIdToUrl(projectId) - writeWorkspaceMode('project') + upsertProject(projectId, resolveProjectRegistryName(projectId)) + if (!navigateToWorkspace(projectId, 'project')) return tabStore.enterWorkspace({ id: PROJECT_TAB_ID, title: t('home.projectCalcTab'), @@ -179,9 +208,8 @@ const closeExistingProjectDialog = () => { const enterExistingProject = (projectIdRaw: string) => { const projectId = String(projectIdRaw || '').trim() if (!projectId) return - upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? t('tab.messages.defaultProjectLabel') : undefined) - writeProjectIdToUrl(projectId) - writeWorkspaceMode('project') + upsertProject(projectId, resolveProjectRegistryName(projectId)) + if (!navigateToWorkspace(projectId, 'project')) return tabStore.enterWorkspace({ id: PROJECT_TAB_ID, title: t('home.projectCalcTab'), @@ -201,6 +229,28 @@ const confirmProjectCalc = async () => { projectSubmitting.value = true try { + const activeProjectId = getActiveProjectId() + if (activeProjectId === DEFAULT_PROJECT_ID) { + const project = createProject(t('xmInfo.defaultProjectName')) + const kvAdapter = createProjectKvAdapter(project.id) + await kvAdapter.setItem(PROJECT_INFO_KEY, { + projectIndustry: industry, + projectName: t('xmInfo.defaultProjectName'), + preparedBy: '', + reviewedBy: '', + preparedCompany: '', + preparedDate: getTodayDateString() + }) + await initializeProjectFactorStates( + kvAdapter, + industry, + PROJECT_CONSULT_CATEGORY_FACTOR_KEY, + PROJECT_MAJOR_FACTOR_KEY + ) + writeWorkspaceMode('project') + window.location.href = buildProjectUrl(project.id, { forceHome: false, newProject: false }) + return + } await kvStore.setItem(PROJECT_INFO_KEY, { projectIndustry: industry, projectName: t('xmInfo.defaultProjectName'), @@ -240,8 +290,7 @@ const loadQuickDefaults = async () => { } const enterQuickCalc = (contractName: string) => { - writeProjectIdToUrl(QUICK_PROJECT_ID) - writeWorkspaceMode('quick') + if (!navigateToWorkspace(QUICK_PROJECT_ID, 'quick')) return tabStore.enterWorkspace({ id: `contract-${QUICK_CONTRACT_ID}`, title: t('home.quickCalcTab'), @@ -310,26 +359,13 @@ const cancelHomeImportConfirm = () => { pendingHomeImportFileName.value = '' } -const confirmHomeImport = () => { +const confirmHomeImport = async () => { const file = pendingHomeImportFile.value if (!file) return - setPendingHomeImportFile(file, { skipWorkspaceConfirm: true }) - const currentProjectId = getActiveProjectId() - const projects = listProjects() - const currentProjectMeta = projects.find(item => item.id === currentProjectId) - const isDeleteFallbackProject = - Boolean(currentProjectMeta) - && projects.length === 1 - && String(currentProjectMeta?.name || '').trim() === t('xmInfo.defaultProjectName') - const targetProjectId = isDeleteFallbackProject ? currentProjectId : createProject().id - writeProjectIdToUrl(targetProjectId) + await setPendingHomeImportFile(file, { skipWorkspaceConfirm: true }) + const targetProject = createProject(t('xmInfo.defaultProjectName')) writeWorkspaceMode('project') - tabStore.enterWorkspace({ - id: PROJECT_TAB_ID, - title: t('home.projectCalcTab'), - componentName: 'ProjectCalcView' - }) - tabStore.hasCompletedSetup = true + window.location.href = buildProjectUrl(targetProject.id, { forceHome: false, newProject: false }) cancelHomeImportConfirm() } @@ -339,7 +375,10 @@ onMounted(() => { void loadQuickDefaults() try { const url = new URL(window.location.href) - if (url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1') { + const isNewProject = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1' + if (isNewProject) { + const projectId = getActiveProjectId() + upsertProject(projectId, resolveProjectRegistryName(projectId)) const openProjectDialog = url.searchParams.get(OPEN_PROJECT_DIALOG_QUERY_KEY) !== '0' if (openProjectDialog) { void openProjectCalc() diff --git a/src/features/xm/components/info.vue b/src/features/xm/components/info.vue index a25fa79..bc0b4b2 100644 --- a/src/features/xm/components/info.vue +++ b/src/features/xm/components/info.vue @@ -7,6 +7,9 @@ import { industryTypeList, } from '@/sql' import { useKvStore } from '@/pinia/kv' +import { upsertProject } from '@/lib/projectRegistry' +import { readCurrentProjectId } from '@/lib/workspace' +import { waitForHydration } from '@/pinia/Plugin/indexdb' import { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { @@ -59,6 +62,7 @@ const getTodayDateString = () => { } const isProjectInitialized = ref(false) +const isBootstrapping = ref(true) const projectName = ref('') const projectIndustry = ref('') @@ -112,9 +116,11 @@ const kvStore = useKvStore() const saveToIndexedDB = async () => { try { + const normalizedProjectName = projectName.value.trim() || DEFAULT_PROJECT_NAME + projectName.value = normalizedProjectName const payload: XmInfoState = { projectIndustry: projectIndustry.value, - projectName: projectName.value, + projectName: normalizedProjectName, preparedBy: preparedBy.value, reviewedBy: reviewedBy.value, preparedCompany: preparedCompany.value, @@ -123,6 +129,7 @@ const saveToIndexedDB = async () => { desc: desc.value } await kvStore.setItem(DB_KEY, payload) + upsertProject(readCurrentProjectId(), normalizedProjectName) } catch (error) { console.error('saveToIndexedDB failed:', error) } @@ -161,6 +168,7 @@ const loadFromIndexedDB = async () => { syncPreparedDatePickerFromString() } catch (error) { console.error('loadFromIndexedDB failed:', error) + isProjectInitialized.value = false projectIndustry.value = DEFAULT_PROJECT_INDUSTRY projectName.value = DEFAULT_PROJECT_NAME preparedBy.value = '' @@ -194,7 +202,12 @@ watch( ) onMounted(async () => { - await loadFromIndexedDB() + try { + await waitForHydration('kv') + await loadFromIndexedDB() + } finally { + isBootstrapping.value = false + } }) @@ -202,7 +215,14 @@ onMounted(async () => {
+ {{ t('common.loading') }} +
+ +
{{ t('xmInfo.createFromHomeFirst') }} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index 780ccea..de4e027 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -4,7 +4,8 @@ export const enUS = { confirm: 'Confirm', delete: 'Delete', close: 'Close', - clear: 'Clear' + clear: 'Clear', + loading: 'Loading...' }, app: { projectConflict: { @@ -31,7 +32,7 @@ export const enUS = { quickCalc: 'Quick Calc', quickCalcDesc: 'Suitable for single service trial, select industry, consultation type, engineering specialty, input base number and get results in seconds', importData: 'Import Data', - importDataDesc: 'Import ".zw" package to restore project state and continue work quickly', + importDataDesc: 'Import a ".zw" package and create a new project automatically to restore the data without overriding existing projects', enter: 'Enter', pickFile: 'Choose File', pickExisting: 'Choose Existing' @@ -44,8 +45,8 @@ export const enUS = { entering: 'Entering...', enterProjectCalc: 'Enter Project Calculation', confirmImport: 'Confirm Import', - confirmImportDesc: 'Import "{file}" and enter workspace immediately, overriding current project data.', - confirmImportAction: 'Import', + confirmImportDesc: 'Import "{file}"', + confirmImportAction: 'Import and Create Project', chooseExistingProject: 'Choose Existing Project', chooseExistingProjectDesc: 'Select a project from the list and enter workspace directly.', noProjectYet: 'No project available. Create a new project first.' @@ -104,60 +105,60 @@ export const enUS = { jumpToStep: 'Jump to step {index}', steps: { step1: { - title: 'Welcome', - description: 'This guide covers major features and the full workflow. It is recommended to go through it in order.', - point1: 'The top area is the tab bar, for quick switching between project, segment, and pricing pages.', - point2: 'Tables and forms on the page auto-save locally. No manual save is needed.', - point3: 'You can reopen this tutorial anytime from the top-right "Guide" button.' + title: 'Project Calculation Overview', + description: 'This guide only explains the main project-calculation flow: project setup -> contract segments -> service pricing -> report export.', + point1: 'The entry is the "Project Card", and the left flow line guides you through the setup in order.', + point2: 'Forms and grids auto-save locally, so manual save is usually unnecessary.', + point3: 'Project-level data affects later segment calculation and report output, so fill it first.' }, step2: { - title: 'Project Card and Four Modules', - description: 'The default "Project Card" tab is the entry. The left flow line contains four project-level modules.', - point1: 'Basic Info: fill project name and project scale details.', - point2: 'Contract Segment Management: create, sort, search, import/export segments.', - point3: 'Consult Category Factor / Major Factor: maintain budget values and remarks.' + title: 'Project-Level Setup', + description: 'Project-level setup mainly includes Basic Info, Scale Info, Consult Category Factor, and Major Factor.', + point1: 'Basic Info: maintain project name, industry, and other base data.', + point2: 'Scale Info: fill project scale by major as input for later budget values.', + point3: 'The two factor pages maintain budget values and notes used to adjust calculation.' }, step3: { - title: 'Fill Basic Info', - description: 'Complete project-level data in "Basic Info" first, then continue to segment-level calculations.', - point1: 'Project name is used in export file names and page display.', - point2: 'Project detail table supports direct edit, copy/paste, and undo/redo.', - point3: 'Group rows auto-summarize, and the pinned top row shows grand total.' + title: 'Fill Basic Info First', + description: 'Complete the Basic Info page first. Project name and industry are core inputs for later project calculation.', + point1: 'Project name is used in the home list, tab labels, and exported reports.', + point2: 'Project industry determines the scale structure, major tree, and part of the budget logic.', + point3: 'If project name is left empty, the system falls back to the default project name.' }, step4: { - title: 'Contract Segment Management', - description: 'Manage the full lifecycle of contract segments in this module.', - point1: '"Add Segment" creates a new one; top-right actions on each card support edit/delete.', - point2: 'Search and grid/list switch are supported; drag-sort is available when not searching.', - point3: 'The more menu supports import/export; click a card to enter segment details.' + title: 'Maintain Scale Info', + description: 'The Scale Info page stores project-level scale data, which is one of the base inputs of project calculation.', + point1: 'Fill scale values by major. These numbers participate in later service budget values and summary.', + point2: 'The grid supports direct edit, batch paste, and undo/redo for fast multi-row input.', + point3: 'Grouped rows and the pinned summary row are calculated automatically for quick checking.' }, step5: { - title: 'Contract Segment Detail', - description: 'Inside a segment, the left flow line includes scale info and consulting services.', - point1: 'Scale Info: fill segment scale data by engineering major.', - point2: 'Consulting Services: choose services from dictionary and generate fee details.', - point3: 'Each segment page has isolated cache and does not interfere with others.' - }, - step6: { - title: 'Service and Pricing Pages', - description: 'The consulting service page manages service details and opens specific pricing method pages.', - point1: 'Click "Browse" to select services, then confirm to generate detail rows.', - point2: 'In detail table, "Edit" opens service pricing page, and "Clear" resets that service calculation.', - point3: 'Pricing page includes investment scale, land scale, workload, and hourly methods.' - }, - step7: { - title: 'Factor Maintenance', - description: 'Project-level factors adjust budget values and can be maintained on two factor pages.', + title: 'Maintain Project Factors', + description: 'Consult Category Factor and Major Factor are used to adjust project budget values and should be reviewed before segment calculation.', point1: 'Consult Category Factor page: maintain budget values and notes by consult category.', point2: 'Major Factor page: maintain budget values and notes by major tree.', - point3: 'Batch paste and undo/redo are supported for efficient multi-row updates.' + point3: 'Both pages support batch paste and undo/redo for efficient maintenance.' + }, + step6: { + title: 'Enter Segment Calculation', + description: 'After project-level setup is complete, go to Contract Segment Management and calculate each segment one by one.', + point1: 'Create a contract segment first, then open its detail page to continue.', + point2: 'Scale data under one segment belongs only to that segment and does not affect others.', + point3: 'The consulting service page generates service rows and acts as the entry for pricing methods.' + }, + step7: { + title: 'Choose Pricing Methods', + description: 'From consulting service details inside a segment, open the service pricing page and fill method data based on the service.', + point1: 'Common methods include investment scale, land scale, workload, and hourly pricing.', + point2: 'Different services can enable different methods, and the system summarizes them into subtotal and final amount.', + point3: 'If the default calculated value needs adjustment, edit the final amount or note fields directly.' }, step8: { - title: 'Data Management and Recovery', - description: 'Top toolbar handles full import/export and reset initialization.', - point1: '"Import/Export" operates on full-project data package.', - point2: '"Reset" clears all local data and restores default page.', - point3: 'It is recommended to export a backup before major changes.' + title: 'Review and Export', + description: 'After project-level and segment-level calculation is complete, review the final summary and then export the report.', + point1: 'Check project name, scale info, factors, and segment service amounts before exporting.', + point2: 'Export uses the current project data and generates the final report accordingly.', + point3: 'If you make large changes, export a backup first before continuing.' } } }, diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c194168..0bea55f 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -4,7 +4,8 @@ export const zhCN = { confirm: '确认', delete: '删除', close: '关闭', - clear: '清空' + clear: '清空', + loading: '加载中...' }, app: { projectConflict: { @@ -31,7 +32,7 @@ export const zhCN = { quickCalc: '单项速算', quickCalcDesc: '适用于单项服务试算,选择行业、咨询类型、工程专业,输入基数秒出结果', importData: '导入数据', - importDataDesc: '导入".zw"数据包,快速恢复项目计算状态,续未完成工作', + importDataDesc: '导入".zw"数据包,并自动新建一个项目用于恢复数据,不会覆盖现有项目', enter: '进入计算', pickFile: '选择文件', pickExisting: '选择已有项目' @@ -44,8 +45,8 @@ export const zhCN = { entering: '进入中...', enterProjectCalc: '进入项目计算', confirmImport: '确认导入数据', - confirmImportDesc: '将导入“{file}”,并立即进入工作台覆盖当前项目数据。', - confirmImportAction: '确认导入', + confirmImportDesc: '将导入“{file}”数据包', + confirmImportAction: '确认导入并新建项目', chooseExistingProject: '选择已有项目', chooseExistingProjectDesc: '从项目列表中选择一个项目并直接进入工作台。', noProjectYet: '当前暂无可进入的项目,请先新建项目。' @@ -104,60 +105,60 @@ export const zhCN = { jumpToStep: '跳转到第 {index} 步', steps: { step1: { - title: '欢迎使用', - description: '这个引导会覆盖系统主要功能和完整使用路径,建议按顺序走一遍。', - point1: '顶部是标签页区,可在项目、合同段、服务计算页之间快速切换。', - point2: '页面里的表格与表单会自动保存到本地,无需手动点击保存。', - point3: '你可以随时点击右上角“使用引导”重新打开本教程。' + title: '项目计算总览', + description: '这个引导只说明项目计算主链路,按“项目级设置 -> 合同段 -> 服务计费 -> 导出报表”的顺序理解即可。', + point1: '项目计算入口是“项目卡片”,左侧流程线会带你按顺序完成配置。', + point2: '页面里的表单和表格会自动保存,本地修改通常无需手动点击保存。', + point3: '项目级数据会影响后续合同段和报表结果,建议先补齐项目级信息。' }, step2: { - title: '项目卡片与四个模块', - description: '默认标签“项目卡片”是入口,左侧流程线包含四个项目级功能。', - point1: '基础信息:填写项目名称与项目规模明细。', - point2: '合同段管理:新建、排序、搜索、导入/导出合同段。', - point3: '咨询分类系数 / 工程专业系数:维护系数预算取值和备注。' + title: '项目级配置入口', + description: '项目级配置主要包括基础信息、规模信息、咨询分类系数、工程专业系数四部分。', + point1: '基础信息:维护项目名称、工程行业等项目基础资料。', + point2: '规模信息:按专业填写项目规模,为后续预算取值提供依据。', + point3: '两个系数页:维护预算取值和说明,作为项目计算的调节项。' }, step3: { - title: '基础信息填写', - description: '先在“基础信息”中补齐项目级数据,再进入后续合同段计算。', - point1: '项目名称会用于导出文件名和页面展示。', - point2: '项目明细表支持直接编辑、复制粘贴、撤销重做。', - point3: '分组行自动汇总,顶部固定行显示总合计。' + title: '先填基础信息', + description: '先完成基础信息页,尤其是项目名称和工程行业,后续所有项目计算都会依赖这里的数据。', + point1: '项目名称会用于主页列表、标签页显示和报表导出。', + point2: '工程行业决定规模信息结构、专业树和部分预算取值逻辑。', + point3: '如果项目名称留空,系统会自动回填默认项目名称。' }, step4: { - title: '合同段管理', - description: '在“合同段管理”中完成合同段生命周期操作。', - point1: '“添加合同段”用于新增,卡片右上角可编辑或删除。', - point2: '支持搜索、网格/列表切换,非搜索状态可拖拽排序。', - point3: '更多菜单可导入/导出合同段;点击卡片进入该合同段详情。' + title: '维护规模信息', + description: '规模信息页用于录入项目级规模数据,这是项目计算的基础输入之一。', + point1: '按专业填写对应规模值,数值会参与后续服务预算取值和汇总。', + point2: '表格支持直接编辑、批量粘贴、撤销重做,适合一次性录入多行数据。', + point3: '分组行和固定汇总行会自动计算,便于快速检查录入结果。' }, step5: { - title: '合同段详情', - description: '进入合同段后,左侧同样是流程线,包含规模信息和咨询服务两部分。', - point1: '规模信息:按工程专业填写当前合同段的规模数据。', - point2: '咨询服务:选择服务词典并生成服务费用明细。', - point3: '合同段页面会独立缓存,不同合同段互不干扰。' - }, - step6: { - title: '咨询服务与计算页', - description: '咨询服务页面用于管理服务明细,并进入具体计费方法页面。', - point1: '先点击“浏览”选择服务,再确认生成明细行。', - point2: '明细表“编辑”可打开服务计算页,“清空”会重置该服务计算结果。', - point3: '服务计算页包含投资规模法、用地规模法、工作量法、工时法四种方法。' - }, - step7: { - title: '系数维护', - description: '项目级系数用于调节预算取值,可在两个系数页分别维护。', + title: '维护项目系数', + description: '咨询分类系数和工程专业系数用于调整项目预算取值,建议在进入合同段前先检查完整。', point1: '咨询分类系数页:按咨询分类维护预算取值与说明。', point2: '工程专业系数页:按专业树维护预算取值与说明。', - point3: '支持批量粘贴、撤销重做,便于一次性维护多行数据。' + point3: '两个系数页都支持批量粘贴和撤销重做,适合集中维护。' + }, + step6: { + title: '进入合同段计算', + description: '项目级配置完成后,再进入合同段管理,逐个维护合同段的规模和服务费用。', + point1: '先新增合同段,再进入合同段详情页继续计算。', + point2: '合同段下的规模信息用于该合同段自己的计费数据,不会和其他合同段串数据。', + point3: '咨询服务页负责生成服务明细,并作为各计费方法页面的入口。' + }, + step7: { + title: '选择计费方法', + description: '在合同段的咨询服务明细中,可进入具体服务计算页,按服务适用情况填写计费方法数据。', + point1: '常见方法包括投资规模法、用地规模法、工作量法和工时法。', + point2: '不同服务可启用不同方法,系统会按填写结果汇总到服务小计和确认金额。', + point3: '如果默认计算值需要调整,可直接修改确认金额或说明字段。' }, step8: { - title: '数据管理与恢复', - description: '顶部工具栏负责全量数据导入导出与初始化重置。', - point1: '“导入/导出”是整项目级别的数据包操作。', - point2: '“重置”会清空本地全部数据并恢复默认页面。', - point3: '建议在重要调整前先导出备份。' + title: '汇总与导出', + description: '完成项目级和合同段级计算后,最后再检查汇总结果并导出报表。', + point1: '先检查项目名称、规模信息、系数和各合同段服务金额是否完整。', + point2: '确认无误后再执行报表导出,导出结果会按当前项目数据生成。', + point3: '如果做了大范围调整,建议先导出备份,再继续修改。' } } }, diff --git a/src/layout/tab.vue b/src/layout/tab.vue index 2cdafa8..444cdcc 100644 --- a/src/layout/tab.vue +++ b/src/layout/tab.vue @@ -150,6 +150,7 @@ import { industryTypeList } from '@/sql' import { initializeProjectFactorStates } from '@/lib/projectWorkspace' +import { createProjectKvAdapter } from '@/lib/projectKvStore' import { I18N_LOCALE_KEY, type AppLocale } from '@/i18n' const { t, locale } = useI18n() @@ -494,33 +495,6 @@ const getTodayDateString = () => { return `${year}-${month}-${day}` } -const createProjectKvAdapter = (projectId: string) => { - const projectKvForage = localforage.createInstance({ - name: getProjectDbName(projectId), - storeName: 'pinia-kv' - }) - return { - setItem: async (keyRaw: string | number, value: T) => { - const key = String(keyRaw || '').trim() - if (!key) return - const currentState = await projectKvForage.getItem>('pinia-kv') - const nextEntries = { - ...( - currentState?.entries && typeof currentState.entries === 'object' - ? (currentState.entries as Record) - : {} - ), - [key]: JSON.parse(JSON.stringify(value)) - } - await projectKvForage.setItem('pinia-kv', { - ...(currentState && typeof currentState === 'object' ? currentState : {}), - entries: nextEntries, - ready: true - }) - } - } -} - const openCreateProjectDialog = () => { if (listProjects().length >= MAX_PROJECT_COUNT) { projectMenuOpen.value = false @@ -549,7 +523,7 @@ const createProjectAndOpen = async () => { if (!industry) return newProjectSubmitting.value = true try { - const project = createProject() + const project = createProject(DEFAULT_PROJECT_NAME) const kvAdapter = createProjectKvAdapter(project.id) await kvAdapter.setItem(PROJECT_INFO_DB_KEY, { projectIndustry: industry, @@ -639,33 +613,10 @@ const removeProjectItem = async (project: ProjectMeta) => { const removed = deleteProject(project.id) if (!removed) return emitProjectDeleted(project.id) - const nextProject = createProject(DEFAULT_PROJECT_NAME) - const defaultIndustry = String(industryTypeList[0]?.id || '').trim() - if (defaultIndustry) { - const kvAdapter = createProjectKvAdapter(nextProject.id) - await kvAdapter.setItem(PROJECT_INFO_DB_KEY, { - projectIndustry: defaultIndustry, - projectName: DEFAULT_PROJECT_NAME, - preparedBy: '', - reviewedBy: '', - preparedCompany: '', - preparedDate: getTodayDateString() - }) - await initializeProjectFactorStates( - kvAdapter, - defaultIndustry, - CONSULT_CATEGORY_FACTOR_DB_KEY, - MAJOR_FACTOR_DB_KEY - ) - } writeWorkspaceMode('project') tabStore.resetTabs() tabStore.hasCompletedSetup = false - window.location.href = buildProjectUrl(nextProject.id, { - newProject: true, - openProjectDialog: false, - forceHome: true - }) + window.location.href = buildProjectUrl('default', { forceHome: true }) return } @@ -1048,8 +999,16 @@ const createRichTextCode = (...parts: string[]): unknown => ({ .map(text => ({ text })) }) -const resolveMethodEnabled = (value: unknown, fallback: boolean) => - typeof value === 'boolean' ? value : fallback +const resolveMethodEnabled = (value: unknown, fallback: boolean) => { + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value === 1 + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true' || normalized === '1') return true + if (normalized === 'false' || normalized === '0') return false + } + return fallback +} const getServiceMethodAvailability = (serviceIdText: string) => { const dict = getServiceDictItemById(serviceIdText) as { @@ -1058,10 +1017,10 @@ const getServiceMethodAvailability = (serviceIdText: string) => { amount?: boolean | null workDay?: boolean | null } | null | undefined - const scale = resolveMethodEnabled(dict?.scale, true) + const scale = resolveMethodEnabled(dict?.scale, false) const onlyCostScale = resolveMethodEnabled(dict?.onlyCostScale, false) - const amount = resolveMethodEnabled(dict?.amount, true) - const workDay = resolveMethodEnabled(dict?.workDay, true) + const amount = resolveMethodEnabled(dict?.amount, false) + const workDay = resolveMethodEnabled(dict?.workDay, false) return { investmentScale: scale, landScale: scale && !onlyCostScale, @@ -1313,10 +1272,24 @@ const buildExportReportPayload = async (): Promise => { const method2Raw = method2State ? { detailRows: method2State.detailRows } : null const method3Raw = method3State ? { detailRows: method3State.detailRows } : null const method4Raw = method4State ? { detailRows: method4State.detailRows } : null + const method2RawRows = Array.isArray(method2Raw?.detailRows) ? method2Raw.detailRows : [] const method1 = methodAvailability.investmentScale ? buildMethod1(method1Raw?.detailRows) : null const method2 = methodAvailability.landScale ? buildMethod2(method2Raw?.detailRows) : null const method3 = methodAvailability.workload ? buildMethod3(method3Raw?.detailRows) : null const method4 = methodAvailability.hourly ? buildMethod4(method4Raw?.detailRows) : null + + if (methodAvailability.landScale && method2RawRows.length > 0 && method2?.det?.length != null) { + const rawLen = method2RawRows.length + const detLen = method2.det.length + if (rawLen > detLen) { + console.warn('[export][landScale-duplicate-rows-deduped]', { + contractId, + serviceId: serviceIdText, + rawRows: rawLen, + exportedRows: detLen + }) + } + } const sanitizedSourceRow = sourceRow ? { ...sourceRow, @@ -1739,7 +1712,6 @@ onMounted(() => { window.addEventListener('mousedown', handleGlobalMouseDown) window.addEventListener('keydown', handleGlobalKeyDown) window.addEventListener('resize', scheduleUpdateTabTitleOverflow) - const pendingHomeImportFile = consumePendingHomeImportFile() const skipWorkspaceImportConfirm = consumePendingHomeImportSkipConfirm() void nextTick(() => { bindTabStripScroll() @@ -1748,12 +1720,14 @@ onMounted(() => { scheduleUpdateTabTitleOverflow() scheduleRestoreTabInnerScrollTop(tabStore.activeTabId) }) - if (pendingHomeImportFile) { - void prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => { + void (async () => { + const pendingHomeImportFile = await consumePendingHomeImportFile() + if (!pendingHomeImportFile) return + await prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => { console.error('home import failed:', error) showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile')) }) - } + })() void (async () => { if (await shouldAutoOpenGuide()) { openUserGuide(0) diff --git a/src/layout/typeLine.vue b/src/layout/typeLine.vue index c9a0cd9..1b7a7db 100644 --- a/src/layout/typeLine.vue +++ b/src/layout/typeLine.vue @@ -284,7 +284,7 @@ useMotionValueEvent( @@ -324,7 +324,7 @@ useMotionValueEvent(
- + {{ t('typeLine.aboutTitle') }}
diff --git a/src/lib/projectKvStore.ts b/src/lib/projectKvStore.ts new file mode 100644 index 0000000..f50ad7e --- /dev/null +++ b/src/lib/projectKvStore.ts @@ -0,0 +1,29 @@ +import localforage from 'localforage' +import { getProjectDbName } from '@/lib/workspace' + +export const createProjectKvAdapter = (projectId: string) => { + const projectKvForage = localforage.createInstance({ + name: getProjectDbName(projectId), + storeName: 'pinia-kv' + }) + return { + setItem: async (keyRaw: string | number, value: T) => { + const key = String(keyRaw || '').trim() + if (!key) return + const currentState = await projectKvForage.getItem>('pinia-kv') + const nextEntries = { + ...( + currentState?.entries && typeof currentState.entries === 'object' + ? (currentState.entries as Record) + : {} + ), + [key]: JSON.parse(JSON.stringify(value)) + } + await projectKvForage.setItem('pinia-kv', { + ...(currentState && typeof currentState === 'object' ? currentState : {}), + entries: nextEntries, + ready: true + }) + } + } +} diff --git a/src/lib/projectRegistry.ts b/src/lib/projectRegistry.ts index 7c6a543..f5c1bc1 100644 --- a/src/lib/projectRegistry.ts +++ b/src/lib/projectRegistry.ts @@ -1,4 +1,4 @@ -import { QUICK_PROJECT_ID, normalizeProjectId } from '@/lib/workspace' +import { DEFAULT_PROJECT_ID, QUICK_PROJECT_ID, normalizeProjectId } from '@/lib/workspace' import { i18n } from '@/i18n' const PROJECT_REGISTRY_KEY = 'jgjs-project-registry-v1' @@ -59,7 +59,7 @@ const writePayload = (payload: ProjectRegistryPayload) => { export const listProjects = () => { const payload = readPayload() return payload.projects - .filter(item => item.id !== QUICK_PROJECT_ID) + .filter(item => item.id !== QUICK_PROJECT_ID && item.id !== DEFAULT_PROJECT_ID) .slice() .sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()) } @@ -73,7 +73,7 @@ export const upsertProject = ( } ) => { const id = normalizeProjectId(projectIdRaw) - if (id === QUICK_PROJECT_ID) return + if (id === QUICK_PROJECT_ID || id === DEFAULT_PROJECT_ID) return const name = String(nameRaw || '').trim() const payload = readPayload() const now = nowIso() @@ -111,7 +111,7 @@ export const touchProjectEdited = ( } ) => { const id = normalizeProjectId(projectIdRaw) - if (id === QUICK_PROJECT_ID) return false + if (id === QUICK_PROJECT_ID || id === DEFAULT_PROJECT_ID) return false const throttleMs = Math.max(0, Number(options?.throttleMs ?? 5000)) const nowMs = Date.now() const lastTouch = lastEditedTouchAt.get(id) ?? 0 @@ -148,7 +148,7 @@ export const createProject = (nameRaw?: string) => { export const deleteProject = (projectIdRaw: string) => { const id = normalizeProjectId(projectIdRaw) - if (id === QUICK_PROJECT_ID) return false + if (id === QUICK_PROJECT_ID || id === DEFAULT_PROJECT_ID) return false const payload = readPayload() const nextProjects = payload.projects.filter(item => item.id !== id) if (nextProjects.length === payload.projects.length) return false diff --git a/src/lib/projectSessionLock.ts b/src/lib/projectSessionLock.ts index 8a1bce0..fda3d4f 100644 --- a/src/lib/projectSessionLock.ts +++ b/src/lib/projectSessionLock.ts @@ -1,8 +1,10 @@ const LOCK_TTL_MS = 12_000 const HEARTBEAT_MS = 4_000 +const SESSION_PROBE_WAIT_MS = 80 export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:' const CHANNEL_NAME = 'jgjs-project-lock-channel' const TAB_SESSION_ID_KEY = 'jgjs-project-tab-session-id' +const runtimeInstanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` type LockPayload = { sessionId: string @@ -34,6 +36,18 @@ const isExpired = (payload: LockPayload) => now() - payload.updatedAt > LOCK_TTL const randomSessionId = () => `${now()}-${Math.random().toString(36).slice(2, 10)}` +const debugLock = (message: string, payload?: Record) => { + try { + if (payload) { + console.debug('[project-lock]', message, payload) + } else { + console.debug('[project-lock]', message) + } + } catch { + // ignore console errors + } +} + const getOrCreateTabSessionId = () => { try { const existing = String(window.sessionStorage.getItem(TAB_SESSION_ID_KEY) || '').trim() @@ -54,22 +68,25 @@ type InitProjectLockParams = { export const initProjectSessionLock = (params: InitProjectLockParams) => { const projectId = String(params.projectId || '').trim() const onConflict = params.onConflict - const sessionId = getOrCreateTabSessionId() + let sessionId = getOrCreateTabSessionId() const key = lockKeyOf(projectId) let conflicted = false let heartbeatTimer: ReturnType | null = null let bc: BroadcastChannel | null = null + let released = false const emitConflict = (next: boolean) => { if (conflicted === next) return conflicted = next + debugLock('emit conflict', { projectId, sessionId, runtimeInstanceId, conflicted: next }) onConflict(next) } const writeHeartbeat = () => { - if (conflicted) return + if (conflicted || released) return const payload: LockPayload = { sessionId, projectId, updatedAt: now() } localStorage.setItem(key, JSON.stringify(payload)) + debugLock('write heartbeat', { projectId, sessionId, runtimeInstanceId, key }) bc?.postMessage({ type: 'heartbeat', projectId, sessionId }) } @@ -77,12 +94,14 @@ export const initProjectSessionLock = (params: InitProjectLockParams) => { const current = parseLockPayload(localStorage.getItem(key)) if (current?.sessionId === sessionId) { localStorage.removeItem(key) + debugLock('clear own lock', { projectId, sessionId, runtimeInstanceId, key }) } bc?.postMessage({ type: 'release', projectId, sessionId }) } const detectConflict = () => { const current = parseLockPayload(localStorage.getItem(key)) + debugLock('detect conflict', { projectId, sessionId, runtimeInstanceId, current, expired: current ? isExpired(current) : null }) if (!current || isExpired(current)) { emitConflict(false) writeHeartbeat() @@ -100,26 +119,45 @@ export const initProjectSessionLock = (params: InitProjectLockParams) => { clearOwnLock() } - try { - const existing = parseLockPayload(localStorage.getItem(key)) - if (existing && !isExpired(existing) && existing.sessionId !== sessionId) { - emitConflict(true) - } else { - writeHeartbeat() - } - } catch (error) { - console.error('init project session lock failed:', error) - } - - if (!conflicted) { + const startHeartbeat = () => { + if (heartbeatTimer || conflicted || released) return heartbeatTimer = setInterval(writeHeartbeat, HEARTBEAT_MS) } if (typeof BroadcastChannel !== 'undefined') { bc = new BroadcastChannel(CHANNEL_NAME) - bc.onmessage = (event: MessageEvent<{ type?: string; projectId?: string; sessionId?: string }>) => { + bc.onmessage = (event: MessageEvent<{ + type?: string + projectId?: string + sessionId?: string + candidateSessionId?: string + runtimeId?: string + responderRuntimeId?: string + targetRuntimeId?: string + }>) => { const payload = event.data if (!payload || payload.projectId !== projectId) return + if (payload.type === 'session-probe') { + if (payload.candidateSessionId !== sessionId) return + if (payload.runtimeId === runtimeInstanceId) return + debugLock('session probe received', { + projectId, + sessionId, + runtimeInstanceId, + fromRuntimeId: payload.runtimeId + }) + bc?.postMessage({ + type: 'session-probe-ack', + projectId, + candidateSessionId: sessionId, + responderRuntimeId: runtimeInstanceId, + targetRuntimeId: payload.runtimeId + }) + return + } + if (payload.type === 'session-probe-ack') { + return + } if (payload.sessionId === sessionId) return if (payload.type === 'heartbeat') { detectConflict() @@ -127,6 +165,79 @@ export const initProjectSessionLock = (params: InitProjectLockParams) => { } } + const ensureUniqueSessionId = async () => { + if (!bc) return + const responders = new Set() + const onMessage = (event: MessageEvent<{ + type?: string + projectId?: string + candidateSessionId?: string + responderRuntimeId?: string + targetRuntimeId?: string + }>) => { + const payload = event.data + if (!payload || payload.type !== 'session-probe-ack') return + if (payload.projectId !== projectId) return + if (payload.candidateSessionId !== sessionId) return + if (payload.targetRuntimeId !== runtimeInstanceId) return + if (payload.responderRuntimeId) { + responders.add(payload.responderRuntimeId) + } + } + bc.addEventListener('message', onMessage as EventListener) + try { + bc.postMessage({ + type: 'session-probe', + projectId, + candidateSessionId: sessionId, + runtimeId: runtimeInstanceId + }) + await new Promise(resolve => window.setTimeout(resolve, SESSION_PROBE_WAIT_MS)) + } finally { + bc.removeEventListener('message', onMessage as EventListener) + } + if (released || responders.size === 0) return + const shouldRotate = Array.from(responders).some(responderRuntimeId => responderRuntimeId < runtimeInstanceId) + debugLock('session probe result', { + projectId, + sessionId, + runtimeInstanceId, + responders: Array.from(responders), + shouldRotate + }) + if (!shouldRotate) return + const nextSessionId = randomSessionId() + try { + window.sessionStorage.setItem(TAB_SESSION_ID_KEY, nextSessionId) + } catch { + // ignore sessionStorage write errors + } + debugLock('rotate duplicated session id', { + projectId, + previousSessionId: sessionId, + nextSessionId, + runtimeInstanceId + }) + sessionId = nextSessionId + } + + void (async () => { + try { + await ensureUniqueSessionId() + if (released) return + const existing = parseLockPayload(localStorage.getItem(key)) + debugLock('init lock state', { projectId, sessionId, runtimeInstanceId, existing }) + if (existing && !isExpired(existing) && existing.sessionId !== sessionId) { + emitConflict(true) + } else { + writeHeartbeat() + } + startHeartbeat() + } catch (error) { + console.error('init project session lock failed:', error) + } + })() + window.addEventListener('storage', onStorage) window.addEventListener('beforeunload', onBeforeUnload) @@ -135,6 +246,7 @@ export const initProjectSessionLock = (params: InitProjectLockParams) => { return conflicted }, release: () => { + released = true if (heartbeatTimer) { clearInterval(heartbeatTimer) heartbeatTimer = null diff --git a/src/lib/reportExportBuilders.ts b/src/lib/reportExportBuilders.ts index 311123c..24df052 100644 --- a/src/lib/reportExportBuilders.ts +++ b/src/lib/reportExportBuilders.ts @@ -245,6 +245,19 @@ export const toScaleProNum = (row: ScaleMethodRowLike): number => { return parsed.proNum > 0 ? parsed.proNum : 1 } +const dedupeScaleMethodRows = (rows: ScaleMethodRowLike[] | undefined): ScaleMethodRowLike[] => { + if (!Array.isArray(rows) || rows.length === 0) return [] + const deduped = new Map() + for (const row of rows) { + const major = toScaleMajorId(row) + if (major == null) continue + const proNum = toScaleProNum(row) + // 同一项目+专业出现重复行时,以最后一条为准,兜底旧浏览器内存态重复污染。 + deduped.set(`${proNum}::${major}`, row) + } + return Array.from(deduped.values()) +} + export const normalizeTaskText = (value: unknown): string => String(value || '').trim() export const resolveTaskRowServiceId = (row: WorkContentRowLike): number | null => @@ -392,10 +405,11 @@ export const toExportScaleRows = (rows: ScaleRowLike[] | undefined) => { } export const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined) => { - if (!Array.isArray(rows)) return null + const normalizedRows = dedupeScaleMethodRows(rows) + if (normalizedRows.length === 0) return null let hasTotalValue = false const proSet = new Set() - const det = rows + const det = normalizedRows .map(row => { const major = toScaleMajorId(row) if (major == null) return null @@ -441,10 +455,11 @@ export const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined) => { } export const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined) => { - if (!Array.isArray(rows)) return null + const normalizedRows = dedupeScaleMethodRows(rows) + if (normalizedRows.length === 0) return null let hasTotalValue = false const proSet = new Set() - const det = rows + const det = normalizedRows .map(row => { const major = toScaleMajorId(row) if (major == null) return null diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index a8150d6..fc1fb39 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -1,4 +1,5 @@ import { i18n } from '@/i18n' +import localforage from 'localforage' export type WorkspaceMode = 'project' | 'quick' @@ -26,8 +27,14 @@ export const QUICK_PROJECT_SCALE_KEY = 'quick-xm-info-v3' export const QUICK_CONSULT_CATEGORY_FACTOR_KEY = 'quick-xm-consult-category-factor-v1' export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1' -let pendingHomeImportFile: File | null = null -let pendingHomeImportSkipWorkspaceConfirm = false +const HOME_IMPORT_TEMP_DB_NAME = 'JGJS-HOME-IMPORT-TEMP' +const HOME_IMPORT_TEMP_STORE_NAME = 'home-import' +const HOME_IMPORT_TEMP_FILE_KEY = 'pending-file' +const HOME_IMPORT_SKIP_CONFIRM_KEY = 'jgjs-home-import-skip-confirm' +const homeImportTempForage = localforage.createInstance({ + name: HOME_IMPORT_TEMP_DB_NAME, + storeName: HOME_IMPORT_TEMP_STORE_NAME +}) export interface QuickContractMeta { id: string @@ -64,20 +71,57 @@ export const setPendingHomeImportFile = ( file: File | null, options?: { skipWorkspaceConfirm?: boolean } ) => { - pendingHomeImportFile = file - pendingHomeImportSkipWorkspaceConfirm = Boolean(options?.skipWorkspaceConfirm) + return (async () => { + try { + if (file) { + await homeImportTempForage.setItem(HOME_IMPORT_TEMP_FILE_KEY, { + name: file.name, + type: file.type, + lastModified: file.lastModified, + blob: file + }) + } else { + await homeImportTempForage.removeItem(HOME_IMPORT_TEMP_FILE_KEY) + } + } catch { + // ignore temp file persistence errors + } + try { + window.sessionStorage.setItem(HOME_IMPORT_SKIP_CONFIRM_KEY, options?.skipWorkspaceConfirm ? '1' : '0') + } catch { + // ignore session storage errors + } + })() } -export const consumePendingHomeImportFile = () => { - const file = pendingHomeImportFile - pendingHomeImportFile = null - return file +export const consumePendingHomeImportFile = async () => { + try { + const payload = await homeImportTempForage.getItem<{ + name?: string + type?: string + lastModified?: number + blob?: Blob + }>(HOME_IMPORT_TEMP_FILE_KEY) + await homeImportTempForage.removeItem(HOME_IMPORT_TEMP_FILE_KEY) + if (!payload?.blob) return null + const name = String(payload.name || 'import.zw').trim() || 'import.zw' + return new File([payload.blob], name, { + type: typeof payload.type === 'string' ? payload.type : '', + lastModified: typeof payload.lastModified === 'number' ? payload.lastModified : Date.now() + }) + } catch { + return null + } } export const consumePendingHomeImportSkipConfirm = () => { - const skip = pendingHomeImportSkipWorkspaceConfirm - pendingHomeImportSkipWorkspaceConfirm = false - return skip + try { + const skip = window.sessionStorage.getItem(HOME_IMPORT_SKIP_CONFIRM_KEY) === '1' + window.sessionStorage.removeItem(HOME_IMPORT_SKIP_CONFIRM_KEY) + return skip + } catch { + return false + } } export const normalizeProjectId = (value: unknown) => { diff --git a/src/pinia/kv.ts b/src/pinia/kv.ts index da9a02a..1821191 100644 --- a/src/pinia/kv.ts +++ b/src/pinia/kv.ts @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' +import { waitForHydration } from '@/pinia/Plugin/indexdb' const cloneJson = (value: T): T => JSON.parse(JSON.stringify(value)) as T @@ -7,6 +8,8 @@ export const useKvStore = defineStore('kv', () => { const entries = ref>({}) const ready = ref(false) const loading = ref | null>(null) + let hydrationReady = false + let hydrationTask: Promise | null = null const ensureReady = async () => { if (ready.value) return @@ -15,6 +18,17 @@ export const useKvStore = defineStore('kv', () => { return } loading.value = (async () => { + if (!hydrationReady) { + if (!hydrationTask) { + hydrationTask = waitForHydration('kv') + .catch(() => undefined) + .finally(() => { + hydrationReady = true + hydrationTask = null + }) + } + await hydrationTask + } ready.value = true })() await loading.value