1
This commit is contained in:
parent
de3585bde3
commit
35f06746fe
51
src/App.vue
51
src/App.vue
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,10 +424,20 @@ 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">
|
||||
<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>
|
||||
|
||||
<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"
|
||||
|
||||
@ -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}'
|
||||
|
||||
@ -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}'
|
||||
|
||||
@ -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
180
src/lib/projectEvents.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"}
|
||||
Loading…
x
Reference in New Issue
Block a user