修复项目管理问题

This commit is contained in:
wintsa 2026-03-26 16:15:21 +08:00
parent be2662d579
commit e72012cfc2
15 changed files with 941 additions and 249 deletions

View File

@ -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. 最后再回头收敛重复项目检测
否则就是继续在同一组冲突需求上打补丁,回归会反复发生。

BIN
public/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -29,6 +29,8 @@ const currentProjectName = ref('')
const conflictProjectList = ref<ProjectMeta[]>([]) const conflictProjectList = ref<ProjectMeta[]>([])
const openedProjectIds = ref<string[]>([]) const openedProjectIds = ref<string[]>([])
const closeCountdown = ref(10) const closeCountdown = ref(10)
const isNewProjectRequest = ref(false)
const isForceHomeRequest = ref(false)
let closeCountdownTimer: ReturnType<typeof setInterval> | null = null let closeCountdownTimer: ReturnType<typeof setInterval> | null = null
let releaseLock: (() => void) | null = null let releaseLock: (() => void) | null = null
let stopProjectDeletedListener: (() => void) | null = null let stopProjectDeletedListener: (() => void) | null = null
@ -42,6 +44,28 @@ const handleImportComplete = () => {
tabStore.hasCompletedSetup = true 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 = () => { const refreshConflictProjectList = () => {
void (async () => { void (async () => {
const projects = listProjects() const projects = listProjects()
@ -106,11 +130,22 @@ const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean
} }
const createProjectAndOpen = () => { const createProjectAndOpen = () => {
const project = createProject() const project = createProject(t('xmInfo.defaultProjectName'))
refreshConflictProjectList() refreshConflictProjectList()
openProjectInNewTab(project.id, { newProject: true }) 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 formatProjectEditedTime = (value: string) => {
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-' if (Number.isNaN(date.getTime())) return '-'
@ -162,33 +197,10 @@ const handleResetAll = () => {
onMounted(() => { onMounted(() => {
currentProjectId.value = ensureProjectIdInUrl() currentProjectId.value = ensureProjectIdInUrl()
syncRouteRequestFlags()
refreshConflictProjectList() refreshConflictProjectList()
let isNewProjectRequest = false if (!isForceHomeRequest.value && currentProjectId.value !== QUICK_PROJECT_ID && currentProjectId.value !== DEFAULT_PROJECT_ID) {
let forceHomeRequest = false initCurrentProjectLock()
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
} }
window.addEventListener('home-import-selected', handleImportComplete) window.addEventListener('home-import-selected', handleImportComplete)
@ -196,13 +208,13 @@ onMounted(() => {
stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted) stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted)
stopResetAllListener = listenResetAll(handleResetAll) stopResetAllListener = listenResetAll(handleResetAll)
waitForHydration('tabs').then(() => { waitForHydration('tabs').then(() => {
if (forceHomeRequest) { if (isForceHomeRequest.value) {
tabStore.resetTabs() tabStore.resetTabs()
tabStore.hasCompletedSetup = false tabStore.hasCompletedSetup = false
} }
if (!tabStore.hasCompletedSetup && !isNewProjectRequest && !forceHomeRequest) { if (!tabStore.hasCompletedSetup && !isNewProjectRequest.value && !isForceHomeRequest.value) {
const hasProjects = listProjects().length > 0 const hasProjects = listProjects().length > 0
if (hasProjects) { if (hasProjects && currentProjectId.value !== DEFAULT_PROJECT_ID) {
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) { if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
tabStore.hasCompletedSetup = true tabStore.hasCompletedSetup = true
} else { } else {
@ -222,6 +234,10 @@ onMounted(() => {
tabStore.activeTabId = tabStore.tabs[0]?.id tabStore.activeTabId = tabStore.tabs[0]?.id
} }
} }
if (!releaseLock) {
lockConflict.value = false
clearCloseCountdown()
}
isReady.value = true isReady.value = true
}) })
}) })

View File

@ -27,6 +27,7 @@ import {
SelectViewport SelectViewport
} from 'reka-ui' } from 'reka-ui'
import { import {
buildProjectUrl,
DEFAULT_PROJECT_ID, DEFAULT_PROJECT_ID,
FORCE_HOME_QUERY_KEY, FORCE_HOME_QUERY_KEY,
NEW_PROJECT_QUERY_KEY, NEW_PROJECT_QUERY_KEY,
@ -45,6 +46,7 @@ import {
writeWorkspaceMode writeWorkspaceMode
} from '@/lib/workspace' } from '@/lib/workspace'
import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry' import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry'
import { createProjectKvAdapter } from '@/lib/projectKvStore'
interface QuickProjectInfoState { interface QuickProjectInfoState {
projectIndustry?: string projectIndustry?: string
@ -104,6 +106,34 @@ const toggleLocale = () => {
const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US' const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
uiPrefsStore.setLocale(next as '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 getTodayDateString = () => {
const now = new Date() const now = new Date()
@ -122,9 +152,8 @@ const formatProjectEditedTime = (value: string) => {
const enterProjectCalc = () => { const enterProjectCalc = () => {
const projectId = getActiveProjectId() const projectId = getActiveProjectId()
upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? t('tab.messages.defaultProjectLabel') : undefined) upsertProject(projectId, resolveProjectRegistryName(projectId))
writeProjectIdToUrl(projectId) if (!navigateToWorkspace(projectId, 'project')) return
writeWorkspaceMode('project')
tabStore.enterWorkspace({ tabStore.enterWorkspace({
id: PROJECT_TAB_ID, id: PROJECT_TAB_ID,
title: t('home.projectCalcTab'), title: t('home.projectCalcTab'),
@ -179,9 +208,8 @@ const closeExistingProjectDialog = () => {
const enterExistingProject = (projectIdRaw: string) => { const enterExistingProject = (projectIdRaw: string) => {
const projectId = String(projectIdRaw || '').trim() const projectId = String(projectIdRaw || '').trim()
if (!projectId) return if (!projectId) return
upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? t('tab.messages.defaultProjectLabel') : undefined) upsertProject(projectId, resolveProjectRegistryName(projectId))
writeProjectIdToUrl(projectId) if (!navigateToWorkspace(projectId, 'project')) return
writeWorkspaceMode('project')
tabStore.enterWorkspace({ tabStore.enterWorkspace({
id: PROJECT_TAB_ID, id: PROJECT_TAB_ID,
title: t('home.projectCalcTab'), title: t('home.projectCalcTab'),
@ -201,6 +229,28 @@ const confirmProjectCalc = async () => {
projectSubmitting.value = true projectSubmitting.value = true
try { try {
const activeProjectId = getActiveProjectId()
if (activeProjectId === DEFAULT_PROJECT_ID) {
const project = createProject(t('xmInfo.defaultProjectName'))
const kvAdapter = createProjectKvAdapter(project.id)
await kvAdapter.setItem<ProjectInfoState>(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<ProjectInfoState>(PROJECT_INFO_KEY, { await kvStore.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
projectIndustry: industry, projectIndustry: industry,
projectName: t('xmInfo.defaultProjectName'), projectName: t('xmInfo.defaultProjectName'),
@ -240,8 +290,7 @@ const loadQuickDefaults = async () => {
} }
const enterQuickCalc = (contractName: string) => { const enterQuickCalc = (contractName: string) => {
writeProjectIdToUrl(QUICK_PROJECT_ID) if (!navigateToWorkspace(QUICK_PROJECT_ID, 'quick')) return
writeWorkspaceMode('quick')
tabStore.enterWorkspace({ tabStore.enterWorkspace({
id: `contract-${QUICK_CONTRACT_ID}`, id: `contract-${QUICK_CONTRACT_ID}`,
title: t('home.quickCalcTab'), title: t('home.quickCalcTab'),
@ -310,26 +359,13 @@ const cancelHomeImportConfirm = () => {
pendingHomeImportFileName.value = '' pendingHomeImportFileName.value = ''
} }
const confirmHomeImport = () => { const confirmHomeImport = async () => {
const file = pendingHomeImportFile.value const file = pendingHomeImportFile.value
if (!file) return if (!file) return
setPendingHomeImportFile(file, { skipWorkspaceConfirm: true }) await setPendingHomeImportFile(file, { skipWorkspaceConfirm: true })
const currentProjectId = getActiveProjectId() const targetProject = createProject(t('xmInfo.defaultProjectName'))
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)
writeWorkspaceMode('project') writeWorkspaceMode('project')
tabStore.enterWorkspace({ window.location.href = buildProjectUrl(targetProject.id, { forceHome: false, newProject: false })
id: PROJECT_TAB_ID,
title: t('home.projectCalcTab'),
componentName: 'ProjectCalcView'
})
tabStore.hasCompletedSetup = true
cancelHomeImportConfirm() cancelHomeImportConfirm()
} }
@ -339,7 +375,10 @@ onMounted(() => {
void loadQuickDefaults() void loadQuickDefaults()
try { try {
const url = new URL(window.location.href) 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' const openProjectDialog = url.searchParams.get(OPEN_PROJECT_DIALOG_QUERY_KEY) !== '0'
if (openProjectDialog) { if (openProjectDialog) {
void openProjectCalc() void openProjectCalc()

View File

@ -7,6 +7,9 @@ import {
industryTypeList, industryTypeList,
} from '@/sql' } from '@/sql'
import { useKvStore } from '@/pinia/kv' 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 { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { import {
@ -59,6 +62,7 @@ const getTodayDateString = () => {
} }
const isProjectInitialized = ref(false) const isProjectInitialized = ref(false)
const isBootstrapping = ref(true)
const projectName = ref('') const projectName = ref('')
const projectIndustry = ref('') const projectIndustry = ref('')
@ -112,9 +116,11 @@ const kvStore = useKvStore()
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
try { try {
const normalizedProjectName = projectName.value.trim() || DEFAULT_PROJECT_NAME
projectName.value = normalizedProjectName
const payload: XmInfoState = { const payload: XmInfoState = {
projectIndustry: projectIndustry.value, projectIndustry: projectIndustry.value,
projectName: projectName.value, projectName: normalizedProjectName,
preparedBy: preparedBy.value, preparedBy: preparedBy.value,
reviewedBy: reviewedBy.value, reviewedBy: reviewedBy.value,
preparedCompany: preparedCompany.value, preparedCompany: preparedCompany.value,
@ -123,6 +129,7 @@ const saveToIndexedDB = async () => {
desc: desc.value desc: desc.value
} }
await kvStore.setItem(DB_KEY, payload) await kvStore.setItem(DB_KEY, payload)
upsertProject(readCurrentProjectId(), normalizedProjectName)
} catch (error) { } catch (error) {
console.error('saveToIndexedDB failed:', error) console.error('saveToIndexedDB failed:', error)
} }
@ -161,6 +168,7 @@ const loadFromIndexedDB = async () => {
syncPreparedDatePickerFromString() syncPreparedDatePickerFromString()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
isProjectInitialized.value = false
projectIndustry.value = DEFAULT_PROJECT_INDUSTRY projectIndustry.value = DEFAULT_PROJECT_INDUSTRY
projectName.value = DEFAULT_PROJECT_NAME projectName.value = DEFAULT_PROJECT_NAME
preparedBy.value = '' preparedBy.value = ''
@ -194,7 +202,12 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
await loadFromIndexedDB() try {
await waitForHydration('kv')
await loadFromIndexedDB()
} finally {
isBootstrapping.value = false
}
}) })
</script> </script>
@ -202,7 +215,14 @@ onMounted(async () => {
<TooltipProvider> <TooltipProvider>
<div class="space-y-6 h-full"> <div class="space-y-6 h-full">
<div <div
v-if="!isProjectInitialized" v-if="isBootstrapping"
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground"
>
{{ t('common.loading') }}
</div>
<div
v-else-if="!isProjectInitialized"
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground" class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground"
> >
{{ t('xmInfo.createFromHomeFirst') }} {{ t('xmInfo.createFromHomeFirst') }}

View File

@ -4,7 +4,8 @@ export const enUS = {
confirm: 'Confirm', confirm: 'Confirm',
delete: 'Delete', delete: 'Delete',
close: 'Close', close: 'Close',
clear: 'Clear' clear: 'Clear',
loading: 'Loading...'
}, },
app: { app: {
projectConflict: { projectConflict: {
@ -31,7 +32,7 @@ export const enUS = {
quickCalc: 'Quick Calc', quickCalc: 'Quick Calc',
quickCalcDesc: 'Suitable for single service trial, select industry, consultation type, engineering specialty, input base number and get results in seconds', quickCalcDesc: 'Suitable for single service trial, select industry, consultation type, engineering specialty, input base number and get results in seconds',
importData: 'Import Data', 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', enter: 'Enter',
pickFile: 'Choose File', pickFile: 'Choose File',
pickExisting: 'Choose Existing' pickExisting: 'Choose Existing'
@ -44,8 +45,8 @@ export const enUS = {
entering: 'Entering...', entering: 'Entering...',
enterProjectCalc: 'Enter Project Calculation', enterProjectCalc: 'Enter Project Calculation',
confirmImport: 'Confirm Import', confirmImport: 'Confirm Import',
confirmImportDesc: 'Import "{file}" and enter workspace immediately, overriding current project data.', confirmImportDesc: 'Import "{file}"',
confirmImportAction: 'Import', confirmImportAction: 'Import and Create Project',
chooseExistingProject: 'Choose Existing Project', chooseExistingProject: 'Choose Existing Project',
chooseExistingProjectDesc: 'Select a project from the list and enter workspace directly.', chooseExistingProjectDesc: 'Select a project from the list and enter workspace directly.',
noProjectYet: 'No project available. Create a new project first.' noProjectYet: 'No project available. Create a new project first.'
@ -104,60 +105,60 @@ export const enUS = {
jumpToStep: 'Jump to step {index}', jumpToStep: 'Jump to step {index}',
steps: { steps: {
step1: { step1: {
title: 'Welcome', title: 'Project Calculation Overview',
description: 'This guide covers major features and the full workflow. It is recommended to go through it in order.', description: 'This guide only explains the main project-calculation flow: project setup -> contract segments -> service pricing -> report export.',
point1: 'The top area is the tab bar, for quick switching between project, segment, and pricing pages.', point1: 'The entry is the "Project Card", and the left flow line guides you through the setup in order.',
point2: 'Tables and forms on the page auto-save locally. No manual save is needed.', point2: 'Forms and grids auto-save locally, so manual save is usually unnecessary.',
point3: 'You can reopen this tutorial anytime from the top-right "Guide" button.' point3: 'Project-level data affects later segment calculation and report output, so fill it first.'
}, },
step2: { step2: {
title: 'Project Card and Four Modules', title: 'Project-Level Setup',
description: 'The default "Project Card" tab is the entry. The left flow line contains four project-level modules.', description: 'Project-level setup mainly includes Basic Info, Scale Info, Consult Category Factor, and Major Factor.',
point1: 'Basic Info: fill project name and project scale details.', point1: 'Basic Info: maintain project name, industry, and other base data.',
point2: 'Contract Segment Management: create, sort, search, import/export segments.', point2: 'Scale Info: fill project scale by major as input for later budget values.',
point3: 'Consult Category Factor / Major Factor: maintain budget values and remarks.' point3: 'The two factor pages maintain budget values and notes used to adjust calculation.'
}, },
step3: { step3: {
title: 'Fill Basic Info', title: 'Fill Basic Info First',
description: 'Complete project-level data in "Basic Info" first, then continue to segment-level calculations.', description: 'Complete the Basic Info page first. Project name and industry are core inputs for later project calculation.',
point1: 'Project name is used in export file names and page display.', point1: 'Project name is used in the home list, tab labels, and exported reports.',
point2: 'Project detail table supports direct edit, copy/paste, and undo/redo.', point2: 'Project industry determines the scale structure, major tree, and part of the budget logic.',
point3: 'Group rows auto-summarize, and the pinned top row shows grand total.' point3: 'If project name is left empty, the system falls back to the default project name.'
}, },
step4: { step4: {
title: 'Contract Segment Management', title: 'Maintain Scale Info',
description: 'Manage the full lifecycle of contract segments in this module.', description: 'The Scale Info page stores project-level scale data, which is one of the base inputs of project calculation.',
point1: '"Add Segment" creates a new one; top-right actions on each card support edit/delete.', point1: 'Fill scale values by major. These numbers participate in later service budget values and summary.',
point2: 'Search and grid/list switch are supported; drag-sort is available when not searching.', point2: 'The grid supports direct edit, batch paste, and undo/redo for fast multi-row input.',
point3: 'The more menu supports import/export; click a card to enter segment details.' point3: 'Grouped rows and the pinned summary row are calculated automatically for quick checking.'
}, },
step5: { step5: {
title: 'Contract Segment Detail', title: 'Maintain Project Factors',
description: 'Inside a segment, the left flow line includes scale info and consulting services.', description: 'Consult Category Factor and Major Factor are used to adjust project budget values and should be reviewed before segment calculation.',
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.',
point1: 'Consult Category Factor page: maintain budget values and notes by consult category.', 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.', 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: { step8: {
title: 'Data Management and Recovery', title: 'Review and Export',
description: 'Top toolbar handles full import/export and reset initialization.', description: 'After project-level and segment-level calculation is complete, review the final summary and then export the report.',
point1: '"Import/Export" operates on full-project data package.', point1: 'Check project name, scale info, factors, and segment service amounts before exporting.',
point2: '"Reset" clears all local data and restores default page.', point2: 'Export uses the current project data and generates the final report accordingly.',
point3: 'It is recommended to export a backup before major changes.' point3: 'If you make large changes, export a backup first before continuing.'
} }
} }
}, },

View File

@ -4,7 +4,8 @@ export const zhCN = {
confirm: '确认', confirm: '确认',
delete: '删除', delete: '删除',
close: '关闭', close: '关闭',
clear: '清空' clear: '清空',
loading: '加载中...'
}, },
app: { app: {
projectConflict: { projectConflict: {
@ -31,7 +32,7 @@ export const zhCN = {
quickCalc: '单项速算', quickCalc: '单项速算',
quickCalcDesc: '适用于单项服务试算,选择行业、咨询类型、工程专业,输入基数秒出结果', quickCalcDesc: '适用于单项服务试算,选择行业、咨询类型、工程专业,输入基数秒出结果',
importData: '导入数据', importData: '导入数据',
importDataDesc: '导入".zw"数据包,快速恢复项目计算状态,续未完成工作', importDataDesc: '导入".zw"数据包,并自动新建一个项目用于恢复数据,不会覆盖现有项目',
enter: '进入计算', enter: '进入计算',
pickFile: '选择文件', pickFile: '选择文件',
pickExisting: '选择已有项目' pickExisting: '选择已有项目'
@ -44,8 +45,8 @@ export const zhCN = {
entering: '进入中...', entering: '进入中...',
enterProjectCalc: '进入项目计算', enterProjectCalc: '进入项目计算',
confirmImport: '确认导入数据', confirmImport: '确认导入数据',
confirmImportDesc: '将导入“{file}”,并立即进入工作台覆盖当前项目数据。', confirmImportDesc: '将导入“{file}”数据包',
confirmImportAction: '确认导入', confirmImportAction: '确认导入并新建项目',
chooseExistingProject: '选择已有项目', chooseExistingProject: '选择已有项目',
chooseExistingProjectDesc: '从项目列表中选择一个项目并直接进入工作台。', chooseExistingProjectDesc: '从项目列表中选择一个项目并直接进入工作台。',
noProjectYet: '当前暂无可进入的项目,请先新建项目。' noProjectYet: '当前暂无可进入的项目,请先新建项目。'
@ -104,60 +105,60 @@ export const zhCN = {
jumpToStep: '跳转到第 {index} 步', jumpToStep: '跳转到第 {index} 步',
steps: { steps: {
step1: { step1: {
title: '欢迎使用', title: '项目计算总览',
description: '这个引导会覆盖系统主要功能和完整使用路径,建议按顺序走一遍。', description: '这个引导只说明项目计算主链路,按“项目级设置 -> 合同段 -> 服务计费 -> 导出报表”的顺序理解即可。',
point1: '顶部是标签页区,可在项目、合同段、服务计算页之间快速切换。', point1: '项目计算入口是“项目卡片”,左侧流程线会带你按顺序完成配置。',
point2: '页面里的表格与表单会自动保存到本地,无需手动点击保存。', point2: '页面里的表单和表格会自动保存,本地修改通常无需手动点击保存。',
point3: '你可以随时点击右上角“使用引导”重新打开本教程。' point3: '项目级数据会影响后续合同段和报表结果,建议先补齐项目级信息。'
}, },
step2: { step2: {
title: '项目卡片与四个模块', title: '项目级配置入口',
description: '默认标签“项目卡片”是入口,左侧流程线包含四个项目级功能。', description: '项目级配置主要包括基础信息、规模信息、咨询分类系数、工程专业系数四部分。',
point1: '基础信息:填写项目名称与项目规模明细。', point1: '基础信息:维护项目名称、工程行业等项目基础资料。',
point2: '合同段管理:新建、排序、搜索、导入/导出合同段。', point2: '规模信息:按专业填写项目规模,为后续预算取值提供依据。',
point3: '咨询分类系数 / 工程专业系数:维护系数预算取值和备注。' point3: '两个系数页:维护预算取值和说明,作为项目计算的调节项。'
}, },
step3: { step3: {
title: '基础信息填写', title: '先填基础信息',
description: '先在“基础信息”中补齐项目级数据,再进入后续合同段计算。', description: '先完成基础信息页,尤其是项目名称和工程行业,后续所有项目计算都会依赖这里的数据。',
point1: '项目名称会用于导出文件名和页面展示。', point1: '项目名称会用于主页列表、标签页显示和报表导出。',
point2: '项目明细表支持直接编辑、复制粘贴、撤销重做。', point2: '工程行业决定规模信息结构、专业树和部分预算取值逻辑。',
point3: '分组行自动汇总,顶部固定行显示总合计。' point3: '如果项目名称留空,系统会自动回填默认项目名称。'
}, },
step4: { step4: {
title: '合同段管理', title: '维护规模信息',
description: '在“合同段管理”中完成合同段生命周期操作。', description: '规模信息页用于录入项目级规模数据,这是项目计算的基础输入之一。',
point1: '“添加合同段”用于新增,卡片右上角可编辑或删除。', point1: '按专业填写对应规模值,数值会参与后续服务预算取值和汇总。',
point2: '支持搜索、网格/列表切换,非搜索状态可拖拽排序。', point2: '表格支持直接编辑、批量粘贴、撤销重做,适合一次性录入多行数据。',
point3: '更多菜单可导入/导出合同段;点击卡片进入该合同段详情。' point3: '分组行和固定汇总行会自动计算,便于快速检查录入结果。'
}, },
step5: { step5: {
title: '合同段详情', title: '维护项目系数',
description: '进入合同段后,左侧同样是流程线,包含规模信息和咨询服务两部分。', description: '咨询分类系数和工程专业系数用于调整项目预算取值,建议在进入合同段前先检查完整。',
point1: '规模信息:按工程专业填写当前合同段的规模数据。',
point2: '咨询服务:选择服务词典并生成服务费用明细。',
point3: '合同段页面会独立缓存,不同合同段互不干扰。'
},
step6: {
title: '咨询服务与计算页',
description: '咨询服务页面用于管理服务明细,并进入具体计费方法页面。',
point1: '先点击“浏览”选择服务,再确认生成明细行。',
point2: '明细表“编辑”可打开服务计算页,“清空”会重置该服务计算结果。',
point3: '服务计算页包含投资规模法、用地规模法、工作量法、工时法四种方法。'
},
step7: {
title: '系数维护',
description: '项目级系数用于调节预算取值,可在两个系数页分别维护。',
point1: '咨询分类系数页:按咨询分类维护预算取值与说明。', point1: '咨询分类系数页:按咨询分类维护预算取值与说明。',
point2: '工程专业系数页:按专业树维护预算取值与说明。', point2: '工程专业系数页:按专业树维护预算取值与说明。',
point3: '支持批量粘贴、撤销重做,便于一次性维护多行数据。' point3: '两个系数页都支持批量粘贴和撤销重做,适合集中维护。'
},
step6: {
title: '进入合同段计算',
description: '项目级配置完成后,再进入合同段管理,逐个维护合同段的规模和服务费用。',
point1: '先新增合同段,再进入合同段详情页继续计算。',
point2: '合同段下的规模信息用于该合同段自己的计费数据,不会和其他合同段串数据。',
point3: '咨询服务页负责生成服务明细,并作为各计费方法页面的入口。'
},
step7: {
title: '选择计费方法',
description: '在合同段的咨询服务明细中,可进入具体服务计算页,按服务适用情况填写计费方法数据。',
point1: '常见方法包括投资规模法、用地规模法、工作量法和工时法。',
point2: '不同服务可启用不同方法,系统会按填写结果汇总到服务小计和确认金额。',
point3: '如果默认计算值需要调整,可直接修改确认金额或说明字段。'
}, },
step8: { step8: {
title: '数据管理与恢复', title: '汇总与导出',
description: '顶部工具栏负责全量数据导入导出与初始化重置。', description: '完成项目级和合同段级计算后,最后再检查汇总结果并导出报表。',
point1: '“导入/导出”是整项目级别的数据包操作。', point1: '先检查项目名称、规模信息、系数和各合同段服务金额是否完整。',
point2: '“重置”会清空本地全部数据并恢复默认页面。', point2: '确认无误后再执行报表导出,导出结果会按当前项目数据生成。',
point3: '建议在重要调整前先导出备份。' point3: '如果做了大范围调整,建议先导出备份,再继续修改。'
} }
} }
}, },

View File

@ -150,6 +150,7 @@ import {
industryTypeList industryTypeList
} from '@/sql' } from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace' import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
import { createProjectKvAdapter } from '@/lib/projectKvStore'
import { I18N_LOCALE_KEY, type AppLocale } from '@/i18n' import { I18N_LOCALE_KEY, type AppLocale } from '@/i18n'
const { t, locale } = useI18n() const { t, locale } = useI18n()
@ -494,33 +495,6 @@ const getTodayDateString = () => {
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
const createProjectKvAdapter = (projectId: string) => {
const projectKvForage = localforage.createInstance({
name: getProjectDbName(projectId),
storeName: 'pinia-kv'
})
return {
setItem: async <T = unknown>(keyRaw: string | number, value: T) => {
const key = String(keyRaw || '').trim()
if (!key) return
const currentState = await projectKvForage.getItem<Record<string, unknown>>('pinia-kv')
const nextEntries = {
...(
currentState?.entries && typeof currentState.entries === 'object'
? (currentState.entries as Record<string, unknown>)
: {}
),
[key]: JSON.parse(JSON.stringify(value))
}
await projectKvForage.setItem('pinia-kv', {
...(currentState && typeof currentState === 'object' ? currentState : {}),
entries: nextEntries,
ready: true
})
}
}
}
const openCreateProjectDialog = () => { const openCreateProjectDialog = () => {
if (listProjects().length >= MAX_PROJECT_COUNT) { if (listProjects().length >= MAX_PROJECT_COUNT) {
projectMenuOpen.value = false projectMenuOpen.value = false
@ -549,7 +523,7 @@ const createProjectAndOpen = async () => {
if (!industry) return if (!industry) return
newProjectSubmitting.value = true newProjectSubmitting.value = true
try { try {
const project = createProject() const project = createProject(DEFAULT_PROJECT_NAME)
const kvAdapter = createProjectKvAdapter(project.id) const kvAdapter = createProjectKvAdapter(project.id)
await kvAdapter.setItem(PROJECT_INFO_DB_KEY, { await kvAdapter.setItem(PROJECT_INFO_DB_KEY, {
projectIndustry: industry, projectIndustry: industry,
@ -639,33 +613,10 @@ const removeProjectItem = async (project: ProjectMeta) => {
const removed = deleteProject(project.id) const removed = deleteProject(project.id)
if (!removed) return if (!removed) return
emitProjectDeleted(project.id) 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') writeWorkspaceMode('project')
tabStore.resetTabs() tabStore.resetTabs()
tabStore.hasCompletedSetup = false tabStore.hasCompletedSetup = false
window.location.href = buildProjectUrl(nextProject.id, { window.location.href = buildProjectUrl('default', { forceHome: true })
newProject: true,
openProjectDialog: false,
forceHome: true
})
return return
} }
@ -1048,8 +999,16 @@ const createRichTextCode = (...parts: string[]): unknown => ({
.map(text => ({ text })) .map(text => ({ text }))
}) })
const resolveMethodEnabled = (value: unknown, fallback: boolean) => const resolveMethodEnabled = (value: unknown, fallback: boolean) => {
typeof value === 'boolean' ? value : fallback 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 getServiceMethodAvailability = (serviceIdText: string) => {
const dict = getServiceDictItemById(serviceIdText) as { const dict = getServiceDictItemById(serviceIdText) as {
@ -1058,10 +1017,10 @@ const getServiceMethodAvailability = (serviceIdText: string) => {
amount?: boolean | null amount?: boolean | null
workDay?: boolean | null workDay?: boolean | null
} | null | undefined } | null | undefined
const scale = resolveMethodEnabled(dict?.scale, true) const scale = resolveMethodEnabled(dict?.scale, false)
const onlyCostScale = resolveMethodEnabled(dict?.onlyCostScale, false) const onlyCostScale = resolveMethodEnabled(dict?.onlyCostScale, false)
const amount = resolveMethodEnabled(dict?.amount, true) const amount = resolveMethodEnabled(dict?.amount, false)
const workDay = resolveMethodEnabled(dict?.workDay, true) const workDay = resolveMethodEnabled(dict?.workDay, false)
return { return {
investmentScale: scale, investmentScale: scale,
landScale: scale && !onlyCostScale, landScale: scale && !onlyCostScale,
@ -1313,10 +1272,24 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const method2Raw = method2State ? { detailRows: method2State.detailRows } : null const method2Raw = method2State ? { detailRows: method2State.detailRows } : null
const method3Raw = method3State ? { detailRows: method3State.detailRows } : null const method3Raw = method3State ? { detailRows: method3State.detailRows } : null
const method4Raw = method4State ? { detailRows: method4State.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 method1 = methodAvailability.investmentScale ? buildMethod1(method1Raw?.detailRows) : null
const method2 = methodAvailability.landScale ? buildMethod2(method2Raw?.detailRows) : null const method2 = methodAvailability.landScale ? buildMethod2(method2Raw?.detailRows) : null
const method3 = methodAvailability.workload ? buildMethod3(method3Raw?.detailRows) : null const method3 = methodAvailability.workload ? buildMethod3(method3Raw?.detailRows) : null
const method4 = methodAvailability.hourly ? buildMethod4(method4Raw?.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 const sanitizedSourceRow = sourceRow
? { ? {
...sourceRow, ...sourceRow,
@ -1739,7 +1712,6 @@ onMounted(() => {
window.addEventListener('mousedown', handleGlobalMouseDown) window.addEventListener('mousedown', handleGlobalMouseDown)
window.addEventListener('keydown', handleGlobalKeyDown) window.addEventListener('keydown', handleGlobalKeyDown)
window.addEventListener('resize', scheduleUpdateTabTitleOverflow) window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
const pendingHomeImportFile = consumePendingHomeImportFile()
const skipWorkspaceImportConfirm = consumePendingHomeImportSkipConfirm() const skipWorkspaceImportConfirm = consumePendingHomeImportSkipConfirm()
void nextTick(() => { void nextTick(() => {
bindTabStripScroll() bindTabStripScroll()
@ -1748,12 +1720,14 @@ onMounted(() => {
scheduleUpdateTabTitleOverflow() scheduleUpdateTabTitleOverflow()
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId) scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
}) })
if (pendingHomeImportFile) { void (async () => {
void prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => { const pendingHomeImportFile = await consumePendingHomeImportFile()
if (!pendingHomeImportFile) return
await prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => {
console.error('home import failed:', error) console.error('home import failed:', error)
showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile')) showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile'))
}) })
} })()
void (async () => { void (async () => {
if (await shouldAutoOpenGuide()) { if (await shouldAutoOpenGuide()) {
openUserGuide(0) openUserGuide(0)

View File

@ -284,7 +284,7 @@ useMotionValueEvent(
<DialogTrigger as-child> <DialogTrigger as-child>
<button type="button" <button type="button"
class="cursor-pointer absolute left-3 right-3 bottom-3 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[11px] leading-4 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"> class="cursor-pointer absolute left-3 right-3 bottom-3 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[11px] leading-4 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground">
<img src="/favicon.ico" :alt="t('typeLine.brandAlt')" class="h-5 w-5 shrink-0 rounded-sm" /> <img src="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-8 w-8 shrink-0 rounded-sm" />
<span>{{ t('typeLine.supportText') }}</span> <span>{{ t('typeLine.supportText') }}</span>
</button> </button>
</DialogTrigger> </DialogTrigger>
@ -324,7 +324,7 @@ useMotionValueEvent(
</div> </div>
<DialogTitle class="mt-2"> <DialogTitle class="mt-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src="/favicon.ico" :alt="t('typeLine.brandAlt')" class="h-7 w-7 shrink-0 rounded-sm" /> <img src="/logo.jpg" :alt="t('typeLine.brandAlt')" class="h-7 w-7 shrink-0 rounded-sm" />
<span class="text-2xl font-semibold leading-none">{{ t('typeLine.aboutTitle') }}</span> <span class="text-2xl font-semibold leading-none">{{ t('typeLine.aboutTitle') }}</span>
</div> </div>
</DialogTitle> </DialogTitle>

29
src/lib/projectKvStore.ts Normal file
View File

@ -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 <T = unknown>(keyRaw: string | number, value: T) => {
const key = String(keyRaw || '').trim()
if (!key) return
const currentState = await projectKvForage.getItem<Record<string, unknown>>('pinia-kv')
const nextEntries = {
...(
currentState?.entries && typeof currentState.entries === 'object'
? (currentState.entries as Record<string, unknown>)
: {}
),
[key]: JSON.parse(JSON.stringify(value))
}
await projectKvForage.setItem('pinia-kv', {
...(currentState && typeof currentState === 'object' ? currentState : {}),
entries: nextEntries,
ready: true
})
}
}
}

View File

@ -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' import { i18n } from '@/i18n'
const PROJECT_REGISTRY_KEY = 'jgjs-project-registry-v1' const PROJECT_REGISTRY_KEY = 'jgjs-project-registry-v1'
@ -59,7 +59,7 @@ const writePayload = (payload: ProjectRegistryPayload) => {
export const listProjects = () => { export const listProjects = () => {
const payload = readPayload() const payload = readPayload()
return payload.projects return payload.projects
.filter(item => item.id !== QUICK_PROJECT_ID) .filter(item => item.id !== QUICK_PROJECT_ID && item.id !== DEFAULT_PROJECT_ID)
.slice() .slice()
.sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()) .sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime())
} }
@ -73,7 +73,7 @@ export const upsertProject = (
} }
) => { ) => {
const id = normalizeProjectId(projectIdRaw) 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 name = String(nameRaw || '').trim()
const payload = readPayload() const payload = readPayload()
const now = nowIso() const now = nowIso()
@ -111,7 +111,7 @@ export const touchProjectEdited = (
} }
) => { ) => {
const id = normalizeProjectId(projectIdRaw) 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 throttleMs = Math.max(0, Number(options?.throttleMs ?? 5000))
const nowMs = Date.now() const nowMs = Date.now()
const lastTouch = lastEditedTouchAt.get(id) ?? 0 const lastTouch = lastEditedTouchAt.get(id) ?? 0
@ -148,7 +148,7 @@ export const createProject = (nameRaw?: string) => {
export const deleteProject = (projectIdRaw: string) => { export const deleteProject = (projectIdRaw: string) => {
const id = normalizeProjectId(projectIdRaw) 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 payload = readPayload()
const nextProjects = payload.projects.filter(item => item.id !== id) const nextProjects = payload.projects.filter(item => item.id !== id)
if (nextProjects.length === payload.projects.length) return false if (nextProjects.length === payload.projects.length) return false

View File

@ -1,8 +1,10 @@
const LOCK_TTL_MS = 12_000 const LOCK_TTL_MS = 12_000
const HEARTBEAT_MS = 4_000 const HEARTBEAT_MS = 4_000
const SESSION_PROBE_WAIT_MS = 80
export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:' export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:'
const CHANNEL_NAME = 'jgjs-project-lock-channel' const CHANNEL_NAME = 'jgjs-project-lock-channel'
const TAB_SESSION_ID_KEY = 'jgjs-project-tab-session-id' const TAB_SESSION_ID_KEY = 'jgjs-project-tab-session-id'
const runtimeInstanceId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
type LockPayload = { type LockPayload = {
sessionId: string 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 randomSessionId = () => `${now()}-${Math.random().toString(36).slice(2, 10)}`
const debugLock = (message: string, payload?: Record<string, unknown>) => {
try {
if (payload) {
console.debug('[project-lock]', message, payload)
} else {
console.debug('[project-lock]', message)
}
} catch {
// ignore console errors
}
}
const getOrCreateTabSessionId = () => { const getOrCreateTabSessionId = () => {
try { try {
const existing = String(window.sessionStorage.getItem(TAB_SESSION_ID_KEY) || '').trim() const existing = String(window.sessionStorage.getItem(TAB_SESSION_ID_KEY) || '').trim()
@ -54,22 +68,25 @@ type InitProjectLockParams = {
export const initProjectSessionLock = (params: InitProjectLockParams) => { export const initProjectSessionLock = (params: InitProjectLockParams) => {
const projectId = String(params.projectId || '').trim() const projectId = String(params.projectId || '').trim()
const onConflict = params.onConflict const onConflict = params.onConflict
const sessionId = getOrCreateTabSessionId() let sessionId = getOrCreateTabSessionId()
const key = lockKeyOf(projectId) const key = lockKeyOf(projectId)
let conflicted = false let conflicted = false
let heartbeatTimer: ReturnType<typeof setInterval> | null = null let heartbeatTimer: ReturnType<typeof setInterval> | null = null
let bc: BroadcastChannel | null = null let bc: BroadcastChannel | null = null
let released = false
const emitConflict = (next: boolean) => { const emitConflict = (next: boolean) => {
if (conflicted === next) return if (conflicted === next) return
conflicted = next conflicted = next
debugLock('emit conflict', { projectId, sessionId, runtimeInstanceId, conflicted: next })
onConflict(next) onConflict(next)
} }
const writeHeartbeat = () => { const writeHeartbeat = () => {
if (conflicted) return if (conflicted || released) return
const payload: LockPayload = { sessionId, projectId, updatedAt: now() } const payload: LockPayload = { sessionId, projectId, updatedAt: now() }
localStorage.setItem(key, JSON.stringify(payload)) localStorage.setItem(key, JSON.stringify(payload))
debugLock('write heartbeat', { projectId, sessionId, runtimeInstanceId, key })
bc?.postMessage({ type: 'heartbeat', projectId, sessionId }) bc?.postMessage({ type: 'heartbeat', projectId, sessionId })
} }
@ -77,12 +94,14 @@ export const initProjectSessionLock = (params: InitProjectLockParams) => {
const current = parseLockPayload(localStorage.getItem(key)) const current = parseLockPayload(localStorage.getItem(key))
if (current?.sessionId === sessionId) { if (current?.sessionId === sessionId) {
localStorage.removeItem(key) localStorage.removeItem(key)
debugLock('clear own lock', { projectId, sessionId, runtimeInstanceId, key })
} }
bc?.postMessage({ type: 'release', projectId, sessionId }) bc?.postMessage({ type: 'release', projectId, sessionId })
} }
const detectConflict = () => { const detectConflict = () => {
const current = parseLockPayload(localStorage.getItem(key)) const current = parseLockPayload(localStorage.getItem(key))
debugLock('detect conflict', { projectId, sessionId, runtimeInstanceId, current, expired: current ? isExpired(current) : null })
if (!current || isExpired(current)) { if (!current || isExpired(current)) {
emitConflict(false) emitConflict(false)
writeHeartbeat() writeHeartbeat()
@ -100,26 +119,45 @@ export const initProjectSessionLock = (params: InitProjectLockParams) => {
clearOwnLock() clearOwnLock()
} }
try { const startHeartbeat = () => {
const existing = parseLockPayload(localStorage.getItem(key)) if (heartbeatTimer || conflicted || released) return
if (existing && !isExpired(existing) && existing.sessionId !== sessionId) {
emitConflict(true)
} else {
writeHeartbeat()
}
} catch (error) {
console.error('init project session lock failed:', error)
}
if (!conflicted) {
heartbeatTimer = setInterval(writeHeartbeat, HEARTBEAT_MS) heartbeatTimer = setInterval(writeHeartbeat, HEARTBEAT_MS)
} }
if (typeof BroadcastChannel !== 'undefined') { if (typeof BroadcastChannel !== 'undefined') {
bc = new BroadcastChannel(CHANNEL_NAME) 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 const payload = event.data
if (!payload || payload.projectId !== projectId) return 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.sessionId === sessionId) return
if (payload.type === 'heartbeat') { if (payload.type === 'heartbeat') {
detectConflict() detectConflict()
@ -127,6 +165,79 @@ export const initProjectSessionLock = (params: InitProjectLockParams) => {
} }
} }
const ensureUniqueSessionId = async () => {
if (!bc) return
const responders = new Set<string>()
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('storage', onStorage)
window.addEventListener('beforeunload', onBeforeUnload) window.addEventListener('beforeunload', onBeforeUnload)
@ -135,6 +246,7 @@ export const initProjectSessionLock = (params: InitProjectLockParams) => {
return conflicted return conflicted
}, },
release: () => { release: () => {
released = true
if (heartbeatTimer) { if (heartbeatTimer) {
clearInterval(heartbeatTimer) clearInterval(heartbeatTimer)
heartbeatTimer = null heartbeatTimer = null

View File

@ -245,6 +245,19 @@ export const toScaleProNum = (row: ScaleMethodRowLike): number => {
return parsed.proNum > 0 ? parsed.proNum : 1 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<string, ScaleMethodRowLike>()
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 normalizeTaskText = (value: unknown): string => String(value || '').trim()
export const resolveTaskRowServiceId = (row: WorkContentRowLike): number | null => export const resolveTaskRowServiceId = (row: WorkContentRowLike): number | null =>
@ -392,10 +405,11 @@ export const toExportScaleRows = (rows: ScaleRowLike[] | undefined) => {
} }
export const buildMethod1 = (rows: ScaleMethodRowLike[] | 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 let hasTotalValue = false
const proSet = new Set<number>() const proSet = new Set<number>()
const det = rows const det = normalizedRows
.map(row => { .map(row => {
const major = toScaleMajorId(row) const major = toScaleMajorId(row)
if (major == null) return null if (major == null) return null
@ -441,10 +455,11 @@ export const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined) => {
} }
export const buildMethod2 = (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 let hasTotalValue = false
const proSet = new Set<number>() const proSet = new Set<number>()
const det = rows const det = normalizedRows
.map(row => { .map(row => {
const major = toScaleMajorId(row) const major = toScaleMajorId(row)
if (major == null) return null if (major == null) return null

View File

@ -1,4 +1,5 @@
import { i18n } from '@/i18n' import { i18n } from '@/i18n'
import localforage from 'localforage'
export type WorkspaceMode = 'project' | 'quick' 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_CONSULT_CATEGORY_FACTOR_KEY = 'quick-xm-consult-category-factor-v1'
export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1' export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1'
let pendingHomeImportFile: File | null = null const HOME_IMPORT_TEMP_DB_NAME = 'JGJS-HOME-IMPORT-TEMP'
let pendingHomeImportSkipWorkspaceConfirm = false 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 { export interface QuickContractMeta {
id: string id: string
@ -64,20 +71,57 @@ export const setPendingHomeImportFile = (
file: File | null, file: File | null,
options?: { skipWorkspaceConfirm?: boolean } options?: { skipWorkspaceConfirm?: boolean }
) => { ) => {
pendingHomeImportFile = file return (async () => {
pendingHomeImportSkipWorkspaceConfirm = Boolean(options?.skipWorkspaceConfirm) 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 = () => { export const consumePendingHomeImportFile = async () => {
const file = pendingHomeImportFile try {
pendingHomeImportFile = null const payload = await homeImportTempForage.getItem<{
return file 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 = () => { export const consumePendingHomeImportSkipConfirm = () => {
const skip = pendingHomeImportSkipWorkspaceConfirm try {
pendingHomeImportSkipWorkspaceConfirm = false const skip = window.sessionStorage.getItem(HOME_IMPORT_SKIP_CONFIRM_KEY) === '1'
return skip window.sessionStorage.removeItem(HOME_IMPORT_SKIP_CONFIRM_KEY)
return skip
} catch {
return false
}
} }
export const normalizeProjectId = (value: unknown) => { export const normalizeProjectId = (value: unknown) => {

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { waitForHydration } from '@/pinia/Plugin/indexdb'
const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
@ -7,6 +8,8 @@ export const useKvStore = defineStore('kv', () => {
const entries = ref<Record<string, unknown>>({}) const entries = ref<Record<string, unknown>>({})
const ready = ref(false) const ready = ref(false)
const loading = ref<Promise<void> | null>(null) const loading = ref<Promise<void> | null>(null)
let hydrationReady = false
let hydrationTask: Promise<void> | null = null
const ensureReady = async () => { const ensureReady = async () => {
if (ready.value) return if (ready.value) return
@ -15,6 +18,17 @@ export const useKvStore = defineStore('kv', () => {
return return
} }
loading.value = (async () => { loading.value = (async () => {
if (!hydrationReady) {
if (!hydrationTask) {
hydrationTask = waitForHydration('kv')
.catch(() => undefined)
.finally(() => {
hydrationReady = true
hydrationTask = null
})
}
await hydrationTask
}
ready.value = true ready.value = true
})() })()
await loading.value await loading.value