修复项目管理问题
This commit is contained in:
parent
be2662d579
commit
e72012cfc2
427
docs/project-list-logic-audit.md
Normal file
427
docs/project-list-logic-audit.md
Normal 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
BIN
public/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
76
src/App.vue
76
src/App.vue
@ -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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
try {
|
||||||
|
await waitForHydration('kv')
|
||||||
await loadFromIndexedDB()
|
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') }}
|
||||||
|
|||||||
@ -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.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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: '如果做了大范围调整,建议先导出备份,再继续修改。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
29
src/lib/projectKvStore.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'
|
||||||
|
window.sessionStorage.removeItem(HOME_IMPORT_SKIP_CONFIRM_KEY)
|
||||||
return skip
|
return skip
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const normalizeProjectId = (value: unknown) => {
|
export const normalizeProjectId = (value: unknown) => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user