From 1f941ca65f2794f59b3ad448612a3963dcd204bd Mon Sep 17 00:00:00 2001 From: wintsa <770775984@qq.com> Date: Wed, 25 Mar 2026 10:40:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 210 ++++++- src/features/ht/components/Ht.vue | 23 +- .../ht/components/HtContractSummary.vue | 36 +- .../ht/components/HtFeeRateMethodForm.vue | 7 +- src/features/ht/components/htCard.vue | 22 +- src/features/ht/components/zxFw.vue | 18 +- .../components/InvestmentScalePricingPane.vue | 76 ++- .../components/LandScalePricingPane.vue | 76 ++- .../components/WorkloadPricingPane.vue | 5 +- .../shared/components/HourlyFeeGrid.vue | 19 +- .../shared/components/HtFeeMethodGrid.vue | 58 +- .../shared/components/XmFactorGrid.vue | 103 +++- src/features/tab/importExport.ts | 2 + .../workbench/components/HomeEntryView.vue | 18 + .../components/QuickCalcWorkbenchView.vue | 8 +- src/layout/tab.vue | 523 +++++++++++++++--- src/layout/typeLine.vue | 8 +- src/lib/decimal.ts | 25 +- src/lib/number.ts | 13 +- src/lib/pricingHourlyCalc.ts | 3 +- src/lib/pricingMethodTotals.ts | 3 +- src/lib/pricingPersistControl.ts | 13 +- src/lib/pricingScaleCalc.ts | 2 +- src/lib/pricingScaleFee.ts | 3 +- src/lib/pricingWorkloadCalc.ts | 3 +- src/lib/projectRegistry.ts | 149 +++++ src/lib/projectSessionLock.ts | 159 ++++++ src/lib/reportExportBuilders.ts | 35 +- src/lib/workspace.ts | 57 ++ src/lib/xmFactorDefaults.ts | 2 +- src/lib/zxFwPricingSync.ts | 281 +++++++++- src/main.ts | 21 + src/pinia/Plugin/indexdb.ts | 23 +- src/pinia/zxFwPricing.ts | 207 ++++++- src/sql.ts | 7 +- tsconfig.tsbuildinfo | 2 +- 36 files changed, 1899 insertions(+), 321 deletions(-) create mode 100644 src/lib/projectRegistry.ts create mode 100644 src/lib/projectSessionLock.ts diff --git a/src/App.vue b/src/App.vue index cde3be2..05a0ea9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,12 +1,31 @@ diff --git a/src/features/ht/components/Ht.vue b/src/features/ht/components/Ht.vue index c6d5829..ba169f9 100644 --- a/src/features/ht/components/Ht.vue +++ b/src/features/ht/components/Ht.vue @@ -54,7 +54,7 @@ import { type ContractSegmentPackage } from '@/lib/contractSegment' import { industryTypeList } from '@/sql' -import { roundTo } from '@/lib/decimal' +import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal' import { formatThousands } from '@/lib/numberFormat' import { AlertDialogAction, @@ -228,12 +228,6 @@ const getCurrentProjectIndustry = async (): Promise => { return typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : '' } -const toFiniteNumber = (value: unknown): number | null => { - if (value == null || value === '') return null - const num = Number(value) - return Number.isFinite(num) ? num : null -} - const formatBudgetAmount = (value: number | null | undefined) => typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--' @@ -296,9 +290,8 @@ const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => sumHourlyMethodFee(hourlyState), sumQuantityMethodFee(quantityState) ] - const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) - if (validParts.length === 0) return null - return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2) + const total = sumNullableNumbers(parts) + return total == null ? null : roundTo(total, 2) } const loadHtMainTotalFee = async (mainStorageKey: string) => { @@ -309,9 +302,8 @@ const loadHtMainTotalFee = async (mainStorageKey: string) => { .filter(Boolean) if (rowIds.length === 0) return null const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId))) - const validTotals = rowTotals.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) - if (validTotals.length === 0) return null - return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2) + const total = sumNullableNumbers(rowTotals) + return total == null ? null : roundTo(total, 2) } const loadContractBudgetFee = async (contractId: string) => { @@ -322,9 +314,8 @@ const loadContractBudgetFee = async (contractId: string) => { loadHtMainTotalFee(`htExtraFee-${contractId}-reserve`) ]) const parts = [serviceFee, additionalFee, reserveFee] - const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item)) - if (validParts.length === 0) return null - return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2) + const total = sumNullableNumbers(parts) + return total == null ? null : roundTo(total, 2) } const refreshContractBudgets = async () => { diff --git a/src/features/ht/components/HtContractSummary.vue b/src/features/ht/components/HtContractSummary.vue index 0d04eb3..e9084a4 100644 --- a/src/features/ht/components/HtContractSummary.vue +++ b/src/features/ht/components/HtContractSummary.vue @@ -5,8 +5,7 @@ import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEve import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat' -import { toFiniteNumberOrNull } from '@/lib/number' -import { roundTo } from '@/lib/decimal' +import { roundTo, toFiniteNumberOrNull } from '@/lib/decimal' import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { additionalWorkList, reserveList } from '@/sql' @@ -58,9 +57,8 @@ const rowData = ref([]) const explanationText = ref('') let reloadTimer: ReturnType | null = null -const toFinite = (value: unknown): number | null => toFiniteNumberOrNull(value) const sum3 = (values: Array) => { - const valid = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v)) + const valid = values.filter((v): v is number => toFiniteNumberOrNull(v) != null) if (valid.length === 0) return null return roundTo(valid.reduce((a, b) => a + b, 0), 3) } @@ -71,15 +69,15 @@ const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null let total = 0 let hasValid = false for (const row of rows) { - const rowBudget = toFinite(row?.serviceBudget) + const rowBudget = toFiniteNumberOrNull(row?.serviceBudget) if (rowBudget != null) { total += rowBudget hasValid = true continue } - const adopted = toFinite(row?.adoptedBudgetUnitPrice) - const personnel = toFinite(row?.personnelCount) - const workday = toFinite(row?.workdayCount) + const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice) + const personnel = toFiniteNumberOrNull(row?.personnelCount) + const workday = toFiniteNumberOrNull(row?.workdayCount) if (adopted == null || personnel == null || workday == null) continue total += adopted * personnel * workday hasValid = true @@ -94,14 +92,14 @@ const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | n let hasValid = false for (const row of rows) { if (String(row?.id || '') === 'fee-subtotal-fixed') continue - const budget = toFinite(row?.budgetFee) + const budget = toFiniteNumberOrNull(row?.budgetFee) if (budget != null) { total += budget hasValid = true continue } - const quantity = toFinite(row?.quantity) - const unitPrice = toFinite(row?.unitPrice) + const quantity = toFiniteNumberOrNull(row?.quantity) + const unitPrice = toFiniteNumberOrNull(row?.unitPrice) if (quantity == null || unitPrice == null) continue total += quantity * unitPrice hasValid = true @@ -120,8 +118,8 @@ const loadHtMethodSummaryByRow = async (mainStorageKey: string, rowId: string): zxFwPricingStore.loadHtFeeMethodState(mainStorageKey, rowId, 'hourly-fee'), zxFwPricingStore.loadHtFeeMethodState(mainStorageKey, rowId, 'quantity-unit-price-fee') ]) - const rateFee = toFinite(rateState?.budgetFee) - const rateValue = toFinite(rateState?.rate) + const rateFee = toFiniteNumberOrNull(rateState?.budgetFee) + const rateValue = toFiniteNumberOrNull(rateState?.rate) const hourlyFee = sumHourlyMethodFee(hourlyState) const quantityFee = sumQuantityMethodFee(quantityState) const subtotal = sum3([rateFee, hourlyFee, quantityFee]) @@ -192,12 +190,12 @@ const buildServiceRows = (): SummaryRow[] => { rowType: 'service' as const, code: row.code || '', name: row.name || '', - investScale: toFinite(row.investScale), - landScale: toFinite(row.landScale), - workload: toFinite(row.workload), - hourly: toFinite(row.hourly), - subtotal: toFinite(row.subtotal), - finalFee: toFinite((row as { finalFee?: unknown }).finalFee) ?? toFinite(row.subtotal) + investScale: toFiniteNumberOrNull(row.investScale), + landScale: toFiniteNumberOrNull(row.landScale), + workload: toFiniteNumberOrNull(row.workload), + hourly: toFiniteNumberOrNull(row.hourly), + subtotal: toFiniteNumberOrNull(row.subtotal), + finalFee: toFiniteNumberOrNull((row as { finalFee?: unknown }).finalFee) ?? toFiniteNumberOrNull(row.subtotal) })) } diff --git a/src/features/ht/components/HtFeeRateMethodForm.vue b/src/features/ht/components/HtFeeRateMethodForm.vue index a501fbd..b378a89 100644 --- a/src/features/ht/components/HtFeeRateMethodForm.vue +++ b/src/features/ht/components/HtFeeRateMethodForm.vue @@ -1,5 +1,6 @@ diff --git a/src/features/tab/importExport.ts b/src/features/tab/importExport.ts index 267ecc1..e43a24a 100644 --- a/src/features/tab/importExport.ts +++ b/src/features/tab/importExport.ts @@ -14,6 +14,7 @@ export interface DataPackage { version: number packageType?: 'project-snapshot' exportedAt: string + projectId?: string localStorage: DataEntry[] sessionStorage: DataEntry[] localforageDefault: DataEntry[] @@ -125,5 +126,6 @@ export const isDataPackageLike = (value: unknown): value is DataPackage => { if (!hasRequiredArrays) return false if (typeof payload.version !== 'number' || !Number.isFinite(payload.version)) return false if (payload.packageType != null && payload.packageType !== 'project-snapshot') return false + if (payload.projectId != null && typeof payload.projectId !== 'string') return false return true } diff --git a/src/features/workbench/components/HomeEntryView.vue b/src/features/workbench/components/HomeEntryView.vue index 9a57770..395b91b 100644 --- a/src/features/workbench/components/HomeEntryView.vue +++ b/src/features/workbench/components/HomeEntryView.vue @@ -28,16 +28,21 @@ import { SelectViewport } from 'reka-ui' import { + DEFAULT_PROJECT_ID, + NEW_PROJECT_QUERY_KEY, PROJECT_TAB_ID, + QUICK_PROJECT_ID, QUICK_CONSULT_CATEGORY_FACTOR_KEY, QUICK_CONTRACT_FALLBACK_NAME, QUICK_CONTRACT_ID, QUICK_CONTRACT_META_KEY, QUICK_MAJOR_FACTOR_KEY, QUICK_PROJECT_INFO_KEY, + writeProjectIdToUrl, setPendingHomeImportFile, writeWorkspaceMode } from '@/lib/workspace' +import { upsertProject } from '@/lib/projectRegistry' interface QuickProjectInfoState { projectIndustry?: string @@ -91,6 +96,8 @@ const getTodayDateString = () => { } const enterProjectCalc = () => { + upsertProject(DEFAULT_PROJECT_ID, '默认项目') + writeProjectIdToUrl(DEFAULT_PROJECT_ID) writeWorkspaceMode('project') tabStore.enterWorkspace({ id: PROJECT_TAB_ID, @@ -162,6 +169,7 @@ const loadQuickDefaults = async () => { } const enterQuickCalc = (contractName: string) => { + writeProjectIdToUrl(QUICK_PROJECT_ID) writeWorkspaceMode('quick') tabStore.enterWorkspace({ id: `contract-${QUICK_CONTRACT_ID}`, @@ -231,6 +239,16 @@ const openHomeImport = () => { onMounted(() => { void loadProjectDefaults() void loadQuickDefaults() + try { + const url = new URL(window.location.href) + if (url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1') { + void openProjectCalc() + url.searchParams.delete(NEW_PROJECT_QUERY_KEY) + window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`) + } + } catch { + // ignore url parsing errors + } }) diff --git a/src/features/workbench/components/QuickCalcWorkbenchView.vue b/src/features/workbench/components/QuickCalcWorkbenchView.vue index b3dd46e..5784a59 100644 --- a/src/features/workbench/components/QuickCalcWorkbenchView.vue +++ b/src/features/workbench/components/QuickCalcWorkbenchView.vue @@ -7,6 +7,7 @@ import { getServiceDictItemById, industryTypeList } from '@/sql' +import { roundTo } from '@/lib/decimal' import { parseNumberOrNull } from '@/lib/number' import { useKvStore } from '@/pinia/kv' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' @@ -302,6 +303,9 @@ const applyScaleInput = (field: 'invest' | 'land') => { landScale.value = normalized } +const formatFactorValue = (value: number | null | undefined) => + value == null ? '--' : String(roundTo(value, 3)) + const applyWorkEnvFactorInput = () => { const next = parseNumberOrNull(workEnvFactor.value, { sanitize: true, precision: 3 }) workEnvFactor.value = next == null ? '' : String(next) @@ -647,14 +651,14 @@ watch(canUseLandScale, enabled => { diff --git a/src/layout/tab.vue b/src/layout/tab.vue index 4a24b5f..a286968 100644 --- a/src/layout/tab.vue +++ b/src/layout/tab.vue @@ -10,7 +10,7 @@ import { useKvStore } from '@/pinia/kv' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' -import { ChevronDown, CircleHelp, Loader2, RotateCcw, X } from 'lucide-vue-next' +import { Check, ChevronDown, CircleHelp, Loader2, X } from 'lucide-vue-next' import localforage from 'localforage' import { AlertDialogAction, @@ -21,12 +21,21 @@ import { AlertDialogPortal, AlertDialogRoot, AlertDialogTitle, - AlertDialogTrigger, ToastDescription, ToastProvider, ToastRoot, ToastTitle, - ToastViewport + ToastViewport, + SelectContent, + SelectIcon, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectPortal, + SelectRoot, + SelectTrigger, + SelectValue, + SelectViewport } from 'reka-ui' import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive' import { formatExportTimestamp } from '@/lib/contractSegment' @@ -36,10 +45,8 @@ import { normalizeEntries, normalizeForageStoreSnapshots, readForage, - readWebStorage, sanitizeFileNamePart, writeForage, - writeWebStorage, type DataPackage, type ForageInstance, type ForageStore @@ -86,13 +93,25 @@ import type { ZxFwStorageLike } from '@/features/tab/types' import { + buildProjectUrl, + getProjectDbName, + readCurrentProjectId, PROJECT_TAB_ID, QUICK_TAB_ID, consumePendingHomeImportFile, readWorkspaceMode, + writeProjectIdToUrl, writeWorkspaceMode } from '@/lib/workspace' -import { addNumbers, roundTo } from '@/lib/decimal' +import { + createProject, + deleteProject, + listProjects, + upsertProject, + type ProjectMeta +} from '@/lib/projectRegistry' +import { collectActiveProjectSessionLocks } from '@/lib/projectSessionLock' +import { addNumbers } from '@/lib/decimal' import { buildMethod0, buildMethod1, @@ -119,13 +138,17 @@ import { toMoney } from '@/lib/reportExportBuilders' import { exportFile } from '@/sql' +import { industryTypeList } from '@/sql' +import { initializeProjectFactorStates } from '@/lib/projectWorkspace' const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1' const PROJECT_INFO_DB_KEY = 'xm-base-info-v1' const LEGACY_PROJECT_DB_KEY = 'xm-info-v3' const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1' const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-v1' -const PINIA_PERSIST_DB_NAME = 'DB' +const DEFAULT_PROJECT_NAME = 'xxx 造价咨询服务' +const MAX_PROJECT_COUNT = 10 +const PINIA_PERSIST_DB_NAME = getProjectDbName(readCurrentProjectId()) const PINIA_PERSIST_BASE_STORE_NAME = 'pinia' const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'zxFwPricingKeys', 'zxFwPricingHtFee', 'kv'] as const const RESET_MIN_LOADING_MS = 1000 @@ -228,6 +251,15 @@ const tabContextRef = ref(null) const dataMenuOpen = ref(false) const dataMenuRef = ref(null) +const projectMenuOpen = ref(false) +const projectMenuRef = ref(null) +const newProjectDialogOpen = ref(false) +const newProjectIndustry = ref(String(industryTypeList[0]?.id || '')) +const newProjectSubmitting = ref(false) +const projectLimitDialogOpen = ref(false) +const projectList = ref([]) +const openedProjectIds = ref([]) +const currentProjectId = ref(readCurrentProjectId()) const resetConfirmOpen = ref(false) const isResetting = ref(false) const importFileRef = ref(null) @@ -301,6 +333,8 @@ const tabsModel = computed({ tabStore.tabs = value } }) +const activeTab = computed(() => tabStore.tabs.find((t: any) => t.id === tabStore.activeTabId) || null) +const activeTabId = computed(() => (activeTab.value ? String(activeTab.value.id || '') : '')) const contextTabIndex = computed(() => tabStore.tabs.findIndex((t:any) => t.id === contextTabId.value)) @@ -326,10 +360,209 @@ const canCloseRight = computed(() => { const canCloseOther = computed(() => tabStore.tabs.slice(1, contextTabIndex.value).length > 1 && contextTabIndex.value !== 0 ) +const projectCountText = computed(() => `${projectList.value.length}/${MAX_PROJECT_COUNT}`) const closeMenus = () => { tabContextOpen.value = false dataMenuOpen.value = false + projectMenuOpen.value = false +} + +const refreshProjectList = async () => { + const baseProjects = listProjects() + openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(baseProjects.map(item => item.id))) + const enriched = await Promise.all( + baseProjects.map(async (project) => { + try { + const kvStoreInstance = localforage.createInstance({ + name: getProjectDbName(project.id), + storeName: 'pinia-kv' + }) + const kvState = await kvStoreInstance.getItem('pinia-kv') + const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null + const projectInfo = entries?.[PROJECT_INFO_DB_KEY] + const projectName = + projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string' + ? projectInfo.projectName.trim() + : '' + return { + ...project, + name: projectName || project.name + } + } catch { + return project + } + }) + ) + projectList.value = enriched +} + +const isProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId) + +const openProjectInNewTab = (projectId: string) => { + if (isProjectOpen(projectId)) return + const href = buildProjectUrl(projectId) + window.open(href, '_blank', 'noopener') + projectMenuOpen.value = false +} + +const getTodayDateString = () => { + const now = new Date() + const year = String(now.getFullYear()) + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +const createProjectKvAdapter = (projectId: string) => { + const projectKvForage = localforage.createInstance({ + name: getProjectDbName(projectId), + storeName: 'pinia-kv' + }) + return { + setItem: async (keyRaw: string | number, value: T) => { + const key = String(keyRaw || '').trim() + if (!key) return + const currentState = await projectKvForage.getItem>('pinia-kv') + const nextEntries = { + ...( + currentState?.entries && typeof currentState.entries === 'object' + ? (currentState.entries as Record) + : {} + ), + [key]: JSON.parse(JSON.stringify(value)) + } + await projectKvForage.setItem('pinia-kv', { + ...(currentState && typeof currentState === 'object' ? currentState : {}), + entries: nextEntries, + ready: true + }) + } + } +} + +const openCreateProjectDialog = () => { + if (listProjects().length >= MAX_PROJECT_COUNT) { + projectMenuOpen.value = false + projectLimitDialogOpen.value = true + return + } + projectMenuOpen.value = false + newProjectIndustry.value = String(industryTypeList[0]?.id || '') + newProjectDialogOpen.value = true +} + +const closeCreateProjectDialog = () => { + if (newProjectSubmitting.value) return + newProjectDialogOpen.value = false +} + +const createProjectAndOpen = async () => { + if (newProjectSubmitting.value) return + if (listProjects().length >= MAX_PROJECT_COUNT) { + projectMenuOpen.value = false + newProjectDialogOpen.value = false + projectLimitDialogOpen.value = true + return + } + const industry = String(newProjectIndustry.value || '').trim() + if (!industry) return + newProjectSubmitting.value = true + try { + const project = createProject() + const kvAdapter = createProjectKvAdapter(project.id) + await kvAdapter.setItem(PROJECT_INFO_DB_KEY, { + projectIndustry: industry, + projectName: DEFAULT_PROJECT_NAME, + preparedBy: '', + reviewedBy: '', + preparedCompany: '', + preparedDate: getTodayDateString() + }) + await initializeProjectFactorStates( + kvAdapter, + industry, + CONSULT_CATEGORY_FACTOR_DB_KEY, + MAJOR_FACTOR_DB_KEY + ) + void refreshProjectList() + const href = buildProjectUrl(project.id) + window.open(href, '_blank', 'noopener') + newProjectDialogOpen.value = false + } finally { + newProjectSubmitting.value = false + } +} + +const handleCreateProjectDialogOpenChange = (open: boolean) => { + if (newProjectSubmitting.value) return + newProjectDialogOpen.value = open +} + +const handleCreateProjectConfirm = () => { + void createProjectAndOpen() + projectMenuOpen.value = false +} + +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 removeProjectItem = async (project: ProjectMeta) => { + const isCurrentProject = project.id === currentProjectId.value + const ok = window.confirm( + isCurrentProject + ? `确认删除当前项目「${project.name}」吗?将先清空该项目全部本地数据,并跳转到新项目选择页。` + : `确认删除项目「${project.name}」吗?这会移除该项目本地数据。` + ) + if (!ok) return + + const clearProjectPersistence = async () => { + await projectDefaultForage.clear() + await Promise.all( + getPiniaPersistStores().map(async ({ store }) => { + await store.clear() + }) + ) + const clearTasks: Promise[] = [] + if (tabStore.$clearPersisted) clearTasks.push(tabStore.$clearPersisted()) + if (zxFwPricingStore.$clearPersisted) clearTasks.push(zxFwPricingStore.$clearPersisted()) + if (zxFwPricingKeysStore.$clearPersisted) clearTasks.push(zxFwPricingKeysStore.$clearPersisted()) + if (zxFwPricingHtFeeStore.$clearPersisted) clearTasks.push(zxFwPricingHtFeeStore.$clearPersisted()) + if (kvStore.$clearPersisted) clearTasks.push(kvStore.$clearPersisted()) + await Promise.all(clearTasks) + } + + if (isCurrentProject) { + await clearProjectPersistence() + const removed = deleteProject(project.id) + if (!removed) return + const nextProject = createProject() + writeWorkspaceMode('project') + window.location.href = buildProjectUrl(nextProject.id, { newProject: true }) + return + } + + const removed = deleteProject(project.id) + if (!removed) return + try { + await new Promise((resolve) => { + const request = window.indexedDB?.deleteDatabase(getProjectDbName(project.id)) + if (!request) { + resolve() + return + } + request.onsuccess = () => resolve() + request.onerror = () => resolve() + request.onblocked = () => resolve() + }) + } catch (error) { + console.error('delete project database failed:', error) + } + await refreshProjectList() } const markGuideCompleted = () => { @@ -432,6 +665,9 @@ const handleGlobalMouseDown = (event: MouseEvent) => { if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) { dataMenuOpen.value = false } + if (projectMenuOpen.value && projectMenuRef.value && !projectMenuRef.value.contains(target)) { + projectMenuOpen.value = false + } } const handleGlobalKeyDown = (event: KeyboardEvent) => { @@ -631,6 +867,10 @@ const createForageStore = (storeName: string): ForageInstance => storeName }) +const projectDefaultForage = localforage.createInstance({ + name: PINIA_PERSIST_DB_NAME +}) + const getPiniaPersistStoreName = (storeId: string) => `${PINIA_PERSIST_BASE_STORE_NAME}-${storeId}` const getPiniaPersistStores = () => @@ -662,17 +902,24 @@ const createRichTextCode = (...parts: string[]): unknown => ({ }) const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => { - if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string }> - return rows - .map(row => { - const id = String(row?.id || '').trim() - if (!id) return null - return { - id, - name: typeof row?.name === 'string' ? row.name : '' - } - }) - .filter((item): item is { id: string; name: string } => Boolean(item)) + if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string; subtotal?: unknown }> + const normalized = rows.map(row => { + const id = String(row?.id || '').trim() + if (!id) return null + const rowAny = row as { subtotal?: unknown } + return { + id, + name: typeof row?.name === 'string' ? row.name : '', + subtotal: rowAny?.subtotal + } + }) + return normalized.filter(Boolean) as Array<{ id: string; name: string; subtotal?: unknown }> +} + +const getHtMainRowSubtotal = (row: { subtotal?: unknown } | null | undefined): number | null => { + const subtotal = toFiniteNumber(row?.subtotal) + if (subtotal == null) return null + return toMoney(subtotal) } const loadHtFeeMethodsByRow = async (mainStorageKey: string, rowId: string) => { @@ -719,19 +966,20 @@ const buildAdditionalExport = async (contractId: string): Promise { const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id) - if (!methodPayload) return null + const rowSubtotal = getHtMainRowSubtotal(row) + if (!methodPayload && rowSubtotal == null) return null const tasks = await buildAdditionalRowTasks(contractId, row.id) const item: ExportAdditionalDetail = { id: row.id, code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] }, name: row.name, - fee: methodPayload.fee, + fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0), tasks } - if (methodPayload.m0) item.m0 = methodPayload.m0 - if (methodPayload.m4) item.m4 = methodPayload.m4 - if (methodPayload.m5) item.m5 = methodPayload.m5 + if (methodPayload?.m0) item.m0 = methodPayload.m0 + if (methodPayload?.m4) item.m4 = methodPayload.m4 + if (methodPayload?.m5) item.m5 = methodPayload.m5 return item }) ) @@ -742,7 +990,7 @@ const buildAdditionalExport = async (contractId: string): Promise item.fee)), + fee: toMoney(sumNumbers(det.map(item => item.fee))), det } } @@ -755,15 +1003,16 @@ const buildReserveExport = async (contractId: string): Promise => { ).filter((item): item is ExportService => Boolean(item)) const fixedFinalFee = toFiniteNumber(fixedRow?.finalFee) - const serviceFinalFeeSum = sumNumbers(services.map(item => item.finalFee)) const fixedSubtotal = toFiniteNumber(fixedRow?.subtotal) - const serviceFeeSum = sumNumbers(services.map(item => item.fee)) - const fixedMethodSum = sumNumbers([ - toFiniteNumber(fixedRow?.investScale), - toFiniteNumber(fixedRow?.landScale), - toFiniteNumber(fixedRow?.workload), - toFiniteNumber(fixedRow?.hourly) - ]) - const serviceFee = - fixedFinalFee ?? - (services.length > 0 ? serviceFinalFeeSum : (fixedSubtotal ?? (serviceFeeSum !== 0 ? serviceFeeSum : fixedMethodSum))) + const serviceFeeRaw = fixedFinalFee ?? fixedSubtotal ?? 0 + const serviceFee = toMoney(serviceFeeRaw) const [addtional, reserve] = await Promise.all([ buildAdditionalExport(contractId), buildReserveExport(contractId) ]) - const addtionalFee = addtional ? addtional.fee : 0 - const reserveFee = reserve ? reserve.fee : 0 - const contractFee = roundTo(addNumbers(serviceFee, addtionalFee, reserveFee), 3) + const addtionalFee = toMoney(addtional ? addtional.fee : 0) + const reserveFee = toMoney(reserve ? reserve.fee : 0) + const contractFee = toMoney(addNumbers(serviceFee, addtionalFee, reserveFee)) const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows) contractScale.push({ majorid: -1, @@ -963,7 +1203,7 @@ const buildExportReportPayload = async (): Promise => { company, date, industry, - fee: sumNumbers(contracts.map(item => item.fee)), + fee: toMoney(sumNumbers(contracts.map(item => item.fee))), scaleCost: projectScaleCost, overview, desc, @@ -977,6 +1217,7 @@ const buildExportReportPayload = async (): Promise => { const exportData = async () => { try { const now = new Date() + const currentProjectId = readCurrentProjectId() const piniaForageStores = await Promise.all( getPiniaPersistStores().map(async ({ storeName, store }) => ({ storeName, @@ -984,12 +1225,13 @@ const exportData = async () => { })) ) const payload: DataPackage = { - version: 2, + version: 3, packageType: 'project-snapshot', exportedAt: now.toISOString(), - localStorage: readWebStorage(localStorage), - sessionStorage: readWebStorage(sessionStorage), - localforageDefault: await readForage(localforage), + projectId: currentProjectId, + localStorage: [], + sessionStorage: [], + localforageDefault: await readForage(projectDefaultForage), localforageStores: piniaForageStores } @@ -1046,6 +1288,14 @@ const prepareImportPayloadFromFile = async (file: File) => { if (!isDataPackageLike(payload)) { throw new Error('INVALID_DATA_PACKAGE') } + const currentProjectId = readCurrentProjectId() + const payloadProjectId = String(payload.projectId || '').trim() + if (!payloadProjectId) { + throw new Error('PROJECT_ID_MISSING') + } + if (payloadProjectId && payloadProjectId !== currentProjectId) { + throw new Error(`PROJECT_ID_MISMATCH:${payloadProjectId}:${currentProjectId}`) + } pendingImportPayload.value = payload pendingImportFileName.value = file.name importConfirmOpen.value = true @@ -1060,6 +1310,14 @@ const importData = async (event: Event) => { await prepareImportPayloadFromFile(file) } catch (error) { console.error('import failed:', error) + if (error instanceof Error && error.message === 'PROJECT_ID_MISSING') { + window.alert('导入失败:该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。') + return + } + if (error instanceof Error && error.message.startsWith('PROJECT_ID_MISMATCH:')) { + window.alert('导入失败:该数据包属于其他项目,不能覆盖当前项目。') + return + } window.alert('导入失败:文件无效、已损坏或被修改。') } finally { input.value = '' @@ -1076,9 +1334,7 @@ const confirmImportOverride = async () => { const payload = pendingImportPayload.value if (!payload) return try { - writeWebStorage(localStorage, normalizeEntries(payload.localStorage)) - writeWebStorage(sessionStorage, normalizeEntries(payload.sessionStorage)) - await writeForage(localforage, normalizeEntries(payload.localforageDefault)) + await writeForage(projectDefaultForage, normalizeEntries(payload.localforageDefault)) const piniaSnapshots = normalizeForageStoreSnapshots(payload.localforageStores) const snapshotMap = new Map(piniaSnapshots.map(item => [item.storeName, item.entries])) await Promise.all( @@ -1143,6 +1399,7 @@ const handleReset = async () => { if (isResetting.value) return const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const resetStartedAt = Date.now() + const allProjectIds = Array.from(new Set(['default', 'quick', ...listProjects().map(item => item.id)])) const deleteIndexedDBByName = (dbName: string) => new Promise((resolve) => { try { @@ -1163,20 +1420,18 @@ const handleReset = async () => { }) const purgeKnownIndexedDB = async () => { - await Promise.all([ - deleteIndexedDBByName('DB'), - deleteIndexedDBByName('localforage') - ]) + await Promise.all(allProjectIds.map(id => deleteIndexedDBByName(getProjectDbName(id)))) } try { isResetting.value = true dataMenuOpen.value = false + projectMenuOpen.value = false // 1) 仅清持久化层,不先改当前页面内存态,避免“先切首页再刷新”的二次闪烁。 localStorage.clear() sessionStorage.clear() - await localforage.clear() + await projectDefaultForage.clear() // 2) 清 pinia 分库持久化 await Promise.all( @@ -1196,6 +1451,7 @@ const handleReset = async () => { await purgeKnownIndexedDB() // 5) 需要保留的最小标记恢复 + writeProjectIdToUrl('default') writeWorkspaceMode('project') localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1') @@ -1219,6 +1475,9 @@ const handleResetConfirmOpenChange = (open: boolean) => { } onMounted(() => { + currentProjectId.value = readCurrentProjectId() + upsertProject(currentProjectId.value) + void refreshProjectList() window.addEventListener('mousedown', handleGlobalMouseDown) window.addEventListener('keydown', handleGlobalKeyDown) window.addEventListener('resize', scheduleUpdateTabTitleOverflow) @@ -1386,22 +1645,70 @@ watch( 使用引导 +
+ +
+
+
+ + +
+
+
+ 项目数量:{{ projectCountText }} + + +
+
+
+ - - - 确认重置 - 将清空所有项目数据,并恢复默认页面,确认继续吗? + 将清空全部项目数据,并恢复默认页面,确认继续吗?
@@ -1434,15 +1741,95 @@ watch( + + + + + + 新建项目 + + 选择工程行业后,将在新标签页直接打开新项目计算页面。 + +
+ + + + + + + + + + + + + {{ item.name }} + + + + + + + + +
+
+ + + + + + +
+
+
+
+ + + + + + 项目数量已达上限 + + 当前项目数量已达到 {{ MAX_PROJECT_COUNT }} 个,请先删除一个项目后再添加。 + +
+ + + +
+
+
+
-
- +
+
diff --git a/src/layout/typeLine.vue b/src/layout/typeLine.vue index 18b0509..6c1cfc3 100644 --- a/src/layout/typeLine.vue +++ b/src/layout/typeLine.vue @@ -14,6 +14,7 @@ import { } from 'reka-ui' import { useWindowSize } from '@vueuse/core' import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v' +import { readCurrentProjectId } from '@/lib/workspace' interface TypeLineCategory { key: string label: string @@ -45,6 +46,7 @@ const props = withDefaults( ) const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`) +const scopedCacheKey = computed(() => `project:${readCurrentProjectId()}:${cacheKey.value}`) const readStoredCategory = (key: string) => { const sessionValue = sessionStorage.getItem(key) @@ -60,7 +62,7 @@ const writeStoredCategory = (key: string, value: string) => { const resolveInitialCategory = () => { const defaultKey = props.defaultCategory || props.categories[0]?.key || '' if (!props.persistActiveCategory) return defaultKey - const savedKey = readStoredCategory(cacheKey.value) + const savedKey = readStoredCategory(scopedCacheKey.value) const validSavedKey = props.categories.some(item => item.key === savedKey) return validSavedKey ? (savedKey as string) : defaultKey } @@ -68,7 +70,7 @@ const resolveInitialCategory = () => { const activeCategory = ref(resolveInitialCategory()) watch( - () => [props.categories, props.defaultCategory, cacheKey.value], + () => [props.categories, props.defaultCategory, scopedCacheKey.value], () => { const isCurrentValid = props.categories.some(item => item.key === activeCategory.value) if (isCurrentValid) return @@ -82,7 +84,7 @@ watch( const switchCategory = (cat: string) => { activeCategory.value = cat if (!props.persistActiveCategory) return - writeStoredCategory(cacheKey.value, cat) + writeStoredCategory(scopedCacheKey.value, cat) } const activeComponent = computed(() => { diff --git a/src/lib/decimal.ts b/src/lib/decimal.ts index 423458c..d625b46 100644 --- a/src/lib/decimal.ts +++ b/src/lib/decimal.ts @@ -8,9 +8,26 @@ export const toDecimal = (value: DecimalInput) => new Decimal(value) export const roundTo = (value: DecimalInput, decimalPlaces = 2) => new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber() -const isFiniteNumber = (value: unknown): value is number => +export const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) +export const toFiniteNumberOrNull = (value: unknown): number | null => + isFiniteNumber(value) ? value : null + +export const toFiniteNumberOrZero = (value: unknown): number => + toFiniteNumberOrNull(value) ?? 0 + +export const toFiniteNumber = (value: unknown): number | null => { + if (isFiniteNumber(value)) return value + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return null + const numeric = Number(trimmed) + return Number.isFinite(numeric) ? numeric : null + } + return null +} + const sumFiniteValues = (values: Iterable) => { let total = new Decimal(0) for (const value of values) { @@ -32,6 +49,12 @@ export const sumByNumber = (list: T[], pick: (item: T) => MaybeNumber) => { return total.toNumber() } +export const sumNullableNumbers = (values: MaybeNumber[]): number | null => { + const validValues = values.filter(isFiniteNumber) + if (validValues.length === 0) return null + return addNumbers(...validValues) +} + export const decimalAggSum = (params: { values?: unknown[] }) => { const values = params.values || [] let hasFinite = false diff --git a/src/lib/number.ts b/src/lib/number.ts index 9da7bab..96594fb 100644 --- a/src/lib/number.ts +++ b/src/lib/number.ts @@ -1,10 +1,11 @@ -import { evaluateDecimalExpression, roundTo } from '@/lib/decimal' +import { + evaluateDecimalExpression, + isFiniteNumber, + roundTo, + toFiniteNumberOrNull +} from '@/lib/decimal' -export const isFiniteNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value) - -export const toFiniteNumberOrNull = (value: unknown): number | null => - isFiniteNumber(value) ? value : null +export { isFiniteNumber, toFiniteNumberOrNull } export const parseNumberOrNull = ( value: unknown, diff --git a/src/lib/pricingHourlyCalc.ts b/src/lib/pricingHourlyCalc.ts index 4c48d9a..77bf4c8 100644 --- a/src/lib/pricingHourlyCalc.ts +++ b/src/lib/pricingHourlyCalc.ts @@ -6,8 +6,7 @@ */ import { expertList } from '@/sql' -import { roundTo, toDecimal } from '@/lib/decimal' -import { toFiniteNumberOrNull } from '@/lib/number' +import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal' import type { HourlyDetailRow, ExpertLite } from '@/types/pricing' /* ---------------------------------------------------------------- diff --git a/src/lib/pricingMethodTotals.ts b/src/lib/pricingMethodTotals.ts index 17fcb11..108c7dd 100644 --- a/src/lib/pricingMethodTotals.ts +++ b/src/lib/pricingMethodTotals.ts @@ -5,8 +5,7 @@ import { getServiceDictById, taskList } from '@/sql' -import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal' -import { toFiniteNumberOrNull } from '@/lib/number' +import { roundTo, sumByNumber, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal' import { getScaleBudgetFee } from '@/lib/pricingScaleFee' import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail' import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing' diff --git a/src/lib/pricingPersistControl.ts b/src/lib/pricingPersistControl.ts index dc22b4b..eaf140e 100644 --- a/src/lib/pricingPersistControl.ts +++ b/src/lib/pricingPersistControl.ts @@ -5,15 +5,20 @@ * 用于控制计价法组件在清除/重建默认数据时的竞态。 */ +import { readCurrentProjectId } from '@/lib/workspace' + const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' +export const buildProjectScopedSessionKey = (prefix: string, dbKey: string) => + `${prefix}${readCurrentProjectId()}:${dbKey}` + /** * 判断当前是否应跳过持久化写入 * 用于防止组件卸载时覆盖刚被清除的数据 */ export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean => { - const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${dbKey}` + const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey) const raw = sessionStorage.getItem(storageKey) if (!raw) return false const now = Date.now() @@ -40,7 +45,7 @@ export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean * 读取后立即清除标记(一次性) */ export const shouldForceDefaultLoad = (dbKey: string): boolean => { - const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${dbKey}` + const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey) const raw = sessionStorage.getItem(storageKey) if (!raw) return false const forceUntil = Number(raw) @@ -54,7 +59,7 @@ export const shouldForceDefaultLoad = (dbKey: string): boolean => { * @param durationMs 有效时长(毫秒),默认 3000ms */ export const markSkipPersist = (dbKey: string, durationMs = 3000): void => { - const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${dbKey}` + const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, dbKey) const now = Date.now() sessionStorage.setItem(storageKey, `${now}:${now + durationMs}`) } @@ -65,6 +70,6 @@ export const markSkipPersist = (dbKey: string, durationMs = 3000): void => { * @param durationMs 有效时长(毫秒),默认 3000ms */ export const markForceDefaultLoad = (dbKey: string, durationMs = 3000): void => { - const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${dbKey}` + const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, dbKey) sessionStorage.setItem(storageKey, String(Date.now() + durationMs)) } diff --git a/src/lib/pricingScaleCalc.ts b/src/lib/pricingScaleCalc.ts index aba09b5..c9c8994 100644 --- a/src/lib/pricingScaleCalc.ts +++ b/src/lib/pricingScaleCalc.ts @@ -6,7 +6,7 @@ */ import { getMajorDictEntries, getMajorIdAliasMap, getServiceDictById } from '@/sql' -import { toFiniteNumberOrNull } from '@/lib/number' +import { toFiniteNumberOrNull } from '@/lib/decimal' import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee' import type { ScaleCalcRow, diff --git a/src/lib/pricingScaleFee.ts b/src/lib/pricingScaleFee.ts index b69906e..73c28e4 100644 --- a/src/lib/pricingScaleFee.ts +++ b/src/lib/pricingScaleFee.ts @@ -1,6 +1,5 @@ import { getBasicFeeFromScale } from '@/sql' -import { addNumbers, roundTo, toDecimal } from '@/lib/decimal' -import { toFiniteNumberOrNull } from '@/lib/number' +import { addNumbers, roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal' type ScaleMode = 'cost' | 'area' diff --git a/src/lib/pricingWorkloadCalc.ts b/src/lib/pricingWorkloadCalc.ts index 171542c..e821450 100644 --- a/src/lib/pricingWorkloadCalc.ts +++ b/src/lib/pricingWorkloadCalc.ts @@ -6,8 +6,7 @@ */ import { taskList } from '@/sql' -import { roundTo, toDecimal } from '@/lib/decimal' -import { toFiniteNumberOrNull } from '@/lib/number' +import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal' import { getDefaultConsultCategoryFactor } from '@/lib/pricingScaleCalc' import type { WorkloadCalcRow, TaskLite } from '@/types/pricing' diff --git a/src/lib/projectRegistry.ts b/src/lib/projectRegistry.ts new file mode 100644 index 0000000..509f6dd --- /dev/null +++ b/src/lib/projectRegistry.ts @@ -0,0 +1,149 @@ +import { QUICK_PROJECT_ID, normalizeProjectId } from '@/lib/workspace' + +const PROJECT_REGISTRY_KEY = 'jgjs-project-registry-v1' + +export interface ProjectMeta { + id: string + name: string + createdAt: string + updatedAt: string + lastOpenedAt: string +} + +type ProjectRegistryPayload = { + projects: ProjectMeta[] +} + +const nowIso = () => new Date().toISOString() +const lastEditedTouchAt = new Map() + +const defaultProjects = (): ProjectMeta[] => { + return [] +} + +const sanitizeProject = (item: Partial | null | undefined): ProjectMeta | null => { + if (!item || typeof item !== 'object') return null + const id = normalizeProjectId(item.id) + const name = String(item.name || '').trim() || (id === QUICK_PROJECT_ID ? '快速计算' : `项目-${id}`) + const createdAt = typeof item.createdAt === 'string' && item.createdAt ? item.createdAt : nowIso() + const updatedAt = typeof item.updatedAt === 'string' && item.updatedAt ? item.updatedAt : createdAt + const lastOpenedAt = + typeof item.lastOpenedAt === 'string' && item.lastOpenedAt ? item.lastOpenedAt : updatedAt + return { id, name, createdAt, updatedAt, lastOpenedAt } +} + +const readPayload = (): ProjectRegistryPayload => { + try { + const raw = localStorage.getItem(PROJECT_REGISTRY_KEY) + if (!raw) return { projects: defaultProjects() } + const parsed = JSON.parse(raw) as Partial + const projects = Array.isArray(parsed?.projects) + ? parsed.projects.map(item => sanitizeProject(item)).filter((item): item is ProjectMeta => Boolean(item)) + : [] + if (projects.length === 0) return { projects: defaultProjects() } + return { projects } + } catch { + return { projects: defaultProjects() } + } +} + +const writePayload = (payload: ProjectRegistryPayload) => { + localStorage.setItem(PROJECT_REGISTRY_KEY, JSON.stringify(payload)) +} + +export const listProjects = () => { + const payload = readPayload() + return payload.projects + .filter(item => item.id !== QUICK_PROJECT_ID) + .slice() + .sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()) +} + +export const upsertProject = ( + projectIdRaw: string, + nameRaw?: string, + options?: { + touchUpdatedAt?: boolean + touchLastOpenedAt?: boolean + } +) => { + const id = normalizeProjectId(projectIdRaw) + if (id === QUICK_PROJECT_ID) return + const name = String(nameRaw || '').trim() + const payload = readPayload() + const now = nowIso() + const touchUpdatedAt = options?.touchUpdatedAt === true + const touchLastOpenedAt = options?.touchLastOpenedAt !== false + const index = payload.projects.findIndex(item => item.id === id) + if (index < 0) { + payload.projects.push({ + id, + name: name || (id === QUICK_PROJECT_ID ? '快速计算' : `项目-${id}`), + createdAt: now, + updatedAt: now, + lastOpenedAt: now + }) + } else { + const current = payload.projects[index] + payload.projects[index] = { + ...current, + name: name || current.name, + updatedAt: touchUpdatedAt ? now : current.updatedAt, + lastOpenedAt: touchLastOpenedAt ? now : current.lastOpenedAt + } + } + writePayload(payload) +} + +export const touchProjectEdited = ( + projectIdRaw: string, + options?: { + throttleMs?: number + } +) => { + const id = normalizeProjectId(projectIdRaw) + if (id === QUICK_PROJECT_ID) return false + const throttleMs = Math.max(0, Number(options?.throttleMs ?? 5000)) + const nowMs = Date.now() + const lastTouch = lastEditedTouchAt.get(id) ?? 0 + if (throttleMs > 0 && nowMs - lastTouch < throttleMs) return false + + const payload = readPayload() + const index = payload.projects.findIndex(item => item.id === id) + if (index < 0) return false + const now = nowIso() + const current = payload.projects[index] + payload.projects[index] = { + ...current, + updatedAt: now, + lastOpenedAt: now + } + writePayload(payload) + lastEditedTouchAt.set(id, nowMs) + return true +} + +export const createProject = (nameRaw?: string) => { + const payload = readPayload() + const now = nowIso() + let id = '' + do { + id = normalizeProjectId(`p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`) + } while (payload.projects.some(item => item.id === id)) + const name = String(nameRaw || '').trim() || `项目-${payload.projects.length + 1}` + const project: ProjectMeta = { id, name, createdAt: now, updatedAt: now, lastOpenedAt: now } + payload.projects.push(project) + writePayload(payload) + return project +} + +export const deleteProject = (projectIdRaw: string) => { + const id = normalizeProjectId(projectIdRaw) + if (id === QUICK_PROJECT_ID) return false + const payload = readPayload() + const nextProjects = payload.projects.filter(item => item.id !== id) + if (nextProjects.length === payload.projects.length) return false + payload.projects = nextProjects + writePayload(payload) + return true +} diff --git a/src/lib/projectSessionLock.ts b/src/lib/projectSessionLock.ts new file mode 100644 index 0000000..4a76810 --- /dev/null +++ b/src/lib/projectSessionLock.ts @@ -0,0 +1,159 @@ +const LOCK_TTL_MS = 12_000 +const HEARTBEAT_MS = 4_000 +export const PROJECT_LOCK_KEY_PREFIX = 'jgjs-project-lock:' +const CHANNEL_NAME = 'jgjs-project-lock-channel' + +type LockPayload = { + sessionId: string + projectId: string + updatedAt: number +} + +const now = () => Date.now() +const lockKeyOf = (projectId: string) => `${PROJECT_LOCK_KEY_PREFIX}${projectId}` + +const parseLockPayload = (raw: string | null): LockPayload | null => { + if (!raw) return null + try { + const data = JSON.parse(raw) as Partial + if (!data || typeof data !== 'object') return null + if (typeof data.sessionId !== 'string' || typeof data.projectId !== 'string') return null + if (typeof data.updatedAt !== 'number' || !Number.isFinite(data.updatedAt)) return null + return { + sessionId: data.sessionId, + projectId: data.projectId, + updatedAt: data.updatedAt + } + } catch { + return null + } +} + +const isExpired = (payload: LockPayload) => now() - payload.updatedAt > LOCK_TTL_MS + +const randomSessionId = () => `${now()}-${Math.random().toString(36).slice(2, 10)}` + +type InitProjectLockParams = { + projectId: string + onConflict: (conflicted: boolean) => void +} + +export const initProjectSessionLock = (params: InitProjectLockParams) => { + const projectId = String(params.projectId || '').trim() + const onConflict = params.onConflict + const sessionId = randomSessionId() + const key = lockKeyOf(projectId) + let conflicted = false + let heartbeatTimer: ReturnType | null = null + let bc: BroadcastChannel | null = null + + const emitConflict = (next: boolean) => { + if (conflicted === next) return + conflicted = next + onConflict(next) + } + + const writeHeartbeat = () => { + if (conflicted) return + const payload: LockPayload = { sessionId, projectId, updatedAt: now() } + localStorage.setItem(key, JSON.stringify(payload)) + bc?.postMessage({ type: 'heartbeat', projectId, sessionId }) + } + + const clearOwnLock = () => { + const current = parseLockPayload(localStorage.getItem(key)) + if (current?.sessionId === sessionId) { + localStorage.removeItem(key) + } + bc?.postMessage({ type: 'release', projectId, sessionId }) + } + + const detectConflict = () => { + const current = parseLockPayload(localStorage.getItem(key)) + if (!current || isExpired(current)) { + emitConflict(false) + writeHeartbeat() + return + } + emitConflict(current.sessionId !== sessionId) + } + + const onStorage = (event: StorageEvent) => { + if (event.key !== key) return + detectConflict() + } + + const onBeforeUnload = () => { + clearOwnLock() + } + + try { + const existing = parseLockPayload(localStorage.getItem(key)) + if (existing && !isExpired(existing) && existing.sessionId !== sessionId) { + emitConflict(true) + } else { + writeHeartbeat() + } + } catch (error) { + console.error('init project session lock failed:', error) + } + + if (!conflicted) { + heartbeatTimer = setInterval(writeHeartbeat, HEARTBEAT_MS) + } + + if (typeof BroadcastChannel !== 'undefined') { + bc = new BroadcastChannel(CHANNEL_NAME) + bc.onmessage = (event: MessageEvent<{ type?: string; projectId?: string; sessionId?: string }>) => { + const payload = event.data + if (!payload || payload.projectId !== projectId) return + if (payload.sessionId === sessionId) return + if (payload.type === 'heartbeat') { + detectConflict() + } + } + } + + window.addEventListener('storage', onStorage) + window.addEventListener('beforeunload', onBeforeUnload) + + return { + get conflicted() { + return conflicted + }, + release: () => { + if (heartbeatTimer) { + clearInterval(heartbeatTimer) + heartbeatTimer = null + } + window.removeEventListener('storage', onStorage) + window.removeEventListener('beforeunload', onBeforeUnload) + clearOwnLock() + if (bc) { + bc.close() + bc = null + } + } + } +} + +export const hasActiveProjectSessionLock = (projectIdRaw: string) => { + const projectId = String(projectIdRaw || '').trim() + if (!projectId) return false + try { + const payload = parseLockPayload(localStorage.getItem(lockKeyOf(projectId))) + return Boolean(payload && !isExpired(payload)) + } catch { + return false + } +} + +export const collectActiveProjectSessionLocks = (projectIds: string[]) => { + const result = new Set() + for (const projectId of projectIds) { + if (hasActiveProjectSessionLock(projectId)) { + result.add(String(projectId || '').trim()) + } + } + return result +} diff --git a/src/lib/reportExportBuilders.ts b/src/lib/reportExportBuilders.ts index 0f160ac..311123c 100644 --- a/src/lib/reportExportBuilders.ts +++ b/src/lib/reportExportBuilders.ts @@ -1,6 +1,7 @@ import { serviceList } from '@/sql' -import { roundTo } from '@/lib/decimal' +import { roundTo, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal' import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee' +export { toFiniteNumber, toFiniteNumberOrZero } interface ScaleMethodRowLike { id: string @@ -168,21 +169,6 @@ interface ExportMethod5DetailLike { remark: string } -export const toFiniteNumber = (value: unknown): number | null => { - if (typeof value === 'number') { - return Number.isFinite(value) ? value : null - } - if (typeof value === 'string') { - const trimmed = value.trim() - if (!trimmed) return null - const num = Number(trimmed) - return Number.isFinite(num) ? num : null - } - return null -} - -export const toFiniteNumberOrZero = (value: unknown): number => toFiniteNumber(value) ?? 0 - export const toSafeInteger = (value: unknown): number | null => { const num = Number(value) if (!Number.isInteger(num)) return null @@ -579,17 +565,7 @@ export const buildServiceFee = ( method4: { fee: number } | null ) => { const subtotal = toFiniteNumber(row?.subtotal) - if (subtotal != null) return subtotal - - const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee]) - if (methodSum !== 0) return methodSum - - return sumNumbers([ - toFiniteNumber(row?.investScale), - toFiniteNumber(row?.landScale), - toFiniteNumber(row?.workload), - toFiniteNumber(row?.hourly) - ]) + return subtotal != null ? toMoney(subtotal) : 0 } export const buildMethod0 = (payload: RateMethodRowLike | null | undefined) => { @@ -644,8 +620,7 @@ export const buildServiceFinalFee = ( method4: { fee: number } | null ) => { const finalFee = toFiniteNumber(row?.finalFee) - if (finalFee != null) return finalFee + if (finalFee != null) return toMoney(finalFee) const subtotal = toFiniteNumber(row?.subtotal) - if (subtotal != null) return subtotal - return sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee]) + return subtotal != null ? toMoney(subtotal) : 0 } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts index 42d05b5..f83dd65 100644 --- a/src/lib/workspace.ts +++ b/src/lib/workspace.ts @@ -6,6 +6,11 @@ export const LEGACY_PROJECT_TAB_ID = 'ProjectCalcView' export const FIXED_WORKSPACE_TAB_IDS = [PROJECT_TAB_ID, QUICK_TAB_ID] as const export const WORKSPACE_MODE_STORAGE_KEY = 'jgjs-workspace-mode-v1' +export const PROJECT_ID_QUERY_KEY = 'projectId' +export const NEW_PROJECT_QUERY_KEY = 'newProject' +export const DEFAULT_PROJECT_ID = 'default' +export const QUICK_PROJECT_ID = 'quick' +export const PROJECT_DB_NAME_PREFIX = 'DB' export const QUICK_CONTRACT_ID = 'quick-contract-default' export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1' @@ -59,3 +64,55 @@ export const consumePendingHomeImportFile = () => { pendingHomeImportFile = null return file } + +export const normalizeProjectId = (value: unknown) => { + const raw = String(value || '').trim() + if (!raw) return DEFAULT_PROJECT_ID + return raw.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || DEFAULT_PROJECT_ID +} + +export const readCurrentProjectId = () => { + try { + const url = new URL(window.location.href) + return normalizeProjectId(url.searchParams.get(PROJECT_ID_QUERY_KEY)) + } catch { + return DEFAULT_PROJECT_ID + } +} + +export const writeProjectIdToUrl = (projectIdRaw: string) => { + try { + const projectId = normalizeProjectId(projectIdRaw) + const url = new URL(window.location.href) + url.searchParams.set(PROJECT_ID_QUERY_KEY, projectId) + window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`) + } catch (error) { + console.error('write project id to url failed:', error) + } +} + +export const ensureProjectIdInUrl = () => { + const id = readCurrentProjectId() + writeProjectIdToUrl(id) + return id +} + +export const buildProjectUrl = (projectIdRaw: string, options?: { newProject?: boolean }) => { + try { + const url = new URL(window.location.href) + url.searchParams.set(PROJECT_ID_QUERY_KEY, normalizeProjectId(projectIdRaw)) + if (options?.newProject) { + url.searchParams.set(NEW_PROJECT_QUERY_KEY, '1') + } else { + url.searchParams.delete(NEW_PROJECT_QUERY_KEY) + } + return `${url.pathname}${url.search}${url.hash}` + } catch { + return `/?${PROJECT_ID_QUERY_KEY}=${encodeURIComponent(normalizeProjectId(projectIdRaw))}` + } +} + +export const getProjectDbName = (projectIdRaw: string) => { + const projectId = normalizeProjectId(projectIdRaw) + return `${PROJECT_DB_NAME_PREFIX}-${projectId}` +} diff --git a/src/lib/xmFactorDefaults.ts b/src/lib/xmFactorDefaults.ts index e840272..60b3369 100644 --- a/src/lib/xmFactorDefaults.ts +++ b/src/lib/xmFactorDefaults.ts @@ -1,5 +1,5 @@ import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql' -import { toFiniteNumberOrNull } from '@/lib/number' +import { toFiniteNumberOrNull } from '@/lib/decimal' import { useKvStore } from '@/pinia/kv' import { useZxFwPricingStore } from '@/pinia/zxFwPricing' diff --git a/src/lib/zxFwPricingSync.ts b/src/lib/zxFwPricingSync.ts index e0e6006..4a942e2 100644 --- a/src/lib/zxFwPricingSync.ts +++ b/src/lib/zxFwPricingSync.ts @@ -1,4 +1,4 @@ -import { roundTo, sumByNumber } from '@/lib/decimal' +import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals' import { isSameNullableNumber, @@ -13,6 +13,7 @@ import { parseScopedRowId, resolveScaleRowMajorDictId as resolveRowMajorDictId } from '@/lib/pricingScaleLink' +import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' import { useKvStore } from '@/pinia/kv' import { getServiceDictItemById } from '@/sql' import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing' @@ -59,6 +60,57 @@ const getScaleMethodTotalBudgetFee = (rows: ScaleDetailRow[]) => { return roundTo(sumByNumber(rows, row => row.budgetFee ?? null), 2) } +type WorkloadDetailRow = { + id: string + conversion?: number | null + workload?: number | null + budgetAdoptedUnitPrice?: number | null + consultCategoryFactor?: number | null + basicFee?: number | null + serviceFee?: number | null +} + +const normalizeServiceIdSet = (serviceIds?: Array) => + new Set((serviceIds || []).map(id => String(id || '').trim()).filter(Boolean)) + +const calcWorkloadBasicFee = (row: WorkloadDetailRow) => { + if (String(row.id || '').startsWith('task-none-')) return null + const price = row.budgetAdoptedUnitPrice + const conversion = row.conversion + const workload = row.workload + if ( + typeof price !== 'number' || + !Number.isFinite(price) || + typeof conversion !== 'number' || + !Number.isFinite(conversion) || + typeof workload !== 'number' || + !Number.isFinite(workload) + ) { + return null + } + return roundTo(toDecimal(price).mul(conversion).mul(workload), 2) +} + +const calcWorkloadServiceFee = (row: WorkloadDetailRow) => { + if (String(row.id || '').startsWith('task-none-')) return null + const factor = row.consultCategoryFactor + const basicFee = calcWorkloadBasicFee(row) + if ( + basicFee == null || + typeof factor !== 'number' || + !Number.isFinite(factor) + ) { + return null + } + return roundTo(toDecimal(basicFee).mul(factor), 2) +} + +const getWorkloadMethodTotalServiceFee = (rows: WorkloadDetailRow[]) => { + const hasValue = rows.some(row => typeof row.serviceFee === 'number' && Number.isFinite(row.serviceFee)) + if (!hasValue) return null + return roundTo(sumByNumber(rows, row => row.serviceFee ?? null), 2) +} + const matchesChangedScaleRow = (row: ScaleDetailRow, changedRowIds?: Set) => { if (!changedRowIds || changedRowIds.size === 0) return true const directRowId = String(row.id || '').trim() @@ -232,6 +284,233 @@ export const syncContractScaleToPricing = async ( } } +const syncScaleMethodFactors = async (params: { + contractId: string + serviceId: string + method: 'investScale' | 'landScale' + syncConsultFactor: boolean + consultFactor: number | null + majorChangedRowIds?: Set + majorFactorMap: Map +}) => { + const store = useZxFwPricingStore() + const methodState = await store.loadServicePricingMethodState( + params.contractId, + params.serviceId, + params.method + ) + if (!methodState?.detailRows?.length) return 0 + + let changed = false + let changedRowCount = 0 + const mode = params.method === 'investScale' ? 'cost' : 'area' + const nextRows = methodState.detailRows.map(rawRow => { + const row = { ...rawRow } + let rowChanged = false + + if (params.syncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, params.consultFactor)) { + row.consultCategoryFactor = params.consultFactor + rowChanged = true + } + + if (params.majorChangedRowIds?.size) { + const majorDictId = resolveRowMajorDictId(row) + if (params.majorChangedRowIds.has(majorDictId)) { + const nextMajorFactor = params.majorFactorMap.get(majorDictId) ?? null + if (!isSameNullableNumber(row.majorFactor, nextMajorFactor)) { + row.majorFactor = nextMajorFactor + rowChanged = true + } + } + } + + if (!rowChanged) return rawRow + const recomputed = recomputeScaleDetailRow(row, mode) + if (isSameScaleDetailRow(rawRow, recomputed, mode)) return rawRow + changed = true + changedRowCount += 1 + return recomputed + }) + + if (!changed) return 0 + + store.setServicePricingMethodState( + params.contractId, + params.serviceId, + params.method, + { + detailRows: nextRows, + projectCount: methodState.projectCount ?? null + }, + { force: true } + ) + + await syncPricingTotalToZxFw({ + contractId: params.contractId, + serviceId: params.serviceId, + field: params.method, + value: getScaleMethodTotalBudgetFee(nextRows) + }) + return changedRowCount +} + +const syncWorkloadMethodConsultFactor = async (params: { + contractId: string + serviceId: string + consultFactor: number | null +}) => { + const store = useZxFwPricingStore() + const methodState = await store.loadServicePricingMethodState( + params.contractId, + params.serviceId, + 'workload' + ) + if (!methodState?.detailRows?.length) return 0 + + let changed = false + let changedRowCount = 0 + const nextRows = methodState.detailRows.map(rawRow => { + const row = { ...rawRow } + let rowChanged = false + + if (!isSameNullableNumber(row.consultCategoryFactor, params.consultFactor)) { + row.consultCategoryFactor = params.consultFactor + rowChanged = true + } + + const nextBasicFee = calcWorkloadBasicFee(row) + const nextServiceFee = calcWorkloadServiceFee(row) + if (!isSameNullableNumber(row.basicFee, nextBasicFee)) { + row.basicFee = nextBasicFee + rowChanged = true + } + if (!isSameNullableNumber(row.serviceFee, nextServiceFee)) { + row.serviceFee = nextServiceFee + rowChanged = true + } + + if (!rowChanged) return rawRow + changed = true + changedRowCount += 1 + return row + }) + + if (!changed) return 0 + + store.setServicePricingMethodState( + params.contractId, + params.serviceId, + 'workload', + { + detailRows: nextRows, + projectCount: methodState.projectCount ?? null + }, + { force: true } + ) + + await syncPricingTotalToZxFw({ + contractId: params.contractId, + serviceId: params.serviceId, + field: 'workload', + value: getWorkloadMethodTotalServiceFee(nextRows) + }) + return changedRowCount +} + +export interface ContractFactorSyncResult { + updatedServiceCount: number + updatedMethodCount: number + updatedRowCount: number +} + +export const syncContractFactorsToPricing = async ( + contractId: string, + options?: { + consultChangedServiceIds?: string[] + majorChangedRowIds?: string[] + } +): Promise => { + const store = useZxFwPricingStore() + await store.loadContract(contractId) + const currentState = store.getContractState(contractId) + const selectedIds = Array.from(new Set((currentState?.selectedIds || []).map(id => String(id || '').trim()).filter(Boolean))) + if (selectedIds.length === 0) { + return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 } + } + + const consultChangedServiceIdSet = normalizeServiceIdSet(options?.consultChangedServiceIds) + const majorChangedRowIdSet = normalizeChangedScaleRowIds(options?.majorChangedRowIds) + if (consultChangedServiceIdSet.size === 0 && majorChangedRowIdSet.size === 0) { + return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 } + } + + const [consultFactorMap, majorFactorMap] = await Promise.all([ + loadConsultCategoryFactorMap(`ht-consult-category-factor-v1-${contractId}`), + loadMajorFactorMap(`ht-major-factor-v1-${contractId}`) + ]) + + let updatedMethodCount = 0 + let updatedRowCount = 0 + const updatedServiceIdSet = new Set() + + for (const serviceId of selectedIds) { + const syncConsultFactor = consultChangedServiceIdSet.has(serviceId) + const syncMajorFactor = majorChangedRowIdSet.size > 0 + if (!syncConsultFactor && !syncMajorFactor) continue + + const consultFactor = consultFactorMap.get(serviceId) ?? null + + const investChangedCount = await syncScaleMethodFactors({ + contractId, + serviceId, + method: 'investScale', + syncConsultFactor, + consultFactor, + majorChangedRowIds: syncMajorFactor ? majorChangedRowIdSet : undefined, + majorFactorMap + }) + if (investChangedCount > 0) { + updatedServiceIdSet.add(serviceId) + updatedMethodCount += 1 + updatedRowCount += investChangedCount + } + + const landChangedCount = await syncScaleMethodFactors({ + contractId, + serviceId, + method: 'landScale', + syncConsultFactor, + consultFactor, + majorChangedRowIds: syncMajorFactor ? majorChangedRowIdSet : undefined, + majorFactorMap + }) + if (landChangedCount > 0) { + updatedServiceIdSet.add(serviceId) + updatedMethodCount += 1 + updatedRowCount += landChangedCount + } + + if (syncConsultFactor) { + const workloadChangedCount = await syncWorkloadMethodConsultFactor({ + contractId, + serviceId, + consultFactor + }) + if (workloadChangedCount > 0) { + updatedServiceIdSet.add(serviceId) + updatedMethodCount += 1 + updatedRowCount += workloadChangedCount + } + } + } + + return { + updatedServiceCount: updatedServiceIdSet.size, + updatedMethodCount, + updatedRowCount + } +} + export const syncPricingTotalToZxFw = async (params: { contractId: string serviceId: string | number diff --git a/src/main.ts b/src/main.ts index 4a7a284..c62766f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,8 @@ import piniaPersistedstate from '@/pinia/Plugin/indexdb' import { createApp } from 'vue' import App from './App.vue' import './style.css' +import { ensureProjectIdInUrl, getProjectDbName } from '@/lib/workspace' +import { listProjects } from '@/lib/projectRegistry' LicenseManager.setLicenseKey( '[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b' @@ -47,9 +49,28 @@ const AG_GRID_MODULES = [ LocaleModule,ValidationModule ,CellSpanModule ,RowStyleModule ,RowSelectionModule ,ServerSideRowModelApiModule ] +const pickBootstrapProjectId = () => { + try { + const url = new URL(window.location.href) + const explicit = String(url.searchParams.get('projectId') || '').trim() + if (explicit) return ensureProjectIdInUrl() + const projects = listProjects() + if (projects.length > 0) { + const lastEdited = projects[0] + url.searchParams.set('projectId', lastEdited.id) + window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`) + } + return ensureProjectIdInUrl() + } catch { + return ensureProjectIdInUrl() + } +} + const pinia = createPinia() +const currentProjectId = pickBootstrapProjectId() pinia.use( piniaPersistedstate({ + name: getProjectDbName(currentProjectId), storeName: 'pinia', mode: 'multiple' }) diff --git a/src/pinia/Plugin/indexdb.ts b/src/pinia/Plugin/indexdb.ts index 6f9ee02..abdc946 100644 --- a/src/pinia/Plugin/indexdb.ts +++ b/src/pinia/Plugin/indexdb.ts @@ -1,5 +1,7 @@ import type { PiniaPluginContext } from 'pinia' import localforage from 'localforage' +import { touchProjectEdited } from '@/lib/projectRegistry' +import { QUICK_PROJECT_ID, readCurrentProjectId } from '@/lib/workspace' export type PersistOption = boolean | { key?: string @@ -84,6 +86,7 @@ export default (config?: PiniaStorageConfig) => { if (!persistOptions) return const storeId = context.store.$id + const shouldTouchProjectEditedAt = storeId !== 'tabs' const baseStoreName = forageConfig.storeName || baseConfig.storeName || 'pinia-storage' const resolvedStoreName = mode === 'multiple' ? `${baseStoreName}-${storeId}` : baseStoreName const key = persistOptions.key || (mode === 'single' ? `${baseStoreName}-${storeId}` : resolvedStoreName) @@ -96,6 +99,12 @@ export default (config?: PiniaStorageConfig) => { const clonedState = JSON.parse(JSON.stringify(state)) return lf.setItem(key, trimStringValuesDeep(clonedState)) } + const markProjectEdited = () => { + if (!shouldTouchProjectEditedAt) return + const currentProjectId = readCurrentProjectId() + if (!currentProjectId || currentProjectId === QUICK_PROJECT_ID) return + touchProjectEdited(currentProjectId) + } const storeExt = context.store as typeof context.store & PersistStoreExt storeExt.$persistNow = async () => { @@ -105,6 +114,7 @@ export default (config?: PiniaStorageConfig) => { } try { await writeState(context.store.$state) + markProjectEdited() } catch (error) { console.error('pinia persist failed:', error) } @@ -124,12 +134,17 @@ export default (config?: PiniaStorageConfig) => { context.store.$subscribe( (_mutation, state) => { - if (!hydrating) userMutatedBeforeHydrate = true + if (hydrating) return + userMutatedBeforeHydrate = true if (timer) clearTimeout(timer) timer = setTimeout(() => { - void writeState(state).catch(error => { - console.error('pinia persist failed:', error) - }) + void writeState(state) + .then(() => { + markProjectEdited() + }) + .catch(error => { + console.error('pinia persist failed:', error) + }) }, Math.max(0, persistDebounce)) }, { detached: true } diff --git a/src/pinia/zxFwPricing.ts b/src/pinia/zxFwPricing.ts index dbe2368..d11bd78 100644 --- a/src/pinia/zxFwPricing.ts +++ b/src/pinia/zxFwPricing.ts @@ -1,7 +1,12 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { addNumbers } from '@/lib/decimal' -import { toFiniteNumberOrNull } from '@/lib/number' +import { + addNumbers, + roundTo, + sumNullableNumbers, + toFiniteNumberOrNull, + toFiniteNumberOrZero +} from '@/lib/decimal' import { waitForHydration } from '@/pinia/Plugin/indexdb' import { parseHtFeeMainStorageKey, @@ -68,17 +73,9 @@ const toKey = (contractId: string | number) => String(contractId || '').trim() const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim() const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) => `${METHOD_STORAGE_PREFIX_MAP[method]}-${contractId}-${serviceId}` -const round3 = (value: number) => Number(value.toFixed(3)) -const isFiniteNumberValue = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value) -const sumNullableNumbers = (values: Array): number | null => { - const validValues = values.filter(isFiniteNumberValue) - if (validValues.length === 0) return null - return addNumbers(...validValues) -} const round3Nullable = (value: number | null | undefined) => { const numeric = toFiniteNumberOrNull(value) - return numeric == null ? null : round3(numeric) + return numeric == null ? null : roundTo(numeric, 3) } const normalizeProcessValue = (value: unknown, rowId: string) => { if (rowId === FIXED_ROW_ID) return null @@ -89,6 +86,37 @@ const cloneAny = (value: T): T => { if (value == null) return value return JSON.parse(JSON.stringify(value)) as T } +interface HtFeeMainDetailRowLike { + id?: unknown + rateFee?: unknown + hourlyFee?: unknown + quantityUnitPriceFee?: unknown + subtotal?: unknown + [key: string]: unknown +} +interface HtRateMethodStateLike { + rate?: unknown + budgetFee?: unknown + [key: string]: unknown +} +interface HtHourlyMethodRowLike { + adoptedBudgetUnitPrice?: unknown + personnelCount?: unknown + workdayCount?: unknown + serviceBudget?: unknown +} +interface HtHourlyMethodStateLike { + detailRows?: HtHourlyMethodRowLike[] +} +interface HtQuantityMethodRowLike { + id?: unknown + budgetFee?: unknown + quantity?: unknown + unitPrice?: unknown +} +interface HtQuantityMethodStateLike { + detailRows?: HtQuantityMethodRowLike[] +} const normalizeRows = (rows: unknown): ZxFwDetailRow[] => (Array.isArray(rows) ? rows : []).map(item => { @@ -422,6 +450,157 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => { const loadHtFeeMethodState = htFeeStore.loadHtFeeMethodState const removeHtFeeMethodState = htFeeStore.removeHtFeeMethodState + const sumHourlyMethodFee = (state: HtHourlyMethodStateLike | null): number | null => { + const rows = Array.isArray(state?.detailRows) + ? state.detailRows.filter(row => toFiniteNumberOrNull(row?.serviceBudget) != null) + : [] + if (rows.length === 0) return null + + let total = 0 + let hasValid = false + for (const row of rows) { + const serviceBudget = toFiniteNumberOrNull(row?.serviceBudget) + if (serviceBudget != null) { + total += serviceBudget + hasValid = true + continue + } + const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice) + const personnel = toFiniteNumberOrNull(row?.personnelCount) + const workday = toFiniteNumberOrNull(row?.workdayCount) + if (adopted == null || personnel == null || workday == null) continue + total += adopted * personnel * workday + hasValid = true + } + return hasValid ? roundTo(total, 3) : null + } + + const sumQuantityMethodFee = (state: HtQuantityMethodStateLike | null): number | null => { + const rows = Array.isArray(state?.detailRows) + ? state.detailRows.filter(row => toFiniteNumberOrNull(row?.budgetFee) != null) + : [] + if (rows.length === 0) return null + + let total = 0 + let hasValid = false + for (const row of rows) { + if (String(row?.id || '') === 'fee-subtotal-fixed') continue + const budget = toFiniteNumberOrNull(row?.budgetFee) + if (budget != null) { + total += budget + hasValid = true + continue + } + const quantity = toFiniteNumberOrNull(row?.quantity) + const unitPrice = toFiniteNumberOrNull(row?.unitPrice) + if (quantity == null || unitPrice == null) continue + total += quantity * unitPrice + hasValid = true + } + return hasValid ? roundTo(total, 3) : null + } + + const getHtRowSubtotal = (row: HtFeeMainDetailRowLike | null | undefined): number | null => { + if (!row) return null + const values = [ + toFiniteNumberOrNull(row.rateFee), + toFiniteNumberOrNull(row.hourlyFee), + toFiniteNumberOrNull(row.quantityUnitPriceFee) + ] + if (!values.some(value => value != null)) return null + let sum = 0 + for (const value of values) { + sum += toFiniteNumberOrZero(value) + } + return roundTo(sum, 3) + } + + const sumMainStateSubtotal = (rows: HtFeeMainDetailRowLike[]): number | null => { + if (!Array.isArray(rows) || rows.length === 0) return null + const subtotalValues = rows.map(row => getHtRowSubtotal(row)).filter(value => value != null) as number[] + if (subtotalValues.length === 0) return null + return roundTo(subtotalValues.reduce((sum, value) => sum + value, 0), 3) + } + + const syncHtMainByBase = async ( + mainStorageKey: string, + baseValue: number | null + ): Promise => { + const mainState = await loadHtFeeMainState(mainStorageKey) + const sourceRows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : [] + if (sourceRows.length === 0) return null + + const nextRows = await Promise.all( + sourceRows.map(async sourceRow => { + const rowId = String(sourceRow?.id || '').trim() + if (!rowId) return sourceRow + + const [rateState, hourlyState, quantityState] = await Promise.all([ + loadHtFeeMethodState(mainStorageKey, rowId, 'rate-fee'), + loadHtFeeMethodState(mainStorageKey, rowId, 'hourly-fee'), + loadHtFeeMethodState(mainStorageKey, rowId, 'quantity-unit-price-fee') + ]) + + const rateValue = toFiniteNumberOrNull(rateState?.rate) + const nextRateFee = baseValue != null && rateValue != null ? roundTo(baseValue * rateValue / 100, 2) : null + if (rateState) { + const currentRateFee = toFiniteNumberOrNull(rateState.budgetFee) + if (currentRateFee !== nextRateFee) { + setHtFeeMethodState( + mainStorageKey, + rowId, + 'rate-fee', + { + ...rateState, + budgetFee: nextRateFee + }, + { force: true } + ) + } + } + + const nextHourlyFee = sumHourlyMethodFee(hourlyState) + const nextQuantityFee = sumQuantityMethodFee(quantityState) + const nextRow = { + ...sourceRow, + rateFee: nextRateFee, + hourlyFee: nextHourlyFee, + quantityUnitPriceFee: nextQuantityFee + } + return { + ...nextRow, + subtotal: getHtRowSubtotal(nextRow) + } + }) + ) + + const prevSnapshot = toKeySnapshot(sourceRows) + const nextSnapshot = toKeySnapshot(nextRows) + if (prevSnapshot !== nextSnapshot) { + setHtFeeMainState(mainStorageKey, { detailRows: nextRows }, { force: true }) + return sumMainStateSubtotal(nextRows) + } + return sumMainStateSubtotal(sourceRows) + } + + const syncHtExtraFeeByContractBase = async (contractId: string) => { + if (!contractId) return + try { + const serviceBase = getBaseSubtotal(contractId) + const additionalKey = `htExtraFee-${contractId}-additional-work` + const reserveKey = `htExtraFee-${contractId}-reserve` + const additionalTotal = await syncHtMainByBase(additionalKey, serviceBase) + + const hasReserveBase = serviceBase != null || additionalTotal != null + const reserveBase = hasReserveBase + ? roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalTotal), 3) + : null + await syncHtMainByBase(reserveKey, reserveBase) + } catch (error) { + console.error('syncHtExtraFeeByContractBase failed:', error) + } + } + const getKeyState = (keyRaw: string | number): T | null => { const key = toKey(keyRaw) if (!key) return null @@ -675,7 +854,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => { if (isSameState(current, nextState)) return false contracts.value[contractId] = nextState contractLoaded.value[contractId] = true - const targetRow = nextState.detailRows.find(row => String(row.id || '') === targetServiceId) + await syncHtExtraFeeByContractBase(contractId) return true } @@ -687,7 +866,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => { if (!state?.detailRows?.length) return null const fixedRow = state.detailRows.find(row => String(row.id || '') === FIXED_ROW_ID) const fixedFinalFee = toFiniteNumberOrNull(fixedRow?.finalFee) - if (fixedFinalFee != null) return round3(fixedFinalFee) + if (fixedFinalFee != null) return roundTo(fixedFinalFee, 3) let hasValid = false const sum = state.detailRows.reduce((acc, row) => { @@ -696,7 +875,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => { if (fee != null) hasValid = true return fee == null ? acc : acc + fee }, 0) - return hasValid ? round3(sum) : null + return hasValid ? roundTo(sum, 3) : null } // 清理合同维度的内存态、版本号以及该合同下所有相关持久化键。 diff --git a/src/sql.ts b/src/sql.ts index c08ce2f..d09985b 100644 --- a/src/sql.ts +++ b/src/sql.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import { addNumbers, roundTo, toDecimal } from '@/lib/decimal' +import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal' import { formatThousands } from '@/lib/numberFormat' import ExcelJS from "ExcelJS"; // 统一数字千分位格式化,默认保留 2 位小数。 @@ -7,10 +7,7 @@ const numberFormatter = (value: unknown, fractionDigits = 2) => formatThousands(value, fractionDigits) // 将任意输入安全转为有限数字;无效值统一按 0 处理。 -const toFiniteNumber = (value: unknown) => { - const num = Number(value) - return Number.isFinite(num) ? num : 0 -} +const toFiniteNumber = toFiniteNumberOrZero // 兼容导出 tasks 对象结构:[{ text: [] }, { serviceid, text: [] }] const normalizeTaskTexts = (tasks: unknown): string[] => { diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 635753e..cb781bc 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -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/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/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/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/ht/ht.vue","./src/components/ht/htadditionalworkfee.vue","./src/components/ht/htbaseinfo.vue","./src/components/ht/htconsultcategoryfactor.vue","./src/components/ht/htcontractsummary.vue","./src/components/ht/htfeeratemethodform.vue","./src/components/ht/htmajorfactor.vue","./src/components/ht/htreservefee.vue","./src/components/ht/htcard.vue","./src/components/ht/htinfo.vue","./src/components/ht/zxfw.vue","./src/components/pricing/hourlypricingpane.vue","./src/components/pricing/investmentscalepricingpane.vue","./src/components/pricing/landscalepricingpane.vue","./src/components/pricing/workloadpricingpane.vue","./src/components/shared/hourlyfeegrid.vue","./src/components/shared/htfeegrid.vue","./src/components/shared/htfeemethodgrid.vue","./src/components/shared/methodunavailablenotice.vue","./src/components/shared/servicecheckboxselector.vue","./src/components/shared/workcontentgrid.vue","./src/components/shared/xmfactorgrid.vue","./src/components/shared/xmcommonaggrid.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/components/views/homeentryview.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/quickcalcworkbenchview.vue","./src/components/views/zxfwview.vue","./src/components/xm/xmconsultcategoryfactor.vue","./src/components/xm/xmmajorfactor.vue","./src/components/xm/info.vue","./src/components/xm/xmcard.vue","./src/components/xm/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"} \ No newline at end of file +{"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/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/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"} \ No newline at end of file