1
This commit is contained in:
1 parent
d5cae61861
commit
eb415cd0d0
33 files changed
+2642
-382
No files matched your search
@@ -113,6 +113,7 @@ const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
|
||||
return round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee))
|
||||
}
|
||||
const toFiniteUnknown = (value: unknown): number | null => {
|
||||
if (value == null || value === '') return null
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
@@ -332,12 +333,14 @@ const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
|
||||
)
|
||||
const rows = sourceRows.map(item => {
|
||||
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
|
||||
|
||||
return {
|
||||
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
||||
name:
|
||||
typeof row.name === 'string'
|
||||
? row.name
|
||||
: (typeof row.feeItem === 'string' ? row.feeItem : ''),
|
||||
|
||||
rateFee: typeof row.rateFee === 'number' ? row.rateFee : null,
|
||||
hourlyFee: typeof row.hourlyFee === 'number' ? row.hourlyFee : null,
|
||||
quantityUnitPriceFee:
|
||||
|
||||
@@ -40,7 +40,7 @@ interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
const BASE_INFO_KEY = 'xm-base-info-v1'
|
||||
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
|
||||
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
||||
const kvStore = useKvStore()
|
||||
|
||||
@@ -198,7 +198,7 @@ const applyPinnedTotalAmount = (
|
||||
const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
|
||||
try {
|
||||
const [baseInfo, contractData] = await Promise.all([
|
||||
kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY),
|
||||
kvStore.getItem<XmBaseInfoState>(props.baseInfoKey || 'xm-base-info-v1'),
|
||||
kvStore.getItem<XmScaleState>(props.dbKey)
|
||||
])
|
||||
|
||||
@@ -296,6 +296,7 @@ const props = defineProps<{
|
||||
title: string
|
||||
dbKey: string
|
||||
xmInfoKey?: string | null
|
||||
baseInfoKey?: string
|
||||
}>()
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -444,11 +445,29 @@ const saveToIndexedDB = async () => {
|
||||
hide: Boolean(row.hide),
|
||||
isGroupRow: false
|
||||
}))
|
||||
const totalAmountFromRows = (() => {
|
||||
let hasValue = false
|
||||
let total = 0
|
||||
for (const row of leafRows) {
|
||||
const amount = row?.amount
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) continue
|
||||
total += amount
|
||||
hasValue = true
|
||||
}
|
||||
return hasValue ? roundTo(total, 2) : null
|
||||
})()
|
||||
const pinnedAmount = pinnedTopRowData.value[0].amount
|
||||
const normalizedPinnedAmount =
|
||||
typeof pinnedAmount === 'number' && Number.isFinite(pinnedAmount)
|
||||
? roundTo(pinnedAmount, 2)
|
||||
: null
|
||||
const normalizedTotalAmount = roughCalcEnabled.value ? normalizedPinnedAmount : totalAmountFromRows
|
||||
pinnedTopRowData.value[0].amount = normalizedTotalAmount
|
||||
const payload: GridPersistState = {
|
||||
detailRows: [...leafRows, ...buildGroupRows(leafRows)]
|
||||
}
|
||||
payload.roughCalcEnabled = roughCalcEnabled.value
|
||||
payload.totalAmount = pinnedTopRowData.value[0].amount
|
||||
payload.totalAmount = normalizedTotalAmount
|
||||
await kvStore.setItem(props.dbKey, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
@@ -462,6 +481,18 @@ const schedulePersist = () => {
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const handleFlushPersistRequest = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ done?: () => void }>
|
||||
const done = customEvent?.detail?.done
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = null
|
||||
}
|
||||
void saveToIndexedDB().finally(() => {
|
||||
done?.()
|
||||
})
|
||||
}
|
||||
|
||||
const setDetailRowsHidden = (hidden: boolean) => {
|
||||
for (const row of detailRows.value) {
|
||||
row.hide = hidden
|
||||
@@ -566,9 +597,14 @@ const syncPinnedTotalForNormalMode = () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
window.removeEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -580,7 +616,7 @@ onBeforeUnmount(() => {
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class=" text-xs text-muted-foreground">粗略计算</span>
|
||||
<span class=" text-xs text-muted-foreground">简要计算</span>
|
||||
<SwitchRoot
|
||||
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
|
||||
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { Calculator, Check, ChevronDown, Download, FolderKanban, X, Zap } from 'lucide-vue-next'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectIcon,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import {
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_CONTRACT_FALLBACK_NAME,
|
||||
QUICK_CONTRACT_ID,
|
||||
QUICK_CONTRACT_META_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY,
|
||||
QUICK_PROJECT_INFO_KEY,
|
||||
writeWorkspaceMode
|
||||
} from '@/lib/workspace'
|
||||
|
||||
interface QuickProjectInfoState {
|
||||
projectIndustry?: string
|
||||
projectName?: string
|
||||
preparedBy?: string
|
||||
reviewedBy?: string
|
||||
preparedCompany?: string
|
||||
preparedDate?: string
|
||||
}
|
||||
|
||||
interface QuickContractMetaState {
|
||||
id?: string
|
||||
name?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface ProjectInfoState {
|
||||
projectIndustry?: string
|
||||
projectName?: string
|
||||
preparedBy?: string
|
||||
reviewedBy?: string
|
||||
preparedCompany?: string
|
||||
preparedDate?: string
|
||||
}
|
||||
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
||||
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
|
||||
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
||||
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const kvStore = useKvStore()
|
||||
const projectDialogOpen = ref(false)
|
||||
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const projectSubmitting = ref(false)
|
||||
const quickDialogOpen = ref(false)
|
||||
const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
||||
const quickSubmitting = ref(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 enterProjectCalc = () => {
|
||||
writeWorkspaceMode('project')
|
||||
tabStore.enterWorkspace({
|
||||
id: PROJECT_TAB_ID,
|
||||
title: '项目计算',
|
||||
componentName: PROJECT_TAB_ID
|
||||
})
|
||||
}
|
||||
|
||||
const loadProjectDefaults = async () => {
|
||||
const savedInfo = await kvStore.getItem<ProjectInfoState>(PROJECT_INFO_KEY)
|
||||
projectIndustry.value =
|
||||
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
||||
? savedInfo.projectIndustry.trim()
|
||||
: String(industryTypeList[0]?.id || '')
|
||||
}
|
||||
|
||||
const openProjectCalc = async () => {
|
||||
const savedInfo = await kvStore.getItem<ProjectInfoState>(PROJECT_INFO_KEY)
|
||||
if (typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()) {
|
||||
enterProjectCalc()
|
||||
return
|
||||
}
|
||||
await loadProjectDefaults()
|
||||
projectDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeProjectCalcDialog = () => {
|
||||
projectDialogOpen.value = false
|
||||
}
|
||||
|
||||
const confirmProjectCalc = async () => {
|
||||
const industry = projectIndustry.value.trim()
|
||||
if (!industry) return
|
||||
|
||||
projectSubmitting.value = true
|
||||
try {
|
||||
await kvStore.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
|
||||
projectIndustry: industry,
|
||||
projectName: DEFAULT_PROJECT_NAME,
|
||||
preparedBy: '',
|
||||
reviewedBy: '',
|
||||
preparedCompany: '',
|
||||
preparedDate: getTodayDateString()
|
||||
})
|
||||
await initializeProjectFactorStates(
|
||||
kvStore,
|
||||
industry,
|
||||
PROJECT_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
PROJECT_MAJOR_FACTOR_KEY
|
||||
)
|
||||
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
|
||||
enterProjectCalc()
|
||||
} finally {
|
||||
projectSubmitting.value = false
|
||||
projectDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadQuickDefaults = async () => {
|
||||
const [savedInfo, savedMeta] = await Promise.all([
|
||||
kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY),
|
||||
kvStore.getItem<QuickContractMetaState>(QUICK_CONTRACT_META_KEY)
|
||||
])
|
||||
|
||||
quickIndustry.value =
|
||||
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
||||
? savedInfo.projectIndustry.trim()
|
||||
: String(industryTypeList[0]?.id || '')
|
||||
quickContractName.value =
|
||||
typeof savedMeta?.name === 'string' && savedMeta.name.trim()
|
||||
? savedMeta.name.trim()
|
||||
: QUICK_CONTRACT_FALLBACK_NAME
|
||||
}
|
||||
|
||||
const openQuickCalcDialog = async () => {
|
||||
await loadQuickDefaults()
|
||||
quickDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeQuickCalcDialog = () => {
|
||||
quickDialogOpen.value = false
|
||||
}
|
||||
|
||||
const confirmQuickCalc = async () => {
|
||||
const contractName = quickContractName.value.trim()
|
||||
const industry = quickIndustry.value.trim()
|
||||
if (!contractName || !industry) return
|
||||
|
||||
quickSubmitting.value = true
|
||||
try {
|
||||
const currentInfo = await kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY)
|
||||
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||||
...currentInfo,
|
||||
projectIndustry: industry,
|
||||
projectName: '快速计算'
|
||||
})
|
||||
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
||||
id: QUICK_CONTRACT_ID,
|
||||
name: contractName,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
await initializeProjectFactorStates(
|
||||
kvStore,
|
||||
industry,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY
|
||||
)
|
||||
|
||||
writeWorkspaceMode('quick')
|
||||
tabStore.enterWorkspace({
|
||||
id: `contract-${QUICK_CONTRACT_ID}`,
|
||||
title: contractName,
|
||||
componentName: 'ContractDetailView',
|
||||
props: {
|
||||
contractId: QUICK_CONTRACT_ID,
|
||||
contractName,
|
||||
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
||||
projectScaleKey: null,
|
||||
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
quickSubmitting.value = false
|
||||
quickDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleHomeImportChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
window.dispatchEvent(new CustomEvent('home-import-selected', {
|
||||
detail: {
|
||||
file
|
||||
}
|
||||
}))
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectDefaults()
|
||||
void loadQuickDefaults()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input id="home-import-input" type="file" accept=".zw" class="sr-only" @change="handleHomeImportChange" />
|
||||
<div class="flex min-h-full items-center justify-center px-4 py-8">
|
||||
<div class="w-full max-w-5xl">
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
<p class="text-sm font-medium tracking-[0.28em] text-muted-foreground">JGJS 2026</p>
|
||||
<h1 class="mt-4 text-4xl font-semibold tracking-tight text-foreground">选择计算入口</h1>
|
||||
<p class="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
首页支持三种入口:继续项目计算、快速单合同测算,或直接导入已有数据包。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 grid items-stretch gap-5 md:grid-cols-3">
|
||||
<Card
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex h-full cursor-pointer flex-col border-border/70 bg-card/90 shadow-[0_18px_48px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_22px_56px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="openProjectCalc"
|
||||
@keydown.enter.prevent="openProjectCalc"
|
||||
@keydown.space.prevent="openProjectCalc"
|
||||
>
|
||||
<CardHeader class="flex min-h-[184px] flex-col space-y-4 pb-4">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900 text-white">
|
||||
<FolderKanban class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="min-h-[88px]">
|
||||
<CardTitle class="text-2xl">项目计算</CardTitle>
|
||||
<CardDescription class="mt-2 text-sm leading-6">
|
||||
继续使用当前项目卡片、流程线、合同段管理和整项目导入导出能力。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="mt-auto flex flex-1 flex-col justify-end space-y-4">
|
||||
<div class="flex min-h-[72px] items-center rounded-2xl border border-border/60 bg-muted/35 p-4 text-sm text-muted-foreground">
|
||||
适合多合同段、项目级系数维护、整项目报表和批量导入导出。
|
||||
</div>
|
||||
<Button class="w-full justify-between" @click.stop="openProjectCalc">
|
||||
进入项目计算
|
||||
<Calculator class="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="flex h-full cursor-pointer flex-col border-border/70 bg-[linear-gradient(135deg,rgba(15,23,42,0.05),rgba(148,163,184,0.12))] shadow-[0_18px_48px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_22px_56px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="openQuickCalcDialog"
|
||||
@keydown.enter.prevent="openQuickCalcDialog"
|
||||
@keydown.space.prevent="openQuickCalcDialog"
|
||||
>
|
||||
<CardHeader class="flex min-h-[184px] flex-col space-y-4 pb-4">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-500 text-slate-950">
|
||||
<Zap class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="min-h-[88px]">
|
||||
<CardTitle class="text-2xl">快速计算</CardTitle>
|
||||
<CardDescription class="mt-2 text-sm leading-6">
|
||||
先填写工程行业和合同名称,再直接进入单合同预算费用测算页面。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="mt-auto flex flex-1 flex-col justify-end space-y-4">
|
||||
<div class="flex min-h-[72px] items-center rounded-2xl border border-border/60 bg-background/80 p-4 text-sm text-muted-foreground">
|
||||
适合快速试算、单合同预算复核和不需要项目级合同段管理的场景。
|
||||
</div>
|
||||
<Button variant="outline" class="w-full justify-between" @click.stop="openQuickCalcDialog">
|
||||
进入快速计算
|
||||
<Zap class="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<label for="home-import-input" class="block h-full cursor-pointer">
|
||||
<Card
|
||||
class="flex h-full flex-col border-border/70 bg-[linear-gradient(135deg,rgba(14,116,144,0.08),rgba(186,230,253,0.26))] shadow-[0_18px_48px_rgba(15,23,42,0.08)] transition hover:-translate-y-0.5 hover:shadow-[0_22px_56px_rgba(15,23,42,0.12)]"
|
||||
>
|
||||
<CardHeader class="flex min-h-[184px] flex-col space-y-4 pb-4">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-cyan-600 text-white">
|
||||
<Download class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="min-h-[88px]">
|
||||
<CardTitle class="text-2xl">导入数据</CardTitle>
|
||||
<CardDescription class="mt-2 text-sm leading-6">
|
||||
直接导入已有 `.zw` 数据包,恢复项目计算或快速计算的本地工作区状态。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="mt-auto flex flex-1 flex-col justify-end space-y-4">
|
||||
<div class="flex min-h-[72px] items-center rounded-2xl border border-border/60 bg-background/80 p-4 text-sm text-muted-foreground">
|
||||
适合继续上次工作、切换设备恢复数据,或直接打开别人发来的测算包。
|
||||
</div>
|
||||
<div class="inline-flex h-10 w-full items-center justify-between rounded-md border border-input bg-secondary px-4 text-sm font-medium text-secondary-foreground shadow-xs transition-colors">
|
||||
<span>选择导入文件</span>
|
||||
<Download class="h-4 w-4" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectDialogOpen"
|
||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
||||
@click.self="closeProjectCalcDialog"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-foreground">新建项目</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">选择工程行业后,直接进入项目计算页面。</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeProjectCalcDialog">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-medium text-foreground">工程行业</span>
|
||||
<SelectRoot v-model="projectIndustry">
|
||||
<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="`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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
|
||||
<Button variant="outline" @click="closeProjectCalcDialog">取消</Button>
|
||||
<Button :disabled="projectSubmitting || !projectIndustry" @click="confirmProjectCalc">
|
||||
{{ projectSubmitting ? '进入中...' : '进入项目计算' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="quickDialogOpen"
|
||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
||||
@click.self="closeQuickCalcDialog"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b px-5 py-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-foreground">快速计算</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">填写工程行业和合同名称后,直接进入单合同计算页面。</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeQuickCalcDialog">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 px-5 py-4">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-medium text-foreground">工程行业</span>
|
||||
<SelectRoot v-model="quickIndustry">
|
||||
<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="`quick-${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>
|
||||
</label>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-sm font-medium text-foreground">合同名称</span>
|
||||
<input
|
||||
v-model="quickContractName"
|
||||
type="text"
|
||||
maxlength="40"
|
||||
placeholder="请输入合同名称"
|
||||
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@keydown.enter="confirmQuickCalc"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
|
||||
<Button variant="outline" @click="closeQuickCalcDialog">取消</Button>
|
||||
<Button :disabled="quickSubmitting || !quickIndustry || !quickContractName.trim()" @click="confirmQuickCalc">
|
||||
{{ quickSubmitting ? '进入中...' : '进入快速计算' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+42
-11
@@ -45,9 +45,24 @@ interface DataEntry {
|
||||
interface ContractSegmentPackage {
|
||||
version: number
|
||||
exportedAt: string
|
||||
projectIndustry: string
|
||||
packageType?: 'contract-segments'
|
||||
project?: {
|
||||
industry: string
|
||||
}
|
||||
storage?: {
|
||||
localforageEntries: DataEntry[]
|
||||
}
|
||||
contracts: ContractItem[]
|
||||
localforageEntries: DataEntry[]
|
||||
projectIndustry?: string
|
||||
localforageEntries?: DataEntry[]
|
||||
pinia?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
servicePricingStates?: Record<string, unknown>
|
||||
htFeeMainStates?: Record<string, unknown>
|
||||
htFeeMethodStates?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
piniaState?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
@@ -99,7 +114,7 @@ interface QuantityMethodStateLike {
|
||||
|
||||
const STORAGE_KEY = 'ht-card-v1'
|
||||
const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
|
||||
const CONTRACT_SEGMENT_VERSION = 2
|
||||
const CONTRACT_SEGMENT_VERSION = 3
|
||||
const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
|
||||
const SERVICE_KEY_PREFIX = 'zxFW-'
|
||||
const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
|
||||
@@ -274,6 +289,7 @@ const getCurrentProjectIndustry = async (): Promise<string> => {
|
||||
}
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | null => {
|
||||
if (value == null || value === '') return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
@@ -607,6 +623,15 @@ const normalizeDataEntries = (value: unknown): DataEntry[] => {
|
||||
}))
|
||||
}
|
||||
|
||||
const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
|
||||
projectIndustry:
|
||||
typeof payload.project?.industry === 'string' && payload.project.industry.trim()
|
||||
? payload.project.industry.trim()
|
||||
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
|
||||
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
|
||||
piniaState: payload.pinia ?? payload.piniaState
|
||||
})
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
|
||||
@@ -799,11 +824,16 @@ const exportSelectedContracts = async () => {
|
||||
const now = new Date()
|
||||
const payload: ContractSegmentPackage = {
|
||||
version: CONTRACT_SEGMENT_VERSION,
|
||||
packageType: 'contract-segments',
|
||||
exportedAt: now.toISOString(),
|
||||
projectIndustry,
|
||||
project: {
|
||||
industry: projectIndustry
|
||||
},
|
||||
contracts: selectedContracts,
|
||||
localforageEntries,
|
||||
piniaState: {
|
||||
storage: {
|
||||
localforageEntries
|
||||
},
|
||||
pinia: {
|
||||
zxFwPricing: piniaPayload
|
||||
}
|
||||
}
|
||||
@@ -845,16 +875,17 @@ const importContractSegments = async (event: Event) => {
|
||||
if (!isContractSegmentPackage(payload)) {
|
||||
throw new Error('INVALID_CONTRACT_SEGMENT_PAYLOAD')
|
||||
}
|
||||
const normalizedPackage = normalizeContractSegmentPackage(payload)
|
||||
|
||||
const currentProjectIndustry = await getCurrentProjectIndustry()
|
||||
if (!currentProjectIndustry) {
|
||||
throw new Error('CURRENT_PROJECT_INDUSTRY_MISSING')
|
||||
}
|
||||
if (typeof payload.projectIndustry !== 'string' || !payload.projectIndustry.trim()) {
|
||||
if (!normalizedPackage.projectIndustry) {
|
||||
throw new Error('IMPORT_PACKAGE_INDUSTRY_MISSING')
|
||||
}
|
||||
if (payload.projectIndustry.trim() !== currentProjectIndustry) {
|
||||
throw new Error(`PROJECT_INDUSTRY_MISMATCH:${payload.projectIndustry.trim()}:${currentProjectIndustry}`)
|
||||
if (normalizedPackage.projectIndustry !== currentProjectIndustry) {
|
||||
throw new Error(`PROJECT_INDUSTRY_MISMATCH:${normalizedPackage.projectIndustry}:${currentProjectIndustry}`)
|
||||
}
|
||||
|
||||
const importedContracts = normalizeContractsFromPayload(payload.contracts)
|
||||
@@ -862,7 +893,7 @@ const importContractSegments = async (event: Event) => {
|
||||
throw new Error('EMPTY_CONTRACTS')
|
||||
}
|
||||
|
||||
const importedEntries = normalizeDataEntries(payload.localforageEntries)
|
||||
const importedEntries = normalizedPackage.localforageEntries
|
||||
const usedIds = new Set(contracts.value.map(item => item.id))
|
||||
const oldToNewIdMap = new Map<string, string>()
|
||||
const nextContracts: ContractItem[] = importedContracts.map((item, index) => {
|
||||
@@ -889,7 +920,7 @@ const importContractSegments = async (event: Event) => {
|
||||
})
|
||||
|
||||
await Promise.all(rewrittenEntries.map(entry => kvStore.setItem(entry.key, entry.value)))
|
||||
await applyImportedContractPiniaPayload(payload.piniaState, oldToNewIdMap)
|
||||
await applyImportedContractPiniaPayload(normalizedPackage.piniaState, oldToNewIdMap)
|
||||
|
||||
contracts.value = [...contracts.value, ...nextContracts]
|
||||
await saveContracts()
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
projectInfoKey?: string
|
||||
parentStorageKey?: string
|
||||
}>()
|
||||
|
||||
interface XmBaseInfoState {
|
||||
@@ -20,13 +22,13 @@ type ServiceItem = {
|
||||
notshowByzxflxs?: boolean
|
||||
}
|
||||
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const projectIndustry = ref('')
|
||||
const kvStore = useKvStore()
|
||||
|
||||
const loadProjectIndustry = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
|
||||
projectIndustry.value =
|
||||
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
} catch (error) {
|
||||
@@ -57,7 +59,7 @@ onActivated(() => {
|
||||
<XmFactorGrid
|
||||
title="咨询分类系数明细"
|
||||
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
|
||||
parent-storage-key="xm-consult-category-factor-v1"
|
||||
:parent-storage-key="props.parentStorageKey || 'xm-consult-category-factor-v1'"
|
||||
:dict="filteredServiceDict"
|
||||
:disable-budget-edit-when-standard-null="true"
|
||||
:exclude-notshow-by-zxflxs="true"
|
||||
|
||||
@@ -18,15 +18,17 @@ type MajorItem = {
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
projectInfoKey?: string
|
||||
parentStorageKey?: string
|
||||
}>()
|
||||
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const projectIndustry = ref('')
|
||||
const kvStore = useKvStore()
|
||||
|
||||
const loadProjectIndustry = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
|
||||
projectIndustry.value =
|
||||
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
} catch (error) {
|
||||
@@ -57,7 +59,7 @@ onActivated(() => {
|
||||
<XmFactorGrid
|
||||
title="工程专业系数明细"
|
||||
:storage-key="`ht-major-factor-v1-${props.contractId}`"
|
||||
parent-storage-key="xm-major-factor-v1"
|
||||
:parent-storage-key="props.parentStorageKey || 'xm-major-factor-v1'"
|
||||
:dict="filteredMajorDict"
|
||||
:disable-budget-edit-when-standard-null="true"
|
||||
:exclude-notshow-by-zxflxs="true"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onActivated, onMounted } from 'vue'
|
||||
import XmCard from '@/components/views/xmCard.vue'
|
||||
import { writeWorkspaceMode } from '@/lib/workspace'
|
||||
|
||||
onMounted(() => {
|
||||
writeWorkspaceMode('project')
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
writeWorkspaceMode('project')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<XmCard />
|
||||
</template>
|
||||
@@ -0,0 +1,413 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { ArrowRight, Calculator, PencilLine } from 'lucide-vue-next'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||||
import {
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_CONTRACT_FALLBACK_NAME,
|
||||
QUICK_CONTRACT_ID,
|
||||
QUICK_CONTRACT_META_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY,
|
||||
QUICK_PROJECT_INFO_KEY,
|
||||
QUICK_PROJECT_SCALE_KEY,
|
||||
createDefaultQuickContractMeta,
|
||||
writeWorkspaceMode
|
||||
} from '@/lib/workspace'
|
||||
|
||||
interface QuickProjectInfoState {
|
||||
projectIndustry?: string
|
||||
projectName?: string
|
||||
preparedBy?: string
|
||||
reviewedBy?: string
|
||||
preparedCompany?: string
|
||||
preparedDate?: string
|
||||
}
|
||||
|
||||
interface QuickContractMetaState {
|
||||
id?: string
|
||||
name?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface HtFeeMainRowLike {
|
||||
id?: unknown
|
||||
}
|
||||
|
||||
interface RateMethodStateLike {
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
interface HourlyMethodRowLike {
|
||||
serviceBudget?: unknown
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
}
|
||||
|
||||
interface HourlyMethodStateLike {
|
||||
detailRows?: HourlyMethodRowLike[]
|
||||
}
|
||||
|
||||
interface QuantityMethodRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
|
||||
interface QuantityMethodStateLike {
|
||||
detailRows?: QuantityMethodRowLike[]
|
||||
}
|
||||
|
||||
const kvStore = useKvStore()
|
||||
const tabStore = useTabStore()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
|
||||
const contractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
||||
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const quickBudget = ref<number | null>(null)
|
||||
const savingIndustry = ref(false)
|
||||
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const availableIndustries = computed(() =>
|
||||
industryTypeList.map(item => ({
|
||||
id: String(item.id),
|
||||
name: item.name
|
||||
}))
|
||||
)
|
||||
|
||||
const formatBudgetAmount = (value: number | null | undefined) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | null => {
|
||||
if (value == null || value === '') return null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
const serviceBudget = toFiniteNumber(row?.serviceBudget)
|
||||
if (serviceBudget != null) {
|
||||
total += serviceBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteNumber(row?.personnelCount)
|
||||
const workday = toFiniteNumber(row?.workdayCount)
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 2) : null
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||||
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
|
||||
if (subtotal != null) return roundTo(subtotal, 2)
|
||||
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteNumber(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteNumber(row?.quantity)
|
||||
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 2) : null
|
||||
}
|
||||
|
||||
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
|
||||
const [rateState, hourlyState, quantityState] = await Promise.all([
|
||||
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||||
])
|
||||
const parts = [
|
||||
toFiniteNumber(rateState?.budgetFee),
|
||||
sumHourlyMethodFee(hourlyState),
|
||||
sumQuantityMethodFee(quantityState)
|
||||
]
|
||||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
if (validParts.length === 0) return null
|
||||
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||||
}
|
||||
|
||||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
|
||||
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
|
||||
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
|
||||
if (rowIds.length === 0) return null
|
||||
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
|
||||
const validTotals = rowTotals.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
if (validTotals.length === 0) return null
|
||||
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
|
||||
}
|
||||
|
||||
const refreshQuickBudget = async () => {
|
||||
await zxFwPricingStore.loadContract(QUICK_CONTRACT_ID)
|
||||
const serviceFee = zxFwPricingStore.getBaseSubtotal(QUICK_CONTRACT_ID)
|
||||
const [additionalFee, reserveFee] = await Promise.all([
|
||||
loadHtMainTotalFee(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work`),
|
||||
loadHtMainTotalFee(`htExtraFee-${QUICK_CONTRACT_ID}-reserve`)
|
||||
])
|
||||
const parts = [serviceFee, additionalFee, reserveFee]
|
||||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
quickBudget.value = validParts.length === 0 ? null : roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||||
}
|
||||
|
||||
const scheduleRefreshQuickBudget = () => {
|
||||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||||
budgetRefreshTimer = setTimeout(() => {
|
||||
void refreshQuickBudget()
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const normalizeQuickContractMeta = (value: QuickContractMetaState | null) => ({
|
||||
id: typeof value?.id === 'string' && value.id.trim() ? value.id.trim() : QUICK_CONTRACT_ID,
|
||||
name: typeof value?.name === 'string' && value.name.trim() ? value.name.trim() : QUICK_CONTRACT_FALLBACK_NAME,
|
||||
updatedAt:
|
||||
typeof value?.updatedAt === 'string' && value.updatedAt.trim()
|
||||
? value.updatedAt
|
||||
: new Date().toISOString()
|
||||
})
|
||||
|
||||
const ensureQuickWorkspaceReady = async () => {
|
||||
const [savedInfo, savedMeta] = await Promise.all([
|
||||
kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY),
|
||||
kvStore.getItem<QuickContractMetaState>(QUICK_CONTRACT_META_KEY)
|
||||
])
|
||||
|
||||
const defaultIndustry = String(industryTypeList[0]?.id || '')
|
||||
const nextIndustry =
|
||||
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
||||
? savedInfo.projectIndustry.trim()
|
||||
: defaultIndustry
|
||||
|
||||
projectIndustry.value = nextIndustry
|
||||
contractName.value = normalizeQuickContractMeta(savedMeta).name
|
||||
|
||||
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||||
projectIndustry: nextIndustry,
|
||||
projectName: '快速计算'
|
||||
})
|
||||
|
||||
const consultState = await kvStore.getItem(QUICK_CONSULT_CATEGORY_FACTOR_KEY)
|
||||
const majorState = await kvStore.getItem(QUICK_MAJOR_FACTOR_KEY)
|
||||
if (!consultState || !majorState) {
|
||||
await initializeProjectFactorStates(
|
||||
kvStore,
|
||||
nextIndustry,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY
|
||||
)
|
||||
}
|
||||
|
||||
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
||||
...createDefaultQuickContractMeta(),
|
||||
...normalizeQuickContractMeta(savedMeta)
|
||||
})
|
||||
}
|
||||
|
||||
const persistQuickContractMeta = async () => {
|
||||
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
||||
id: QUICK_CONTRACT_ID,
|
||||
name: contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const persistQuickIndustry = async (industry: string) => {
|
||||
if (!industry) return
|
||||
savingIndustry.value = true
|
||||
try {
|
||||
const current = await kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY)
|
||||
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||||
...current,
|
||||
projectIndustry: industry,
|
||||
projectName: '快速计算'
|
||||
})
|
||||
await initializeProjectFactorStates(
|
||||
kvStore,
|
||||
industry,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY
|
||||
)
|
||||
} finally {
|
||||
savingIndustry.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openQuickContract = () => {
|
||||
writeWorkspaceMode('quick')
|
||||
tabStore.openTab({
|
||||
id: `contract-${QUICK_CONTRACT_ID}`,
|
||||
title: `快速计算-${contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME}`,
|
||||
componentName: 'ContractDetailView',
|
||||
props: {
|
||||
contractId: QUICK_CONTRACT_ID,
|
||||
contractName: contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME,
|
||||
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
||||
projectScaleKey: QUICK_PROJECT_SCALE_KEY,
|
||||
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => contractName.value,
|
||||
() => {
|
||||
void persistQuickContractMeta()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => projectIndustry.value,
|
||||
nextIndustry => {
|
||||
if (!nextIndustry) return
|
||||
void persistQuickIndustry(nextIndustry)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
zxFwPricingStore.contractVersions[QUICK_CONTRACT_ID] || 0,
|
||||
zxFwPricingStore.getKeyVersion(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work`),
|
||||
zxFwPricingStore.getKeyVersion(`htExtraFee-${QUICK_CONTRACT_ID}-reserve`),
|
||||
Object.entries(zxFwPricingStore.keyVersions)
|
||||
.filter(([key]) =>
|
||||
key.startsWith(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work-`) ||
|
||||
key.startsWith(`htExtraFee-${QUICK_CONTRACT_ID}-reserve-`)
|
||||
)
|
||||
.map(([key, version]) => `${key}:${version}`)
|
||||
.join('|')
|
||||
],
|
||||
scheduleRefreshQuickBudget
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
writeWorkspaceMode('quick')
|
||||
await ensureQuickWorkspaceReady()
|
||||
await refreshQuickBudget()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
writeWorkspaceMode('quick')
|
||||
scheduleRefreshQuickBudget()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.25fr)_minmax(320px,0.75fr)]">
|
||||
<Card class="border-border/70">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-2xl">
|
||||
<Calculator class="h-5 w-5" />
|
||||
快速计算
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
保留一个默认合同卡片,不再经过项目卡片入口,直接进入单合同预算费用计算。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="grid gap-4 md:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-sm font-medium text-foreground">工程行业</span>
|
||||
<select
|
||||
v-model="projectIndustry"
|
||||
class="h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option v-for="item in availableIndustries" :key="item.id" :value="item.id">
|
||||
{{ item.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-sm font-medium text-foreground">合同名称</span>
|
||||
<div class="relative">
|
||||
<PencilLine class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
v-model="contractName"
|
||||
type="text"
|
||||
maxlength="40"
|
||||
class="h-11 w-full rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="请输入合同名称"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="border-border/70 bg-muted/25">
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-base">当前状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="grid gap-2 text-sm text-muted-foreground">
|
||||
<div>模式:单合同快速计算</div>
|
||||
<div>合同ID:{{ QUICK_CONTRACT_ID }}</div>
|
||||
<div>行业切换会同步重建快速计算专用系数基线</div>
|
||||
<div>{{ savingIndustry ? '正在切换行业并刷新系数...' : '行业与合同名称已自动保存' }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
class="group cursor-pointer border-border/70 transition-colors hover:border-primary"
|
||||
@click="openQuickContract"
|
||||
>
|
||||
<CardHeader class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-2">
|
||||
<CardTitle class="text-xl">
|
||||
{{ contractName.trim() || QUICK_CONTRACT_FALLBACK_NAME }}
|
||||
</CardTitle>
|
||||
<CardDescription>默认单合同卡片,点击后进入预算费用计算详情。</CardDescription>
|
||||
</div>
|
||||
<Button class="shrink-0 md:self-center" @click.stop="openQuickContract">
|
||||
进入计算
|
||||
<ArrowRight class="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="grid gap-4 border-t pt-5 text-sm text-muted-foreground md:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">合同ID</div>
|
||||
<div class="mt-2 break-all text-foreground">{{ QUICK_CONTRACT_ID }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">预算费用</div>
|
||||
<div class="mt-2 text-foreground">{{ formatBudgetAmount(quickBudget) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">行业</div>
|
||||
<div class="mt-2 text-foreground">
|
||||
{{ availableIndustries.find(item => item.id === projectIndustry)?.name || '--' }}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,6 +28,7 @@ const props = defineProps<{
|
||||
serviceId: string|number
|
||||
fwName:string
|
||||
type?: ServiceMethodType
|
||||
projectInfoKey?: string
|
||||
}>()
|
||||
|
||||
interface PricingCategoryItem {
|
||||
@@ -64,7 +65,11 @@ const createPricingPane = (name: string) =>
|
||||
}
|
||||
})
|
||||
|
||||
return () => h(AsyncPricingView, { contractId: props.contractId, serviceId: props.serviceId })
|
||||
return () => h(AsyncPricingView, {
|
||||
contractId: props.contractId,
|
||||
serviceId: props.serviceId,
|
||||
projectInfoKey: props.projectInfoKey
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
scene="ht-tab"
|
||||
:title="`合同段:${contractName}`"
|
||||
:subtitle="`合同段ID:${contractId}`"
|
||||
:meta-text="`合同段预算金额:${formatBudgetAmount(contractBudget)}`"
|
||||
:copy-text="contractId"
|
||||
:storage-key="`project-active-cat-${contractId}`"
|
||||
default-category="info"
|
||||
@@ -12,14 +13,170 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { markRaw, defineAsyncComponent, defineComponent, h, 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 { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
|
||||
// 1. 完善 Props 类型 + 添加校验(可选但推荐)
|
||||
const props = defineProps<{
|
||||
contractId: string; // 合同ID(必传)
|
||||
contractName: string; // 合同名称(必传)
|
||||
projectInfoKey?: string; // 工作区基础信息键
|
||||
projectScaleKey?: string | null; // 工作区规模信息键
|
||||
projectConsultCategoryFactorKey?: string; // 工作区咨询分类系数键
|
||||
projectMajorFactorKey?: string; // 工作区工程专业系数键
|
||||
}>();
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
|
||||
interface HtFeeMainRowLike {
|
||||
id?: unknown
|
||||
}
|
||||
|
||||
interface RateMethodStateLike {
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
interface HourlyMethodRowLike {
|
||||
serviceBudget?: unknown
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
}
|
||||
|
||||
interface HourlyMethodStateLike {
|
||||
detailRows?: HourlyMethodRowLike[]
|
||||
}
|
||||
|
||||
interface QuantityMethodRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
|
||||
interface QuantityMethodStateLike {
|
||||
detailRows?: QuantityMethodRowLike[]
|
||||
}
|
||||
|
||||
const contractBudget = ref<number | 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) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
||||
|
||||
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
const serviceBudget = toFiniteNumber(row?.serviceBudget)
|
||||
if (serviceBudget != null) {
|
||||
total += serviceBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteNumber(row?.personnelCount)
|
||||
const workday = toFiniteNumber(row?.workdayCount)
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 2) : null
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
|
||||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||||
if (rows.length === 0) return null
|
||||
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||||
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
|
||||
if (subtotal != null) return roundTo(subtotal, 2)
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteNumber(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteNumber(row?.quantity)
|
||||
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? roundTo(total, 2) : null
|
||||
}
|
||||
|
||||
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
|
||||
const [rateState, hourlyState, quantityState] = await Promise.all([
|
||||
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||||
])
|
||||
const parts = [
|
||||
toFiniteNumber(rateState?.budgetFee),
|
||||
sumHourlyMethodFee(hourlyState),
|
||||
sumQuantityMethodFee(quantityState)
|
||||
]
|
||||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
if (validParts.length === 0) return null
|
||||
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||||
}
|
||||
|
||||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||||
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
|
||||
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
|
||||
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
|
||||
if (rowIds.length === 0) return null
|
||||
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
|
||||
const validTotals = rowTotals.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
if (validTotals.length === 0) return null
|
||||
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
|
||||
}
|
||||
|
||||
const refreshContractBudget = async () => {
|
||||
await zxFwPricingStore.loadContract(props.contractId)
|
||||
const serviceFee = zxFwPricingStore.getBaseSubtotal(props.contractId)
|
||||
const [additionalFee, reserveFee] = await Promise.all([
|
||||
loadHtMainTotalFee(`htExtraFee-${props.contractId}-additional-work`),
|
||||
loadHtMainTotalFee(`htExtraFee-${props.contractId}-reserve`)
|
||||
])
|
||||
const parts = [serviceFee, additionalFee, reserveFee]
|
||||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||||
contractBudget.value = validParts.length === 0 ? null : roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||||
}
|
||||
|
||||
const budgetRefreshSignature = computed(() => {
|
||||
const contractVersion = zxFwPricingStore.contractVersions[props.contractId] || 0
|
||||
const additionalMainKey = `htExtraFee-${props.contractId}-additional-work`
|
||||
const reserveMainKey = `htExtraFee-${props.contractId}-reserve`
|
||||
const keyVersionEntries = Object.entries(zxFwPricingStore.keyVersions)
|
||||
const methodKeySig = keyVersionEntries
|
||||
.filter(([key]) => key.startsWith(`${additionalMainKey}-`) || key.startsWith(`${reserveMainKey}-`))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, version]) => `${key}:${version}`)
|
||||
.join(',')
|
||||
return `${contractVersion}:${zxFwPricingStore.getKeyVersion(additionalMainKey)}:${zxFwPricingStore.getKeyVersion(reserveMainKey)}:${methodKeySig}`
|
||||
})
|
||||
|
||||
const scheduleRefreshContractBudget = () => {
|
||||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||||
budgetRefreshTimer = setTimeout(() => {
|
||||
void refreshContractBudget()
|
||||
}, 80)
|
||||
}
|
||||
|
||||
// 2. 定义分类项的 TS 类型(核心:明确 categories 结构)
|
||||
interface XmCategoryItem {
|
||||
@@ -39,7 +196,11 @@ const htView = markRaw(
|
||||
console.error('加载 htInfo 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtInfo, { contractId: props.contractId });
|
||||
return () => h(AsyncHtInfo, {
|
||||
contractId: props.contractId,
|
||||
projectScaleKey: props.projectScaleKey,
|
||||
projectInfoKey: props.projectInfoKey
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -54,7 +215,11 @@ const zxfwView = markRaw(
|
||||
console.error('加载 zxFw 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncZxFw, { contractId: props.contractId, contractName: props.contractName });
|
||||
return () => h(AsyncZxFw, {
|
||||
contractId: props.contractId,
|
||||
contractName: props.contractName,
|
||||
projectInfoKey: props.projectInfoKey
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -69,7 +234,11 @@ const consultCategoryFactorView = markRaw(
|
||||
console.error('加载 HtConsultCategoryFactor 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtConsultCategoryFactor, { contractId: props.contractId });
|
||||
return () => h(AsyncHtConsultCategoryFactor, {
|
||||
contractId: props.contractId,
|
||||
projectInfoKey: props.projectInfoKey,
|
||||
parentStorageKey: props.projectConsultCategoryFactorKey
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -84,7 +253,11 @@ const majorFactorView = markRaw(
|
||||
console.error('加载 HtMajorFactor 组件失败:', err);
|
||||
}
|
||||
});
|
||||
return () => h(AsyncHtMajorFactor, { contractId: props.contractId });
|
||||
return () => h(AsyncHtMajorFactor, {
|
||||
contractId: props.contractId,
|
||||
projectInfoKey: props.projectInfoKey,
|
||||
parentStorageKey: props.projectMajorFactorKey
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -130,4 +303,21 @@ const xmCategories: XmCategoryItem[] = [
|
||||
{ key: 'reserve-fee', label: '预备费', component: reserveFeeView },
|
||||
|
||||
];
|
||||
|
||||
watch(budgetRefreshSignature, (next, prev) => {
|
||||
if (next === prev) return
|
||||
scheduleRefreshContractBudget()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void refreshContractBudget()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void refreshContractBudget()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -5,13 +5,19 @@ import CommonAgGrid from '@/components/common/xmCommonAgGrid.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
projectScaleKey?: string | null
|
||||
projectInfoKey?: string
|
||||
}>()
|
||||
const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
||||
const XM_DB_KEY = 'xm-info-v3'
|
||||
const XM_DB_KEY = computed(() => {
|
||||
if (props.projectScaleKey === null) return undefined
|
||||
return props.projectScaleKey || 'xm-info-v3'
|
||||
})
|
||||
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CommonAgGrid title="合同规模明细" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY"/>
|
||||
<CommonAgGrid title="合同规模明细" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/>
|
||||
</template>
|
||||
@@ -2,12 +2,7 @@
|
||||
import { parseDate } from '@internationalized/date'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
getMajorDictEntries,
|
||||
getServiceDictEntries,
|
||||
getIndustryTypeValue,
|
||||
industryTypeList,
|
||||
isIndustryEnabledByType,
|
||||
isMajorIdInIndustryScope
|
||||
} from '@/sql'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next'
|
||||
@@ -45,31 +40,10 @@ interface XmInfoState {
|
||||
}
|
||||
|
||||
type MajorParentNode = { id: string; name: string }
|
||||
type DictItemLite = {
|
||||
code?: string
|
||||
name?: string
|
||||
defCoe?: number | null
|
||||
notshowByzxflxs?: boolean
|
||||
}
|
||||
type FactorPersistRow = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
standardFactor: number | null
|
||||
budgetValue: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
type FactorPersistState = {
|
||||
detailRows: FactorPersistRow[]
|
||||
}
|
||||
|
||||
const DB_KEY = 'xm-base-info-v1'
|
||||
const XM_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
||||
const XM_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
|
||||
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
||||
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
|
||||
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
|
||||
const getTodayDateString = () => {
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
@@ -79,8 +53,6 @@ const getTodayDateString = () => {
|
||||
}
|
||||
|
||||
const isProjectInitialized = ref(false)
|
||||
const showCreateDialog = ref(false)
|
||||
const pendingIndustry = ref('')
|
||||
|
||||
const projectName = ref('')
|
||||
const projectIndustry = ref('')
|
||||
@@ -128,76 +100,6 @@ const majorParentCodeSet = new Set(majorParentNodes.map(item => item.id))
|
||||
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.id || ''
|
||||
const kvStore = useKvStore()
|
||||
|
||||
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
|
||||
const parts = code.split('-').filter(Boolean)
|
||||
if (!parts.length) return [selfId]
|
||||
const path: string[] = []
|
||||
let currentCode = parts[0]
|
||||
const firstId = codeIdMap.get(currentCode)
|
||||
if (firstId) path.push(firstId)
|
||||
for (let i = 1; i < parts.length; i += 1) {
|
||||
currentCode = `${currentCode}-${parts[i]}`
|
||||
const id = codeIdMap.get(currentCode)
|
||||
if (id) path.push(id)
|
||||
}
|
||||
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
|
||||
return path
|
||||
}
|
||||
|
||||
const buildFactorRowsFromEntries = (entries: Array<{ id: string; item: DictItemLite }>): FactorPersistRow[] => {
|
||||
const codeIdMap = new Map<string, string>()
|
||||
for (const entry of entries) {
|
||||
const code = String(entry.item?.code || '').trim()
|
||||
if (!code) continue
|
||||
codeIdMap.set(code, entry.id)
|
||||
}
|
||||
return entries
|
||||
.map(entry => {
|
||||
const code = String(entry.item?.code || '').trim()
|
||||
const name = String(entry.item?.name || '').trim()
|
||||
if (!code || !name) return null
|
||||
const standardFactor =
|
||||
typeof entry.item?.defCoe === 'number' && Number.isFinite(entry.item.defCoe)
|
||||
? entry.item.defCoe
|
||||
: null
|
||||
return {
|
||||
id: entry.id,
|
||||
code,
|
||||
name,
|
||||
standardFactor,
|
||||
budgetValue: standardFactor,
|
||||
remark: '',
|
||||
path: buildCodePath(code, entry.id, codeIdMap)
|
||||
}
|
||||
})
|
||||
.filter((item): item is FactorPersistRow => Boolean(item))
|
||||
}
|
||||
|
||||
const initializeProjectFactorStates = async (industry: string) => {
|
||||
const industryType = getIndustryTypeValue(industry)
|
||||
const consultEntries = getServiceDictEntries()
|
||||
.map(({ id, item }) => ({ id, item: item as DictItemLite }))
|
||||
.filter(({ item }) => {
|
||||
if (item.notshowByzxflxs === true) return false
|
||||
return isIndustryEnabledByType(item as Record<string, unknown>, industryType)
|
||||
})
|
||||
const majorEntries = getMajorDictEntries()
|
||||
.map(({ id, item }) => ({ id, item: item as DictItemLite }))
|
||||
.filter(({ id, item }) => item.notshowByzxflxs !== true && isMajorIdInIndustryScope(id, industry))
|
||||
|
||||
const consultPayload: FactorPersistState = {
|
||||
detailRows: buildFactorRowsFromEntries(consultEntries)
|
||||
}
|
||||
const majorPayload: FactorPersistState = {
|
||||
detailRows: buildFactorRowsFromEntries(majorEntries)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
kvStore.setItem(XM_CONSULT_CATEGORY_FACTOR_KEY, consultPayload),
|
||||
kvStore.setItem(XM_MAJOR_FACTOR_KEY, majorPayload)
|
||||
])
|
||||
}
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload: XmInfoState = {
|
||||
@@ -268,38 +170,6 @@ const handleProjectNameBlur = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
pendingIndustry.value = DEFAULT_PROJECT_INDUSTRY
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
const closeCreateDialog = () => {
|
||||
showCreateDialog.value = false
|
||||
}
|
||||
|
||||
const createProject = async () => {
|
||||
const selectedIndustry = majorParentCodeSet.has(pendingIndustry.value)
|
||||
? pendingIndustry.value
|
||||
: DEFAULT_PROJECT_INDUSTRY
|
||||
|
||||
projectIndustry.value = selectedIndustry
|
||||
projectName.value = DEFAULT_PROJECT_NAME
|
||||
preparedBy.value = ''
|
||||
reviewedBy.value = ''
|
||||
preparedCompany.value = ''
|
||||
preparedDate.value = getTodayDateString()
|
||||
syncPreparedDatePickerFromString()
|
||||
isProjectInitialized.value = true
|
||||
showCreateDialog.value = false
|
||||
await saveToIndexedDB()
|
||||
try {
|
||||
await initializeProjectFactorStates(selectedIndustry)
|
||||
} catch (error) {
|
||||
console.error('initializeProjectFactorStates failed:', error)
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
|
||||
}
|
||||
|
||||
watch(
|
||||
[projectIndustry, projectName, preparedBy, reviewedBy, preparedCompany, preparedDate],
|
||||
schedulePersist
|
||||
@@ -315,15 +185,9 @@ onMounted(async () => {
|
||||
<div class="space-y-6 h-full">
|
||||
<div
|
||||
v-if="!isProjectInitialized"
|
||||
class=" bg-card p-10 h-full flex items-center justify-center"
|
||||
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer h-10 rounded-lg bg-primary px-6 text-sm font-medium text-primary-foreground transition hover:opacity-90"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
新建项目
|
||||
</button>
|
||||
请从首页先新建项目后再进入此页面。
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
|
||||
@@ -516,44 +380,6 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showCreateDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
|
||||
@click.self="closeCreateDialog"
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-xl border bg-card p-5 shadow-lg">
|
||||
<h3 class="text-base font-semibold text-foreground">新建项目</h3>
|
||||
<p class="mt-1 text-sm text-muted-foreground">请选择工程行业</p>
|
||||
<div class="mt-4 max-h-72 space-y-2 overflow-auto rounded-lg border p-3">
|
||||
<label
|
||||
v-for="item in majorParentNodes"
|
||||
:key="item.id"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/60"
|
||||
>
|
||||
<input v-model="pendingIndustry" type="radio" :value="item.id" class="h-4 w-4 accent-primary" />
|
||||
<span class="text-sm text-foreground"> {{ item.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer h-9 rounded-lg border px-4 text-sm text-foreground transition hover:bg-muted"
|
||||
@click="closeCreateDialog"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer h-9 rounded-lg bg-primary px-4 text-sm text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!pendingIndustry"
|
||||
@click="createProject"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -92,6 +92,7 @@ interface ServiceLite {
|
||||
const props = defineProps<{
|
||||
contractId: string,
|
||||
serviceId: string | number
|
||||
projectInfoKey?: string
|
||||
}>()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const kvStore = useKvStore()
|
||||
@@ -99,7 +100,7 @@ const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
|
||||
const HT_DB_KEY = computed(() => `ht-info-v3-${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 BASE_INFO_KEY = 'xm-base-info-v1'
|
||||
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const activeIndustryCode = ref('')
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
@@ -1149,7 +1150,7 @@ const applyProjectCountChange = async (nextValue: unknown) => {
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||||
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
|
||||
activeIndustryCode.value =
|
||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
projectCount.value = 1
|
||||
@@ -1214,7 +1215,7 @@ const loadFromIndexedDB = async () => {
|
||||
|
||||
const importContractData = async () => {
|
||||
try {
|
||||
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||||
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
|
||||
activeIndustryCode.value =
|
||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ interface ServiceLite {
|
||||
const props = defineProps<{
|
||||
contractId: string,
|
||||
serviceId: string | number
|
||||
projectInfoKey?: string
|
||||
}>()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const kvStore = useKvStore()
|
||||
@@ -99,7 +100,7 @@ const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
|
||||
const HT_DB_KEY = computed(() => `ht-info-v3-${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 BASE_INFO_KEY = 'xm-base-info-v1'
|
||||
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const activeIndustryCode = ref('')
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
@@ -997,7 +998,7 @@ const applyProjectCountChange = async (nextValue: unknown) => {
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||||
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
|
||||
activeIndustryCode.value =
|
||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
projectCount.value = 1
|
||||
@@ -1049,7 +1050,7 @@ const loadFromIndexedDB = async () => {
|
||||
|
||||
const importContractData = async () => {
|
||||
try {
|
||||
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||||
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
|
||||
activeIndustryCode.value =
|
||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
scene="xm-tab"
|
||||
title=""
|
||||
storage-key="project-active-cat"
|
||||
default-category="info"
|
||||
default-category="scale-info"
|
||||
:categories="xmCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, markRaw, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { defineAsyncComponent, markRaw } from 'vue'
|
||||
import TypeLine from '@/layout/typeLine.vue'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
const infoView = markRaw(defineAsyncComponent(() => import('@/components/views/info.vue')))
|
||||
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
|
||||
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
|
||||
const consultCategoryFactorView = markRaw(
|
||||
@@ -23,49 +20,10 @@ const majorFactorView = markRaw(
|
||||
defineAsyncComponent(() => import('@/components/views/XmMajorFactor.vue'))
|
||||
)
|
||||
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
|
||||
|
||||
const hasProjectBaseInfo = ref(false)
|
||||
const kvStore = useKvStore()
|
||||
|
||||
const fullXmCategories = [
|
||||
{ key: 'info', label: '基础信息', component: infoView },
|
||||
{ key: 'contract', label: '合同段管理', component: htView },
|
||||
const xmCategories = [
|
||||
{ key: 'scale-info', label: '规模信息', component: scaleInfoView },
|
||||
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
|
||||
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }
|
||||
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView },
|
||||
{ key: 'contract', label: '合同段管理', component: htView }
|
||||
]
|
||||
|
||||
const xmCategories = computed(() =>
|
||||
hasProjectBaseInfo.value ? fullXmCategories : [fullXmCategories[0]]
|
||||
)
|
||||
|
||||
const refreshProjectBaseInfoState = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem(PROJECT_INFO_KEY)
|
||||
hasProjectBaseInfo.value = Boolean(data)
|
||||
} catch (error) {
|
||||
console.error('read project base info failed:', error)
|
||||
hasProjectBaseInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleProjectInitChanged = (event: Event) => {
|
||||
const detail = (event as CustomEvent<boolean>).detail
|
||||
if (typeof detail === 'boolean') {
|
||||
hasProjectBaseInfo.value = detail
|
||||
return
|
||||
}
|
||||
void refreshProjectBaseInfoState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshProjectBaseInfoState()
|
||||
window.addEventListener(PROJECT_INIT_CHANGED_EVENT, handleProjectInitChanged as EventListener)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(PROJECT_INIT_CHANGED_EVENT, handleProjectInitChanged as EventListener)
|
||||
})
|
||||
</script>
|
||||
@@ -74,11 +74,12 @@ interface ServiceMethodType {
|
||||
const props = defineProps<{
|
||||
contractId: string
|
||||
contractName?: string
|
||||
projectInfoKey?: string
|
||||
}>()
|
||||
const tabStore = useTabStore()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const kvStore = useKvStore()
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const PRICING_CLEAR_SKIP_TTL_MS = 5000
|
||||
@@ -500,7 +501,8 @@ const openEditTab = (row: DetailRow) => {
|
||||
contractName: props.contractName || '',
|
||||
serviceId: row.id,
|
||||
fwName: row.code + row.name,
|
||||
type: serviceType ? { ...serviceType } : undefined
|
||||
type: serviceType ? { ...serviceType } : undefined,
|
||||
projectInfoKey: props.projectInfoKey
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1098,7 +1100,7 @@ const initializeContractState = async () => {
|
||||
|
||||
const loadProjectIndustry = async () => {
|
||||
try {
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
|
||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
|
||||
projectIndustry.value =
|
||||
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
|
||||
} catch (error) {
|
||||
|
||||
+450
-82
@@ -27,8 +27,28 @@ import {
|
||||
ToastViewport,
|
||||
} from 'reka-ui'
|
||||
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
||||
import { addNumbers, roundTo } from '@/lib/decimal'
|
||||
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
import { exportFile, serviceList, additionalWorkList, reserveList } from '@/sql'
|
||||
import {
|
||||
HOME_TAB_ID,
|
||||
LEGACY_PROJECT_TAB_ID,
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_CONTRACT_FALLBACK_NAME,
|
||||
QUICK_CONTRACT_ID,
|
||||
QUICK_CONTRACT_META_KEY,
|
||||
QUICK_CONTRACT_TAB_ID,
|
||||
QUICK_MAJOR_FACTOR_KEY,
|
||||
QUICK_PROJECT_INFO_KEY,
|
||||
QUICK_PROJECT_SCALE_KEY,
|
||||
QUICK_TAB_ID,
|
||||
type QuickContractMeta,
|
||||
normalizeWorkspaceMode,
|
||||
readWorkspaceMode,
|
||||
writeWorkspaceMode,
|
||||
type WorkspaceMode
|
||||
} from '@/lib/workspace'
|
||||
|
||||
interface DataEntry {
|
||||
key: string
|
||||
@@ -40,15 +60,29 @@ interface ForageStoreSnapshot {
|
||||
entries: DataEntry[]
|
||||
}
|
||||
|
||||
interface DataPackage {
|
||||
version: number
|
||||
exportedAt: string
|
||||
interface DataPackageStorage {
|
||||
localStorage: DataEntry[]
|
||||
sessionStorage: DataEntry[]
|
||||
localforageDefault: DataEntry[]
|
||||
localforageStores?: ForageStoreSnapshot[]
|
||||
}
|
||||
|
||||
interface DataPackageWorkspace {
|
||||
mode: WorkspaceMode
|
||||
}
|
||||
|
||||
interface DataPackage {
|
||||
version: number
|
||||
exportedAt: string
|
||||
packageType?: 'workspace-snapshot'
|
||||
workspace?: DataPackageWorkspace
|
||||
storage?: DataPackageStorage
|
||||
localStorage?: DataEntry[]
|
||||
sessionStorage?: DataEntry[]
|
||||
localforageDefault?: DataEntry[]
|
||||
localforageStores?: ForageStoreSnapshot[]
|
||||
}
|
||||
|
||||
interface UserGuideStep {
|
||||
title: string
|
||||
description: string
|
||||
@@ -85,6 +119,16 @@ interface ContractCardItem {
|
||||
order?: number
|
||||
}
|
||||
|
||||
interface ReportWorkspaceConfig {
|
||||
mode: WorkspaceMode
|
||||
projectInfoKey: string
|
||||
projectScaleKey: string
|
||||
consultCategoryFactorKey: string
|
||||
majorFactorKey: string
|
||||
contractCardsKey?: string
|
||||
quickContractKey?: string
|
||||
}
|
||||
|
||||
interface ZxFwRowLike {
|
||||
id: string
|
||||
process?: unknown
|
||||
@@ -107,6 +151,8 @@ interface ScaleMethodRowLike extends ScaleRowLike {
|
||||
benchmarkBudget?: unknown
|
||||
benchmarkBudgetBasic?: unknown
|
||||
benchmarkBudgetOptional?: unknown
|
||||
benchmarkBudgetBasicChecked?: unknown
|
||||
benchmarkBudgetOptionalChecked?: unknown
|
||||
budgetFee?: unknown
|
||||
budgetFeeBasic?: unknown
|
||||
budgetFeeOptional?: unknown
|
||||
@@ -139,6 +185,7 @@ interface QuantityMethodRowLike {
|
||||
|
||||
interface WorkloadMethodRowLike {
|
||||
id: string
|
||||
conversion?: unknown
|
||||
budgetAdoptedUnitPrice?: unknown
|
||||
workload?: unknown
|
||||
basicFee?: unknown
|
||||
@@ -343,17 +390,32 @@ interface ExportReserve {
|
||||
}
|
||||
|
||||
interface ExportReportPayload {
|
||||
version: number
|
||||
reportType: 'budget-report'
|
||||
mode: WorkspaceMode
|
||||
name: string
|
||||
writer: string
|
||||
reviewer: string
|
||||
date: string
|
||||
industry: number
|
||||
fee: number
|
||||
scaleCost: number
|
||||
scaleCost: number | null
|
||||
scale: ExportScaleRow[]
|
||||
serviceCoes: ExportServiceCoe[]
|
||||
majorCoes: ExportMajorCoe[]
|
||||
contracts: ExportContract[]
|
||||
project: {
|
||||
name: string
|
||||
writer: string
|
||||
reviewer: string
|
||||
date: string
|
||||
industry: number
|
||||
fee: number
|
||||
scaleCost: number | null
|
||||
scale: ExportScaleRow[]
|
||||
serviceCoes: ExportServiceCoe[]
|
||||
majorCoes: ExportMajorCoe[]
|
||||
}
|
||||
}
|
||||
|
||||
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
|
||||
@@ -364,6 +426,7 @@ const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-v1'
|
||||
const PINIA_PERSIST_DB_NAME = 'DB'
|
||||
const PINIA_PERSIST_BASE_STORE_NAME = 'pinia'
|
||||
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'kv'] as const
|
||||
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
|
||||
const userGuideSteps: UserGuideStep[] = [
|
||||
{
|
||||
title: '欢迎使用',
|
||||
@@ -440,7 +503,9 @@ const userGuideSteps: UserGuideStep[] = [
|
||||
]
|
||||
|
||||
const componentMap: Record<string, any> = {
|
||||
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/xmCard.vue'))),
|
||||
[HOME_TAB_ID]: markRaw(defineAsyncComponent(() => import('@/components/views/HomeEntryView.vue'))),
|
||||
[PROJECT_TAB_ID]: markRaw(defineAsyncComponent(() => import('@/components/views/ProjectWorkspaceView.vue'))),
|
||||
[LEGACY_PROJECT_TAB_ID]: markRaw(defineAsyncComponent(() => import('@/components/views/ProjectWorkspaceView.vue'))),
|
||||
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/htCard.vue'))),
|
||||
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
|
||||
HtFeeMethodTypeLineView: markRaw(defineAsyncComponent(() => import('@/components/views/HtFeeMethodTypeLineView.vue'))),
|
||||
@@ -449,13 +514,15 @@ const componentMap: Record<string, any> = {
|
||||
const tabStore = useTabStore()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const kvStore = useKvStore()
|
||||
const protectedTabIdSet = new Set<string>([HOME_TAB_ID, QUICK_CONTRACT_TAB_ID])
|
||||
const isTabClosable = (tabId: string) => !protectedTabIdSet.has(tabId)
|
||||
|
||||
|
||||
|
||||
const tabContextOpen = ref(false)
|
||||
const tabContextX = ref(0)
|
||||
const tabContextY = ref(0)
|
||||
const contextTabId = ref<string>('XmView')
|
||||
const contextTabId = ref<string>(HOME_TAB_ID)
|
||||
const tabContextRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const dataMenuOpen = ref(false)
|
||||
@@ -491,8 +558,13 @@ const tabsModel = computed({
|
||||
})
|
||||
|
||||
const contextTabIndex = computed(() => tabStore.tabs.findIndex(t => t.id === contextTabId.value))
|
||||
const isHomeOnlyView = computed(
|
||||
() => tabStore.tabs.length === 1 && tabStore.tabs[0]?.id === HOME_TAB_ID && tabStore.activeTabId === HOME_TAB_ID
|
||||
)
|
||||
const showTabStrip = computed(() => !isHomeOnlyView.value)
|
||||
const showWorkspaceActions = computed(() => !isHomeOnlyView.value)
|
||||
|
||||
const hasClosableTabs = computed(() => tabStore.tabs.some(t => t.id !== 'XmView'))
|
||||
const hasClosableTabs = computed(() => tabStore.tabs.some(t => isTabClosable(t.id)))
|
||||
const activeGuideStep = computed(
|
||||
() => userGuideSteps[userGuideStepIndex.value] || userGuideSteps[0]
|
||||
)
|
||||
@@ -501,14 +573,14 @@ const isLastGuideStep = computed(() => userGuideStepIndex.value >= userGuideStep
|
||||
const guideProgressText = computed(() => `${userGuideStepIndex.value + 1} / ${userGuideSteps.length}`)
|
||||
const canCloseLeft = computed(() => {
|
||||
if (contextTabIndex.value <= 0) return false
|
||||
return tabStore.tabs.slice(0, contextTabIndex.value).some(t => t.id !== 'XmView')
|
||||
return tabStore.tabs.slice(0, contextTabIndex.value).some(t => isTabClosable(t.id))
|
||||
})
|
||||
const canCloseRight = computed(() => {
|
||||
if (contextTabIndex.value < 0) return false
|
||||
return tabStore.tabs.slice(contextTabIndex.value + 1).some(t => t.id !== 'XmView')
|
||||
return tabStore.tabs.slice(contextTabIndex.value + 1).some(t => isTabClosable(t.id))
|
||||
})
|
||||
const canCloseOther = computed(() =>
|
||||
tabStore.tabs.some(t => t.id !== 'XmView' && t.id !== contextTabId.value)
|
||||
tabStore.tabs.some(t => isTabClosable(t.id) && t.id !== contextTabId.value)
|
||||
)
|
||||
|
||||
const closeMenus = () => {
|
||||
@@ -516,6 +588,79 @@ const closeMenus = () => {
|
||||
dataMenuOpen.value = false
|
||||
}
|
||||
|
||||
const normalizeTabItem = (tab: Record<string, unknown>) => {
|
||||
const rawId = typeof tab.id === 'string' ? tab.id : ''
|
||||
const rawComponentName = typeof tab.componentName === 'string' ? tab.componentName : rawId
|
||||
if (!rawId && !rawComponentName) return null
|
||||
|
||||
if (rawId === LEGACY_PROJECT_TAB_ID || rawComponentName === LEGACY_PROJECT_TAB_ID) {
|
||||
return {
|
||||
...tab,
|
||||
id: PROJECT_TAB_ID,
|
||||
title: '项目计算',
|
||||
componentName: PROJECT_TAB_ID
|
||||
}
|
||||
}
|
||||
|
||||
if (rawId === HOME_TAB_ID || rawComponentName === HOME_TAB_ID) {
|
||||
return {
|
||||
...tab,
|
||||
id: HOME_TAB_ID,
|
||||
title: typeof tab.title === 'string' && tab.title.trim() ? tab.title : '首页',
|
||||
componentName: HOME_TAB_ID
|
||||
}
|
||||
}
|
||||
|
||||
if (rawId === PROJECT_TAB_ID || rawComponentName === PROJECT_TAB_ID) {
|
||||
return {
|
||||
...tab,
|
||||
id: PROJECT_TAB_ID,
|
||||
title: typeof tab.title === 'string' && tab.title.trim() ? tab.title : '项目计算',
|
||||
componentName: PROJECT_TAB_ID
|
||||
}
|
||||
}
|
||||
|
||||
if (rawId === QUICK_TAB_ID || rawComponentName === QUICK_TAB_ID) {
|
||||
return {
|
||||
...tab,
|
||||
id: HOME_TAB_ID,
|
||||
title: '首页',
|
||||
componentName: HOME_TAB_ID
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...tab,
|
||||
id: rawId || rawComponentName,
|
||||
componentName: rawComponentName || rawId
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeTabStoreState = () => {
|
||||
const normalizedTabs = (Array.isArray(tabStore.tabs) ? tabStore.tabs : [])
|
||||
.map(tab => normalizeTabItem(tab as unknown as Record<string, unknown>))
|
||||
.filter((tab): tab is NonNullable<ReturnType<typeof normalizeTabItem>> => Boolean(tab))
|
||||
|
||||
const uniqueTabs = normalizedTabs.filter((tab, index, source) =>
|
||||
source.findIndex(item => item.id === tab.id) === index
|
||||
)
|
||||
|
||||
const nextTabs = uniqueTabs.filter(tab => !(tab.id === HOME_TAB_ID && uniqueTabs.length > 1))
|
||||
tabStore.tabs = (nextTabs.length > 0
|
||||
? nextTabs
|
||||
: [{
|
||||
id: HOME_TAB_ID,
|
||||
title: '首页',
|
||||
componentName: HOME_TAB_ID
|
||||
}]) as any
|
||||
if (tabStore.activeTabId === LEGACY_PROJECT_TAB_ID) {
|
||||
tabStore.activeTabId = PROJECT_TAB_ID
|
||||
}
|
||||
if (!tabStore.tabs.some(tab => tab.id === tabStore.activeTabId)) {
|
||||
tabStore.activeTabId = tabStore.tabs[0]?.id || HOME_TAB_ID
|
||||
}
|
||||
}
|
||||
|
||||
const clearReportExportToastTimer = () => {
|
||||
if (!reportExportToastTimer) return
|
||||
clearTimeout(reportExportToastTimer)
|
||||
@@ -564,9 +709,9 @@ const hasNonDefaultTabState = () => {
|
||||
if (!raw) return false
|
||||
const parsed = JSON.parse(raw) as { tabs?: Array<{ id?: string }>; activeTabId?: string }
|
||||
const tabs = Array.isArray(parsed?.tabs) ? parsed.tabs : []
|
||||
const hasCustomTabs = tabs.some(item => item?.id && item.id !== 'XmView')
|
||||
const hasCustomTabs = tabs.some(item => item?.id && isTabClosable(item.id))
|
||||
const activeTabId = typeof parsed?.activeTabId === 'string' ? parsed.activeTabId : ''
|
||||
return hasCustomTabs || (activeTabId !== '' && activeTabId !== 'XmView')
|
||||
return hasCustomTabs || (activeTabId !== '' && isTabClosable(activeTabId))
|
||||
} catch (error) {
|
||||
console.error('parse tabs cache failed:', error)
|
||||
return false
|
||||
@@ -574,6 +719,7 @@ const hasNonDefaultTabState = () => {
|
||||
}
|
||||
|
||||
const shouldAutoOpenGuide = async () => {
|
||||
if (readWorkspaceMode() === 'home') return false
|
||||
if (hasGuideCompleted()) return false
|
||||
if (hasNonDefaultTabState()) return false
|
||||
try {
|
||||
@@ -672,7 +818,7 @@ const runTabMenuAction = (action: 'all' | 'left' | 'right' | 'other') => {
|
||||
const canMoveTab = (event: any) => {
|
||||
const draggedId = event?.draggedContext?.element?.id
|
||||
const targetIndex = event?.relatedContext?.index
|
||||
if (draggedId === 'XmView') return false
|
||||
if (protectedTabIdSet.has(draggedId)) return false
|
||||
if (typeof targetIndex === 'number' && targetIndex === 0) return false
|
||||
return true
|
||||
}
|
||||
@@ -889,6 +1035,27 @@ const flushPiniaPersistNow = async () => {
|
||||
])
|
||||
}
|
||||
|
||||
const requestXmScalePersistFlush = async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve()
|
||||
}, 350)
|
||||
window.dispatchEvent(new CustomEvent(XM_SCALE_FLUSH_EVENT, {
|
||||
detail: {
|
||||
done: () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const readForage = async (store: ForageStore): Promise<DataEntry[]> => {
|
||||
const keys = await store.keys()
|
||||
const entries: DataEntry[] = []
|
||||
@@ -954,12 +1121,80 @@ const formatExportTimestamp = (date: Date): string => {
|
||||
return `${yyyy}${mm}${dd}-${hh}${mi}`
|
||||
}
|
||||
|
||||
const getExportProjectName = (entries: DataEntry[]): string => {
|
||||
const getExportProjectName = (entries: DataEntry[], forageStores: ForageStoreSnapshot[] = []): string => {
|
||||
const target =
|
||||
entries.find(item => item.key === PROJECT_INFO_DB_KEY) ||
|
||||
entries.find(item => item.key === LEGACY_PROJECT_DB_KEY)
|
||||
const data = (target?.value || {}) as XmInfoLike
|
||||
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
|
||||
if (typeof data.projectName === 'string' && data.projectName.trim()) {
|
||||
return sanitizeFileNamePart(data.projectName)
|
||||
}
|
||||
|
||||
const quickContract =
|
||||
entries.find(item => item.key === QUICK_CONTRACT_META_KEY) ||
|
||||
forageStores.flatMap(store => store.entries).find(item => item.key === QUICK_CONTRACT_META_KEY)
|
||||
const quickName = (quickContract?.value as QuickContractMeta | null)?.name
|
||||
return typeof quickName === 'string' && quickName.trim()
|
||||
? sanitizeFileNamePart(quickName)
|
||||
: '造价项目'
|
||||
}
|
||||
|
||||
const normalizeDataPackageStorage = (payload: DataPackage): DataPackageStorage => {
|
||||
const storage = payload.storage
|
||||
return {
|
||||
localStorage: normalizeEntries(storage?.localStorage ?? payload.localStorage),
|
||||
sessionStorage: normalizeEntries(storage?.sessionStorage ?? payload.sessionStorage),
|
||||
localforageDefault: normalizeEntries(storage?.localforageDefault ?? payload.localforageDefault),
|
||||
localforageStores: normalizeForageStoreSnapshots(storage?.localforageStores ?? payload.localforageStores)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeDataPackageWorkspace = (payload: DataPackage): DataPackageWorkspace => ({
|
||||
mode: normalizeWorkspaceMode(payload.workspace?.mode ?? readWorkspaceMode())
|
||||
})
|
||||
|
||||
const resolveWorkspaceModeForExport = () => {
|
||||
const activeTab = tabStore.tabs.find(tab => tab.id === tabStore.activeTabId)
|
||||
const activeContractId = typeof activeTab?.props?.contractId === 'string' ? activeTab.props.contractId : ''
|
||||
if (tabStore.activeTabId === QUICK_TAB_ID || activeContractId === QUICK_CONTRACT_ID) return 'quick'
|
||||
if (activeContractId) return 'project'
|
||||
if (tabStore.activeTabId === PROJECT_TAB_ID || tabStore.activeTabId === LEGACY_PROJECT_TAB_ID) return 'project'
|
||||
return normalizeWorkspaceMode(readWorkspaceMode())
|
||||
}
|
||||
|
||||
const getReportWorkspaceConfig = (): ReportWorkspaceConfig => {
|
||||
const mode = resolveWorkspaceModeForExport()
|
||||
if (mode === 'quick') {
|
||||
return {
|
||||
mode,
|
||||
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
||||
projectScaleKey: QUICK_PROJECT_SCALE_KEY,
|
||||
consultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
majorFactorKey: QUICK_MAJOR_FACTOR_KEY,
|
||||
quickContractKey: QUICK_CONTRACT_META_KEY
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'project',
|
||||
projectInfoKey: PROJECT_INFO_DB_KEY,
|
||||
projectScaleKey: LEGACY_PROJECT_DB_KEY,
|
||||
consultCategoryFactorKey: CONSULT_CATEGORY_FACTOR_DB_KEY,
|
||||
majorFactorKey: MAJOR_FACTOR_DB_KEY,
|
||||
contractCardsKey: 'ht-card-v1'
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeQuickContractMeta = (value: unknown): QuickContractMeta => {
|
||||
const raw = (value || {}) as Partial<QuickContractMeta>
|
||||
return {
|
||||
id: typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : QUICK_CONTRACT_ID,
|
||||
name: typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : QUICK_CONTRACT_FALLBACK_NAME,
|
||||
updatedAt:
|
||||
typeof raw.updatedAt === 'string' && raw.updatedAt.trim()
|
||||
? raw.updatedAt
|
||||
: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | null => {
|
||||
@@ -985,6 +1220,74 @@ const sumNumbers = (values: Array<number | null | undefined>): number =>
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
const resolveScaleMethodComputedValues = (
|
||||
row: ScaleMethodRowLike,
|
||||
mode: 'cost' | 'area'
|
||||
) => {
|
||||
const scaleValue = mode === 'cost' ? row.amount : row.landArea
|
||||
const rawSplit = getBenchmarkBudgetSplitByScale(scaleValue, mode)
|
||||
const basicChecked = row.benchmarkBudgetBasicChecked !== false
|
||||
const optionalChecked = row.benchmarkBudgetOptionalChecked !== false
|
||||
const fallbackBasic = rawSplit ? (basicChecked ? rawSplit.basic : 0) : null
|
||||
const fallbackOptional = rawSplit ? (optionalChecked ? rawSplit.optional : 0) : null
|
||||
const benchmarkBudgetBasic = toFiniteNumber(row.benchmarkBudgetBasic) ?? fallbackBasic
|
||||
const benchmarkBudgetOptional = toFiniteNumber(row.benchmarkBudgetOptional) ?? fallbackOptional
|
||||
const benchmarkBudget =
|
||||
toFiniteNumber(row.benchmarkBudget) ??
|
||||
(
|
||||
benchmarkBudgetBasic != null || benchmarkBudgetOptional != null
|
||||
? roundTo(addNumbers(benchmarkBudgetBasic ?? 0, benchmarkBudgetOptional ?? 0), 2)
|
||||
: null
|
||||
)
|
||||
const budgetFeeSplit = getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic,
|
||||
benchmarkBudgetOptional,
|
||||
majorFactor: row.majorFactor,
|
||||
consultCategoryFactor: row.consultCategoryFactor,
|
||||
workStageFactor: row.workStageFactor,
|
||||
workRatio: row.workRatio
|
||||
})
|
||||
|
||||
return {
|
||||
benchmarkBudget,
|
||||
benchmarkBudgetBasic,
|
||||
benchmarkBudgetOptional,
|
||||
basicFormula:
|
||||
typeof row.basicFormula === 'string'
|
||||
? row.basicFormula
|
||||
: (basicChecked ? (rawSplit?.basicFormula ?? '') : ''),
|
||||
optionalFormula:
|
||||
typeof row.optionalFormula === 'string'
|
||||
? row.optionalFormula
|
||||
: (optionalChecked ? (rawSplit?.optionalFormula ?? '') : ''),
|
||||
budgetFee: toFiniteNumber(row.budgetFee) ?? budgetFeeSplit?.total ?? null,
|
||||
budgetFeeBasic: toFiniteNumber(row.budgetFeeBasic) ?? budgetFeeSplit?.basic ?? null,
|
||||
budgetFeeOptional: toFiniteNumber(row.budgetFeeOptional) ?? budgetFeeSplit?.optional ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const calcWorkloadBasicFeeFromRow = (row: WorkloadMethodRowLike) => {
|
||||
const price = toFiniteNumber(row.budgetAdoptedUnitPrice)
|
||||
const conversion = toFiniteNumber(row.conversion)
|
||||
const workload = toFiniteNumber(row.workload)
|
||||
if (price == null || conversion == null || workload == null) return null
|
||||
return roundTo(toDecimal(price).mul(conversion).mul(workload), 2)
|
||||
}
|
||||
|
||||
const calcWorkloadServiceFeeFromRow = (row: WorkloadMethodRowLike, basicFee: number | null) => {
|
||||
const serviceCoe = toFiniteNumber(row.consultCategoryFactor)
|
||||
if (basicFee == null || serviceCoe == null) return null
|
||||
return roundTo(toDecimal(basicFee).mul(serviceCoe), 2)
|
||||
}
|
||||
|
||||
const calcHourlyServiceFeeFromRow = (row: HourlyMethodRowLike) => {
|
||||
const price = toFiniteNumber(row.adoptedBudgetUnitPrice)
|
||||
const personNum = toFiniteNumber(row.personnelCount)
|
||||
const workDay = toFiniteNumber(row.workdayCount)
|
||||
if (price == null || personNum == null || workDay == null) return null
|
||||
return roundTo(toDecimal(price).mul(personNum).mul(workDay), 2)
|
||||
}
|
||||
|
||||
const getTaskIdFromRowId = (value: string): number | null => {
|
||||
const match = /^task-(\d+)-\d+$/.exec(value)
|
||||
return match ? toSafeInteger(match[1]) : null
|
||||
@@ -1063,18 +1366,6 @@ const toExportScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] =
|
||||
.filter((item): item is ExportScaleRow => Boolean(item))
|
||||
}
|
||||
|
||||
const sumLeafScaleCost = (rows: ScaleRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return 0
|
||||
return sumNumbers(
|
||||
rows.map(row => {
|
||||
if (row?.isGroupRow === true) return null
|
||||
return toFiniteNumber(row?.amount)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | null => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
@@ -1083,10 +1374,11 @@ const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | n
|
||||
const major = toSafeInteger(row.id)
|
||||
if (major == null) return null
|
||||
const cost = toFiniteNumber(row.amount)
|
||||
const basicFee = toFiniteNumber(row.benchmarkBudget)
|
||||
const basicFeeBasic = toFiniteNumber(row.benchmarkBudgetBasic)
|
||||
const basicFeeOptional = toFiniteNumber(row.benchmarkBudgetOptional)
|
||||
const fee = toFiniteNumber(row.budgetFee)
|
||||
const computed = resolveScaleMethodComputedValues(row, 'cost')
|
||||
const basicFee = computed.benchmarkBudget
|
||||
const basicFeeBasic = computed.benchmarkBudgetBasic
|
||||
const basicFeeOptional = computed.benchmarkBudgetOptional
|
||||
const fee = computed.budgetFee
|
||||
if (basicFee != null || fee != null) hasTotalValue = true
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
const hasValue =
|
||||
@@ -1101,9 +1393,9 @@ const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | n
|
||||
major,
|
||||
cost: cost ?? 0,
|
||||
basicFee: basicFee ?? 0,
|
||||
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
|
||||
basicFormula: computed.basicFormula,
|
||||
basicFee_basic: basicFeeBasic ?? 0,
|
||||
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
|
||||
optionalFormula: computed.optionalFormula,
|
||||
basicFee_optional: basicFeeOptional ?? 0,
|
||||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||||
majorCoe: toFiniteNumberOrZero(row.majorFactor),
|
||||
@@ -1134,10 +1426,11 @@ const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | n
|
||||
const major = toSafeInteger(row.id)
|
||||
if (major == null) return null
|
||||
const area = toFiniteNumber(row.landArea)
|
||||
const basicFee = toFiniteNumber(row.benchmarkBudget)
|
||||
const basicFeeBasic = toFiniteNumber(row.benchmarkBudgetBasic)
|
||||
const basicFeeOptional = toFiniteNumber(row.benchmarkBudgetOptional)
|
||||
const fee = toFiniteNumber(row.budgetFee)
|
||||
const computed = resolveScaleMethodComputedValues(row, 'area')
|
||||
const basicFee = computed.benchmarkBudget
|
||||
const basicFeeBasic = computed.benchmarkBudgetBasic
|
||||
const basicFeeOptional = computed.benchmarkBudgetOptional
|
||||
const fee = computed.budgetFee
|
||||
if (basicFee != null || fee != null) hasTotalValue = true
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
const hasValue =
|
||||
@@ -1152,9 +1445,9 @@ const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | n
|
||||
major,
|
||||
area: area ?? 0,
|
||||
basicFee: basicFee ?? 0,
|
||||
basicFormula: typeof row.basicFormula === 'string' ? row.basicFormula : '',
|
||||
basicFormula: computed.basicFormula,
|
||||
basicFee_basic: basicFeeBasic ?? 0,
|
||||
optionalFormula: typeof row.optionalFormula === 'string' ? row.optionalFormula : '',
|
||||
optionalFormula: computed.optionalFormula,
|
||||
basicFee_optional: basicFeeOptional ?? 0,
|
||||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||||
majorCoe: toFiniteNumberOrZero(row.majorFactor),
|
||||
@@ -1183,10 +1476,10 @@ const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined): ExportMethod3
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const task = getTaskIdFromRowId(row.id)
|
||||
if (task == null || row.basicFee == null) return null
|
||||
if (task == null) return null
|
||||
const amount = toFiniteNumber(row.workload)
|
||||
const basicFee = toFiniteNumber(row.basicFee)
|
||||
const fee = toFiniteNumber(row.serviceFee)
|
||||
const basicFee = toFiniteNumber(row.basicFee) ?? calcWorkloadBasicFeeFromRow(row)
|
||||
const fee = toFiniteNumber(row.serviceFee) ?? calcWorkloadServiceFeeFromRow(row, basicFee)
|
||||
if (fee != null) hasTotalValue = true
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
const hasValue = amount != null || basicFee != null || fee != null || isNonEmptyString(remark)
|
||||
@@ -1217,10 +1510,10 @@ const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined): ExportMethod4 |
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const expert = getExpertIdFromRowId(row.id)
|
||||
if (expert == null || row.serviceBudget == null) return null
|
||||
if (expert == null) return null
|
||||
const personNum = toFiniteNumber(row.personnelCount)
|
||||
const workDay = toFiniteNumber(row.workdayCount)
|
||||
const fee = toFiniteNumber(row.serviceBudget)
|
||||
const fee = toFiniteNumber(row.serviceBudget) ?? calcHourlyServiceFeeFromRow(row)
|
||||
if (fee != null) hasTotalValue = true
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
const hasValue = personNum != null || workDay != null || fee != null || isNonEmptyString(remark)
|
||||
@@ -1416,18 +1709,20 @@ const buildReserveExport = async (contractId: string): Promise<ExportReserve | n
|
||||
}
|
||||
|
||||
const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
const [projectInfoRaw, projectScaleRaw, consultCategoryFactorRaw, majorFactorRaw, contractCardsRaw] = await Promise.all([
|
||||
kvStore.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
|
||||
kvStore.getItem<XmInfoStorageLike>(LEGACY_PROJECT_DB_KEY),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(CONSULT_CATEGORY_FACTOR_DB_KEY),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(MAJOR_FACTOR_DB_KEY),
|
||||
kvStore.getItem<ContractCardItem[]>('ht-card-v1')
|
||||
const config = getReportWorkspaceConfig()
|
||||
const [projectInfoRaw, projectScaleRaw, consultCategoryFactorRaw, majorFactorRaw, contractCardsRaw, quickContractRaw] = await Promise.all([
|
||||
kvStore.getItem<XmInfoLike>(config.projectInfoKey),
|
||||
kvStore.getItem<XmInfoStorageLike>(config.projectScaleKey),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(config.consultCategoryFactorKey),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(config.majorFactorKey),
|
||||
config.contractCardsKey ? kvStore.getItem<ContractCardItem[]>(config.contractCardsKey) : Promise.resolve(null),
|
||||
config.quickContractKey ? kvStore.getItem<QuickContractMeta>(config.quickContractKey) : Promise.resolve(null)
|
||||
])
|
||||
|
||||
const projectInfo = projectInfoRaw || {}
|
||||
const projectScaleSource = projectScaleRaw || {}
|
||||
const projectScale = projectScaleSource.roughCalcEnabled ? [] : toExportScaleRows(projectScaleSource.detailRows)
|
||||
const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumLeafScaleCost(projectScaleSource.detailRows)
|
||||
const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount)
|
||||
projectScale.push({
|
||||
major: -1, cost: projectScaleCost,
|
||||
area: null
|
||||
@@ -1435,13 +1730,21 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
|
||||
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorRaw?.detailRows)
|
||||
const projectMajorCoes = buildProjectMajorCoes(majorFactorRaw?.detailRows)
|
||||
const projectName = isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目'
|
||||
const quickContract = normalizeQuickContractMeta(quickContractRaw)
|
||||
const projectName =
|
||||
config.mode === 'quick'
|
||||
? `${quickContract.name}-快速计算`
|
||||
: (isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目')
|
||||
const writer = isNonEmptyString(projectInfo.preparedBy) ? projectInfo.preparedBy.trim() : ''
|
||||
const reviewer = isNonEmptyString(projectInfo.reviewedBy) ? projectInfo.reviewedBy.trim() : ''
|
||||
const date = isNonEmptyString(projectInfo.preparedDate) ? projectInfo.preparedDate.trim() : ''
|
||||
const industry = mapIndustryCodeToExportIndustry(projectInfo.projectIndustry)
|
||||
|
||||
const contractCards = (Array.isArray(contractCardsRaw) ? contractCardsRaw : [])
|
||||
const contractCards = (
|
||||
config.mode === 'quick'
|
||||
? [{ id: quickContract.id, name: quickContract.name, order: 0 }]
|
||||
: (Array.isArray(contractCardsRaw) ? contractCardsRaw : [])
|
||||
)
|
||||
.filter(item => item && typeof item.id === 'string')
|
||||
.sort((a, b) => (typeof a.order === 'number' ? a.order : Number.MAX_SAFE_INTEGER) - (typeof b.order === 'number' ? b.order : Number.MAX_SAFE_INTEGER))
|
||||
|
||||
@@ -1576,6 +1879,9 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
reportType: 'budget-report',
|
||||
mode: config.mode,
|
||||
name: projectName,
|
||||
writer,
|
||||
reviewer,
|
||||
@@ -1586,7 +1892,19 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
scale: projectScale,
|
||||
serviceCoes: projectServiceCoes,
|
||||
majorCoes: projectMajorCoes,
|
||||
contracts
|
||||
contracts,
|
||||
project: {
|
||||
name: projectName,
|
||||
writer,
|
||||
reviewer,
|
||||
date,
|
||||
industry,
|
||||
fee: sumNumbers(contracts.map(item => item.fee)),
|
||||
scaleCost: projectScaleCost,
|
||||
scale: projectScale,
|
||||
serviceCoes: projectServiceCoes,
|
||||
majorCoes: projectMajorCoes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1602,12 +1920,18 @@ const exportData = async () => {
|
||||
}))
|
||||
)
|
||||
const payload: DataPackage = {
|
||||
version: 2,
|
||||
version: 3,
|
||||
packageType: 'workspace-snapshot',
|
||||
exportedAt: now.toISOString(),
|
||||
localStorage: readWebStorage(localStorage),
|
||||
sessionStorage: readWebStorage(sessionStorage),
|
||||
localforageDefault: await readForage(localforage),
|
||||
localforageStores: piniaForageStores
|
||||
workspace: {
|
||||
mode: resolveWorkspaceModeForExport()
|
||||
},
|
||||
storage: {
|
||||
localStorage: readWebStorage(localStorage),
|
||||
sessionStorage: readWebStorage(sessionStorage),
|
||||
localforageDefault: await readForage(localforage),
|
||||
localforageStores: piniaForageStores
|
||||
}
|
||||
}
|
||||
|
||||
const content = await encodeZwArchive(payload)
|
||||
@@ -1617,7 +1941,7 @@ const exportData = async () => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
const projectName = getExportProjectName(payload.localforageDefault)
|
||||
const projectName = getExportProjectName(payload.storage?.localforageDefault || [], piniaForageStores)
|
||||
const timestamp = formatExportTimestamp(now)
|
||||
link.download = `${projectName}-${timestamp}${ZW_FILE_EXTENSION}`
|
||||
document.body.appendChild(link)
|
||||
@@ -1635,12 +1959,12 @@ const exportData = async () => {
|
||||
const exportReport = async () => {
|
||||
try {
|
||||
showReportExportProgress(10, '正在准备报表导出...')
|
||||
await requestXmScalePersistFlush()
|
||||
const now = new Date()
|
||||
showReportExportProgress(40, '正在汇总报表数据...')
|
||||
const payload = await buildExportReportPayload()
|
||||
showReportExportProgress(80, '正在生成并写出报表文件...')
|
||||
const fileName = `${sanitizeFileNamePart(payload.name)}-报表-${formatExportTimestamp(now)}`
|
||||
console.log(payload)
|
||||
await exportFile(fileName, payload)
|
||||
finishReportExportProgress(true, '报表导出完成')
|
||||
} catch (error) {
|
||||
@@ -1654,9 +1978,7 @@ const triggerImport = () => {
|
||||
importFileRef.value?.click()
|
||||
}
|
||||
|
||||
const importData = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
const handleSelectedImportFile = async (file: File) => {
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
@@ -1665,12 +1987,34 @@ const importData = async (event: Event) => {
|
||||
}
|
||||
const buffer = await file.arrayBuffer()
|
||||
const payload = await decodeZwArchive<DataPackage>(buffer)
|
||||
pendingImportPayload.value = payload
|
||||
const normalizedStorage = normalizeDataPackageStorage(payload)
|
||||
pendingImportPayload.value = {
|
||||
...payload,
|
||||
workspace: normalizeDataPackageWorkspace(payload),
|
||||
storage: normalizedStorage
|
||||
}
|
||||
pendingImportFileName.value = file.name
|
||||
importConfirmOpen.value = true
|
||||
} catch (error) {
|
||||
console.error('import failed:', error)
|
||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHomeImportSelected = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ file?: File | null }>
|
||||
const file = customEvent.detail?.file
|
||||
if (!file) return
|
||||
void handleSelectedImportFile(file)
|
||||
}
|
||||
|
||||
const importData = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
await handleSelectedImportFile(file)
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
@@ -1686,10 +2030,12 @@ const confirmImportOverride = async () => {
|
||||
const payload = pendingImportPayload.value
|
||||
if (!payload) return
|
||||
try {
|
||||
writeWebStorage(localStorage, normalizeEntries(payload.localStorage))
|
||||
writeWebStorage(sessionStorage, normalizeEntries(payload.sessionStorage))
|
||||
await writeForage(localforage, normalizeEntries(payload.localforageDefault))
|
||||
const piniaSnapshots = normalizeForageStoreSnapshots(payload.localforageStores)
|
||||
const normalizedStorage = normalizeDataPackageStorage(payload)
|
||||
const normalizedWorkspace = normalizeDataPackageWorkspace(payload)
|
||||
writeWebStorage(localStorage, normalizedStorage.localStorage)
|
||||
writeWebStorage(sessionStorage, normalizedStorage.sessionStorage)
|
||||
await writeForage(localforage, normalizedStorage.localforageDefault)
|
||||
const piniaSnapshots = normalizeForageStoreSnapshots(normalizedStorage.localforageStores)
|
||||
const snapshotMap = new Map(piniaSnapshots.map(item => [item.storeName, item.entries]))
|
||||
await Promise.all(
|
||||
getPiniaPersistStores().map(async ({ storeName, store }) => {
|
||||
@@ -1714,6 +2060,7 @@ const confirmImportOverride = async () => {
|
||||
} else {
|
||||
tabStore.resetTabs()
|
||||
}
|
||||
normalizeTabStoreState()
|
||||
|
||||
const zxFwPricingState = readPersistedState('zxFwPricing')
|
||||
if (zxFwPricingState) {
|
||||
@@ -1730,6 +2077,7 @@ const confirmImportOverride = async () => {
|
||||
zxFwPricingStore.$persistNow?.(),
|
||||
kvStore.$persistNow?.()
|
||||
])
|
||||
writeWorkspaceMode(normalizedWorkspace.mode)
|
||||
dataMenuOpen.value = false
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
@@ -1761,8 +2109,10 @@ const handleReset = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
normalizeTabStoreState()
|
||||
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||
window.addEventListener('home-import-selected', handleHomeImportSelected as EventListener)
|
||||
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||
void nextTick(() => {
|
||||
bindTabStripScroll()
|
||||
@@ -1782,6 +2132,7 @@ onBeforeUnmount(() => {
|
||||
clearReportExportToastTimer()
|
||||
window.removeEventListener('mousedown', handleGlobalMouseDown)
|
||||
window.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
window.removeEventListener('home-import-selected', handleHomeImportSelected as EventListener)
|
||||
window.removeEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||
if (tabStripViewportEl) {
|
||||
tabStripViewportEl.removeEventListener('scroll', handleTabStripScroll)
|
||||
@@ -1834,8 +2185,15 @@ watch(
|
||||
<ToastProvider>
|
||||
<TooltipProvider>
|
||||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2 border-b bg-muted/30 px-2 pt-1 h-15 flex-none">
|
||||
<div class="flex min-w-0 items-start gap-1 h-full" @mouseenter="isTabStripHover = true"
|
||||
<div
|
||||
v-if="!isHomeOnlyView"
|
||||
class="grid items-start gap-2 border-b bg-muted/30 px-2 pt-1 h-15 flex-none"
|
||||
:class="showTabStrip ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-[1fr_auto]'"
|
||||
>
|
||||
<div
|
||||
v-if="showTabStrip"
|
||||
class="flex min-w-0 items-start gap-1 h-full"
|
||||
@mouseenter="isTabStripHover = true"
|
||||
@mouseleave="isTabStripHover = false">
|
||||
<button type="button" :class="[
|
||||
'h-9 w-8 self-center shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
|
||||
@@ -1857,7 +2215,7 @@ watch(
|
||||
tabStore.activeTabId === tab.id && !isTabDragging
|
||||
? 'z-10 bg-background text-foreground !border-border !border-b-0 font-medium'
|
||||
: 'bg-muted/25 text-muted-foreground hover:bg-muted/40 hover:text-foreground hover:border-border/70',
|
||||
tab.id !== 'XmView' ? 'cursor-move' : ''
|
||||
isTabClosable(tab.id) ? 'cursor-move' : ''
|
||||
]">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -1868,7 +2226,7 @@ watch(
|
||||
<TooltipContent v-if="tabTitleOverflowMap[tab.id]" side="bottom">{{ tab.title }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
|
||||
<Button v-if="tab.id !== 'XmView'" variant="ghost" size="icon"
|
||||
<Button v-if="isTabClosable(tab.id)" variant="ghost" size="icon"
|
||||
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
|
||||
@click.stop="tabStore.removeTab(tab.id)">
|
||||
<X class="h-3 w-3" />
|
||||
@@ -1885,14 +2243,18 @@ watch(
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex shrink-0 self-center items-center gap-1">
|
||||
<div ref="dataMenuRef" class="relative shrink-0">
|
||||
<Button variant="outline" size="sm"
|
||||
<div v-if="showWorkspaceActions" ref="dataMenuRef" class="relative shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="dataMenuOpen = !dataMenuOpen">
|
||||
@click="dataMenuOpen = !dataMenuOpen"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 mr-1" />
|
||||
导入/导出
|
||||
</Button>
|
||||
<div v-if="dataMenuOpen"
|
||||
<div
|
||||
v-if="dataMenuOpen"
|
||||
class="absolute right-0 top-full mt-1 z-50 rounded-md border bg-background p-1 shadow-md">
|
||||
<button class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||
@click="triggerImport">
|
||||
@@ -1907,16 +2269,22 @@ watch(
|
||||
导出报表
|
||||
</button>
|
||||
</div>
|
||||
<input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="openUserGuide(0)">
|
||||
<input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" />
|
||||
|
||||
<Button
|
||||
v-if="showWorkspaceActions"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="openUserGuide(0)"
|
||||
>
|
||||
<CircleHelp class="h-4 w-4 mr-1" />
|
||||
使用引导
|
||||
</Button>
|
||||
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogRoot v-if="showWorkspaceActions">
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" size="sm"
|
||||
class="h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer">
|
||||
|
||||
@@ -25,6 +25,7 @@ const props = withDefaults(
|
||||
scene?: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
metaText?: string
|
||||
copyText?: string
|
||||
categories: TypeLineCategory[]
|
||||
storageKey?: string
|
||||
@@ -34,6 +35,7 @@ const props = withDefaults(
|
||||
scene: 'default',
|
||||
title: '配置',
|
||||
subtitle: '',
|
||||
metaText: '',
|
||||
copyText: '',
|
||||
storageKey: '',
|
||||
defaultCategory: ''
|
||||
@@ -201,7 +203,7 @@ useMotionValueEvent(
|
||||
<TooltipProvider>
|
||||
<div class="flex h-full w-full bg-background">
|
||||
<div class="w-12/100 border-r p-2 flex flex-col gap-8 relative">
|
||||
<div v-if="props.title || props.subtitle" class="space-y-1">
|
||||
<div v-if="props.title || props.subtitle || props.metaText" class="space-y-1">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<div v-if="props.title" ref="titleRef" class="title-ellipsis-2 max-w-full font-bold text-base leading-6 text-primary">
|
||||
@@ -224,6 +226,9 @@ useMotionValueEvent(
|
||||
{{ copyBtnText }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="props.metaText" class="text-xs leading-5 text-muted-foreground">
|
||||
{{ props.metaText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-10 relative">
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
getIndustryTypeValue,
|
||||
getMajorDictEntries,
|
||||
getServiceDictEntries,
|
||||
isIndustryEnabledByType,
|
||||
isMajorIdInIndustryScope
|
||||
} from '@/sql'
|
||||
|
||||
interface KvStoreLike {
|
||||
setItem: <T = unknown>(keyRaw: string | number, value: T) => Promise<void>
|
||||
}
|
||||
|
||||
type DictItemLite = {
|
||||
code?: string
|
||||
name?: string
|
||||
defCoe?: number | null
|
||||
notshowByzxflxs?: boolean
|
||||
}
|
||||
|
||||
type FactorPersistRow = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
standardFactor: number | null
|
||||
budgetValue: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
type FactorPersistState = {
|
||||
detailRows: FactorPersistRow[]
|
||||
}
|
||||
|
||||
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
|
||||
const parts = code.split('-').filter(Boolean)
|
||||
if (!parts.length) return [selfId]
|
||||
|
||||
const path: string[] = []
|
||||
let currentCode = parts[0]
|
||||
const firstId = codeIdMap.get(currentCode)
|
||||
if (firstId) path.push(firstId)
|
||||
|
||||
for (let index = 1; index < parts.length; index += 1) {
|
||||
currentCode = `${currentCode}-${parts[index]}`
|
||||
const id = codeIdMap.get(currentCode)
|
||||
if (id) path.push(id)
|
||||
}
|
||||
|
||||
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
|
||||
return path
|
||||
}
|
||||
|
||||
const buildFactorRowsFromEntries = (entries: Array<{ id: string; item: DictItemLite }>): FactorPersistRow[] => {
|
||||
const codeIdMap = new Map<string, string>()
|
||||
for (const entry of entries) {
|
||||
const code = String(entry.item?.code || '').trim()
|
||||
if (!code) continue
|
||||
codeIdMap.set(code, entry.id)
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(entry => {
|
||||
const code = String(entry.item?.code || '').trim()
|
||||
const name = String(entry.item?.name || '').trim()
|
||||
if (!code || !name) return null
|
||||
const standardFactor =
|
||||
typeof entry.item?.defCoe === 'number' && Number.isFinite(entry.item.defCoe)
|
||||
? entry.item.defCoe
|
||||
: null
|
||||
return {
|
||||
id: entry.id,
|
||||
code,
|
||||
name,
|
||||
standardFactor,
|
||||
budgetValue: standardFactor,
|
||||
remark: '',
|
||||
path: buildCodePath(code, entry.id, codeIdMap)
|
||||
}
|
||||
})
|
||||
.filter((item): item is FactorPersistRow => Boolean(item))
|
||||
}
|
||||
|
||||
export const initializeProjectFactorStates = async (
|
||||
kvStore: KvStoreLike,
|
||||
industry: string,
|
||||
consultCategoryFactorKey: string,
|
||||
majorFactorKey: string
|
||||
) => {
|
||||
const industryType = getIndustryTypeValue(industry)
|
||||
const consultEntries = getServiceDictEntries()
|
||||
.map(({ id, item }) => ({ id, item: item as DictItemLite }))
|
||||
.filter(({ item }) => {
|
||||
if (item.notshowByzxflxs === true) return false
|
||||
return isIndustryEnabledByType(item as Record<string, unknown>, industryType)
|
||||
})
|
||||
|
||||
const majorEntries = getMajorDictEntries()
|
||||
.map(({ id, item }) => ({ id, item: item as DictItemLite }))
|
||||
.filter(({ id, item }) => item.notshowByzxflxs !== true && isMajorIdInIndustryScope(id, industry))
|
||||
|
||||
const consultPayload: FactorPersistState = {
|
||||
detailRows: buildFactorRowsFromEntries(consultEntries)
|
||||
}
|
||||
const majorPayload: FactorPersistState = {
|
||||
detailRows: buildFactorRowsFromEntries(majorEntries)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
kvStore.setItem(consultCategoryFactorKey, consultPayload),
|
||||
kvStore.setItem(majorFactorKey, majorPayload)
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export type WorkspaceMode = 'home' | 'project' | 'quick'
|
||||
|
||||
export const HOME_TAB_ID = 'HomeView'
|
||||
export const PROJECT_TAB_ID = 'ProjectCalcView'
|
||||
export const QUICK_TAB_ID = 'QuickCalcView'
|
||||
export const LEGACY_PROJECT_TAB_ID = 'XmView'
|
||||
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 QUICK_CONTRACT_ID = 'quick-contract-default'
|
||||
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
|
||||
export const QUICK_CONTRACT_FALLBACK_NAME = '快速计算合同'
|
||||
export const QUICK_CONTRACT_TAB_ID = `contract-${QUICK_CONTRACT_ID}`
|
||||
|
||||
export const QUICK_PROJECT_INFO_KEY = 'quick-xm-base-info-v1'
|
||||
export const QUICK_PROJECT_SCALE_KEY = 'quick-xm-info-v3'
|
||||
export const QUICK_CONSULT_CATEGORY_FACTOR_KEY = 'quick-xm-consult-category-factor-v1'
|
||||
export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1'
|
||||
|
||||
export interface QuickContractMeta {
|
||||
id: string
|
||||
name: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const normalizeWorkspaceMode = (value: unknown): WorkspaceMode => {
|
||||
if (value === 'project' || value === 'quick' || value === 'home') return value
|
||||
return 'home'
|
||||
}
|
||||
|
||||
export const readWorkspaceMode = (): WorkspaceMode => {
|
||||
try {
|
||||
return normalizeWorkspaceMode(window.localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY))
|
||||
} catch {
|
||||
return 'home'
|
||||
}
|
||||
}
|
||||
|
||||
export const writeWorkspaceMode = (mode: WorkspaceMode) => {
|
||||
try {
|
||||
window.localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, normalizeWorkspaceMode(mode))
|
||||
} catch {
|
||||
// 忽略只读或隐私模式下的写入失败。
|
||||
}
|
||||
}
|
||||
|
||||
export const createDefaultQuickContractMeta = (): QuickContractMeta => ({
|
||||
id: QUICK_CONTRACT_ID,
|
||||
name: QUICK_CONTRACT_FALLBACK_NAME,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
@@ -123,18 +123,19 @@ export default (config?: PiniaStorageConfig) => {
|
||||
{ detached: true }
|
||||
)
|
||||
|
||||
hydrating = true
|
||||
void lf.getItem<Record<string, unknown>>(key)
|
||||
.then(state => {
|
||||
if (!state || typeof state !== 'object') return
|
||||
// 若在异步hydrate返回前,store已被用户修改(如removeTab),不再回填旧缓存覆盖当前状态。
|
||||
if (userMutatedBeforeHydrate) return
|
||||
hydrating = true
|
||||
context.store.$patch(state as any)
|
||||
hydrating = false
|
||||
})
|
||||
.catch(error => {
|
||||
hydrating = false
|
||||
console.error('pinia hydrate failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
hydrating = false
|
||||
})
|
||||
}
|
||||
}
|
||||
+16
-9
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { HOME_TAB_ID, PROJECT_TAB_ID, QUICK_CONTRACT_TAB_ID } from '@/lib/workspace'
|
||||
|
||||
export interface TabItem<TProps = Record<string, unknown>> {
|
||||
id: string
|
||||
@@ -8,14 +9,14 @@ export interface TabItem<TProps = Record<string, unknown>> {
|
||||
props?: TProps
|
||||
}
|
||||
|
||||
const HOME_TAB_ID = 'XmView'
|
||||
const DEFAULT_TAB: TabItem = {
|
||||
id: HOME_TAB_ID,
|
||||
title: '项目卡片',
|
||||
title: '首页',
|
||||
componentName: HOME_TAB_ID
|
||||
}
|
||||
|
||||
const createDefaultTabs = (): TabItem[] => [{ ...DEFAULT_TAB }]
|
||||
const PROTECTED_TAB_ID_SET = new Set<string>([HOME_TAB_ID, QUICK_CONTRACT_TAB_ID])
|
||||
|
||||
export const useTabStore = defineStore(
|
||||
'tabs',
|
||||
@@ -36,6 +37,11 @@ export const useTabStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
const enterWorkspace = (config: TabItem) => {
|
||||
tabs.value = [{ ...config }]
|
||||
activeTabId.value = config.id
|
||||
}
|
||||
|
||||
const openTab = (config: TabItem) => {
|
||||
if (!tabs.value.some(tab => tab.id === config.id)) {
|
||||
tabs.value = [...tabs.value, config]
|
||||
@@ -44,8 +50,7 @@ export const useTabStore = defineStore(
|
||||
}
|
||||
|
||||
const removeTab = (id: string) => {
|
||||
// 首页标签固定保留,不允许关闭。
|
||||
if (id === HOME_TAB_ID) return
|
||||
if (PROTECTED_TAB_ID_SET.has(id)) return
|
||||
|
||||
const index = tabs.value.findIndex(tab => tab.id === id)
|
||||
if (index < 0) return
|
||||
@@ -64,26 +69,27 @@ export const useTabStore = defineStore(
|
||||
}
|
||||
|
||||
const closeAllTabs = () => {
|
||||
tabs.value = createDefaultTabs()
|
||||
activeTabId.value = HOME_TAB_ID
|
||||
const protectedTabs = tabs.value.filter(tab => PROTECTED_TAB_ID_SET.has(tab.id))
|
||||
tabs.value = protectedTabs.length > 0 ? protectedTabs : createDefaultTabs()
|
||||
activeTabId.value = tabs.value[0]?.id ?? HOME_TAB_ID
|
||||
}
|
||||
|
||||
const closeLeftTabs = (targetId: string) => {
|
||||
const targetIndex = tabs.value.findIndex(tab => tab.id === targetId)
|
||||
if (targetIndex < 0) return
|
||||
tabs.value = tabs.value.filter((tab, index) => tab.id === HOME_TAB_ID || index >= targetIndex)
|
||||
tabs.value = tabs.value.filter((tab, index) => PROTECTED_TAB_ID_SET.has(tab.id) || index >= targetIndex)
|
||||
ensureActiveValid()
|
||||
}
|
||||
|
||||
const closeRightTabs = (targetId: string) => {
|
||||
const targetIndex = tabs.value.findIndex(tab => tab.id === targetId)
|
||||
if (targetIndex < 0) return
|
||||
tabs.value = tabs.value.filter((tab, index) => tab.id === HOME_TAB_ID || index <= targetIndex)
|
||||
tabs.value = tabs.value.filter((tab, index) => PROTECTED_TAB_ID_SET.has(tab.id) || index <= targetIndex)
|
||||
ensureActiveValid()
|
||||
}
|
||||
|
||||
const closeOtherTabs = (targetId: string) => {
|
||||
tabs.value = tabs.value.filter(tab => tab.id === HOME_TAB_ID || tab.id === targetId)
|
||||
tabs.value = tabs.value.filter(tab => PROTECTED_TAB_ID_SET.has(tab.id) || tab.id === targetId)
|
||||
ensureHomeTab()
|
||||
activeTabId.value = tabs.value.some(tab => tab.id === targetId) ? targetId : HOME_TAB_ID
|
||||
}
|
||||
@@ -96,6 +102,7 @@ export const useTabStore = defineStore(
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
enterWorkspace,
|
||||
openTab,
|
||||
removeTab,
|
||||
closeAllTabs,
|
||||
|
||||
@@ -328,6 +328,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return state[method] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取咨询服务计价方法状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const getServicePricingMethodState = <TRow = unknown>(
|
||||
contractIdRaw: string | number,
|
||||
serviceIdRaw: string | number,
|
||||
@@ -339,6 +346,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return (servicePricingStates.value[contractId]?.[serviceId]?.[method] as ServicePricingMethodState<TRow> | undefined) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 设置咨询服务计价方法状态并同步版本
|
||||
* @returns {*}
|
||||
*/
|
||||
const setServicePricingMethodState = <TRow = unknown>(
|
||||
contractIdRaw: string | number,
|
||||
serviceIdRaw: string | number,
|
||||
@@ -379,6 +393,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 从缓存或持久化存储加载咨询服务计价方法状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const loadServicePricingMethodState = async <TRow = unknown>(
|
||||
contractIdRaw: string | number,
|
||||
serviceIdRaw: string | number,
|
||||
@@ -404,6 +425,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return getServicePricingMethodState<TRow>(contractId, serviceId, method)
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 删除单个咨询服务计价方法状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const removeServicePricingMethodState = (
|
||||
contractIdRaw: string | number,
|
||||
serviceIdRaw: string | number,
|
||||
@@ -422,6 +450,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return had
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取单个咨询服务计价方法存储键
|
||||
* @returns {*}
|
||||
*/
|
||||
const getServicePricingStorageKey = (
|
||||
contractIdRaw: string | number,
|
||||
serviceIdRaw: string | number,
|
||||
@@ -433,6 +468,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return serviceMethodDbKeyOf(contractId, serviceId, method)
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取咨询服务全部计价方法存储键
|
||||
* @returns {*}
|
||||
*/
|
||||
const getServicePricingStorageKeys = (contractIdRaw: string | number, serviceIdRaw: string | number) => {
|
||||
const contractId = toKey(contractIdRaw)
|
||||
const serviceId = toServiceKey(serviceIdRaw)
|
||||
@@ -442,6 +484,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 删除咨询服务全部计价方法状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const removeAllServicePricingMethodStates = (contractIdRaw: string | number, serviceIdRaw: string | number) => {
|
||||
let changed = false
|
||||
for (const method of Object.keys(METHOD_STORAGE_PREFIX_MAP) as ServicePricingMethod[]) {
|
||||
@@ -450,12 +499,26 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return changed
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取合同附加费用主表状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const getHtFeeMainState = <TRow = unknown>(mainStorageKeyRaw: string | number): HtFeeMainState<TRow> | null => {
|
||||
const mainStorageKey = toKey(mainStorageKeyRaw)
|
||||
if (!mainStorageKey) return null
|
||||
return (htFeeMainStates.value[mainStorageKey] as HtFeeMainState<TRow> | undefined) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 设置合同附加费用主表状态并同步版本
|
||||
* @returns {*}
|
||||
*/
|
||||
const setHtFeeMainState = <TRow = unknown>(
|
||||
mainStorageKeyRaw: string | number,
|
||||
payload: Partial<HtFeeMainState<TRow>> | null | undefined,
|
||||
@@ -494,10 +557,18 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 从缓存或持久化存储加载合同附加费用主表状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const loadHtFeeMainState = async <TRow = unknown>(
|
||||
mainStorageKeyRaw: string | number,
|
||||
force = false
|
||||
): Promise<HtFeeMainState<TRow> | null> => {
|
||||
|
||||
const mainStorageKey = toKey(mainStorageKeyRaw)
|
||||
if (!mainStorageKey) return null
|
||||
if (!force) {
|
||||
@@ -513,6 +584,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return getHtFeeMainState<TRow>(mainStorageKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 删除合同附加费用主表状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const removeHtFeeMainState = (mainStorageKeyRaw: string | number) =>
|
||||
setHtFeeMainState(mainStorageKeyRaw, null)
|
||||
|
||||
@@ -529,6 +607,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return htFeeMethodStates.value[mainStorageKey][rowId]
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取合同附加费用子方法存储键
|
||||
* @returns {*}
|
||||
*/
|
||||
const getHtFeeMethodStorageKey = (
|
||||
mainStorageKeyRaw: string | number,
|
||||
rowIdRaw: string | number,
|
||||
@@ -540,6 +625,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return `${mainStorageKey}-${rowId}-${method}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取合同附加费用子方法状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const getHtFeeMethodState = <TPayload = HtFeeMethodPayload>(
|
||||
mainStorageKeyRaw: string | number,
|
||||
rowIdRaw: string | number,
|
||||
@@ -552,6 +644,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return value == null ? null : (cloneAny(value) as TPayload)
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 设置合同附加费用子方法状态并同步版本
|
||||
* @returns {*}
|
||||
*/
|
||||
const setHtFeeMethodState = <TPayload = HtFeeMethodPayload>(
|
||||
mainStorageKeyRaw: string | number,
|
||||
rowIdRaw: string | number,
|
||||
@@ -564,6 +663,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
) => {
|
||||
const mainStorageKey = toKey(mainStorageKeyRaw)
|
||||
const rowId = toKey(rowIdRaw)
|
||||
|
||||
if (!mainStorageKey || !rowId) return false
|
||||
const storageKey = getHtFeeMethodStorageKey(mainStorageKey, rowId, method)
|
||||
if (!storageKey) return false
|
||||
@@ -605,6 +705,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 从缓存或持久化存储加载合同附加费用子方法状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const loadHtFeeMethodState = async <TPayload = HtFeeMethodPayload>(
|
||||
mainStorageKeyRaw: string | number,
|
||||
rowIdRaw: string | number,
|
||||
@@ -628,12 +735,26 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return getHtFeeMethodState<TPayload>(mainStorageKey, rowId, method)
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 删除合同附加费用子方法状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const removeHtFeeMethodState = (
|
||||
mainStorageKeyRaw: string | number,
|
||||
rowIdRaw: string | number,
|
||||
method: HtFeeMethodType
|
||||
) => setHtFeeMethodState(mainStorageKeyRaw, rowIdRaw, method, null)
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 按通用键获取状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const getKeyState = <T = unknown>(keyRaw: string | number): T | null => {
|
||||
const key = toKey(keyRaw)
|
||||
if (!key) return null
|
||||
@@ -664,6 +785,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return cloneAny(keyedStates.value[key] as T)
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 按通用键从缓存或持久化存储加载状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const loadKeyState = async <T = unknown>(keyRaw: string | number, force = false): Promise<T | null> => {
|
||||
const key = toKey(keyRaw)
|
||||
if (!key) return null
|
||||
@@ -725,6 +853,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 按通用键设置状态并同步版本
|
||||
* @returns {*}
|
||||
*/
|
||||
const setKeyState = <T = unknown>(
|
||||
keyRaw: string | number,
|
||||
value: T,
|
||||
@@ -749,6 +884,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
{ force: true, syncKeyState: false }
|
||||
)
|
||||
}
|
||||
|
||||
const htMethodMeta = parseHtFeeMethodStorageKey(key)
|
||||
if (htMethodMeta) {
|
||||
setHtFeeMethodState(
|
||||
@@ -769,6 +905,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 按通用键删除状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const removeKeyState = (keyRaw: string | number) => {
|
||||
const key = toKey(keyRaw)
|
||||
if (!key) return false
|
||||
@@ -795,12 +938,26 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return hadValue
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取指定键的版本号
|
||||
* @returns {*}
|
||||
*/
|
||||
const getKeyVersion = (keyRaw: string | number) => {
|
||||
const key = toKey(keyRaw)
|
||||
if (!key) return 0
|
||||
return keyVersions.value[key] || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取合同咨询服务主表状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const getContractState = (contractIdRaw: string | number) => {
|
||||
const contractId = toKey(contractIdRaw)
|
||||
if (!contractId) return null
|
||||
@@ -808,6 +965,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return data ? cloneState(data) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 从缓存或持久化存储加载合同咨询服务主表状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const loadContract = async (contractIdRaw: string | number, force = false) => {
|
||||
const contractId = toKey(contractIdRaw)
|
||||
if (!contractId) return null
|
||||
@@ -843,6 +1007,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 设置合同咨询服务主表状态
|
||||
* @returns {*}
|
||||
*/
|
||||
const setContractState = async (contractIdRaw: string | number, state: ZxFwState) => {
|
||||
const contractId = toKey(contractIdRaw)
|
||||
if (!contractId) return false
|
||||
@@ -855,6 +1026,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 更新合同咨询服务行的计价汇总字段
|
||||
* @returns {*}
|
||||
*/
|
||||
const updatePricingField = async (params: {
|
||||
contractId: string
|
||||
serviceId: string | number
|
||||
@@ -900,6 +1078,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 获取合同咨询服务基础小计
|
||||
* @returns {*}
|
||||
*/
|
||||
const getBaseSubtotal = (contractIdRaw: string | number): number | null => {
|
||||
const contractId = toKey(contractIdRaw)
|
||||
if (!contractId) return null
|
||||
@@ -920,6 +1105,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||
return hasValid ? round3(sum) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* @Author: wintsa
|
||||
* @Date: 2026-03-13
|
||||
* @LastEditors: wintsa
|
||||
* @Description: 删除合同关联的咨询服务、附加费用和键状态数据
|
||||
* @returns {*}
|
||||
*/
|
||||
const removeContractData = (contractIdRaw: string | number) => {
|
||||
const contractId = toKey(contractIdRaw)
|
||||
if (!contractId) return false
|
||||
|
||||
+12
-10
@@ -1114,16 +1114,18 @@ async function generateTemplate(data) {
|
||||
}
|
||||
});
|
||||
let endRows = 1;
|
||||
cusInsertRowFunc(ci.services.length + 3 + endRows, [sheet_1.getRow(3)], sheet_1, (targetRow) => {
|
||||
targetRow.getCell(1).value = ci.services.length + endRows;
|
||||
targetRow.getCell(2).value = '';
|
||||
targetRow.getCell(3).value = '基本、可选工作小计';
|
||||
targetRow.getCell(4).value = numberFormatter(m1Sum, 2);
|
||||
targetRow.getCell(5).value = numberFormatter(m2Sum, 2);
|
||||
targetRow.getCell(6).value = numberFormatter(m3Sum, 2);
|
||||
targetRow.getCell(7).value = numberFormatter(m4Sum, 2);
|
||||
targetRow.getCell(8).value = numberFormatter(serviceSum, 2);
|
||||
});
|
||||
if (ci.services.length) {
|
||||
cusInsertRowFunc(ci.services.length + 3 + endRows, [sheet_1.getRow(3)], sheet_1, (targetRow) => {
|
||||
targetRow.getCell(1).value = ci.services.length + endRows;
|
||||
targetRow.getCell(2).value = '';
|
||||
targetRow.getCell(3).value = '基本、可选工作小计';
|
||||
targetRow.getCell(4).value = numberFormatter(m1Sum, 2);
|
||||
targetRow.getCell(5).value = numberFormatter(m2Sum, 2);
|
||||
targetRow.getCell(6).value = numberFormatter(m3Sum, 2);
|
||||
targetRow.getCell(7).value = numberFormatter(m4Sum, 2);
|
||||
targetRow.getCell(8).value = numberFormatter(serviceSum, 2);
|
||||
});
|
||||
}
|
||||
if (ci.addtional) {
|
||||
endRows++;
|
||||
cusInsertRowFunc(ci.services.length + 3 + endRows, [sheet_1.getRow(3)], sheet_1, (targetRow) => {
|
||||
|
||||
Reference in new issue
Block a user