This commit is contained in:
wintsa 2026-03-26 10:09:16 +08:00
parent de3585bde3
commit 35f06746fe
8 changed files with 489 additions and 20 deletions

View File

@ -8,6 +8,7 @@ import { waitForHydration } from '@/pinia/Plugin/indexdb'
import localforage from 'localforage'
import {
buildProjectUrl,
DEFAULT_PROJECT_ID,
ensureProjectIdInUrl,
FORCE_HOME_QUERY_KEY,
getProjectDbName,
@ -16,6 +17,7 @@ import {
QUICK_PROJECT_ID
} from '@/lib/workspace'
import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/projectSessionLock'
import { listenProjectDeleted, listenResetAll } from '@/lib/projectEvents'
import { createProject, listProjects, type ProjectMeta } from '@/lib/projectRegistry'
const tabStore = useTabStore()
@ -29,6 +31,10 @@ const openedProjectIds = ref<string[]>([])
const closeCountdown = ref(10)
let closeCountdownTimer: ReturnType<typeof setInterval> | null = null
let releaseLock: (() => void) | null = null
let stopProjectDeletedListener: (() => void) | null = null
let stopResetAllListener: (() => void) | null = null
let isHandlingDeletedProject = false
let isHandlingGlobalReset = false
const showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
@ -119,6 +125,41 @@ const handleReleaseProjectLock = () => {
lockConflict.value = false
}
const handleProjectDeleted = (deletedProjectId: string) => {
if (String(deletedProjectId || '').trim() !== currentProjectId.value) return
if (isHandlingDeletedProject) return
isHandlingDeletedProject = true
handleReleaseProjectLock()
tabStore.resetTabs()
tabStore.hasCompletedSetup = false
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
try {
window.close()
} catch {
// ignore and fallback to redirect
}
window.setTimeout(() => {
window.location.href = href
}, 120)
}
const handleResetAll = () => {
if (isHandlingGlobalReset) return
isHandlingGlobalReset = true
handleReleaseProjectLock()
tabStore.resetTabs()
tabStore.hasCompletedSetup = false
const href = buildProjectUrl(DEFAULT_PROJECT_ID, { forceHome: true })
try {
window.close()
} catch {
// ignore and fallback to redirect
}
window.setTimeout(() => {
window.location.href = href
}, 120)
}
onMounted(() => {
currentProjectId.value = ensureProjectIdInUrl()
refreshConflictProjectList()
@ -152,6 +193,8 @@ onMounted(() => {
window.addEventListener('home-import-selected', handleImportComplete)
window.addEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
stopProjectDeletedListener = listenProjectDeleted(handleProjectDeleted)
stopResetAllListener = listenResetAll(handleResetAll)
waitForHydration('tabs').then(() => {
if (forceHomeRequest) {
tabStore.resetTabs()
@ -187,6 +230,14 @@ onBeforeUnmount(() => {
clearCloseCountdown()
window.removeEventListener('home-import-selected', handleImportComplete)
window.removeEventListener('jgjs-release-project-lock', handleReleaseProjectLock)
if (stopProjectDeletedListener) {
stopProjectDeletedListener()
stopProjectDeletedListener = null
}
if (stopResetAllListener) {
stopResetAllListener()
stopResetAllListener = null
}
if (releaseLock) {
releaseLock()
releaseLock = null

View File

@ -30,7 +30,8 @@ import {
} from 'reka-ui'
import { Button } from '@/components/ui/button'
import { TooltipProvider } from '@/components/ui/tooltip'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import { getServiceDictEntries, getServiceDictItemById, getWorkListEntries, isIndustryEnabledByType, getIndustryTypeValue, wholeProcessTasks } from '@/sql'
import type { WorkType } from '@/sql'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
@ -64,6 +65,19 @@ interface ZxFwViewState {
detailRows: DetailRow[]
}
interface WorkContentRowState {
id: string
content: string
type: WorkType
dictOrder?: number
serviceGroup?: string
serviceid?: number | null
remark: string
checked: boolean
custom: boolean
path: string[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
@ -962,6 +976,7 @@ const handleServiceSelectionChange = async (ids: string[]) => {
const nextSelectedIds = getCurrentContractState().selectedIds || []
const nextSelectedSet = new Set(nextSelectedIds)
const addedIds = nextSelectedIds.filter(id => !prevIds.includes(id) && nextSelectedSet.has(id))
await ensureWorkContentStateForServices(addedIds)
await fillPricingTotalsForServiceIds(addedIds)
await ensurePricingDetailRowsForCurrentSelection()
}
@ -980,6 +995,7 @@ const initializeContractState = async () => {
// finalFee
const allServiceIds = getSelectedServiceIdsWithoutFixed()
if (allServiceIds.length > 0) {
await ensureWorkContentStateForServices(allServiceIds)
await fillPricingTotalsForServiceIds(allServiceIds)
}
} catch (error) {
@ -1015,6 +1031,88 @@ const loadProjectIndustry = async () => {
}
}
const resolveProjectIndustryId = () => {
const raw = String(projectIndustry.value || '').trim()
if (!raw) return null
if (raw.toUpperCase() === 'E2') return 0
if (raw.toUpperCase() === 'E3') return 1
if (raw.toUpperCase() === 'E4') return 2
const parsed = Number(raw)
return Number.isFinite(parsed) ? parsed : null
}
const buildDefaultWorkContentRowsForService = (serviceIdRaw: string) => {
const serviceId = Number(serviceIdRaw)
if (!Number.isFinite(serviceId)) return [] as WorkContentRowState[]
const entries = getWorkListEntries(locale.value) as Array<{ text: string; serviceid: number; order: number; type: number }>
const industryId = resolveProjectIndustryId()
const wholeProcessGroup = wholeProcessTasks.find(
item => Number(item.fid) === serviceId && Number(item.industry) === industryId
)
const filtered = wholeProcessGroup
? (() => {
const groupedServiceIds = Array.isArray(wholeProcessGroup.sid)
? wholeProcessGroup.sid.map(id => Number(id)).filter(Number.isFinite)
: []
const groupedSet = new Set(groupedServiceIds)
return entries
.filter(item => groupedSet.has(Number(item.serviceid)))
.sort((a, b) => {
const indexA = groupedServiceIds.indexOf(Number(a.serviceid))
const indexB = groupedServiceIds.indexOf(Number(b.serviceid))
if (indexA !== indexB) return indexA - indexB
return a.order - b.order
})
})()
: entries.filter(item => Number(item.serviceid) === serviceId).sort((a, b) => a.order - b.order)
const toTypeLabel = (type: number): WorkType => {
if (type === 1) return t('workContent.type.optional') as WorkType
if (type === 2) return t('workContent.type.daily') as WorkType
if (type === 3) return t('workContent.type.special') as WorkType
if (type === 4) return t('workContent.type.additional') as WorkType
return t('workContent.type.basic') as WorkType
}
return filtered
.map<WorkContentRowState | null>((entry) => {
const content = String(entry.text || '').trim()
if (!content) return null
const serviceItem = getServiceDictItemById(entry.serviceid) as { code?: string; name?: string } | undefined
const serviceGroup = serviceItem
? `${String(serviceItem.code || '').trim()} ${String(serviceItem.name || '').trim()}`.trim()
: ''
return {
id: `dict-${entry.serviceid}-${entry.order}`,
content,
type: toTypeLabel(entry.type),
dictOrder: entry.order,
serviceGroup,
serviceid: Number.isFinite(Number(entry.serviceid)) ? Number(entry.serviceid) : null,
remark: '',
checked: true,
custom: false,
path: serviceGroup ? [serviceGroup, content] : [toTypeLabel(entry.type), content]
}
})
.filter((item): item is WorkContentRowState => Boolean(item))
}
const ensureWorkContentStateForService = async (serviceId: string) => {
const storageKey = `work-content-${props.contractId}-${serviceId}`
const saved = await zxFwPricingStore.loadKeyState<{ detailRows?: unknown[] }>(storageKey)
if (Array.isArray(saved?.detailRows) && saved.detailRows.length > 0) return
const defaultRows = buildDefaultWorkContentRowsForService(serviceId)
if (defaultRows.length === 0) return
zxFwPricingStore.setKeyState(storageKey, { detailRows: defaultRows })
}
const ensureWorkContentStateForServices = async (serviceIds: string[]) => {
const uniqueIds = Array.from(new Set(serviceIds.map(id => String(id || '').trim()).filter(Boolean)))
if (uniqueIds.length === 0) return
await Promise.all(uniqueIds.map(id => ensureWorkContentStateForService(id)))
}
watch(serviceIdSignature, () => {
const availableIds = new Set(serviceDict.value.map(item => item.id))
const nextSelectedIds = selectedIds.value.filter(id => availableIds.has(id))

View File

@ -44,7 +44,7 @@ import {
setPendingHomeImportFile,
writeWorkspaceMode
} from '@/lib/workspace'
import { createProject, upsertProject } from '@/lib/projectRegistry'
import { createProject, listProjects, upsertProject } from '@/lib/projectRegistry'
interface QuickProjectInfoState {
projectIndustry?: string
@ -90,6 +90,10 @@ const homeImportInputRef = ref<HTMLInputElement | null>(null)
const homeImportConfirmOpen = ref(false)
const pendingHomeImportFile = ref<File | null>(null)
const pendingHomeImportFileName = ref('')
const existingProjectDialogOpen = ref(false)
const existingProjects = ref<Array<{ id: string; name: string; updatedAt: string }>>([])
const existingProjectLoading = ref(false)
const hasExistingProjects = ref(false)
const projectIndustryLabel = computed(() => {
const target = String(projectIndustry.value || '').trim()
if (!target) return ''
@ -109,6 +113,13 @@ const getTodayDateString = () => {
return `${year}-${month}-${day}`
}
const formatProjectEditedTime = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (num: number) => String(num).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const enterProjectCalc = () => {
const projectId = getActiveProjectId()
upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? t('tab.messages.defaultProjectLabel') : undefined)
@ -135,6 +146,51 @@ const openProjectCalc = async () => {
projectDialogOpen.value = true
}
const refreshExistingProjects = async () => {
existingProjectLoading.value = true
try {
const projects = listProjects()
.filter(item => item.id !== QUICK_PROJECT_ID)
.sort((a, b) => {
const left = new Date(a.updatedAt).getTime()
const right = new Date(b.updatedAt).getTime()
return (Number.isFinite(right) ? right : 0) - (Number.isFinite(left) ? left : 0)
})
existingProjects.value = projects.map(project => ({
id: project.id,
name: project.name,
updatedAt: project.updatedAt
}))
hasExistingProjects.value = projects.length > 0
} finally {
existingProjectLoading.value = false
}
}
const openExistingProjectDialog = async () => {
existingProjectDialogOpen.value = true
await refreshExistingProjects()
}
const closeExistingProjectDialog = () => {
existingProjectDialogOpen.value = false
}
const enterExistingProject = (projectIdRaw: string) => {
const projectId = String(projectIdRaw || '').trim()
if (!projectId) return
upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? t('tab.messages.defaultProjectLabel') : undefined)
writeProjectIdToUrl(projectId)
writeWorkspaceMode('project')
tabStore.enterWorkspace({
id: PROJECT_TAB_ID,
title: t('home.projectCalcTab'),
componentName: 'ProjectCalcView'
})
tabStore.hasCompletedSetup = true
closeExistingProjectDialog()
}
const closeProjectCalcDialog = () => {
projectDialogOpen.value = false
}
@ -258,11 +314,15 @@ const confirmHomeImport = () => {
const file = pendingHomeImportFile.value
if (!file) return
setPendingHomeImportFile(file, { skipWorkspaceConfirm: true })
window.dispatchEvent(new CustomEvent('home-import-selected', {
detail: { file }
}))
const project = createProject()
writeProjectIdToUrl(project.id)
const currentProjectId = getActiveProjectId()
const projects = listProjects()
const currentProjectMeta = projects.find(item => item.id === currentProjectId)
const isDeleteFallbackProject =
Boolean(currentProjectMeta)
&& projects.length === 1
&& String(currentProjectMeta?.name || '').trim() === t('xmInfo.defaultProjectName')
const targetProjectId = isDeleteFallbackProject ? currentProjectId : createProject().id
writeProjectIdToUrl(targetProjectId)
writeWorkspaceMode('project')
tabStore.enterWorkspace({
id: PROJECT_TAB_ID,
@ -274,6 +334,7 @@ const confirmHomeImport = () => {
}
onMounted(() => {
void refreshExistingProjects()
void loadProjectDefaults()
void loadQuickDefaults()
try {
@ -363,9 +424,19 @@ onMounted(() => {
{{ t('home.cards.projectBudgetDesc') }}
</CardDescription>
</CardHeader>
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>{{ t('home.cards.enter') }}</span>
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
<div class="mt-4 flex items-center justify-between gap-2">
<button
v-if="hasExistingProjects"
type="button"
class="cursor-pointer rounded-md border border-slate-200 px-2.5 py-1 text-xs font-medium text-slate-500 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700"
@click.stop="openExistingProjectDialog"
>
{{ t('home.cards.pickExisting') }}
</button>
<div class="flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
<span>{{ t('home.cards.enter') }}</span>
<svg class="ml-1 h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</div>
</div>
</Card>
@ -443,6 +514,52 @@ onMounted(() => {
</div>
</div>
<div
v-if="existingProjectDialogOpen"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
@click.self="closeExistingProjectDialog"
>
<div class="w-full max-w-lg rounded-xl border bg-background shadow-2xl">
<div class="flex items-center justify-between border-b px-5 py-4">
<div>
<h3 class="text-base font-semibold text-foreground">{{ t('home.dialog.chooseExistingProject') }}</h3>
<p class="mt-1 text-sm text-muted-foreground">{{ t('home.dialog.chooseExistingProjectDesc') }}</p>
</div>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeExistingProjectDialog">
<X class="h-4 w-4" />
</Button>
</div>
<div class="max-h-80 space-y-2 overflow-auto px-5 py-4">
<div
v-if="!existingProjectLoading && existingProjects.length === 0"
class="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-5 text-center text-sm text-slate-500"
>
{{ t('home.dialog.noProjectYet') }}
</div>
<button
v-for="project in existingProjects"
:key="project.id"
type="button"
class="flex w-full cursor-pointer items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left transition hover:border-slate-300 hover:bg-slate-50"
@click="enterExistingProject(project.id)"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-slate-800">{{ project.name }}</div>
<div class="mt-0.5 text-xs text-slate-500">{{ project.id }}</div>
</div>
<div class="shrink-0 pl-2 text-xs text-slate-500">
{{ t('tab.toolbar.lastEdited', { time: formatProjectEditedTime(project.updatedAt) }) }}
</div>
</button>
</div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
<Button variant="outline" @click="closeExistingProjectDialog">{{ t('common.cancel') }}</Button>
</div>
</div>
</div>
<div
v-if="projectDialogOpen"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"

View File

@ -33,7 +33,8 @@ export const enUS = {
importData: 'Import Data',
importDataDesc: 'Import ".zw" package to restore project state and continue work quickly',
enter: 'Enter',
pickFile: 'Choose File'
pickFile: 'Choose File',
pickExisting: 'Choose Existing'
},
dialog: {
newProject: 'New Project',
@ -44,7 +45,10 @@ export const enUS = {
enterProjectCalc: 'Enter Project Calculation',
confirmImport: 'Confirm Import',
confirmImportDesc: 'Import "{file}" and enter workspace immediately, overriding current project data.',
confirmImportAction: 'Import'
confirmImportAction: 'Import',
chooseExistingProject: 'Choose Existing Project',
chooseExistingProjectDesc: 'Select a project from the list and enter workspace directly.',
noProjectYet: 'No project available. Create a new project first.'
}
},
tab: {
@ -62,6 +66,7 @@ export const enUS = {
projectList: 'Projects',
projectCount: 'Projects: {count}',
createProject: 'New Project',
backHome: 'Back Home',
resetAll: 'Reset All',
opened: '(Opened)',
lastEdited: 'Last edited: {time}'

View File

@ -33,7 +33,8 @@ export const zhCN = {
importData: '导入数据',
importDataDesc: '导入".zw"数据包,快速恢复项目计算状态,续未完成工作',
enter: '进入计算',
pickFile: '选择文件'
pickFile: '选择文件',
pickExisting: '选择已有项目'
},
dialog: {
newProject: '新建项目',
@ -44,7 +45,10 @@ export const zhCN = {
enterProjectCalc: '进入项目计算',
confirmImport: '确认导入数据',
confirmImportDesc: '将导入“{file}”,并立即进入工作台覆盖当前项目数据。',
confirmImportAction: '确认导入'
confirmImportAction: '确认导入',
chooseExistingProject: '选择已有项目',
chooseExistingProjectDesc: '从项目列表中选择一个项目并直接进入工作台。',
noProjectYet: '当前暂无可进入的项目,请先新建项目。'
}
},
tab: {
@ -62,6 +66,7 @@ export const zhCN = {
projectList: '项目列表',
projectCount: '项目数量:{count}',
createProject: '新建项目',
backHome: '返回首页',
resetAll: '重置全部项目',
opened: '(已打开)',
lastEdited: '最后编辑:{time}'

View File

@ -113,6 +113,7 @@ import {
upsertProject,
type ProjectMeta
} from '@/lib/projectRegistry'
import { emitProjectDeleted, emitResetAll } from '@/lib/projectEvents'
import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock'
import { addNumbers } from '@/lib/decimal'
import {
@ -583,6 +584,14 @@ const handleCreateProjectConfirm = () => {
projectMenuOpen.value = false
}
const returnToHome = () => {
projectMenuOpen.value = false
const href = buildProjectUrl(currentProjectId.value || readCurrentProjectId(), {
forceHome: true
})
window.location.href = href
}
const formatProjectEditedTime = (value: string) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
@ -629,6 +638,7 @@ const removeProjectItem = async (project: ProjectMeta) => {
await deleteIndexedDBByName(getProjectDbName(project.id))
const removed = deleteProject(project.id)
if (!removed) return
emitProjectDeleted(project.id)
const nextProject = createProject(DEFAULT_PROJECT_NAME)
const defaultIndustry = String(industryTypeList[0]?.id || '').trim()
if (defaultIndustry) {
@ -661,6 +671,7 @@ const removeProjectItem = async (project: ProjectMeta) => {
const removed = deleteProject(project.id)
if (!removed) return
emitProjectDeleted(project.id)
try {
await deleteIndexedDBByName(getProjectDbName(project.id))
} catch (error) {
@ -1564,6 +1575,7 @@ const handleReset = async () => {
isResetting.value = true
dataMenuOpen.value = false
projectMenuOpen.value = false
emitResetAll()
window.dispatchEvent(new CustomEvent('jgjs-release-project-lock'))
const themePreference =
typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_PREFERENCE_KEY) : null
@ -1882,14 +1894,12 @@ watch(
</Button>
<Button
v-if="workspaceModeForUi === 'quick'"
variant="destructive"
variant="outline"
size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer"
:disabled="isResetting"
@click="resetConfirmOpen = true"
@click="returnToHome"
>
<Loader2 v-if="isResetting" class="mr-1 h-4 w-4 animate-spin" />
{{ isResetting ? t('tab.toolbar.resetting') : t('tab.toolbar.reset') }}
{{ t('tab.toolbar.backHome') }}
</Button>
<div v-if="workspaceModeForUi !== 'quick'" ref="projectMenuRef" class="relative shrink-0">
@ -1946,6 +1956,9 @@ watch(
<Button size="sm" class="h-8 px-3 text-xs" @click="openCreateProjectDialog">
{{ t('tab.toolbar.createProject') }}
</Button>
<Button variant="outline" size="sm" class="h-8 px-3 text-xs" @click="returnToHome">
{{ t('tab.toolbar.backHome') }}
</Button>
<Button variant="destructive" size="sm" class="h-8 px-3 text-xs" @click="resetConfirmOpen = true">
{{ t('tab.toolbar.resetAll') }}
</Button>

180
src/lib/projectEvents.ts Normal file
View File

@ -0,0 +1,180 @@
const PROJECT_EVENTS_CHANNEL = 'jgjs-project-events'
const PROJECT_DELETED_STORAGE_KEY = 'jgjs-project-event:project-deleted'
const RESET_ALL_STORAGE_KEY = 'jgjs-project-event:reset-all'
const PROJECT_EVENT_SESSION_ID_KEY = 'jgjs-project-event-session-id'
type ProjectDeletedPayload = {
type: 'project-deleted'
projectId: string
sourceSessionId: string
at: number
}
type ResetAllPayload = {
type: 'reset-all'
sourceSessionId: string
at: number
}
const randomSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
const getOrCreateSessionId = () => {
try {
const existing = String(window.sessionStorage.getItem(PROJECT_EVENT_SESSION_ID_KEY) || '').trim()
if (existing) return existing
const next = randomSessionId()
window.sessionStorage.setItem(PROJECT_EVENT_SESSION_ID_KEY, next)
return next
} catch {
return randomSessionId()
}
}
const parseProjectDeletedPayload = (raw: string | null): ProjectDeletedPayload | null => {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as Partial<ProjectDeletedPayload>
if (!parsed || typeof parsed !== 'object') return null
if (parsed.type !== 'project-deleted') return null
if (typeof parsed.projectId !== 'string' || !parsed.projectId.trim()) return null
if (typeof parsed.sourceSessionId !== 'string' || !parsed.sourceSessionId.trim()) return null
if (typeof parsed.at !== 'number' || !Number.isFinite(parsed.at)) return null
return {
type: 'project-deleted',
projectId: parsed.projectId.trim(),
sourceSessionId: parsed.sourceSessionId.trim(),
at: parsed.at
}
} catch {
return null
}
}
const parseResetAllPayload = (raw: string | null): ResetAllPayload | null => {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as Partial<ResetAllPayload>
if (!parsed || typeof parsed !== 'object') return null
if (parsed.type !== 'reset-all') return null
if (typeof parsed.sourceSessionId !== 'string' || !parsed.sourceSessionId.trim()) return null
if (typeof parsed.at !== 'number' || !Number.isFinite(parsed.at)) return null
return {
type: 'reset-all',
sourceSessionId: parsed.sourceSessionId.trim(),
at: parsed.at
}
} catch {
return null
}
}
export const emitProjectDeleted = (projectIdRaw: string) => {
const projectId = String(projectIdRaw || '').trim()
if (!projectId) return
const payload: ProjectDeletedPayload = {
type: 'project-deleted',
projectId,
sourceSessionId: getOrCreateSessionId(),
at: Date.now()
}
try {
if (typeof BroadcastChannel !== 'undefined') {
const bc = new BroadcastChannel(PROJECT_EVENTS_CHANNEL)
bc.postMessage(payload)
bc.close()
}
} catch {
// ignore
}
try {
localStorage.setItem(PROJECT_DELETED_STORAGE_KEY, JSON.stringify(payload))
} catch {
// ignore
}
}
export const listenProjectDeleted = (onDeleted: (projectId: string) => void) => {
const localSessionId = getOrCreateSessionId()
let bc: BroadcastChannel | null = null
const handlePayload = (payload: ProjectDeletedPayload | null) => {
if (!payload) return
if (payload.sourceSessionId === localSessionId) return
onDeleted(payload.projectId)
}
const onStorage = (event: StorageEvent) => {
if (event.key !== PROJECT_DELETED_STORAGE_KEY) return
handlePayload(parseProjectDeletedPayload(event.newValue))
}
if (typeof BroadcastChannel !== 'undefined') {
bc = new BroadcastChannel(PROJECT_EVENTS_CHANNEL)
bc.onmessage = (event: MessageEvent<ProjectDeletedPayload>) => {
handlePayload(parseProjectDeletedPayload(JSON.stringify(event.data)))
}
}
window.addEventListener('storage', onStorage)
return () => {
window.removeEventListener('storage', onStorage)
if (bc) {
bc.close()
bc = null
}
}
}
export const emitResetAll = () => {
const payload: ResetAllPayload = {
type: 'reset-all',
sourceSessionId: getOrCreateSessionId(),
at: Date.now()
}
try {
if (typeof BroadcastChannel !== 'undefined') {
const bc = new BroadcastChannel(PROJECT_EVENTS_CHANNEL)
bc.postMessage(payload)
bc.close()
}
} catch {
// ignore
}
try {
localStorage.setItem(RESET_ALL_STORAGE_KEY, JSON.stringify(payload))
} catch {
// ignore
}
}
export const listenResetAll = (onResetAll: () => void) => {
const localSessionId = getOrCreateSessionId()
let bc: BroadcastChannel | null = null
const handlePayload = (payload: ResetAllPayload | null) => {
if (!payload) return
if (payload.sourceSessionId === localSessionId) return
onResetAll()
}
const onStorage = (event: StorageEvent) => {
if (event.key !== RESET_ALL_STORAGE_KEY) return
handlePayload(parseResetAllPayload(event.newValue))
}
if (typeof BroadcastChannel !== 'undefined') {
bc = new BroadcastChannel(PROJECT_EVENTS_CHANNEL)
bc.onmessage = (event: MessageEvent<ResetAllPayload>) => {
handlePayload(parseResetAllPayload(JSON.stringify(event.data)))
}
}
window.addEventListener('storage', onStorage)
return () => {
window.removeEventListener('storage', onStorage)
if (bc) {
bc.close()
bc = null
}
}
}

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/features/ht/contracts.ts","./src/features/ht/importexport.ts","./src/features/ht/types.ts","./src/features/tab/importexport.ts","./src/features/tab/types.ts","./src/i18n/dictionary-en.ts","./src/i18n/index.ts","./src/i18n/locales/en-us.ts","./src/i18n/locales/zh-cn.ts","./src/lib/aggridreadonlyautoheight.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectregistry.ts","./src/lib/projectsessionlock.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/uiprefs.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/features/ht/components/ht.vue","./src/features/ht/components/htadditionalworkfee.vue","./src/features/ht/components/htbaseinfo.vue","./src/features/ht/components/htconsultcategoryfactor.vue","./src/features/ht/components/htcontractsummary.vue","./src/features/ht/components/htfeeratemethodform.vue","./src/features/ht/components/htmajorfactor.vue","./src/features/ht/components/htreservefee.vue","./src/features/ht/components/htcard.vue","./src/features/ht/components/htinfo.vue","./src/features/ht/components/zxfw.vue","./src/features/pricing/components/hourlypricingpane.vue","./src/features/pricing/components/investmentscalepricingpane.vue","./src/features/pricing/components/landscalepricingpane.vue","./src/features/pricing/components/workloadpricingpane.vue","./src/features/shared/components/hourlyfeegrid.vue","./src/features/shared/components/htfeegrid.vue","./src/features/shared/components/htfeemethodgrid.vue","./src/features/shared/components/methodunavailablenotice.vue","./src/features/shared/components/servicecheckboxselector.vue","./src/features/shared/components/workcontentgrid.vue","./src/features/shared/components/xmfactorgrid.vue","./src/features/shared/components/xmcommonaggrid.vue","./src/features/workbench/components/homeentryview.vue","./src/features/workbench/components/htfeemethodtypelineview.vue","./src/features/workbench/components/quickcalcworkbenchview.vue","./src/features/workbench/components/zxfwview.vue","./src/features/xm/components/xmconsultcategoryfactor.vue","./src/features/xm/components/xmmajorfactor.vue","./src/features/xm/components/info.vue","./src/features/xm/components/xmcard.vue","./src/features/xm/components/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/features/ht/contracts.ts","./src/features/ht/importexport.ts","./src/features/ht/types.ts","./src/features/tab/importexport.ts","./src/features/tab/types.ts","./src/i18n/dictionary-en.ts","./src/i18n/index.ts","./src/i18n/locales/en-us.ts","./src/i18n/locales/zh-cn.ts","./src/lib/aggridreadonlyautoheight.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectevents.ts","./src/lib/projectregistry.ts","./src/lib/projectsessionlock.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/uiprefs.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/features/ht/components/ht.vue","./src/features/ht/components/htadditionalworkfee.vue","./src/features/ht/components/htbaseinfo.vue","./src/features/ht/components/htconsultcategoryfactor.vue","./src/features/ht/components/htcontractsummary.vue","./src/features/ht/components/htfeeratemethodform.vue","./src/features/ht/components/htmajorfactor.vue","./src/features/ht/components/htreservefee.vue","./src/features/ht/components/htcard.vue","./src/features/ht/components/htinfo.vue","./src/features/ht/components/zxfw.vue","./src/features/pricing/components/hourlypricingpane.vue","./src/features/pricing/components/investmentscalepricingpane.vue","./src/features/pricing/components/landscalepricingpane.vue","./src/features/pricing/components/workloadpricingpane.vue","./src/features/shared/components/hourlyfeegrid.vue","./src/features/shared/components/htfeegrid.vue","./src/features/shared/components/htfeemethodgrid.vue","./src/features/shared/components/methodunavailablenotice.vue","./src/features/shared/components/servicecheckboxselector.vue","./src/features/shared/components/workcontentgrid.vue","./src/features/shared/components/xmfactorgrid.vue","./src/features/shared/components/xmcommonaggrid.vue","./src/features/workbench/components/homeentryview.vue","./src/features/workbench/components/htfeemethodtypelineview.vue","./src/features/workbench/components/quickcalcworkbenchview.vue","./src/features/workbench/components/zxfwview.vue","./src/features/xm/components/xmconsultcategoryfactor.vue","./src/features/xm/components/xmmajorfactor.vue","./src/features/xm/components/info.vue","./src/features/xm/components/xmcard.vue","./src/features/xm/components/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}