优化跳转
This commit is contained in:
parent
cd9cffe588
commit
1f941ca65f
210
src/App.vue
210
src/App.vue
@ -1,12 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
|
import HomeEntryView from '@/features/workbench/components/HomeEntryView.vue'
|
||||||
import Tab from '@/layout/tab.vue'
|
import Tab from '@/layout/tab.vue'
|
||||||
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
||||||
|
import localforage from 'localforage'
|
||||||
|
import {
|
||||||
|
buildProjectUrl,
|
||||||
|
ensureProjectIdInUrl,
|
||||||
|
getProjectDbName,
|
||||||
|
NEW_PROJECT_QUERY_KEY,
|
||||||
|
PROJECT_TAB_ID,
|
||||||
|
QUICK_PROJECT_ID
|
||||||
|
} from '@/lib/workspace'
|
||||||
|
import { collectActiveProjectSessionLocks, initProjectSessionLock } from '@/lib/projectSessionLock'
|
||||||
|
import { createProject, listProjects, type ProjectMeta } from '@/lib/projectRegistry'
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
|
const lockConflict = ref(false)
|
||||||
|
const currentProjectId = ref('')
|
||||||
|
const currentProjectName = ref('')
|
||||||
|
const conflictProjectList = ref<ProjectMeta[]>([])
|
||||||
|
const openedProjectIds = ref<string[]>([])
|
||||||
|
const closeCountdown = ref(10)
|
||||||
|
let closeCountdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let releaseLock: (() => void) | null = null
|
||||||
|
|
||||||
const showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
|
const showHomeEntry = computed(() => !tabStore.hasCompletedSetup)
|
||||||
|
|
||||||
@ -14,17 +33,202 @@ const handleImportComplete = () => {
|
|||||||
tabStore.hasCompletedSetup = true
|
tabStore.hasCompletedSetup = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshConflictProjectList = () => {
|
||||||
|
void (async () => {
|
||||||
|
const projects = listProjects()
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
projects.map(async (project) => {
|
||||||
|
try {
|
||||||
|
const kvStoreInstance = localforage.createInstance({
|
||||||
|
name: getProjectDbName(project.id),
|
||||||
|
storeName: 'pinia-kv'
|
||||||
|
})
|
||||||
|
const kvState = await kvStoreInstance.getItem<any>('pinia-kv')
|
||||||
|
const entries = kvState?.entries && typeof kvState.entries === 'object' ? kvState.entries : null
|
||||||
|
const projectInfo = entries?.['xm-base-info-v1']
|
||||||
|
const projectName =
|
||||||
|
projectInfo && typeof projectInfo === 'object' && typeof projectInfo.projectName === 'string'
|
||||||
|
? projectInfo.projectName.trim()
|
||||||
|
: ''
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
name: projectName || project.name
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
conflictProjectList.value = enriched
|
||||||
|
const hit = enriched.find(item => item.id === currentProjectId.value)
|
||||||
|
currentProjectName.value = hit?.name || currentProjectId.value
|
||||||
|
openedProjectIds.value = Array.from(collectActiveProjectSessionLocks(enriched.map(item => item.id)))
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCloseCountdown = () => {
|
||||||
|
if (!closeCountdownTimer) return
|
||||||
|
clearInterval(closeCountdownTimer)
|
||||||
|
closeCountdownTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCloseCountdown = () => {
|
||||||
|
clearCloseCountdown()
|
||||||
|
closeCountdown.value = 10
|
||||||
|
closeCountdownTimer = setInterval(() => {
|
||||||
|
closeCountdown.value -= 1
|
||||||
|
if (closeCountdown.value <= 0) {
|
||||||
|
clearCloseCountdown()
|
||||||
|
try {
|
||||||
|
window.close()
|
||||||
|
} catch {
|
||||||
|
// 部分浏览器会阻止关闭,保留阻断页。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConflictProjectOpen = (projectId: string) => openedProjectIds.value.includes(projectId)
|
||||||
|
|
||||||
|
const openProjectInNewTab = (projectId: string, options?: { newProject?: boolean }) => {
|
||||||
|
if (isConflictProjectOpen(projectId)) return
|
||||||
|
const href = buildProjectUrl(projectId, options)
|
||||||
|
window.open(href, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProjectAndOpen = () => {
|
||||||
|
const project = createProject()
|
||||||
|
refreshConflictProjectList()
|
||||||
|
openProjectInNewTab(project.id, { newProject: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
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())}`
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
currentProjectId.value = ensureProjectIdInUrl()
|
||||||
|
refreshConflictProjectList()
|
||||||
|
let isNewProjectRequest = false
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
isNewProjectRequest = url.searchParams.get(NEW_PROJECT_QUERY_KEY) === '1'
|
||||||
|
} catch {
|
||||||
|
isNewProjectRequest = false
|
||||||
|
}
|
||||||
|
if (currentProjectId.value !== QUICK_PROJECT_ID) {
|
||||||
|
const lock = initProjectSessionLock({
|
||||||
|
projectId: currentProjectId.value,
|
||||||
|
onConflict: (next) => {
|
||||||
|
lockConflict.value = next
|
||||||
|
if (next) {
|
||||||
|
refreshConflictProjectList()
|
||||||
|
startCloseCountdown()
|
||||||
|
} else {
|
||||||
|
clearCloseCountdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
releaseLock = lock.release
|
||||||
|
} else {
|
||||||
|
lockConflict.value = false
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('home-import-selected', handleImportComplete)
|
window.addEventListener('home-import-selected', handleImportComplete)
|
||||||
waitForHydration('tabs').then(() => {
|
waitForHydration('tabs').then(() => {
|
||||||
|
if (!tabStore.hasCompletedSetup && !isNewProjectRequest) {
|
||||||
|
const hasProjects = listProjects().length > 0
|
||||||
|
if (hasProjects) {
|
||||||
|
if (Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
||||||
|
tabStore.hasCompletedSetup = true
|
||||||
|
} else {
|
||||||
|
tabStore.enterWorkspace({
|
||||||
|
id: PROJECT_TAB_ID,
|
||||||
|
title: '项目计算',
|
||||||
|
componentName: 'ProjectCalcView'
|
||||||
|
})
|
||||||
|
tabStore.hasCompletedSetup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tabStore.hasCompletedSetup && Array.isArray(tabStore.tabs) && tabStore.tabs.length > 0) {
|
||||||
|
const activeId = typeof tabStore.activeTabId === 'string' ? tabStore.activeTabId : ''
|
||||||
|
const hasActive = Boolean(activeId) && tabStore.tabs.some(tab => tab.id === activeId)
|
||||||
|
if (!hasActive) {
|
||||||
|
tabStore.activeTabId = tabStore.tabs[0]?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
isReady.value = true
|
isReady.value = true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearCloseCountdown()
|
||||||
|
window.removeEventListener('home-import-selected', handleImportComplete)
|
||||||
|
if (releaseLock) {
|
||||||
|
releaseLock()
|
||||||
|
releaseLock = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="isReady">
|
<template v-if="isReady">
|
||||||
<HomeEntryView v-if="showHomeEntry" />
|
<div
|
||||||
<Tab v-else />
|
v-if="lockConflict"
|
||||||
|
class="flex min-h-screen items-center justify-center bg-slate-50 px-4"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-lg rounded-xl border border-red-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">检测到项目重复打开</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-600">
|
||||||
|
项目「{{ currentProjectName }}」已在其他页面处于活跃状态。为避免 IndexedDB 数据冲突,本页面已阻断编辑。
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-slate-500">
|
||||||
|
本页将在 {{ closeCountdown }} 秒后自动尝试关闭。你也可以先在新标签页打开其他项目。
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 max-h-52 space-y-2 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-2">
|
||||||
|
<button
|
||||||
|
v-for="project in conflictProjectList"
|
||||||
|
:key="project.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded-md border border-transparent bg-white px-3 py-2 text-left text-sm transition"
|
||||||
|
:class="isConflictProjectOpen(project.id) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-200 hover:bg-slate-100'"
|
||||||
|
:disabled="isConflictProjectOpen(project.id)"
|
||||||
|
@click="openProjectInNewTab(project.id)"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-slate-700">
|
||||||
|
{{ project.name }}
|
||||||
|
<span v-if="isConflictProjectOpen(project.id)" class="ml-1 text-xs text-slate-500">(已打开)</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate-500">最后编辑:{{ formatProjectEditedTime(project.updatedAt) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-100"
|
||||||
|
@click="createProjectAndOpen"
|
||||||
|
>
|
||||||
|
新建项目并打开
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700"
|
||||||
|
:class="isConflictProjectOpen('default') ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-slate-100'"
|
||||||
|
:disabled="isConflictProjectOpen('default')"
|
||||||
|
@click="openProjectInNewTab('default')"
|
||||||
|
>
|
||||||
|
打开默认项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<HomeEntryView v-if="showHomeEntry" />
|
||||||
|
<Tab v-else />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -54,7 +54,7 @@ import {
|
|||||||
type ContractSegmentPackage
|
type ContractSegmentPackage
|
||||||
} from '@/lib/contractSegment'
|
} from '@/lib/contractSegment'
|
||||||
import { industryTypeList } from '@/sql'
|
import { industryTypeList } from '@/sql'
|
||||||
import { roundTo } from '@/lib/decimal'
|
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
|
||||||
import { formatThousands } from '@/lib/numberFormat'
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
import {
|
import {
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -228,12 +228,6 @@ const getCurrentProjectIndustry = async (): Promise<string> => {
|
|||||||
return typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
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) =>
|
const formatBudgetAmount = (value: number | null | undefined) =>
|
||||||
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
||||||
|
|
||||||
@ -296,9 +290,8 @@ const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) =>
|
|||||||
sumHourlyMethodFee(hourlyState),
|
sumHourlyMethodFee(hourlyState),
|
||||||
sumQuantityMethodFee(quantityState)
|
sumQuantityMethodFee(quantityState)
|
||||||
]
|
]
|
||||||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
const total = sumNullableNumbers(parts)
|
||||||
if (validParts.length === 0) return null
|
return total == null ? null : roundTo(total, 2)
|
||||||
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||||
@ -309,9 +302,8 @@ const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
if (rowIds.length === 0) return null
|
if (rowIds.length === 0) return null
|
||||||
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
|
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))
|
const total = sumNullableNumbers(rowTotals)
|
||||||
if (validTotals.length === 0) return null
|
return total == null ? null : roundTo(total, 2)
|
||||||
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadContractBudgetFee = async (contractId: string) => {
|
const loadContractBudgetFee = async (contractId: string) => {
|
||||||
@ -322,9 +314,8 @@ const loadContractBudgetFee = async (contractId: string) => {
|
|||||||
loadHtMainTotalFee(`htExtraFee-${contractId}-reserve`)
|
loadHtMainTotalFee(`htExtraFee-${contractId}-reserve`)
|
||||||
])
|
])
|
||||||
const parts = [serviceFee, additionalFee, reserveFee]
|
const parts = [serviceFee, additionalFee, reserveFee]
|
||||||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
const total = sumNullableNumbers(parts)
|
||||||
if (validParts.length === 0) return null
|
return total == null ? null : roundTo(total, 2)
|
||||||
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshContractBudgets = async () => {
|
const refreshContractBudgets = async () => {
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEve
|
|||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
import { roundTo, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||||
import { roundTo } from '@/lib/decimal'
|
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
import { additionalWorkList, reserveList } from '@/sql'
|
import { additionalWorkList, reserveList } from '@/sql'
|
||||||
|
|
||||||
@ -58,9 +57,8 @@ const rowData = ref<SummaryRow[]>([])
|
|||||||
const explanationText = ref('')
|
const explanationText = ref('')
|
||||||
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const toFinite = (value: unknown): number | null => toFiniteNumberOrNull(value)
|
|
||||||
const sum3 = (values: Array<number | null | undefined>) => {
|
const sum3 = (values: Array<number | null | undefined>) => {
|
||||||
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
|
if (valid.length === 0) return null
|
||||||
return roundTo(valid.reduce((a, b) => a + b, 0), 3)
|
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 total = 0
|
||||||
let hasValid = false
|
let hasValid = false
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const rowBudget = toFinite(row?.serviceBudget)
|
const rowBudget = toFiniteNumberOrNull(row?.serviceBudget)
|
||||||
if (rowBudget != null) {
|
if (rowBudget != null) {
|
||||||
total += rowBudget
|
total += rowBudget
|
||||||
hasValid = true
|
hasValid = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const adopted = toFinite(row?.adoptedBudgetUnitPrice)
|
const adopted = toFiniteNumberOrNull(row?.adoptedBudgetUnitPrice)
|
||||||
const personnel = toFinite(row?.personnelCount)
|
const personnel = toFiniteNumberOrNull(row?.personnelCount)
|
||||||
const workday = toFinite(row?.workdayCount)
|
const workday = toFiniteNumberOrNull(row?.workdayCount)
|
||||||
if (adopted == null || personnel == null || workday == null) continue
|
if (adopted == null || personnel == null || workday == null) continue
|
||||||
total += adopted * personnel * workday
|
total += adopted * personnel * workday
|
||||||
hasValid = true
|
hasValid = true
|
||||||
@ -94,14 +92,14 @@ const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | n
|
|||||||
let hasValid = false
|
let hasValid = false
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||||
const budget = toFinite(row?.budgetFee)
|
const budget = toFiniteNumberOrNull(row?.budgetFee)
|
||||||
if (budget != null) {
|
if (budget != null) {
|
||||||
total += budget
|
total += budget
|
||||||
hasValid = true
|
hasValid = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const quantity = toFinite(row?.quantity)
|
const quantity = toFiniteNumberOrNull(row?.quantity)
|
||||||
const unitPrice = toFinite(row?.unitPrice)
|
const unitPrice = toFiniteNumberOrNull(row?.unitPrice)
|
||||||
if (quantity == null || unitPrice == null) continue
|
if (quantity == null || unitPrice == null) continue
|
||||||
total += quantity * unitPrice
|
total += quantity * unitPrice
|
||||||
hasValid = true
|
hasValid = true
|
||||||
@ -120,8 +118,8 @@ const loadHtMethodSummaryByRow = async (mainStorageKey: string, rowId: string):
|
|||||||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||||||
])
|
])
|
||||||
const rateFee = toFinite(rateState?.budgetFee)
|
const rateFee = toFiniteNumberOrNull(rateState?.budgetFee)
|
||||||
const rateValue = toFinite(rateState?.rate)
|
const rateValue = toFiniteNumberOrNull(rateState?.rate)
|
||||||
const hourlyFee = sumHourlyMethodFee(hourlyState)
|
const hourlyFee = sumHourlyMethodFee(hourlyState)
|
||||||
const quantityFee = sumQuantityMethodFee(quantityState)
|
const quantityFee = sumQuantityMethodFee(quantityState)
|
||||||
const subtotal = sum3([rateFee, hourlyFee, quantityFee])
|
const subtotal = sum3([rateFee, hourlyFee, quantityFee])
|
||||||
@ -192,12 +190,12 @@ const buildServiceRows = (): SummaryRow[] => {
|
|||||||
rowType: 'service' as const,
|
rowType: 'service' as const,
|
||||||
code: row.code || '',
|
code: row.code || '',
|
||||||
name: row.name || '',
|
name: row.name || '',
|
||||||
investScale: toFinite(row.investScale),
|
investScale: toFiniteNumberOrNull(row.investScale),
|
||||||
landScale: toFinite(row.landScale),
|
landScale: toFiniteNumberOrNull(row.landScale),
|
||||||
workload: toFinite(row.workload),
|
workload: toFiniteNumberOrNull(row.workload),
|
||||||
hourly: toFinite(row.hourly),
|
hourly: toFiniteNumberOrNull(row.hourly),
|
||||||
subtotal: toFinite(row.subtotal),
|
subtotal: toFiniteNumberOrNull(row.subtotal),
|
||||||
finalFee: toFinite((row as { finalFee?: unknown }).finalFee) ?? toFinite(row.subtotal)
|
finalFee: toFiniteNumberOrNull((row as { finalFee?: unknown }).finalFee) ?? toFiniteNumberOrNull(row.subtotal)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { roundTo } from '@/lib/decimal'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
@ -54,7 +55,7 @@ const baseLabel = computed(() =>
|
|||||||
|
|
||||||
const budgetFee = computed<number | null>(() => {
|
const budgetFee = computed<number | null>(() => {
|
||||||
if (baseValue.value == null || rate.value == null) return null
|
if (baseValue.value == null || rate.value == null) return null
|
||||||
return Number((baseValue.value * rate.value/100).toFixed(3))
|
return roundTo(baseValue.value * rate.value / 100, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatAmount = (value: number | null) =>
|
const formatAmount = (value: number | null) =>
|
||||||
@ -67,7 +68,7 @@ const ensureContractLoaded = async () => {
|
|||||||
await zxFwPricingStore.loadContract(contractId)
|
await zxFwPricingStore.loadContract(contractId)
|
||||||
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
||||||
if (!isReserveFee.value) {
|
if (!isReserveFee.value) {
|
||||||
baseValue.value = serviceBase == null ? null : Number(serviceBase.toFixed(3))
|
baseValue.value = serviceBase == null ? null : roundTo(serviceBase, 3)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||||
@ -87,7 +88,7 @@ const ensureContractLoaded = async () => {
|
|||||||
}, 0)
|
}, 0)
|
||||||
const serviceBaseSafe = typeof serviceBase === 'number' && Number.isFinite(serviceBase) ? serviceBase : 0
|
const serviceBaseSafe = typeof serviceBase === 'number' && Number.isFinite(serviceBase) ? serviceBase : 0
|
||||||
const hasAny = (serviceBase != null) || additionalTotal !== 0
|
const hasAny = (serviceBase != null) || additionalTotal !== 0
|
||||||
baseValue.value = hasAny ? Number((serviceBaseSafe + additionalTotal).toFixed(3)) : null
|
baseValue.value = hasAny ? roundTo(serviceBaseSafe + additionalTotal, 3) : null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('load contract for rate base failed:', error)
|
console.error('load contract for rate base failed:', error)
|
||||||
baseValue.value = null
|
baseValue.value = null
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
|
import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
|
||||||
import TypeLine from '@/layout/typeLine.vue';
|
import TypeLine from '@/layout/typeLine.vue';
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
import { roundTo } from '@/lib/decimal'
|
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
|
||||||
import { formatThousands } from '@/lib/numberFormat'
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
|
|
||||||
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
|
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
|
||||||
@ -64,12 +64,6 @@ const contractBudget = ref<number | null>(null)
|
|||||||
const typeLineStorageKey = computed(() => `project-active-cat-${props.contractId}`)
|
const typeLineStorageKey = computed(() => `project-active-cat-${props.contractId}`)
|
||||||
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
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) =>
|
const formatBudgetAmount = (value: number | null | undefined) =>
|
||||||
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
||||||
|
|
||||||
@ -131,9 +125,8 @@ const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) =>
|
|||||||
sumHourlyMethodFee(hourlyState),
|
sumHourlyMethodFee(hourlyState),
|
||||||
sumQuantityMethodFee(quantityState)
|
sumQuantityMethodFee(quantityState)
|
||||||
]
|
]
|
||||||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
const total = sumNullableNumbers(parts)
|
||||||
if (validParts.length === 0) return null
|
return total == null ? null : roundTo(total, 2)
|
||||||
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||||
@ -142,9 +135,8 @@ const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
|||||||
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
|
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
|
||||||
if (rowIds.length === 0) return null
|
if (rowIds.length === 0) return null
|
||||||
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
|
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))
|
const total = sumNullableNumbers(rowTotals)
|
||||||
if (validTotals.length === 0) return null
|
return total == null ? null : roundTo(total, 2)
|
||||||
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshContractBudget = async () => {
|
const refreshContractBudget = async () => {
|
||||||
@ -155,8 +147,8 @@ const refreshContractBudget = async () => {
|
|||||||
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
|
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
|
||||||
])
|
])
|
||||||
const parts = [serviceFee, additionalFee, reserveFee]
|
const parts = [serviceFee, additionalFee, reserveFee]
|
||||||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
const total = sumNullableNumbers(parts)
|
||||||
contractBudget.value = validParts.length === 0 ? null : roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
contractBudget.value = total == null ? null : roundTo(total, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const budgetRefreshSignature = computed(() => {
|
const budgetRefreshSignature = computed(() => {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams
|
|||||||
import type { FirstDataRenderedEvent } from 'ag-grid-community'
|
import type { FirstDataRenderedEvent } from 'ag-grid-community'
|
||||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
import { addNumbers, roundTo } from '@/lib/decimal'
|
import { roundTo, sumNullableNumbers } from '@/lib/decimal'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import {
|
import {
|
||||||
@ -33,6 +33,7 @@ import { useTabStore } from '@/pinia/tab'
|
|||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
import ServiceCheckboxSelector from '@/features/shared/components/ServiceCheckboxSelector.vue'
|
import ServiceCheckboxSelector from '@/features/shared/components/ServiceCheckboxSelector.vue'
|
||||||
|
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||||
|
|
||||||
interface ServiceItem {
|
interface ServiceItem {
|
||||||
id: string
|
id: string
|
||||||
@ -308,17 +309,6 @@ const numericParser = (newValue: any): number | null => {
|
|||||||
return parseNumberOrNull(newValue, { precision: 3 })
|
return parseNumberOrNull(newValue, { precision: 3 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 类型守卫:有限数字。 */
|
|
||||||
const isFiniteNumberValue = (value: unknown): value is number =>
|
|
||||||
typeof value === 'number' && Number.isFinite(value)
|
|
||||||
|
|
||||||
/** 可空数字求和:全为空返回 null。 */
|
|
||||||
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
|
|
||||||
const validValues = values.filter(isFiniteNumberValue)
|
|
||||||
if (validValues.length === 0) return null
|
|
||||||
return addNumbers(...validValues)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSameNullableNumber = (a: number | null | undefined, b: number | null | undefined, precision = 3) => {
|
const isSameNullableNumber = (a: number | null | undefined, b: number | null | undefined, precision = 3) => {
|
||||||
if (a == null && b == null) return true
|
if (a == null && b == null) return true
|
||||||
if (a == null || b == null) return false
|
if (a == null || b == null) return false
|
||||||
@ -431,8 +421,8 @@ const clearPricingPaneValues = async (serviceId: string) => {
|
|||||||
const skipUntil = clearIssuedAt + PRICING_CLEAR_SKIP_TTL_MS
|
const skipUntil = clearIssuedAt + PRICING_CLEAR_SKIP_TTL_MS
|
||||||
const skipToken = `${clearIssuedAt}:${skipUntil}`
|
const skipToken = `${clearIssuedAt}:${skipUntil}`
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
sessionStorage.setItem(`${PRICING_CLEAR_SKIP_PREFIX}${key}`, skipToken)
|
sessionStorage.setItem(buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, key), skipToken)
|
||||||
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
|
sessionStorage.setItem(buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, key), String(skipUntil))
|
||||||
}
|
}
|
||||||
zxFwPricingStore.removeAllServicePricingMethodStates(props.contractId, serviceId)
|
zxFwPricingStore.removeAllServicePricingMethodStates(props.contractId, serviceId)
|
||||||
// Reset后会立刻有逻辑读取IndexedDB计算默认值,这里强制同步删除持久层,避免读到旧数据。
|
// Reset后会立刻有逻辑读取IndexedDB计算默认值,这里强制同步删除持久层,避免读到旧数据。
|
||||||
|
|||||||
@ -54,6 +54,7 @@ import {
|
|||||||
normalizeScaleProjectCount
|
normalizeScaleProjectCount
|
||||||
} from '@/lib/pricingScaleProject'
|
} from '@/lib/pricingScaleProject'
|
||||||
import { mergeScaleRowsFromProjectMajorMap } from '@/lib/pricingScaleRowMap'
|
import { mergeScaleRowsFromProjectMajorMap } from '@/lib/pricingScaleRowMap'
|
||||||
|
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||||
import {
|
import {
|
||||||
buildContractScaleIdMap,
|
buildContractScaleIdMap,
|
||||||
buildContractScaleMap,
|
buildContractScaleMap,
|
||||||
@ -128,6 +129,11 @@ interface ContractScaleChangeState {
|
|||||||
updatedAt?: number
|
updatedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FactorChangeState {
|
||||||
|
changedRowIds?: string[]
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceLite {
|
interface ServiceLite {
|
||||||
mutiple?: boolean | null
|
mutiple?: boolean | null
|
||||||
onlyCostScale?: boolean | null
|
onlyCostScale?: boolean | null
|
||||||
@ -145,6 +151,8 @@ const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
|||||||
const HT_SCALE_CHANGE_KEY = computed(() => `ht-info-scale-change-v1-${props.contractId}`)
|
const HT_SCALE_CHANGE_KEY = computed(() => `ht-info-scale-change-v1-${props.contractId}`)
|
||||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||||
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
|
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
|
||||||
|
const HT_CONSULT_FACTOR_CHANGE_KEY = computed(() => `${HT_CONSULT_FACTOR_KEY.value}-change`)
|
||||||
|
const HT_MAJOR_FACTOR_CHANGE_KEY = computed(() => `${HT_MAJOR_FACTOR_KEY.value}-change`)
|
||||||
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||||
const activeIndustryCode = ref('')
|
const activeIndustryCode = ref('')
|
||||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||||
@ -154,6 +162,8 @@ const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
|||||||
let factorDefaultsLoaded = false
|
let factorDefaultsLoaded = false
|
||||||
const paneInstanceCreatedAt = Date.now()
|
const paneInstanceCreatedAt = Date.now()
|
||||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||||
|
const lastAppliedConsultFactorChangeAt = ref(0)
|
||||||
|
const lastAppliedMajorFactorChangeAt = ref(0)
|
||||||
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
|
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
|
||||||
const industryNameMap = new Map(
|
const industryNameMap = new Map(
|
||||||
industryTypeList.flatMap(item => [
|
industryTypeList.flatMap(item => [
|
||||||
@ -204,7 +214,7 @@ const ensureFactorDefaultsLoaded = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldSkipPersist = () => {
|
const shouldSkipPersist = () => {
|
||||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@ -227,7 +237,7 @@ const shouldSkipPersist = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldForceDefaultLoad = () => {
|
const shouldForceDefaultLoad = () => {
|
||||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const forceUntil = Number(raw)
|
const forceUntil = Number(raw)
|
||||||
@ -714,27 +724,47 @@ const saveToIndexedDB = async (options?: { skipComputedSync?: boolean }) => {
|
|||||||
|
|
||||||
const syncLinkedFieldsFromContractAndFactors = async () => {
|
const syncLinkedFieldsFromContractAndFactors = async () => {
|
||||||
if (detailRows.value.length === 0) return
|
if (detailRows.value.length === 0) return
|
||||||
|
const consultChangeState =
|
||||||
|
(zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||||
|
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||||
|
?? null) as FactorChangeState | null
|
||||||
|
const majorChangeState =
|
||||||
|
(zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||||
|
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||||
|
?? null) as FactorChangeState | null
|
||||||
|
|
||||||
|
const consultUpdatedAt = Number(consultChangeState?.updatedAt)
|
||||||
|
const majorUpdatedAt = Number(majorChangeState?.updatedAt)
|
||||||
|
const hasNewConsultChange = Number.isFinite(consultUpdatedAt) && consultUpdatedAt > lastAppliedConsultFactorChangeAt.value
|
||||||
|
const hasNewMajorChange = Number.isFinite(majorUpdatedAt) && majorUpdatedAt > lastAppliedMajorFactorChangeAt.value
|
||||||
|
|
||||||
|
const consultChangedRowIds = new Set(
|
||||||
|
(consultChangeState?.changedRowIds || []).map(id => String(id || '').trim()).filter(Boolean)
|
||||||
|
)
|
||||||
|
const majorChangedRowIdSet = normalizeChangedScaleRowIds(majorChangeState?.changedRowIds)
|
||||||
|
const shouldSyncConsultFactor = hasNewConsultChange && consultChangedRowIds.has(String(props.serviceId).trim())
|
||||||
|
const shouldSyncMajorFactor = hasNewMajorChange && majorChangedRowIdSet.size > 0
|
||||||
|
|
||||||
|
if (hasNewConsultChange) lastAppliedConsultFactorChangeAt.value = consultUpdatedAt
|
||||||
|
if (hasNewMajorChange) lastAppliedMajorFactorChangeAt.value = majorUpdatedAt
|
||||||
|
if (!shouldSyncConsultFactor && !shouldSyncMajorFactor) return
|
||||||
|
|
||||||
await loadFactorDefaults()
|
await loadFactorDefaults()
|
||||||
const consultFactor = getDefaultConsultCategoryFactor()
|
const consultFactor = getDefaultConsultCategoryFactor()
|
||||||
|
|
||||||
let changed = false
|
let changed = false
|
||||||
detailRows.value = detailRows.value.map(row => {
|
for (const row of detailRows.value) {
|
||||||
|
if (shouldSyncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, consultFactor)) {
|
||||||
|
row.consultCategoryFactor = consultFactor
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (!shouldSyncMajorFactor) continue
|
||||||
const majorDictId = resolveRowMajorDictId(row)
|
const majorDictId = resolveRowMajorDictId(row)
|
||||||
const nextConsultFactor = consultFactor
|
if (!majorChangedRowIdSet.has(majorDictId)) continue
|
||||||
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
|
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
|
||||||
if (
|
if (isSameNullableNumber(row.majorFactor, nextMajorFactor)) continue
|
||||||
isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)
|
row.majorFactor = nextMajorFactor
|
||||||
&& isSameNullableNumber(row.majorFactor, nextMajorFactor)
|
|
||||||
) {
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
changed = true
|
changed = true
|
||||||
return {
|
}
|
||||||
...row,
|
|
||||||
consultCategoryFactor: nextConsultFactor,
|
|
||||||
majorFactor: nextMajorFactor
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!changed) return
|
if (!changed) return
|
||||||
syncComputedValuesToDetailRows()
|
syncComputedValuesToDetailRows()
|
||||||
await saveToIndexedDB({ skipComputedSync: true })
|
await saveToIndexedDB({ skipComputedSync: true })
|
||||||
@ -777,13 +807,13 @@ const detailGridOptions: GridOptions<DetailRow> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const linkedSourceSignature = computed(() => JSON.stringify({
|
const linkedSourceSignature = computed(() => JSON.stringify({
|
||||||
consultFactor:
|
consultFactorChange:
|
||||||
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
|
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||||
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
|
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||||
?? null,
|
?? null,
|
||||||
majorFactor:
|
majorFactorChange:
|
||||||
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_KEY.value]
|
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||||
?? kvStore.entries[HT_MAJOR_FACTOR_KEY.value]
|
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||||
?? null
|
?? null
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,7 @@ import {
|
|||||||
normalizeScaleProjectCount
|
normalizeScaleProjectCount
|
||||||
} from '@/lib/pricingScaleProject'
|
} from '@/lib/pricingScaleProject'
|
||||||
import { mergeScaleRowsFromProjectMajorMap } from '@/lib/pricingScaleRowMap'
|
import { mergeScaleRowsFromProjectMajorMap } from '@/lib/pricingScaleRowMap'
|
||||||
|
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||||
import {
|
import {
|
||||||
buildContractScaleIdMap,
|
buildContractScaleIdMap,
|
||||||
buildContractScaleMap,
|
buildContractScaleMap,
|
||||||
@ -129,6 +130,11 @@ interface ContractScaleChangeState {
|
|||||||
updatedAt?: number
|
updatedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FactorChangeState {
|
||||||
|
changedRowIds?: string[]
|
||||||
|
updatedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceLite {
|
interface ServiceLite {
|
||||||
mutiple?: boolean | null
|
mutiple?: boolean | null
|
||||||
}
|
}
|
||||||
@ -145,6 +151,8 @@ const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
|||||||
const HT_SCALE_CHANGE_KEY = computed(() => `ht-info-scale-change-v1-${props.contractId}`)
|
const HT_SCALE_CHANGE_KEY = computed(() => `ht-info-scale-change-v1-${props.contractId}`)
|
||||||
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
|
||||||
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
|
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
|
||||||
|
const HT_CONSULT_FACTOR_CHANGE_KEY = computed(() => `${HT_CONSULT_FACTOR_KEY.value}-change`)
|
||||||
|
const HT_MAJOR_FACTOR_CHANGE_KEY = computed(() => `${HT_MAJOR_FACTOR_KEY.value}-change`)
|
||||||
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||||
const activeIndustryCode = ref('')
|
const activeIndustryCode = ref('')
|
||||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||||
@ -154,6 +162,8 @@ const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
|||||||
let factorDefaultsLoaded = false
|
let factorDefaultsLoaded = false
|
||||||
const paneInstanceCreatedAt = Date.now()
|
const paneInstanceCreatedAt = Date.now()
|
||||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||||
|
const lastAppliedConsultFactorChangeAt = ref(0)
|
||||||
|
const lastAppliedMajorFactorChangeAt = ref(0)
|
||||||
const industryNameMap = new Map(
|
const industryNameMap = new Map(
|
||||||
industryTypeList.flatMap(item => [
|
industryTypeList.flatMap(item => [
|
||||||
[String(item.id).trim(), item.name],
|
[String(item.id).trim(), item.name],
|
||||||
@ -216,7 +226,7 @@ const ensureFactorDefaultsLoaded = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldForceDefaultLoad = () => {
|
const shouldForceDefaultLoad = () => {
|
||||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const forceUntil = Number(raw)
|
const forceUntil = Number(raw)
|
||||||
@ -225,7 +235,7 @@ const shouldForceDefaultLoad = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldSkipPersist = () => {
|
const shouldSkipPersist = () => {
|
||||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@ -592,27 +602,47 @@ const saveToIndexedDB = async (options?: { skipComputedSync?: boolean }) => {
|
|||||||
|
|
||||||
const syncLinkedFieldsFromContractAndFactors = async () => {
|
const syncLinkedFieldsFromContractAndFactors = async () => {
|
||||||
if (detailRows.value.length === 0) return
|
if (detailRows.value.length === 0) return
|
||||||
|
const consultChangeState =
|
||||||
|
(zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||||
|
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||||
|
?? null) as FactorChangeState | null
|
||||||
|
const majorChangeState =
|
||||||
|
(zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||||
|
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||||
|
?? null) as FactorChangeState | null
|
||||||
|
|
||||||
|
const consultUpdatedAt = Number(consultChangeState?.updatedAt)
|
||||||
|
const majorUpdatedAt = Number(majorChangeState?.updatedAt)
|
||||||
|
const hasNewConsultChange = Number.isFinite(consultUpdatedAt) && consultUpdatedAt > lastAppliedConsultFactorChangeAt.value
|
||||||
|
const hasNewMajorChange = Number.isFinite(majorUpdatedAt) && majorUpdatedAt > lastAppliedMajorFactorChangeAt.value
|
||||||
|
|
||||||
|
const consultChangedRowIds = new Set(
|
||||||
|
(consultChangeState?.changedRowIds || []).map(id => String(id || '').trim()).filter(Boolean)
|
||||||
|
)
|
||||||
|
const majorChangedRowIdSet = normalizeChangedScaleRowIds(majorChangeState?.changedRowIds)
|
||||||
|
const shouldSyncConsultFactor = hasNewConsultChange && consultChangedRowIds.has(String(props.serviceId).trim())
|
||||||
|
const shouldSyncMajorFactor = hasNewMajorChange && majorChangedRowIdSet.size > 0
|
||||||
|
|
||||||
|
if (hasNewConsultChange) lastAppliedConsultFactorChangeAt.value = consultUpdatedAt
|
||||||
|
if (hasNewMajorChange) lastAppliedMajorFactorChangeAt.value = majorUpdatedAt
|
||||||
|
if (!shouldSyncConsultFactor && !shouldSyncMajorFactor) return
|
||||||
|
|
||||||
await loadFactorDefaults()
|
await loadFactorDefaults()
|
||||||
const consultFactor = getDefaultConsultCategoryFactor()
|
const consultFactor = getDefaultConsultCategoryFactor()
|
||||||
|
|
||||||
let changed = false
|
let changed = false
|
||||||
detailRows.value = detailRows.value.map(row => {
|
for (const row of detailRows.value) {
|
||||||
|
if (shouldSyncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, consultFactor)) {
|
||||||
|
row.consultCategoryFactor = consultFactor
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (!shouldSyncMajorFactor) continue
|
||||||
const majorDictId = resolveRowMajorDictId(row)
|
const majorDictId = resolveRowMajorDictId(row)
|
||||||
const nextConsultFactor = consultFactor
|
if (!majorChangedRowIdSet.has(majorDictId)) continue
|
||||||
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
|
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
|
||||||
if (
|
if (isSameNullableNumber(row.majorFactor, nextMajorFactor)) continue
|
||||||
isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)
|
row.majorFactor = nextMajorFactor
|
||||||
&& isSameNullableNumber(row.majorFactor, nextMajorFactor)
|
|
||||||
) {
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
changed = true
|
changed = true
|
||||||
return {
|
}
|
||||||
...row,
|
|
||||||
consultCategoryFactor: nextConsultFactor,
|
|
||||||
majorFactor: nextMajorFactor
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!changed) return
|
if (!changed) return
|
||||||
syncComputedValuesToDetailRows()
|
syncComputedValuesToDetailRows()
|
||||||
await saveToIndexedDB({ skipComputedSync: true })
|
await saveToIndexedDB({ skipComputedSync: true })
|
||||||
@ -650,13 +680,13 @@ const detailGridOptions: GridOptions<DetailRow> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const linkedSourceSignature = computed(() => JSON.stringify({
|
const linkedSourceSignature = computed(() => JSON.stringify({
|
||||||
consultFactor:
|
consultFactorChange:
|
||||||
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
|
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||||
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
|
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
|
||||||
?? null,
|
?? null,
|
||||||
majorFactor:
|
majorFactorChange:
|
||||||
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_KEY.value]
|
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||||
?? kvStore.entries[HT_MAJOR_FACTOR_KEY.value]
|
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
|
||||||
?? null
|
?? null
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { usePricingPaneLifecycle } from '@/lib/pricingScalePaneLifecycle'
|
|||||||
import { sumNullableBy } from '@/lib/pricingScaleCalc'
|
import { sumNullableBy } from '@/lib/pricingScaleCalc'
|
||||||
import { createPinnedTopRowData } from '@/lib/pricingPinnedRows'
|
import { createPinnedTopRowData } from '@/lib/pricingPinnedRows'
|
||||||
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
|
||||||
|
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
|
|
||||||
@ -66,7 +67,7 @@ const ensureFactorDefaultsLoaded = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldSkipPersist = () => {
|
const shouldSkipPersist = () => {
|
||||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, DB_KEY.value)
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@ -89,7 +90,7 @@ const shouldSkipPersist = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldForceDefaultLoad = () => {
|
const shouldForceDefaultLoad = () => {
|
||||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, DB_KEY.value)
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const forceUntil = Number(raw)
|
const forceUntil = Number(raw)
|
||||||
|
|||||||
@ -11,12 +11,13 @@ import type {
|
|||||||
} from 'ag-grid-community'
|
} from 'ag-grid-community'
|
||||||
import { expertList } from '@/sql'
|
import { expertList } from '@/sql'
|
||||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||||
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
import { decimalAggSum, roundTo, sumByNumber, sumNullableNumbers, toDecimal } from '@/lib/decimal'
|
||||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync'
|
import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync'
|
||||||
import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
|
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
|
||||||
|
|
||||||
interface DetailRow {
|
interface DetailRow {
|
||||||
id: string
|
id: string
|
||||||
@ -60,7 +61,7 @@ const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
|||||||
const paneInstanceCreatedAt = Date.now()
|
const paneInstanceCreatedAt = Date.now()
|
||||||
|
|
||||||
const shouldSkipPersist = () => {
|
const shouldSkipPersist = () => {
|
||||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}`
|
const storageKey = buildProjectScopedSessionKey(PRICING_CLEAR_SKIP_PREFIX, props.storageKey)
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@ -83,7 +84,7 @@ const shouldSkipPersist = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shouldForceDefaultLoad = () => {
|
const shouldForceDefaultLoad = () => {
|
||||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${props.storageKey}`
|
const storageKey = buildProjectScopedSessionKey(PRICING_FORCE_DEFAULT_PREFIX, props.storageKey)
|
||||||
const raw = sessionStorage.getItem(storageKey)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const forceUntil = Number(raw)
|
const forceUntil = Number(raw)
|
||||||
@ -439,17 +440,7 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
|||||||
|
|
||||||
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
||||||
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
||||||
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
|
const totalServiceBudget = computed(() => sumNullableNumbers(detailRows.value.map(row => calcServiceBudget(row))))
|
||||||
let hasValid = false
|
|
||||||
const total = sumByNumber(rows, row => {
|
|
||||||
const value = Number(pick(row))
|
|
||||||
if (!Number.isFinite(value)) return null
|
|
||||||
hasValid = true
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
return hasValid ? total : null
|
|
||||||
}
|
|
||||||
const totalServiceBudget = computed(() => sumNullableBy(detailRows.value, row => calcServiceBudget(row)))
|
|
||||||
const pinnedTopRowData = computed(() => [
|
const pinnedTopRowData = computed(() => [
|
||||||
{
|
{
|
||||||
id: 'pinned-total-row',
|
id: 'pinned-total-row',
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { AgGridVue } from 'ag-grid-vue3'
|
|||||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||||
|
import { roundTo, sumNullableNumbers, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { Pencil, Eraser } from 'lucide-vue-next'
|
import { Pencil, Eraser } from 'lucide-vue-next'
|
||||||
@ -90,32 +91,17 @@ const createDefaultRow = (name = ''): FeeMethodRow => ({
|
|||||||
const SUMMARY_ROW_ID = 'fee-method-summary'
|
const SUMMARY_ROW_ID = 'fee-method-summary'
|
||||||
|
|
||||||
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
|
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
|
||||||
const toFinite = (value: number | null | undefined) =>
|
|
||||||
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
||||||
const round3 = (value: number) => Number(value.toFixed(3))
|
|
||||||
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
|
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
|
||||||
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
|
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
|
||||||
let hasValid = false
|
const total = sumNullableNumbers(rows.map(row => pick(row)))
|
||||||
let total = 0
|
return total == null ? null : roundTo(total, 3)
|
||||||
for (const row of rows) {
|
|
||||||
const value = pick(row)
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) continue
|
|
||||||
total += value
|
|
||||||
hasValid = true
|
|
||||||
}
|
|
||||||
return hasValid ? round3(total) : null
|
|
||||||
}
|
}
|
||||||
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
|
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
|
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
|
||||||
const hasValid = values.some(value => typeof value === 'number' && Number.isFinite(value))
|
const total = sumNullableNumbers(values)
|
||||||
if (!hasValid) return null
|
if (total == null) return null
|
||||||
return round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee))
|
return roundTo(total, 3)
|
||||||
}
|
|
||||||
const toFiniteUnknown = (value: unknown): number | null => {
|
|
||||||
if (value == null || value === '') return null
|
|
||||||
const numeric = Number(value)
|
|
||||||
return Number.isFinite(numeric) ? numeric : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
|
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
|
||||||
@ -141,12 +127,12 @@ const loadContractServiceFeeBase = async (): Promise<number | null> => {
|
|||||||
await zxFwPricingStore.loadContract(contractId)
|
await zxFwPricingStore.loadContract(contractId)
|
||||||
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
||||||
if (!isReserveStorageKey(props.storageKey)) {
|
if (!isReserveStorageKey(props.storageKey)) {
|
||||||
return serviceBase == null ? null : round3(serviceBase)
|
return serviceBase == null ? null : roundTo(serviceBase, 3)
|
||||||
}
|
}
|
||||||
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
|
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
|
||||||
const hasAnyBase = serviceBase != null || additionalFeeTotal != null
|
const hasAnyBase = serviceBase != null || additionalFeeTotal != null
|
||||||
if (!hasAnyBase) return null
|
if (!hasAnyBase) return null
|
||||||
return round3(toFinite(serviceBase) + toFinite(additionalFeeTotal))
|
return roundTo(toFiniteNumberOrZero(serviceBase) + toFiniteNumberOrZero(additionalFeeTotal), 3)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('loadContractServiceFeeBase failed:', error)
|
console.error('loadContractServiceFeeBase failed:', error)
|
||||||
return null
|
return null
|
||||||
@ -163,21 +149,21 @@ const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
|
|||||||
let hasValid = false
|
let hasValid = false
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
|
||||||
const rowBudget = toFiniteUnknown(row?.serviceBudget)
|
const rowBudget = toFiniteNumber(row?.serviceBudget)
|
||||||
if (rowBudget != null) {
|
if (rowBudget != null) {
|
||||||
total += rowBudget
|
total += rowBudget
|
||||||
hasValid = true
|
hasValid = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice)
|
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
||||||
const personnel = toFiniteUnknown(row?.personnelCount)
|
const personnel = toFiniteNumber(row?.personnelCount)
|
||||||
const workday = toFiniteUnknown(row?.workdayCount)
|
const workday = toFiniteNumber(row?.workdayCount)
|
||||||
|
|
||||||
if (adopted == null || personnel == null || workday == null) continue
|
if (adopted == null || personnel == null || workday == null) continue
|
||||||
total += adopted * personnel * workday
|
total += adopted * personnel * workday
|
||||||
hasValid = true
|
hasValid = true
|
||||||
}
|
}
|
||||||
return hasValid ? round3(total) : null
|
return hasValid ? roundTo(total, 3) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
|
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
|
||||||
@ -188,19 +174,19 @@ const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null
|
|||||||
let hasValid = false
|
let hasValid = false
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||||
const budget = toFiniteUnknown(row?.budgetFee)
|
const budget = toFiniteNumber(row?.budgetFee)
|
||||||
if (budget != null) {
|
if (budget != null) {
|
||||||
total += budget
|
total += budget
|
||||||
hasValid = true
|
hasValid = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const quantity = toFiniteUnknown(row?.quantity)
|
const quantity = toFiniteNumber(row?.quantity)
|
||||||
const unitPrice = toFiniteUnknown(row?.unitPrice)
|
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||||||
if (quantity == null || unitPrice == null) continue
|
if (quantity == null || unitPrice == null) continue
|
||||||
total += quantity * unitPrice
|
total += quantity * unitPrice
|
||||||
hasValid = true
|
hasValid = true
|
||||||
}
|
}
|
||||||
return hasValid ? round3(total) : null
|
return hasValid ? roundTo(total, 3) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
|
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
|
||||||
@ -215,13 +201,13 @@ const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMet
|
|||||||
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
|
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
|
||||||
])
|
])
|
||||||
|
|
||||||
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
|
const storedRateFee = toFiniteNumber(rateData?.budgetFee)
|
||||||
const rateValue = toFiniteUnknown(rateData?.rate)
|
const rateValue = toFiniteNumber(rateData?.rate)
|
||||||
const rateFee =
|
const rateFee =
|
||||||
contractBase != null && rateValue != null
|
contractBase != null && rateValue != null
|
||||||
? round3(contractBase * rateValue / 100)
|
? roundTo(contractBase * rateValue / 100, 2)
|
||||||
: storedRateFee != null
|
: storedRateFee != null
|
||||||
? round3(storedRateFee)
|
? roundTo(storedRateFee, 2)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const hourlyFee = sumHourlyMethodFee(hourlyData)
|
const hourlyFee = sumHourlyMethodFee(hourlyData)
|
||||||
@ -322,7 +308,7 @@ const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
|
|||||||
typeof row.unitPrice === 'number' &&
|
typeof row.unitPrice === 'number' &&
|
||||||
Number.isFinite(row.unitPrice)
|
Number.isFinite(row.unitPrice)
|
||||||
) {
|
) {
|
||||||
return Number((row.quantity * row.unitPrice).toFixed(2))
|
return roundTo(row.quantity * row.unitPrice, 2)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
||||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||||
@ -7,6 +7,7 @@ import { parseNumberOrNull } from '@/lib/number'
|
|||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
|
import { syncContractFactorsToPricing } from '@/lib/zxFwPricingSync'
|
||||||
|
|
||||||
interface DictItem {
|
interface DictItem {
|
||||||
code: string
|
code: string
|
||||||
@ -31,6 +32,11 @@ interface GridState {
|
|||||||
detailRows: FactorRow[]
|
detailRows: FactorRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FactorChangeState {
|
||||||
|
changedRowIds: string[]
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
type DictSource = Record<string, DictItem>
|
type DictSource = Record<string, DictItem>
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -47,6 +53,7 @@ const zxFwPricingStore = useZxFwPricingStore()
|
|||||||
const kvStore = useKvStore()
|
const kvStore = useKvStore()
|
||||||
const detailRows = ref<FactorRow[]>([])
|
const detailRows = ref<FactorRow[]>([])
|
||||||
const gridApi = ref<GridApi<FactorRow> | null>(null)
|
const gridApi = ref<GridApi<FactorRow> | null>(null)
|
||||||
|
const CHANGE_STORAGE_KEY = computed(() => `${props.storageKey}-change`)
|
||||||
|
|
||||||
const formatReadonlyFactor = (value: unknown) => {
|
const formatReadonlyFactor = (value: unknown) => {
|
||||||
if (value == null || value === '') return ''
|
if (value == null || value === '') return ''
|
||||||
@ -239,6 +246,59 @@ const saveToIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeFactorBudgetValue = (value: unknown) => {
|
||||||
|
const parsed = parseNumberOrNull(value, { precision: 6 })
|
||||||
|
return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSameNullableFactorNumber = (left: unknown, right: unknown) => {
|
||||||
|
const normalizedLeft = normalizeFactorBudgetValue(left)
|
||||||
|
const normalizedRight = normalizeFactorBudgetValue(right)
|
||||||
|
if (normalizedLeft == null && normalizedRight == null) return true
|
||||||
|
if (normalizedLeft == null || normalizedRight == null) return false
|
||||||
|
return normalizedLeft === normalizedRight
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseContractFactorMeta = (storageKey: string) => {
|
||||||
|
const consultMatch = /^ht-consult-category-factor-v1-(.+)$/.exec(storageKey)
|
||||||
|
if (consultMatch) {
|
||||||
|
return {
|
||||||
|
factorType: 'consult' as const,
|
||||||
|
contractId: String(consultMatch[1] || '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const majorMatch = /^ht-major-factor-v1-(.+)$/.exec(storageKey)
|
||||||
|
if (majorMatch) {
|
||||||
|
return {
|
||||||
|
factorType: 'major' as const,
|
||||||
|
contractId: String(majorMatch[1] || '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveFactorChangeState = async (changedRowIds: string[]) => {
|
||||||
|
if (changedRowIds.length === 0) return
|
||||||
|
const payload: FactorChangeState = {
|
||||||
|
changedRowIds: Array.from(new Set(changedRowIds.map(id => String(id || '').trim()).filter(Boolean))),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
if (payload.changedRowIds.length === 0) return
|
||||||
|
zxFwPricingStore.setKeyState(CHANGE_STORAGE_KEY.value, payload, { force: true })
|
||||||
|
|
||||||
|
const contractMeta = parseContractFactorMeta(props.storageKey)
|
||||||
|
if (!contractMeta?.contractId) return
|
||||||
|
if (contractMeta.factorType === 'consult') {
|
||||||
|
await syncContractFactorsToPricing(contractMeta.contractId, {
|
||||||
|
consultChangedServiceIds: payload.changedRowIds
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await syncContractFactorsToPricing(contractMeta.contractId, {
|
||||||
|
majorChangedRowIds: payload.changedRowIds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
|
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
|
||||||
if (!storageKey) return null
|
if (!storageKey) return null
|
||||||
const piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
|
const piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
|
||||||
@ -288,25 +348,60 @@ const loadFromIndexedDB = async () => {
|
|||||||
|
|
||||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let isBulkClipboardMutation = false
|
let isBulkClipboardMutation = false
|
||||||
|
let bulkBudgetSnapshot: Map<string, number | null> | null = null
|
||||||
|
const pendingChangedRowIds = new Set<string>()
|
||||||
|
|
||||||
|
const queueChangedRowId = (rowId: unknown) => {
|
||||||
|
const normalizedId = String(rowId || '').trim()
|
||||||
|
if (!normalizedId) return
|
||||||
|
pendingChangedRowIds.add(normalizedId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushGridPersist = async () => {
|
||||||
|
await saveToIndexedDB()
|
||||||
|
if (pendingChangedRowIds.size === 0) return
|
||||||
|
const changedRowIds = Array.from(pendingChangedRowIds)
|
||||||
|
pendingChangedRowIds.clear()
|
||||||
|
await saveFactorChangeState(changedRowIds)
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleGridPersist = () => {
|
const scheduleGridPersist = () => {
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
gridPersistTimer = setTimeout(() => {
|
gridPersistTimer = setTimeout(() => {
|
||||||
void saveToIndexedDB()
|
void flushGridPersist()
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCellValueChanged = () => {
|
const handleCellValueChanged = (event?: any) => {
|
||||||
if (isBulkClipboardMutation) return
|
if (isBulkClipboardMutation) return
|
||||||
|
const field = String(event?.colDef?.field || '')
|
||||||
|
if (field === 'budgetValue' && !isSameNullableFactorNumber(event?.oldValue, event?.newValue)) {
|
||||||
|
queueChangedRowId(event?.data?.id)
|
||||||
|
}
|
||||||
scheduleGridPersist()
|
scheduleGridPersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkMutationStart = () => {
|
const handleBulkMutationStart = () => {
|
||||||
isBulkClipboardMutation = true
|
isBulkClipboardMutation = true
|
||||||
|
bulkBudgetSnapshot = new Map(
|
||||||
|
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkMutationEnd = () => {
|
const handleBulkMutationEnd = () => {
|
||||||
isBulkClipboardMutation = false
|
isBulkClipboardMutation = false
|
||||||
|
if (bulkBudgetSnapshot) {
|
||||||
|
const nextBudgetMap = new Map(
|
||||||
|
detailRows.value.map(row => [String(row.id || '').trim(), normalizeFactorBudgetValue(row.budgetValue)] as const)
|
||||||
|
)
|
||||||
|
const allRowIds = new Set<string>([...bulkBudgetSnapshot.keys(), ...nextBudgetMap.keys()])
|
||||||
|
for (const rowId of allRowIds) {
|
||||||
|
if (!isSameNullableFactorNumber(bulkBudgetSnapshot.get(rowId), nextBudgetMap.get(rowId))) {
|
||||||
|
queueChangedRowId(rowId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bulkBudgetSnapshot = null
|
||||||
|
}
|
||||||
scheduleGridPersist()
|
scheduleGridPersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,7 +443,7 @@ onBeforeUnmount(() => {
|
|||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
gridApi.value?.stopEditing()
|
gridApi.value?.stopEditing()
|
||||||
gridApi.value = null
|
gridApi.value = null
|
||||||
void saveToIndexedDB()
|
void flushGridPersist()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export interface DataPackage {
|
|||||||
version: number
|
version: number
|
||||||
packageType?: 'project-snapshot'
|
packageType?: 'project-snapshot'
|
||||||
exportedAt: string
|
exportedAt: string
|
||||||
|
projectId?: string
|
||||||
localStorage: DataEntry[]
|
localStorage: DataEntry[]
|
||||||
sessionStorage: DataEntry[]
|
sessionStorage: DataEntry[]
|
||||||
localforageDefault: DataEntry[]
|
localforageDefault: DataEntry[]
|
||||||
@ -125,5 +126,6 @@ export const isDataPackageLike = (value: unknown): value is DataPackage => {
|
|||||||
if (!hasRequiredArrays) return false
|
if (!hasRequiredArrays) return false
|
||||||
if (typeof payload.version !== 'number' || !Number.isFinite(payload.version)) 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.packageType != null && payload.packageType !== 'project-snapshot') return false
|
||||||
|
if (payload.projectId != null && typeof payload.projectId !== 'string') return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,16 +28,21 @@ import {
|
|||||||
SelectViewport
|
SelectViewport
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_PROJECT_ID,
|
||||||
|
NEW_PROJECT_QUERY_KEY,
|
||||||
PROJECT_TAB_ID,
|
PROJECT_TAB_ID,
|
||||||
|
QUICK_PROJECT_ID,
|
||||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||||
QUICK_CONTRACT_FALLBACK_NAME,
|
QUICK_CONTRACT_FALLBACK_NAME,
|
||||||
QUICK_CONTRACT_ID,
|
QUICK_CONTRACT_ID,
|
||||||
QUICK_CONTRACT_META_KEY,
|
QUICK_CONTRACT_META_KEY,
|
||||||
QUICK_MAJOR_FACTOR_KEY,
|
QUICK_MAJOR_FACTOR_KEY,
|
||||||
QUICK_PROJECT_INFO_KEY,
|
QUICK_PROJECT_INFO_KEY,
|
||||||
|
writeProjectIdToUrl,
|
||||||
setPendingHomeImportFile,
|
setPendingHomeImportFile,
|
||||||
writeWorkspaceMode
|
writeWorkspaceMode
|
||||||
} from '@/lib/workspace'
|
} from '@/lib/workspace'
|
||||||
|
import { upsertProject } from '@/lib/projectRegistry'
|
||||||
|
|
||||||
interface QuickProjectInfoState {
|
interface QuickProjectInfoState {
|
||||||
projectIndustry?: string
|
projectIndustry?: string
|
||||||
@ -91,6 +96,8 @@ const getTodayDateString = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enterProjectCalc = () => {
|
const enterProjectCalc = () => {
|
||||||
|
upsertProject(DEFAULT_PROJECT_ID, '默认项目')
|
||||||
|
writeProjectIdToUrl(DEFAULT_PROJECT_ID)
|
||||||
writeWorkspaceMode('project')
|
writeWorkspaceMode('project')
|
||||||
tabStore.enterWorkspace({
|
tabStore.enterWorkspace({
|
||||||
id: PROJECT_TAB_ID,
|
id: PROJECT_TAB_ID,
|
||||||
@ -162,6 +169,7 @@ const loadQuickDefaults = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enterQuickCalc = (contractName: string) => {
|
const enterQuickCalc = (contractName: string) => {
|
||||||
|
writeProjectIdToUrl(QUICK_PROJECT_ID)
|
||||||
writeWorkspaceMode('quick')
|
writeWorkspaceMode('quick')
|
||||||
tabStore.enterWorkspace({
|
tabStore.enterWorkspace({
|
||||||
id: `contract-${QUICK_CONTRACT_ID}`,
|
id: `contract-${QUICK_CONTRACT_ID}`,
|
||||||
@ -231,6 +239,16 @@ const openHomeImport = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadProjectDefaults()
|
void loadProjectDefaults()
|
||||||
void loadQuickDefaults()
|
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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
getServiceDictItemById,
|
getServiceDictItemById,
|
||||||
industryTypeList
|
industryTypeList
|
||||||
} from '@/sql'
|
} from '@/sql'
|
||||||
|
import { roundTo } from '@/lib/decimal'
|
||||||
import { parseNumberOrNull } from '@/lib/number'
|
import { parseNumberOrNull } from '@/lib/number'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
@ -302,6 +303,9 @@ const applyScaleInput = (field: 'invest' | 'land') => {
|
|||||||
landScale.value = normalized
|
landScale.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatFactorValue = (value: number | null | undefined) =>
|
||||||
|
value == null ? '--' : String(roundTo(value, 3))
|
||||||
|
|
||||||
const applyWorkEnvFactorInput = () => {
|
const applyWorkEnvFactorInput = () => {
|
||||||
const next = parseNumberOrNull(workEnvFactor.value, { sanitize: true, precision: 3 })
|
const next = parseNumberOrNull(workEnvFactor.value, { sanitize: true, precision: 3 })
|
||||||
workEnvFactor.value = next == null ? '' : String(next)
|
workEnvFactor.value = next == null ? '' : String(next)
|
||||||
@ -647,14 +651,14 @@ watch(canUseLandScale, enabled => {
|
|||||||
<label class="quick-calc-field">
|
<label class="quick-calc-field">
|
||||||
<span class="quick-calc-field__label">咨询分类系数</span>
|
<span class="quick-calc-field__label">咨询分类系数</span>
|
||||||
<div class="quick-calc-field__readonly">
|
<div class="quick-calc-field__readonly">
|
||||||
{{ consultCategoryFactor == null ? '--' : consultCategoryFactor.toFixed(3) }}
|
{{ formatFactorValue(consultCategoryFactor) }}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="quick-calc-field">
|
<label class="quick-calc-field">
|
||||||
<span class="quick-calc-field__label">工程专业系数</span>
|
<span class="quick-calc-field__label">工程专业系数</span>
|
||||||
<div class="quick-calc-field__readonly">
|
<div class="quick-calc-field__readonly">
|
||||||
{{ engineeringMajorFactor == null ? '--' : engineeringMajorFactor.toFixed(3) }}
|
{{ formatFactorValue(engineeringMajorFactor) }}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { useKvStore } from '@/pinia/kv'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
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 localforage from 'localforage'
|
||||||
import {
|
import {
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -21,12 +21,21 @@ import {
|
|||||||
AlertDialogPortal,
|
AlertDialogPortal,
|
||||||
AlertDialogRoot,
|
AlertDialogRoot,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
|
||||||
ToastDescription,
|
ToastDescription,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastRoot,
|
ToastRoot,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastViewport
|
ToastViewport,
|
||||||
|
SelectContent,
|
||||||
|
SelectIcon,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
SelectPortal,
|
||||||
|
SelectRoot,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectViewport
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
||||||
import { formatExportTimestamp } from '@/lib/contractSegment'
|
import { formatExportTimestamp } from '@/lib/contractSegment'
|
||||||
@ -36,10 +45,8 @@ import {
|
|||||||
normalizeEntries,
|
normalizeEntries,
|
||||||
normalizeForageStoreSnapshots,
|
normalizeForageStoreSnapshots,
|
||||||
readForage,
|
readForage,
|
||||||
readWebStorage,
|
|
||||||
sanitizeFileNamePart,
|
sanitizeFileNamePart,
|
||||||
writeForage,
|
writeForage,
|
||||||
writeWebStorage,
|
|
||||||
type DataPackage,
|
type DataPackage,
|
||||||
type ForageInstance,
|
type ForageInstance,
|
||||||
type ForageStore
|
type ForageStore
|
||||||
@ -86,13 +93,25 @@ import type {
|
|||||||
ZxFwStorageLike
|
ZxFwStorageLike
|
||||||
} from '@/features/tab/types'
|
} from '@/features/tab/types'
|
||||||
import {
|
import {
|
||||||
|
buildProjectUrl,
|
||||||
|
getProjectDbName,
|
||||||
|
readCurrentProjectId,
|
||||||
PROJECT_TAB_ID,
|
PROJECT_TAB_ID,
|
||||||
QUICK_TAB_ID,
|
QUICK_TAB_ID,
|
||||||
consumePendingHomeImportFile,
|
consumePendingHomeImportFile,
|
||||||
readWorkspaceMode,
|
readWorkspaceMode,
|
||||||
|
writeProjectIdToUrl,
|
||||||
writeWorkspaceMode
|
writeWorkspaceMode
|
||||||
} from '@/lib/workspace'
|
} 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 {
|
import {
|
||||||
buildMethod0,
|
buildMethod0,
|
||||||
buildMethod1,
|
buildMethod1,
|
||||||
@ -119,13 +138,17 @@ import {
|
|||||||
toMoney
|
toMoney
|
||||||
} from '@/lib/reportExportBuilders'
|
} from '@/lib/reportExportBuilders'
|
||||||
import { exportFile } from '@/sql'
|
import { exportFile } from '@/sql'
|
||||||
|
import { industryTypeList } from '@/sql'
|
||||||
|
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||||||
|
|
||||||
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
|
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
|
||||||
const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
|
const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
|
||||||
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
|
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
|
||||||
const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
|
const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
|
||||||
const MAJOR_FACTOR_DB_KEY = 'xm-major-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_BASE_STORE_NAME = 'pinia'
|
||||||
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'zxFwPricingKeys', 'zxFwPricingHtFee', 'kv'] as const
|
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'zxFwPricingKeys', 'zxFwPricingHtFee', 'kv'] as const
|
||||||
const RESET_MIN_LOADING_MS = 1000
|
const RESET_MIN_LOADING_MS = 1000
|
||||||
@ -228,6 +251,15 @@ const tabContextRef = ref<HTMLElement | null>(null)
|
|||||||
|
|
||||||
const dataMenuOpen = ref(false)
|
const dataMenuOpen = ref(false)
|
||||||
const dataMenuRef = ref<HTMLElement | null>(null)
|
const dataMenuRef = ref<HTMLElement | null>(null)
|
||||||
|
const projectMenuOpen = ref(false)
|
||||||
|
const projectMenuRef = ref<HTMLElement | null>(null)
|
||||||
|
const newProjectDialogOpen = ref(false)
|
||||||
|
const newProjectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||||
|
const newProjectSubmitting = ref(false)
|
||||||
|
const projectLimitDialogOpen = ref(false)
|
||||||
|
const projectList = ref<ProjectMeta[]>([])
|
||||||
|
const openedProjectIds = ref<string[]>([])
|
||||||
|
const currentProjectId = ref(readCurrentProjectId())
|
||||||
const resetConfirmOpen = ref(false)
|
const resetConfirmOpen = ref(false)
|
||||||
const isResetting = ref(false)
|
const isResetting = ref(false)
|
||||||
const importFileRef = ref<HTMLInputElement | null>(null)
|
const importFileRef = ref<HTMLInputElement | null>(null)
|
||||||
@ -301,6 +333,8 @@ const tabsModel = computed({
|
|||||||
tabStore.tabs = value
|
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))
|
const contextTabIndex = computed(() => tabStore.tabs.findIndex((t:any) => t.id === contextTabId.value))
|
||||||
|
|
||||||
@ -326,10 +360,209 @@ const canCloseRight = computed(() => {
|
|||||||
const canCloseOther = computed(() =>
|
const canCloseOther = computed(() =>
|
||||||
tabStore.tabs.slice(1, contextTabIndex.value).length > 1 && contextTabIndex.value !== 0
|
tabStore.tabs.slice(1, contextTabIndex.value).length > 1 && contextTabIndex.value !== 0
|
||||||
)
|
)
|
||||||
|
const projectCountText = computed(() => `${projectList.value.length}/${MAX_PROJECT_COUNT}`)
|
||||||
|
|
||||||
const closeMenus = () => {
|
const closeMenus = () => {
|
||||||
tabContextOpen.value = false
|
tabContextOpen.value = false
|
||||||
dataMenuOpen.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<any>('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 <T = unknown>(keyRaw: string | number, value: T) => {
|
||||||
|
const key = String(keyRaw || '').trim()
|
||||||
|
if (!key) return
|
||||||
|
const currentState = await projectKvForage.getItem<Record<string, unknown>>('pinia-kv')
|
||||||
|
const nextEntries = {
|
||||||
|
...(
|
||||||
|
currentState?.entries && typeof currentState.entries === 'object'
|
||||||
|
? (currentState.entries as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
),
|
||||||
|
[key]: JSON.parse(JSON.stringify(value))
|
||||||
|
}
|
||||||
|
await projectKvForage.setItem('pinia-kv', {
|
||||||
|
...(currentState && typeof currentState === 'object' ? currentState : {}),
|
||||||
|
entries: nextEntries,
|
||||||
|
ready: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateProjectDialog = () => {
|
||||||
|
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<void>[] = []
|
||||||
|
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<void>((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 = () => {
|
const markGuideCompleted = () => {
|
||||||
@ -432,6 +665,9 @@ const handleGlobalMouseDown = (event: MouseEvent) => {
|
|||||||
if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) {
|
if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) {
|
||||||
dataMenuOpen.value = false
|
dataMenuOpen.value = false
|
||||||
}
|
}
|
||||||
|
if (projectMenuOpen.value && projectMenuRef.value && !projectMenuRef.value.contains(target)) {
|
||||||
|
projectMenuOpen.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||||
@ -631,6 +867,10 @@ const createForageStore = (storeName: string): ForageInstance =>
|
|||||||
storeName
|
storeName
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const projectDefaultForage = localforage.createInstance({
|
||||||
|
name: PINIA_PERSIST_DB_NAME
|
||||||
|
})
|
||||||
|
|
||||||
const getPiniaPersistStoreName = (storeId: string) => `${PINIA_PERSIST_BASE_STORE_NAME}-${storeId}`
|
const getPiniaPersistStoreName = (storeId: string) => `${PINIA_PERSIST_BASE_STORE_NAME}-${storeId}`
|
||||||
|
|
||||||
const getPiniaPersistStores = () =>
|
const getPiniaPersistStores = () =>
|
||||||
@ -662,17 +902,24 @@ const createRichTextCode = (...parts: string[]): unknown => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
|
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
|
||||||
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string }>
|
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string; subtotal?: unknown }>
|
||||||
return rows
|
const normalized = rows.map(row => {
|
||||||
.map(row => {
|
const id = String(row?.id || '').trim()
|
||||||
const id = String(row?.id || '').trim()
|
if (!id) return null
|
||||||
if (!id) return null
|
const rowAny = row as { subtotal?: unknown }
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: typeof row?.name === 'string' ? row.name : ''
|
name: typeof row?.name === 'string' ? row.name : '',
|
||||||
}
|
subtotal: rowAny?.subtotal
|
||||||
})
|
}
|
||||||
.filter((item): item is { id: string; name: string } => Boolean(item))
|
})
|
||||||
|
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) => {
|
const loadHtFeeMethodsByRow = async (mainStorageKey: string, rowId: string) => {
|
||||||
@ -719,19 +966,20 @@ const buildAdditionalExport = async (contractId: string): Promise<ExportAddition
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
rows.map(async row => {
|
rows.map(async row => {
|
||||||
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
|
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 tasks = await buildAdditionalRowTasks(contractId, row.id)
|
||||||
const item: ExportAdditionalDetail = {
|
const item: ExportAdditionalDetail = {
|
||||||
id: row.id,
|
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' }] },
|
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,
|
name: row.name,
|
||||||
fee: methodPayload.fee,
|
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
|
||||||
tasks
|
tasks
|
||||||
}
|
}
|
||||||
if (methodPayload.m0) item.m0 = methodPayload.m0
|
if (methodPayload?.m0) item.m0 = methodPayload.m0
|
||||||
if (methodPayload.m4) item.m4 = methodPayload.m4
|
if (methodPayload?.m4) item.m4 = methodPayload.m4
|
||||||
if (methodPayload.m5) item.m5 = methodPayload.m5
|
if (methodPayload?.m5) item.m5 = methodPayload.m5
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@ -742,7 +990,7 @@ const buildAdditionalExport = async (contractId: string): Promise<ExportAddition
|
|||||||
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: 'C' }] },
|
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: 'C' }] },
|
||||||
|
|
||||||
name: '附加工作',
|
name: '附加工作',
|
||||||
fee: sumNumbers(det.map(item => item.fee)),
|
fee: toMoney(sumNumbers(det.map(item => item.fee))),
|
||||||
det
|
det
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -755,15 +1003,16 @@ const buildReserveExport = async (contractId: string): Promise<ExportReserve | n
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
|
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
|
||||||
if (!methodPayload) continue
|
const rowSubtotal = getHtMainRowSubtotal(row)
|
||||||
|
if (!methodPayload && rowSubtotal == null) continue
|
||||||
const reserve: ExportReserve = {
|
const reserve: ExportReserve = {
|
||||||
name: row.name || '预备费',
|
name: row.name || '预备费',
|
||||||
fee: methodPayload.fee,
|
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
|
||||||
tasks: []
|
tasks: []
|
||||||
}
|
}
|
||||||
if (methodPayload.m0) reserve.m0 = methodPayload.m0
|
if (methodPayload?.m0) reserve.m0 = methodPayload.m0
|
||||||
if (methodPayload.m4) reserve.m4 = methodPayload.m4
|
if (methodPayload?.m4) reserve.m4 = methodPayload.m4
|
||||||
if (methodPayload.m5) reserve.m5 = methodPayload.m5
|
if (methodPayload?.m5) reserve.m5 = methodPayload.m5
|
||||||
return reserve
|
return reserve
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@ -900,25 +1149,16 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
|||||||
).filter((item): item is ExportService => Boolean(item))
|
).filter((item): item is ExportService => Boolean(item))
|
||||||
|
|
||||||
const fixedFinalFee = toFiniteNumber(fixedRow?.finalFee)
|
const fixedFinalFee = toFiniteNumber(fixedRow?.finalFee)
|
||||||
const serviceFinalFeeSum = sumNumbers(services.map(item => item.finalFee))
|
|
||||||
const fixedSubtotal = toFiniteNumber(fixedRow?.subtotal)
|
const fixedSubtotal = toFiniteNumber(fixedRow?.subtotal)
|
||||||
const serviceFeeSum = sumNumbers(services.map(item => item.fee))
|
const serviceFeeRaw = fixedFinalFee ?? fixedSubtotal ?? 0
|
||||||
const fixedMethodSum = sumNumbers([
|
const serviceFee = toMoney(serviceFeeRaw)
|
||||||
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 [addtional, reserve] = await Promise.all([
|
const [addtional, reserve] = await Promise.all([
|
||||||
buildAdditionalExport(contractId),
|
buildAdditionalExport(contractId),
|
||||||
buildReserveExport(contractId)
|
buildReserveExport(contractId)
|
||||||
])
|
])
|
||||||
const addtionalFee = addtional ? addtional.fee : 0
|
const addtionalFee = toMoney(addtional ? addtional.fee : 0)
|
||||||
const reserveFee = reserve ? reserve.fee : 0
|
const reserveFee = toMoney(reserve ? reserve.fee : 0)
|
||||||
const contractFee = roundTo(addNumbers(serviceFee, addtionalFee, reserveFee), 3)
|
const contractFee = toMoney(addNumbers(serviceFee, addtionalFee, reserveFee))
|
||||||
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows)
|
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows)
|
||||||
contractScale.push({
|
contractScale.push({
|
||||||
majorid: -1,
|
majorid: -1,
|
||||||
@ -963,7 +1203,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
|||||||
company,
|
company,
|
||||||
date,
|
date,
|
||||||
industry,
|
industry,
|
||||||
fee: sumNumbers(contracts.map(item => item.fee)),
|
fee: toMoney(sumNumbers(contracts.map(item => item.fee))),
|
||||||
scaleCost: projectScaleCost,
|
scaleCost: projectScaleCost,
|
||||||
overview,
|
overview,
|
||||||
desc,
|
desc,
|
||||||
@ -977,6 +1217,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
|||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const currentProjectId = readCurrentProjectId()
|
||||||
const piniaForageStores = await Promise.all(
|
const piniaForageStores = await Promise.all(
|
||||||
getPiniaPersistStores().map(async ({ storeName, store }) => ({
|
getPiniaPersistStores().map(async ({ storeName, store }) => ({
|
||||||
storeName,
|
storeName,
|
||||||
@ -984,12 +1225,13 @@ const exportData = async () => {
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
const payload: DataPackage = {
|
const payload: DataPackage = {
|
||||||
version: 2,
|
version: 3,
|
||||||
packageType: 'project-snapshot',
|
packageType: 'project-snapshot',
|
||||||
exportedAt: now.toISOString(),
|
exportedAt: now.toISOString(),
|
||||||
localStorage: readWebStorage(localStorage),
|
projectId: currentProjectId,
|
||||||
sessionStorage: readWebStorage(sessionStorage),
|
localStorage: [],
|
||||||
localforageDefault: await readForage(localforage),
|
sessionStorage: [],
|
||||||
|
localforageDefault: await readForage(projectDefaultForage),
|
||||||
localforageStores: piniaForageStores
|
localforageStores: piniaForageStores
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1046,6 +1288,14 @@ const prepareImportPayloadFromFile = async (file: File) => {
|
|||||||
if (!isDataPackageLike(payload)) {
|
if (!isDataPackageLike(payload)) {
|
||||||
throw new Error('INVALID_DATA_PACKAGE')
|
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
|
pendingImportPayload.value = payload
|
||||||
pendingImportFileName.value = file.name
|
pendingImportFileName.value = file.name
|
||||||
importConfirmOpen.value = true
|
importConfirmOpen.value = true
|
||||||
@ -1060,6 +1310,14 @@ const importData = async (event: Event) => {
|
|||||||
await prepareImportPayloadFromFile(file)
|
await prepareImportPayloadFromFile(file)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('import failed:', 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('导入失败:文件无效、已损坏或被修改。')
|
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||||
} finally {
|
} finally {
|
||||||
input.value = ''
|
input.value = ''
|
||||||
@ -1076,9 +1334,7 @@ const confirmImportOverride = async () => {
|
|||||||
const payload = pendingImportPayload.value
|
const payload = pendingImportPayload.value
|
||||||
if (!payload) return
|
if (!payload) return
|
||||||
try {
|
try {
|
||||||
writeWebStorage(localStorage, normalizeEntries(payload.localStorage))
|
await writeForage(projectDefaultForage, normalizeEntries(payload.localforageDefault))
|
||||||
writeWebStorage(sessionStorage, normalizeEntries(payload.sessionStorage))
|
|
||||||
await writeForage(localforage, normalizeEntries(payload.localforageDefault))
|
|
||||||
const piniaSnapshots = normalizeForageStoreSnapshots(payload.localforageStores)
|
const piniaSnapshots = normalizeForageStoreSnapshots(payload.localforageStores)
|
||||||
const snapshotMap = new Map(piniaSnapshots.map(item => [item.storeName, item.entries]))
|
const snapshotMap = new Map(piniaSnapshots.map(item => [item.storeName, item.entries]))
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@ -1143,6 +1399,7 @@ const handleReset = async () => {
|
|||||||
if (isResetting.value) return
|
if (isResetting.value) return
|
||||||
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||||
const resetStartedAt = Date.now()
|
const resetStartedAt = Date.now()
|
||||||
|
const allProjectIds = Array.from(new Set(['default', 'quick', ...listProjects().map(item => item.id)]))
|
||||||
const deleteIndexedDBByName = (dbName: string) =>
|
const deleteIndexedDBByName = (dbName: string) =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
try {
|
try {
|
||||||
@ -1163,20 +1420,18 @@ const handleReset = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const purgeKnownIndexedDB = async () => {
|
const purgeKnownIndexedDB = async () => {
|
||||||
await Promise.all([
|
await Promise.all(allProjectIds.map(id => deleteIndexedDBByName(getProjectDbName(id))))
|
||||||
deleteIndexedDBByName('DB'),
|
|
||||||
deleteIndexedDBByName('localforage')
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isResetting.value = true
|
isResetting.value = true
|
||||||
dataMenuOpen.value = false
|
dataMenuOpen.value = false
|
||||||
|
projectMenuOpen.value = false
|
||||||
|
|
||||||
// 1) 仅清持久化层,不先改当前页面内存态,避免“先切首页再刷新”的二次闪烁。
|
// 1) 仅清持久化层,不先改当前页面内存态,避免“先切首页再刷新”的二次闪烁。
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
await localforage.clear()
|
await projectDefaultForage.clear()
|
||||||
|
|
||||||
// 2) 清 pinia 分库持久化
|
// 2) 清 pinia 分库持久化
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@ -1196,6 +1451,7 @@ const handleReset = async () => {
|
|||||||
await purgeKnownIndexedDB()
|
await purgeKnownIndexedDB()
|
||||||
|
|
||||||
// 5) 需要保留的最小标记恢复
|
// 5) 需要保留的最小标记恢复
|
||||||
|
writeProjectIdToUrl('default')
|
||||||
writeWorkspaceMode('project')
|
writeWorkspaceMode('project')
|
||||||
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
|
localStorage.setItem(USER_GUIDE_COMPLETED_KEY, '1')
|
||||||
|
|
||||||
@ -1219,6 +1475,9 @@ const handleResetConfirmOpenChange = (open: boolean) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
currentProjectId.value = readCurrentProjectId()
|
||||||
|
upsertProject(currentProjectId.value)
|
||||||
|
void refreshProjectList()
|
||||||
window.addEventListener('mousedown', handleGlobalMouseDown)
|
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||||
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||||
@ -1386,22 +1645,70 @@ watch(
|
|||||||
使用引导
|
使用引导
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div v-if="readWorkspaceMode() !== 'quick'" ref="projectMenuRef" class="relative shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="app-toolbar-btn shrink-0 cursor-pointer"
|
||||||
|
:disabled="isResetting"
|
||||||
|
@click="projectMenuOpen = !projectMenuOpen; if (projectMenuOpen) void refreshProjectList()"
|
||||||
|
>
|
||||||
|
<ChevronDown class="mr-1 h-4 w-4" />
|
||||||
|
项目列表
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-if="projectMenuOpen"
|
||||||
|
class="absolute right-0 top-full z-50 mt-1 w-[420px] rounded-md border bg-background p-2 shadow-md"
|
||||||
|
>
|
||||||
|
<div class="max-h-56 space-y-1 overflow-auto">
|
||||||
|
<div
|
||||||
|
v-for="project in projectList"
|
||||||
|
:key="project.id"
|
||||||
|
class="flex items-center gap-2 rounded px-2 py-1.5"
|
||||||
|
:class="isProjectOpen(project.id) ? 'opacity-60' : 'hover:bg-muted'"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex-1 text-left text-sm"
|
||||||
|
:class="isProjectOpen(project.id) ? 'cursor-not-allowed' : 'cursor-pointer'"
|
||||||
|
:disabled="isProjectOpen(project.id)"
|
||||||
|
@click="openProjectInNewTab(project.id)"
|
||||||
|
>
|
||||||
|
<div class="font-medium leading-5">
|
||||||
|
{{ project.name }}
|
||||||
|
<span v-if="isProjectOpen(project.id)" class="ml-1 text-xs text-muted-foreground">(已打开)</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">最后编辑:{{ formatProjectEditedTime(project.updatedAt) }}</div>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
@click="removeProjectItem(project)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center justify-end gap-2 border-t pt-2">
|
||||||
|
<span class="mr-auto text-xs text-muted-foreground">项目数量:{{ projectCountText }}</span>
|
||||||
|
<Button size="sm" class="h-8 px-3 text-xs" @click="openCreateProjectDialog">
|
||||||
|
新建项目
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" class="h-8 px-3 text-xs" @click="resetConfirmOpen = true">
|
||||||
|
重置全部项目
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AlertDialogRoot :open="resetConfirmOpen" @update:open="handleResetConfirmOpenChange">
|
<AlertDialogRoot :open="resetConfirmOpen" @update:open="handleResetConfirmOpenChange">
|
||||||
<AlertDialogTrigger as-child>
|
|
||||||
<Button variant="destructive" size="sm"
|
|
||||||
class="app-toolbar-btn shrink-0 cursor-pointer"
|
|
||||||
:disabled="isResetting">
|
|
||||||
<RotateCcw class="h-4 w-4 mr-1" />
|
|
||||||
重置
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||||
<AlertDialogContent
|
<AlertDialogContent
|
||||||
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||||
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
|
<AlertDialogTitle class="text-base font-semibold">确认重置</AlertDialogTitle>
|
||||||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||||
将清空所有项目数据,并恢复默认页面,确认继续吗?
|
将清空全部项目数据,并恢复默认页面,确认继续吗?
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
<div class="mt-4 flex items-center justify-end gap-2">
|
<div class="mt-4 flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" :disabled="isResetting" @click="resetConfirmOpen = false">取消</Button>
|
<Button variant="outline" :disabled="isResetting" @click="resetConfirmOpen = false">取消</Button>
|
||||||
@ -1434,15 +1741,95 @@ watch(
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
</AlertDialogRoot>
|
</AlertDialogRoot>
|
||||||
|
|
||||||
|
<AlertDialogRoot :open="newProjectDialogOpen" @update:open="handleCreateProjectDialogOpenChange">
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||||
|
<AlertDialogContent
|
||||||
|
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||||
|
<AlertDialogTitle class="text-base font-semibold">新建项目</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||||
|
选择工程行业后,将在新标签页直接打开新项目计算页面。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
<label class="text-sm font-medium text-foreground">工程行业</label>
|
||||||
|
<SelectRoot v-model="newProjectIndustry">
|
||||||
|
<SelectTrigger
|
||||||
|
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="请选择工程行业" />
|
||||||
|
<SelectIcon as-child>
|
||||||
|
<ChevronDown class="h-4 w-4 text-muted-foreground" />
|
||||||
|
</SelectIcon>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
:side-offset="6"
|
||||||
|
position="popper"
|
||||||
|
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
|
||||||
|
>
|
||||||
|
<SelectViewport class="p-1">
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in industryTypeList"
|
||||||
|
:key="`new-project-${item.id}`"
|
||||||
|
:value="String(item.id)"
|
||||||
|
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.name }}</SelectItemText>
|
||||||
|
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectViewport>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</SelectRoot>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<AlertDialogCancel as-child>
|
||||||
|
<Button variant="outline" :disabled="newProjectSubmitting" @click="closeCreateProjectDialog">取消</Button>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction as-child>
|
||||||
|
<Button :disabled="newProjectSubmitting || !newProjectIndustry" @click="handleCreateProjectConfirm">
|
||||||
|
<Loader2 v-if="newProjectSubmitting" class="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
{{ newProjectSubmitting ? '创建中...' : '新建并打开' }}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialogRoot>
|
||||||
|
|
||||||
|
<AlertDialogRoot :open="projectLimitDialogOpen" @update:open="projectLimitDialogOpen = $event">
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||||||
|
<AlertDialogContent
|
||||||
|
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||||||
|
<AlertDialogTitle class="text-base font-semibold">项目数量已达上限</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||||||
|
当前项目数量已达到 {{ MAX_PROJECT_COUNT }} 个,请先删除一个项目后再添加。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<div class="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<AlertDialogAction as-child>
|
||||||
|
<Button @click="projectLimitDialogOpen = false">我知道了</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialogRoot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isResetting" class="fixed inset-0 z-40 cursor-wait bg-transparent" />
|
<div v-if="isResetting" class="fixed inset-0 z-40 cursor-wait bg-transparent" />
|
||||||
|
|
||||||
<div class="flex-1 overflow-auto relative">
|
<div class="flex-1 overflow-auto relative">
|
||||||
<div v-for="tab in tabStore.tabs" :key="tab.id" :ref="el => setTabPanelRef(tab.id, el)"
|
<div
|
||||||
v-show="tabStore.activeTabId === tab.id" class="h-full w-full animate-in fade-in duration-300">
|
v-if="activeTab"
|
||||||
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
|
:key="activeTab.id"
|
||||||
|
:ref="el => setTabPanelRef(activeTabId, el)"
|
||||||
|
class="h-full w-full animate-in fade-in duration-300"
|
||||||
|
>
|
||||||
|
<component :is="componentMap[activeTab.componentName]" v-bind="activeTab.props || {}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import { useWindowSize } from '@vueuse/core'
|
import { useWindowSize } from '@vueuse/core'
|
||||||
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
|
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
|
||||||
|
import { readCurrentProjectId } from '@/lib/workspace'
|
||||||
interface TypeLineCategory {
|
interface TypeLineCategory {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
@ -45,6 +46,7 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
|
const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
|
||||||
|
const scopedCacheKey = computed(() => `project:${readCurrentProjectId()}:${cacheKey.value}`)
|
||||||
|
|
||||||
const readStoredCategory = (key: string) => {
|
const readStoredCategory = (key: string) => {
|
||||||
const sessionValue = sessionStorage.getItem(key)
|
const sessionValue = sessionStorage.getItem(key)
|
||||||
@ -60,7 +62,7 @@ const writeStoredCategory = (key: string, value: string) => {
|
|||||||
const resolveInitialCategory = () => {
|
const resolveInitialCategory = () => {
|
||||||
const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
|
const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
|
||||||
if (!props.persistActiveCategory) return defaultKey
|
if (!props.persistActiveCategory) return defaultKey
|
||||||
const savedKey = readStoredCategory(cacheKey.value)
|
const savedKey = readStoredCategory(scopedCacheKey.value)
|
||||||
const validSavedKey = props.categories.some(item => item.key === savedKey)
|
const validSavedKey = props.categories.some(item => item.key === savedKey)
|
||||||
return validSavedKey ? (savedKey as string) : defaultKey
|
return validSavedKey ? (savedKey as string) : defaultKey
|
||||||
}
|
}
|
||||||
@ -68,7 +70,7 @@ const resolveInitialCategory = () => {
|
|||||||
const activeCategory = ref(resolveInitialCategory())
|
const activeCategory = ref(resolveInitialCategory())
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.categories, props.defaultCategory, cacheKey.value],
|
() => [props.categories, props.defaultCategory, scopedCacheKey.value],
|
||||||
() => {
|
() => {
|
||||||
const isCurrentValid = props.categories.some(item => item.key === activeCategory.value)
|
const isCurrentValid = props.categories.some(item => item.key === activeCategory.value)
|
||||||
if (isCurrentValid) return
|
if (isCurrentValid) return
|
||||||
@ -82,7 +84,7 @@ watch(
|
|||||||
const switchCategory = (cat: string) => {
|
const switchCategory = (cat: string) => {
|
||||||
activeCategory.value = cat
|
activeCategory.value = cat
|
||||||
if (!props.persistActiveCategory) return
|
if (!props.persistActiveCategory) return
|
||||||
writeStoredCategory(cacheKey.value, cat)
|
writeStoredCategory(scopedCacheKey.value, cat)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeComponent = computed(() => {
|
const activeComponent = computed(() => {
|
||||||
|
|||||||
@ -8,9 +8,26 @@ export const toDecimal = (value: DecimalInput) => new Decimal(value)
|
|||||||
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
|
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
|
||||||
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
|
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)
|
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<unknown>) => {
|
const sumFiniteValues = (values: Iterable<unknown>) => {
|
||||||
let total = new Decimal(0)
|
let total = new Decimal(0)
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
@ -32,6 +49,12 @@ export const sumByNumber = <T>(list: T[], pick: (item: T) => MaybeNumber) => {
|
|||||||
return total.toNumber()
|
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[] }) => {
|
export const decimalAggSum = (params: { values?: unknown[] }) => {
|
||||||
const values = params.values || []
|
const values = params.values || []
|
||||||
let hasFinite = false
|
let hasFinite = false
|
||||||
|
|||||||
@ -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 =>
|
export { isFiniteNumber, toFiniteNumberOrNull }
|
||||||
typeof value === 'number' && Number.isFinite(value)
|
|
||||||
|
|
||||||
export const toFiniteNumberOrNull = (value: unknown): number | null =>
|
|
||||||
isFiniteNumber(value) ? value : null
|
|
||||||
|
|
||||||
export const parseNumberOrNull = (
|
export const parseNumberOrNull = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
|
|||||||
@ -6,8 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { expertList } from '@/sql'
|
import { expertList } from '@/sql'
|
||||||
import { roundTo, toDecimal } from '@/lib/decimal'
|
import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
|
||||||
import type { HourlyDetailRow, ExpertLite } from '@/types/pricing'
|
import type { HourlyDetailRow, ExpertLite } from '@/types/pricing'
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
|
|||||||
@ -5,8 +5,7 @@ import {
|
|||||||
getServiceDictById,
|
getServiceDictById,
|
||||||
taskList
|
taskList
|
||||||
} from '@/sql'
|
} from '@/sql'
|
||||||
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
import { roundTo, sumByNumber, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
|
||||||
import { getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
import { getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||||||
import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail'
|
import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail'
|
||||||
import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
||||||
|
|||||||
@ -5,15 +5,20 @@
|
|||||||
* 用于控制计价法组件在清除/重建默认数据时的竞态。
|
* 用于控制计价法组件在清除/重建默认数据时的竞态。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { readCurrentProjectId } from '@/lib/workspace'
|
||||||
|
|
||||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
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 => {
|
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)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@ -40,7 +45,7 @@ export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean
|
|||||||
* 读取后立即清除标记(一次性)
|
* 读取后立即清除标记(一次性)
|
||||||
*/
|
*/
|
||||||
export const shouldForceDefaultLoad = (dbKey: string): 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)
|
const raw = sessionStorage.getItem(storageKey)
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
const forceUntil = Number(raw)
|
const forceUntil = Number(raw)
|
||||||
@ -54,7 +59,7 @@ export const shouldForceDefaultLoad = (dbKey: string): boolean => {
|
|||||||
* @param durationMs 有效时长(毫秒),默认 3000ms
|
* @param durationMs 有效时长(毫秒),默认 3000ms
|
||||||
*/
|
*/
|
||||||
export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
|
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()
|
const now = Date.now()
|
||||||
sessionStorage.setItem(storageKey, `${now}:${now + durationMs}`)
|
sessionStorage.setItem(storageKey, `${now}:${now + durationMs}`)
|
||||||
}
|
}
|
||||||
@ -65,6 +70,6 @@ export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
|
|||||||
* @param durationMs 有效时长(毫秒),默认 3000ms
|
* @param durationMs 有效时长(毫秒),默认 3000ms
|
||||||
*/
|
*/
|
||||||
export const markForceDefaultLoad = (dbKey: string, durationMs = 3000): void => {
|
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))
|
sessionStorage.setItem(storageKey, String(Date.now() + durationMs))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getMajorDictEntries, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
import { getMajorDictEntries, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
import { toFiniteNumberOrNull } from '@/lib/decimal'
|
||||||
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||||||
import type {
|
import type {
|
||||||
ScaleCalcRow,
|
ScaleCalcRow,
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { getBasicFeeFromScale } from '@/sql'
|
import { getBasicFeeFromScale } from '@/sql'
|
||||||
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
|
import { addNumbers, roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
|
||||||
|
|
||||||
type ScaleMode = 'cost' | 'area'
|
type ScaleMode = 'cost' | 'area'
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { taskList } from '@/sql'
|
import { taskList } from '@/sql'
|
||||||
import { roundTo, toDecimal } from '@/lib/decimal'
|
import { roundTo, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
|
||||||
import { getDefaultConsultCategoryFactor } from '@/lib/pricingScaleCalc'
|
import { getDefaultConsultCategoryFactor } from '@/lib/pricingScaleCalc'
|
||||||
import type { WorkloadCalcRow, TaskLite } from '@/types/pricing'
|
import type { WorkloadCalcRow, TaskLite } from '@/types/pricing'
|
||||||
|
|
||||||
|
|||||||
149
src/lib/projectRegistry.ts
Normal file
149
src/lib/projectRegistry.ts
Normal file
@ -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<string, number>()
|
||||||
|
|
||||||
|
const defaultProjects = (): ProjectMeta[] => {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeProject = (item: Partial<ProjectMeta> | 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<ProjectRegistryPayload>
|
||||||
|
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
|
||||||
|
}
|
||||||
159
src/lib/projectSessionLock.ts
Normal file
159
src/lib/projectSessionLock.ts
Normal file
@ -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<LockPayload>
|
||||||
|
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<typeof setInterval> | 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<string>()
|
||||||
|
for (const projectId of projectIds) {
|
||||||
|
if (hasActiveProjectSessionLock(projectId)) {
|
||||||
|
result.add(String(projectId || '').trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { serviceList } from '@/sql'
|
import { serviceList } from '@/sql'
|
||||||
import { roundTo } from '@/lib/decimal'
|
import { roundTo, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
|
||||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||||
|
export { toFiniteNumber, toFiniteNumberOrZero }
|
||||||
|
|
||||||
interface ScaleMethodRowLike {
|
interface ScaleMethodRowLike {
|
||||||
id: string
|
id: string
|
||||||
@ -168,21 +169,6 @@ interface ExportMethod5DetailLike {
|
|||||||
remark: string
|
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 => {
|
export const toSafeInteger = (value: unknown): number | null => {
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
if (!Number.isInteger(num)) return null
|
if (!Number.isInteger(num)) return null
|
||||||
@ -579,17 +565,7 @@ export const buildServiceFee = (
|
|||||||
method4: { fee: number } | null
|
method4: { fee: number } | null
|
||||||
) => {
|
) => {
|
||||||
const subtotal = toFiniteNumber(row?.subtotal)
|
const subtotal = toFiniteNumber(row?.subtotal)
|
||||||
if (subtotal != null) return subtotal
|
return subtotal != null ? toMoney(subtotal) : 0
|
||||||
|
|
||||||
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)
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildMethod0 = (payload: RateMethodRowLike | null | undefined) => {
|
export const buildMethod0 = (payload: RateMethodRowLike | null | undefined) => {
|
||||||
@ -644,8 +620,7 @@ export const buildServiceFinalFee = (
|
|||||||
method4: { fee: number } | null
|
method4: { fee: number } | null
|
||||||
) => {
|
) => {
|
||||||
const finalFee = toFiniteNumber(row?.finalFee)
|
const finalFee = toFiniteNumber(row?.finalFee)
|
||||||
if (finalFee != null) return finalFee
|
if (finalFee != null) return toMoney(finalFee)
|
||||||
const subtotal = toFiniteNumber(row?.subtotal)
|
const subtotal = toFiniteNumber(row?.subtotal)
|
||||||
if (subtotal != null) return subtotal
|
return subtotal != null ? toMoney(subtotal) : 0
|
||||||
return sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 FIXED_WORKSPACE_TAB_IDS = [PROJECT_TAB_ID, QUICK_TAB_ID] as const
|
||||||
|
|
||||||
export const WORKSPACE_MODE_STORAGE_KEY = 'jgjs-workspace-mode-v1'
|
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_ID = 'quick-contract-default'
|
||||||
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
|
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
|
||||||
@ -59,3 +64,55 @@ export const consumePendingHomeImportFile = () => {
|
|||||||
pendingHomeImportFile = null
|
pendingHomeImportFile = null
|
||||||
return file
|
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}`
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
import { toFiniteNumberOrNull } from '@/lib/decimal'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { roundTo, sumByNumber } from '@/lib/decimal'
|
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||||
import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals'
|
import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals'
|
||||||
import {
|
import {
|
||||||
isSameNullableNumber,
|
isSameNullableNumber,
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
parseScopedRowId,
|
parseScopedRowId,
|
||||||
resolveScaleRowMajorDictId as resolveRowMajorDictId
|
resolveScaleRowMajorDictId as resolveRowMajorDictId
|
||||||
} from '@/lib/pricingScaleLink'
|
} from '@/lib/pricingScaleLink'
|
||||||
|
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
import { getServiceDictItemById } from '@/sql'
|
import { getServiceDictItemById } from '@/sql'
|
||||||
import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing'
|
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)
|
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<string | number>) =>
|
||||||
|
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<string>) => {
|
const matchesChangedScaleRow = (row: ScaleDetailRow, changedRowIds?: Set<string>) => {
|
||||||
if (!changedRowIds || changedRowIds.size === 0) return true
|
if (!changedRowIds || changedRowIds.size === 0) return true
|
||||||
const directRowId = String(row.id || '').trim()
|
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<string>
|
||||||
|
majorFactorMap: Map<string, number | null>
|
||||||
|
}) => {
|
||||||
|
const store = useZxFwPricingStore()
|
||||||
|
const methodState = await store.loadServicePricingMethodState<ScaleDetailRow>(
|
||||||
|
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<WorkloadDetailRow>(
|
||||||
|
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<ContractFactorSyncResult> => {
|
||||||
|
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<string>()
|
||||||
|
|
||||||
|
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: {
|
export const syncPricingTotalToZxFw = async (params: {
|
||||||
contractId: string
|
contractId: string
|
||||||
serviceId: string | number
|
serviceId: string | number
|
||||||
|
|||||||
21
src/main.ts
21
src/main.ts
@ -23,6 +23,8 @@ import piniaPersistedstate from '@/pinia/Plugin/indexdb'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import { ensureProjectIdInUrl, getProjectDbName } from '@/lib/workspace'
|
||||||
|
import { listProjects } from '@/lib/projectRegistry'
|
||||||
|
|
||||||
LicenseManager.setLicenseKey(
|
LicenseManager.setLicenseKey(
|
||||||
'[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b'
|
'[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b'
|
||||||
@ -47,9 +49,28 @@ const AG_GRID_MODULES = [
|
|||||||
LocaleModule,ValidationModule ,CellSpanModule ,RowStyleModule ,RowSelectionModule ,ServerSideRowModelApiModule
|
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 pinia = createPinia()
|
||||||
|
const currentProjectId = pickBootstrapProjectId()
|
||||||
pinia.use(
|
pinia.use(
|
||||||
piniaPersistedstate({
|
piniaPersistedstate({
|
||||||
|
name: getProjectDbName(currentProjectId),
|
||||||
storeName: 'pinia',
|
storeName: 'pinia',
|
||||||
mode: 'multiple'
|
mode: 'multiple'
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import type { PiniaPluginContext } from 'pinia'
|
import type { PiniaPluginContext } from 'pinia'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
|
import { touchProjectEdited } from '@/lib/projectRegistry'
|
||||||
|
import { QUICK_PROJECT_ID, readCurrentProjectId } from '@/lib/workspace'
|
||||||
|
|
||||||
export type PersistOption = boolean | {
|
export type PersistOption = boolean | {
|
||||||
key?: string
|
key?: string
|
||||||
@ -84,6 +86,7 @@ export default (config?: PiniaStorageConfig) => {
|
|||||||
if (!persistOptions) return
|
if (!persistOptions) return
|
||||||
|
|
||||||
const storeId = context.store.$id
|
const storeId = context.store.$id
|
||||||
|
const shouldTouchProjectEditedAt = storeId !== 'tabs'
|
||||||
const baseStoreName = forageConfig.storeName || baseConfig.storeName || 'pinia-storage'
|
const baseStoreName = forageConfig.storeName || baseConfig.storeName || 'pinia-storage'
|
||||||
const resolvedStoreName = mode === 'multiple' ? `${baseStoreName}-${storeId}` : baseStoreName
|
const resolvedStoreName = mode === 'multiple' ? `${baseStoreName}-${storeId}` : baseStoreName
|
||||||
const key = persistOptions.key || (mode === 'single' ? `${baseStoreName}-${storeId}` : resolvedStoreName)
|
const key = persistOptions.key || (mode === 'single' ? `${baseStoreName}-${storeId}` : resolvedStoreName)
|
||||||
@ -96,6 +99,12 @@ export default (config?: PiniaStorageConfig) => {
|
|||||||
const clonedState = JSON.parse(JSON.stringify(state))
|
const clonedState = JSON.parse(JSON.stringify(state))
|
||||||
return lf.setItem(key, trimStringValuesDeep(clonedState))
|
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
|
const storeExt = context.store as typeof context.store & PersistStoreExt
|
||||||
|
|
||||||
storeExt.$persistNow = async () => {
|
storeExt.$persistNow = async () => {
|
||||||
@ -105,6 +114,7 @@ export default (config?: PiniaStorageConfig) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await writeState(context.store.$state)
|
await writeState(context.store.$state)
|
||||||
|
markProjectEdited()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('pinia persist failed:', error)
|
console.error('pinia persist failed:', error)
|
||||||
}
|
}
|
||||||
@ -124,12 +134,17 @@ export default (config?: PiniaStorageConfig) => {
|
|||||||
|
|
||||||
context.store.$subscribe(
|
context.store.$subscribe(
|
||||||
(_mutation, state) => {
|
(_mutation, state) => {
|
||||||
if (!hydrating) userMutatedBeforeHydrate = true
|
if (hydrating) return
|
||||||
|
userMutatedBeforeHydrate = true
|
||||||
if (timer) clearTimeout(timer)
|
if (timer) clearTimeout(timer)
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
void writeState(state).catch(error => {
|
void writeState(state)
|
||||||
console.error('pinia persist failed:', error)
|
.then(() => {
|
||||||
})
|
markProjectEdited()
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('pinia persist failed:', error)
|
||||||
|
})
|
||||||
}, Math.max(0, persistDebounce))
|
}, Math.max(0, persistDebounce))
|
||||||
},
|
},
|
||||||
{ detached: true }
|
{ detached: true }
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { addNumbers } from '@/lib/decimal'
|
import {
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
addNumbers,
|
||||||
|
roundTo,
|
||||||
|
sumNullableNumbers,
|
||||||
|
toFiniteNumberOrNull,
|
||||||
|
toFiniteNumberOrZero
|
||||||
|
} from '@/lib/decimal'
|
||||||
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
||||||
import {
|
import {
|
||||||
parseHtFeeMainStorageKey,
|
parseHtFeeMainStorageKey,
|
||||||
@ -68,17 +73,9 @@ const toKey = (contractId: string | number) => String(contractId || '').trim()
|
|||||||
const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim()
|
const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim()
|
||||||
const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) =>
|
const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) =>
|
||||||
`${METHOD_STORAGE_PREFIX_MAP[method]}-${contractId}-${serviceId}`
|
`${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 | undefined>): number | null => {
|
|
||||||
const validValues = values.filter(isFiniteNumberValue)
|
|
||||||
if (validValues.length === 0) return null
|
|
||||||
return addNumbers(...validValues)
|
|
||||||
}
|
|
||||||
const round3Nullable = (value: number | null | undefined) => {
|
const round3Nullable = (value: number | null | undefined) => {
|
||||||
const numeric = toFiniteNumberOrNull(value)
|
const numeric = toFiniteNumberOrNull(value)
|
||||||
return numeric == null ? null : round3(numeric)
|
return numeric == null ? null : roundTo(numeric, 3)
|
||||||
}
|
}
|
||||||
const normalizeProcessValue = (value: unknown, rowId: string) => {
|
const normalizeProcessValue = (value: unknown, rowId: string) => {
|
||||||
if (rowId === FIXED_ROW_ID) return null
|
if (rowId === FIXED_ROW_ID) return null
|
||||||
@ -89,6 +86,37 @@ const cloneAny = <T>(value: T): T => {
|
|||||||
if (value == null) return value
|
if (value == null) return value
|
||||||
return JSON.parse(JSON.stringify(value)) as T
|
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[] =>
|
const normalizeRows = (rows: unknown): ZxFwDetailRow[] =>
|
||||||
(Array.isArray(rows) ? rows : []).map(item => {
|
(Array.isArray(rows) ? rows : []).map(item => {
|
||||||
@ -422,6 +450,157 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
const loadHtFeeMethodState = htFeeStore.loadHtFeeMethodState
|
const loadHtFeeMethodState = htFeeStore.loadHtFeeMethodState
|
||||||
const removeHtFeeMethodState = htFeeStore.removeHtFeeMethodState
|
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<number | null> => {
|
||||||
|
const mainState = await loadHtFeeMainState<HtFeeMainDetailRowLike>(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<HtRateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||||||
|
loadHtFeeMethodState<HtHourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||||
|
loadHtFeeMethodState<HtQuantityMethodStateLike>(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 = <T = unknown>(keyRaw: string | number): T | null => {
|
const getKeyState = <T = unknown>(keyRaw: string | number): T | null => {
|
||||||
const key = toKey(keyRaw)
|
const key = toKey(keyRaw)
|
||||||
if (!key) return null
|
if (!key) return null
|
||||||
@ -675,7 +854,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
if (isSameState(current, nextState)) return false
|
if (isSameState(current, nextState)) return false
|
||||||
contracts.value[contractId] = nextState
|
contracts.value[contractId] = nextState
|
||||||
contractLoaded.value[contractId] = true
|
contractLoaded.value[contractId] = true
|
||||||
const targetRow = nextState.detailRows.find(row => String(row.id || '') === targetServiceId)
|
await syncHtExtraFeeByContractBase(contractId)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -687,7 +866,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
if (!state?.detailRows?.length) return null
|
if (!state?.detailRows?.length) return null
|
||||||
const fixedRow = state.detailRows.find(row => String(row.id || '') === FIXED_ROW_ID)
|
const fixedRow = state.detailRows.find(row => String(row.id || '') === FIXED_ROW_ID)
|
||||||
const fixedFinalFee = toFiniteNumberOrNull(fixedRow?.finalFee)
|
const fixedFinalFee = toFiniteNumberOrNull(fixedRow?.finalFee)
|
||||||
if (fixedFinalFee != null) return round3(fixedFinalFee)
|
if (fixedFinalFee != null) return roundTo(fixedFinalFee, 3)
|
||||||
|
|
||||||
let hasValid = false
|
let hasValid = false
|
||||||
const sum = state.detailRows.reduce((acc, row) => {
|
const sum = state.detailRows.reduce((acc, row) => {
|
||||||
@ -696,7 +875,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
if (fee != null) hasValid = true
|
if (fee != null) hasValid = true
|
||||||
return fee == null ? acc : acc + fee
|
return fee == null ? acc : acc + fee
|
||||||
}, 0)
|
}, 0)
|
||||||
return hasValid ? round3(sum) : null
|
return hasValid ? roundTo(sum, 3) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理合同维度的内存态、版本号以及该合同下所有相关持久化键。
|
// 清理合同维度的内存态、版本号以及该合同下所有相关持久化键。
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
|
import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal'
|
||||||
import { formatThousands } from '@/lib/numberFormat'
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
import ExcelJS from "ExcelJS";
|
import ExcelJS from "ExcelJS";
|
||||||
// 统一数字千分位格式化,默认保留 2 位小数。
|
// 统一数字千分位格式化,默认保留 2 位小数。
|
||||||
@ -7,10 +7,7 @@ const numberFormatter = (value: unknown, fractionDigits = 2) =>
|
|||||||
formatThousands(value, fractionDigits)
|
formatThousands(value, fractionDigits)
|
||||||
|
|
||||||
// 将任意输入安全转为有限数字;无效值统一按 0 处理。
|
// 将任意输入安全转为有限数字;无效值统一按 0 处理。
|
||||||
const toFiniteNumber = (value: unknown) => {
|
const toFiniteNumber = toFiniteNumberOrZero
|
||||||
const num = Number(value)
|
|
||||||
return Number.isFinite(num) ? num : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容导出 tasks 对象结构:[{ text: [] }, { serviceid, text: [] }]
|
// 兼容导出 tasks 对象结构:[{ text: [] }, { serviceid, text: [] }]
|
||||||
const normalizeTaskTexts = (tasks: unknown): string[] => {
|
const normalizeTaskTexts = (tasks: unknown): string[] => {
|
||||||
|
|||||||
@ -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"}
|
{"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"}
|
||||||
Loading…
x
Reference in New Issue
Block a user