This commit is contained in:
wintsa 2026-03-25 17:18:35 +08:00
parent 9a6462f22a
commit 1d016f8c51
48 changed files with 2822 additions and 986 deletions

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { Card, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -53,7 +54,7 @@ import {
SERVICE_KEY_PREFIX, SERVICE_KEY_PREFIX,
type ContractSegmentPackage type ContractSegmentPackage
} from '@/lib/contractSegment' } from '@/lib/contractSegment'
import { industryTypeList } from '@/sql' import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal' import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat' import { formatThousands } from '@/lib/numberFormat'
import { import {
@ -79,6 +80,7 @@ const zxFwPricingStore = useZxFwPricingStore()
const zxFwPricingKeysStore = useZxFwPricingKeysStore() const zxFwPricingKeysStore = useZxFwPricingKeysStore()
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore() const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
const kvStore = useKvStore() const kvStore = useKvStore()
const { t, locale } = useI18n()
@ -95,7 +97,7 @@ const showCreateModal = ref(false)
const contractNameInput = ref('') const contractNameInput = ref('')
const editingContractId = ref<string | null>(null) const editingContractId = ref<string | null>(null)
const toastOpen = ref(false) const toastOpen = ref(false)
const toastTitle = ref('操作成功') const toastTitle = ref(t('ht.toastSuccessTitle'))
const toastText = ref('') const toastText = ref('')
const deleteConfirmOpen = ref(false) const deleteConfirmOpen = ref(false)
const pendingDeleteContractId = ref<string | null>(null) const pendingDeleteContractId = ref<string | null>(null)
@ -182,7 +184,7 @@ const budgetRefreshSignature = computed(() => {
}) })
const notify = (text: string) => { const notify = (text: string) => {
toastTitle.value = '操作成功' toastTitle.value = t('ht.toastSuccessTitle')
toastText.value = text toastText.value = text
toastOpen.value = false toastOpen.value = false
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -202,6 +204,11 @@ const pendingDeleteContractName = computed(() => {
return target?.name || pendingDeleteContractId.value return target?.name || pendingDeleteContractId.value
}) })
const batchDeleteCount = computed(() => {
const selectedSet = new Set(selectedContractIds.value)
return contracts.value.filter(item => selectedSet.has(item.id)).length
})
const handleDeleteConfirmOpenChange = (open: boolean) => { const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open deleteConfirmOpen.value = open
} }
@ -239,7 +246,9 @@ const getCurrentProjectIndustry = async (): Promise<string> => {
} }
const formatBudgetAmount = (value: number | null | undefined) => const formatBudgetAmount = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)}` : '--' typeof value === 'number' && Number.isFinite(value)
? `${formatThousands(value, 2)} ${t('htCard.currencySuffix')}`
: '--'
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => { const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : [] const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
@ -362,17 +371,10 @@ const scheduleRefreshContractBudgets = () => {
}, 80) }, 80)
} }
const industryNameByCode = (() => {
const map = new Map<string, string>()
for (const item of industryTypeList) {
map.set(item.id, item.name)
}
return map
})()
const formatIndustryLabel = (code: string) => { const formatIndustryLabel = (code: string) => {
const trimmed = code.trim() const trimmed = code.trim()
const name = industryNameByCode.get(trimmed) const target = industryTypeList.find(item => String(item.id || '').trim() === trimmed)
const name = target ? getIndustryDisplayName(target.id, locale.value) : ''
return name ? `${trimmed} ${name}` : trimmed return name ? `${trimmed} ${name}` : trimmed
} }
@ -529,7 +531,7 @@ const initializeContractScaleData = async (contractId: string) => {
const exportSelectedContracts = async () => { const exportSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) { if (selectedContractIds.value.length === 0) {
showMessageDialog('提示', '请先勾选至少一个合同段。') showMessageDialog(t('ht.tipTitle'), t('ht.selectAtLeastOne'))
return return
} }
@ -554,7 +556,7 @@ const exportSelectedContracts = async () => {
const projectIndustry = await getCurrentProjectIndustry() const projectIndustry = await getCurrentProjectIndustry()
if (!projectIndustry) { if (!projectIndustry) {
showMessageDialog('导出失败', '未读取到当前项目工程行业,请先在“基础信息”里新建项目。') showMessageDialog(t('ht.exportFailedTitle'), t('ht.industryMissingForExport'))
return return
} }
@ -583,17 +585,17 @@ const exportSelectedContracts = async () => {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = `合同段导出-${formatExportTimestamp(now)}${CONTRACT_SEGMENT_FILE_EXTENSION}` link.download = `contract-segments-${formatExportTimestamp(now)}${CONTRACT_SEGMENT_FILE_EXTENSION}`
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
notify(`导出成功(${selectedContracts.length} 个合同段)`) notify(t('ht.exportSuccess', { count: selectedContracts.length }))
exitContractSelectionMode() exitContractSelectionMode()
} catch (error) { } catch (error) {
console.error('export selected contracts failed:', error) console.error('export selected contracts failed:', error)
showMessageDialog('导出失败', '请重试。') showMessageDialog(t('ht.exportFailedTitle'), t('ht.retry'))
} }
} }
@ -691,7 +693,7 @@ const importContractSegments = async (event: Event) => {
zxFwPricingHtFeeStore.$persistNow?.() zxFwPricingHtFeeStore.$persistNow?.()
]) ])
await refreshContractBudgets() await refreshContractBudgets()
notify(`导入成功(${nextContracts.length} 个合同段)`) notify(t('ht.importSuccess', { count: nextContracts.length }))
await nextTick() await nextTick()
scrollContractsToBottom() scrollContractsToBottom()
} catch (error) { } catch (error) {
@ -700,15 +702,18 @@ const importContractSegments = async (event: Event) => {
if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) { if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) {
const [, importIndustry = '', currentIndustry = ''] = message.split(':') const [, importIndustry = '', currentIndustry = ''] = message.split(':')
showMessageDialog( showMessageDialog(
'导入失败', t('ht.importFailedTitle'),
`工程行业不一致(导入包:${formatIndustryLabel(importIndustry)},当前项目:${formatIndustryLabel(currentIndustry)})。` t('ht.importIndustryMismatch', {
importIndustry: formatIndustryLabel(importIndustry),
currentIndustry: formatIndustryLabel(currentIndustry)
})
) )
} else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') { } else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') {
showMessageDialog('导入失败', '当前项目未设置工程行业,请先在“基础信息”里新建项目。') showMessageDialog(t('ht.importFailedTitle'), t('ht.importCurrentIndustryMissing'))
} else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') { } else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') {
showMessageDialog('导入失败', '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。') showMessageDialog(t('ht.importFailedTitle'), t('ht.importPackageIndustryMissing'))
} else { } else {
showMessageDialog('导入失败', '文件无效、已损坏或不是合同段导出文件。') showMessageDialog(t('ht.importFailedTitle'), t('ht.importFileInvalid'))
} }
} finally { } finally {
input.value = '' input.value = ''
@ -811,7 +816,7 @@ const createContract = async () => {
item.id === editingContractId.value ? { ...item, name } : item item.id === editingContractId.value ? { ...item, name } : item
) )
await saveContracts() await saveContracts()
notify('编辑成功') notify(t('ht.editSuccess'))
closeCreateModal() closeCreateModal()
return return
} }
@ -831,7 +836,7 @@ const createContract = async () => {
console.error('initialize contract scale failed:', error) console.error('initialize contract scale failed:', error)
} }
await refreshContractBudgets() await refreshContractBudgets()
notify('新建成功') notify(t('ht.createSuccess'))
closeCreateModal() closeCreateModal()
await nextTick() await nextTick()
scrollContractsToBottom() scrollContractsToBottom()
@ -850,19 +855,19 @@ const deleteContract = async (id: string) => {
selectedContractIds.value = selectedContractIds.value.filter(item => item !== id) selectedContractIds.value = selectedContractIds.value.filter(item => item !== id)
await saveContracts() await saveContracts()
await refreshContractBudgets() await refreshContractBudgets()
notify('删除成功') notify(t('ht.deleteSuccess'))
} }
const deleteSelectedContracts = async () => { const deleteSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) { if (selectedContractIds.value.length === 0) {
showMessageDialog('提示', '请先勾选至少一个合同段。') showMessageDialog(t('ht.tipTitle'), t('ht.selectAtLeastOne'))
return return
} }
const selectedSet = new Set(selectedContractIds.value) const selectedSet = new Set(selectedContractIds.value)
const targets = contracts.value.filter(item => selectedSet.has(item.id)) const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) { if (targets.length === 0) {
showMessageDialog('提示', '未找到可删除的合同段。') showMessageDialog(t('ht.tipTitle'), t('ht.noContractsToDelete'))
return return
} }
batchDeleteConfirmOpen.value = true batchDeleteConfirmOpen.value = true
@ -873,7 +878,7 @@ const confirmDeleteSelectedContracts = async () => {
const targets = contracts.value.filter(item => selectedSet.has(item.id)) const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) { if (targets.length === 0) {
batchDeleteConfirmOpen.value = false batchDeleteConfirmOpen.value = false
showMessageDialog('提示', '未找到可删除的合同段。') showMessageDialog(t('ht.tipTitle'), t('ht.noContractsToDelete'))
return return
} }
@ -895,11 +900,11 @@ const confirmDeleteSelectedContracts = async () => {
selectedContractIds.value = selectedContractIds.value.filter(item => !selectedSet.has(item)) selectedContractIds.value = selectedContractIds.value.filter(item => !selectedSet.has(item))
await saveContracts() await saveContracts()
await refreshContractBudgets() await refreshContractBudgets()
notify(`删除成功(${targetIds.length} 个合同段)`) notify(t('ht.deleteBatchSuccess', { count: targetIds.length }))
exitContractSelectionMode() exitContractSelectionMode()
} catch (error) { } catch (error) {
console.error('delete selected contracts failed:', error) console.error('delete selected contracts failed:', error)
showMessageDialog('批量删除失败', '请重试。') showMessageDialog(t('ht.batchDeleteFailedTitle'), t('ht.retry'))
} finally { } finally {
batchDeleteConfirmOpen.value = false batchDeleteConfirmOpen.value = false
} }
@ -916,7 +921,7 @@ const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) =>
} }
await saveContracts() await saveContracts()
notify('排序完成') notify(t('ht.sortDone'))
} }
const updateDragPointerPosition = (event: MouseEvent | DragEvent) => { const updateDragPointerPosition = (event: MouseEvent | DragEvent) => {
@ -990,7 +995,7 @@ const handleCardClick = (item: ContractItem) => {
} }
tabStore.openTab({ tabStore.openTab({
id: `contract-${item.id}`, id: `contract-${item.id}`,
title: `合同段${item.name}`, title: t('ht.contractTabTitle', { name: item.name }),
componentName: 'QuickCalcView', componentName: 'QuickCalcView',
props: { contractId: item.id, contractName: item.name } props: { contractId: item.id, contractName: item.name }
}) })
@ -1064,32 +1069,32 @@ watch(budgetRefreshSignature, (next, prev) => {
<ToastProvider> <ToastProvider>
<TooltipProvider> <TooltipProvider>
<div class="flex h-full min-h-0 flex-col overflow-hidden"> <div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="shrink-0 border-b bg-background/95 px-1 pb-4 backdrop-blur supports-[backdrop-filter]:bg-background/80"> <div class="relative z-30 shrink-0 overflow-visible border-b bg-background/95 px-1 pb-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div class="mb-6 flex items-center justify-between pt-1"> <div class="mb-6 flex items-center justify-between pt-1">
<div class="space-y-1"> <div class="space-y-1">
<h3 class="text-lg font-bold">合同段列表</h3> <h3 class="text-lg font-bold">{{ t('ht.title') }}</h3>
<div class="text-xs text-muted-foreground"> <div class="text-xs text-muted-foreground">
项目总预算金额{{ contractBudgetLoading ? '计算中...' : formatBudgetAmount(projectTotalBudget) }} {{ t('ht.projectTotalBudget', { amount: contractBudgetLoading ? t('ht.budgetLoading') : formatBudgetAmount(projectTotalBudget) }) }}
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<template v-if="isSelectingContracts"> <template v-if="isSelectingContracts">
<div class="text-xs text-muted-foreground">已选 {{ selectedContractCount }} </div> <div class="text-xs text-muted-foreground">{{ t('ht.selectedCount', { count: selectedContractCount }) }}</div>
<Button <Button
variant="outline" variant="outline"
:disabled="selectedContractCount === 0" :disabled="selectedContractCount === 0"
@click="selectionMode === 'export' ? exportSelectedContracts() : deleteSelectedContracts()" @click="selectionMode === 'export' ? exportSelectedContracts() : deleteSelectedContracts()"
> >
{{ selectionMode === 'export' ? '导出已选' : '删除已选' }} {{ selectionMode === 'export' ? t('ht.exportSelected') : t('ht.deleteSelected') }}
</Button> </Button>
<Button variant="ghost" @click="exitContractSelectionMode"> <Button variant="ghost" @click="exitContractSelectionMode">
取消 {{ t('ht.cancelSelect') }}
</Button> </Button>
</template> </template>
<template v-else> <template v-else>
<Button :disabled="!canManageContracts" @click="openCreateModal"> <Button class="whitespace-nowrap" :disabled="!canManageContracts" @click="openCreateModal">
<Plus class="mr-2 h-4 w-4" /> <Plus class="mr-2 h-4 w-4" />
添加合同段 {{ t('ht.addContract') }}
</Button> </Button>
<div ref="contractDataMenuRef" class="relative"> <div ref="contractDataMenuRef" class="relative">
<Button <Button
@ -1102,32 +1107,32 @@ watch(budgetRefreshSignature, (next, prev) => {
</Button> </Button>
<div <div
v-if="contractDataMenuOpen" v-if="contractDataMenuOpen"
class="absolute right-0 top-full z-50 mt-1 min-w-[132px] rounded-md border bg-background p-1 shadow-md" class="absolute right-0 top-full z-[80] mt-1 w-max rounded-md border bg-background p-1 shadow-md"
> >
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm" class="block whitespace-nowrap rounded px-3 py-1.5 text-left text-sm"
:class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'" :class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!hasContracts" :disabled="!hasContracts"
@click="enterContractDeleteMode" @click="enterContractDeleteMode"
> >
批量删除 {{ t('ht.batchDelete') }}
</button> </button>
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm" class="block whitespace-nowrap rounded px-3 py-1.5 text-left text-sm"
:class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'" :class="hasContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!hasContracts" :disabled="!hasContracts"
@click="enterContractExportMode" @click="enterContractExportMode"
> >
导出合同段 {{ t('ht.exportContracts') }}
</button> </button>
<button <button
class="w-full rounded px-3 py-1.5 text-left text-sm" class="block whitespace-nowrap rounded px-3 py-1.5 text-left text-sm"
:class="canManageContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'" :class="canManageContracts ? 'cursor-pointer hover:bg-muted' : 'cursor-not-allowed text-muted-foreground'"
:disabled="!canManageContracts" :disabled="!canManageContracts"
@click="triggerContractImport" @click="triggerContractImport"
> >
导入合同段 {{ t('ht.importContracts') }}
</button> </button>
</div> </div>
<input <input
@ -1148,7 +1153,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<input <input
v-model="contractSearchKeyword" v-model="contractSearchKeyword"
type="text" type="text"
placeholder="搜索合同段名称或ID" :placeholder="t('ht.searchPlaceholder')"
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" 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"
/> />
<Button <Button
@ -1158,22 +1163,22 @@ watch(budgetRefreshSignature, (next, prev) => {
class="h-10 shrink-0 px-3" class="h-10 shrink-0 px-3"
@click="contractSearchKeyword = ''" @click="contractSearchKeyword = ''"
> >
清空筛选 {{ t('ht.clearFilter') }}
</Button> </Button>
</div> </div>
<div v-if="isSearchingContracts" class="mt-1 text-xs text-muted-foreground"> <div v-if="isSearchingContracts" class="mt-1 text-xs text-muted-foreground">
搜索中{{ filteredContracts.length }} / {{ contracts.length }}已关闭拖拽排序 {{ t('ht.searchingHint', { filtered: filteredContracts.length, total: contracts.length }) }}
</div> </div>
<div v-if="isSelectingContracts" class="mt-1 text-xs text-muted-foreground"> <div v-if="isSelectingContracts" class="mt-1 text-xs text-muted-foreground">
{{ selectionMode === 'export' ? '导出选择模式:勾选合同段后点击“导出已选”' : '删除选择模式:勾选合同段后点击“删除已选”' }} {{ selectionMode === 'export' ? t('ht.selectModeExportHint') : t('ht.selectModeDeleteHint') }}
</div> </div>
<div v-if="!canManageContracts" class="mt-1 text-xs text-muted-foreground"> <div v-if="!canManageContracts" class="mt-1 text-xs text-muted-foreground">
请先在基础信息里新建项目并选择工程行业后再新增或导入合同段 {{ t('ht.setupRequiredHint') }}
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-2 md:ml-auto"> <div class="flex flex-wrap items-center gap-2 md:ml-auto">
<label class="inline-flex cursor-pointer items-center gap-2 text-xs text-muted-foreground select-none"> <label class="inline-flex cursor-pointer items-center gap-2 text-xs text-muted-foreground select-none">
<span>{{ isListLayout ? '列表布局' : '网格布局' }}</span> <span>{{ isListLayout ? t('ht.listLayout') : t('ht.gridLayout') }}</span>
<button <button
type="button" type="button"
role="switch" role="switch"
@ -1259,10 +1264,10 @@ watch(budgetRefreshSignature, (next, prev) => {
ID: {{ element.id }} ID: {{ element.id }}
</span> </span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground"> <span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
预算{{ formatBudgetAmount(contractBudgetById[element.id]) }} {{ t('ht.contractBudget', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}
</span> </span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground"> <span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
创建时间{{ formatDateTime(element.createdAt) }} {{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
</span> </span>
</template> </template>
</CardTitle> </CardTitle>
@ -1283,7 +1288,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" /> <GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">拖动排序</TooltipContent> <TooltipContent side="top">{{ t('ht.dragSort') }}</TooltipContent>
</TooltipRoot> </TooltipRoot>
<TooltipRoot> <TooltipRoot>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@ -1296,7 +1301,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" /> <Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">编辑</TooltipContent> <TooltipContent side="top">{{ t('ht.edit') }}</TooltipContent>
</TooltipRoot> </TooltipRoot>
<TooltipRoot> <TooltipRoot>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@ -1309,7 +1314,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" /> <Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">删除</TooltipContent> <TooltipContent side="top">{{ t('ht.remove') }}</TooltipContent>
</TooltipRoot> </TooltipRoot>
</div> </div>
</CardHeader> </CardHeader>
@ -1320,9 +1325,9 @@ watch(budgetRefreshSignature, (next, prev) => {
'space-y-1 pb-1' 'space-y-1 pb-1'
]" ]"
> >
<div class="break-all">ID{{ element.id }}</div> <div class="break-all">{{ t('ht.idLabel', { id: element.id }) }}</div>
<div>本合同预算金额{{ formatBudgetAmount(contractBudgetById[element.id]) }}</div> <div>{{ t('ht.contractBudgetLine', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}</div>
<div>创建时间{{ formatDateTime(element.createdAt) }}</div> <div>{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}</div>
</div> </div>
</Card> </Card>
</template> </template>
@ -1331,8 +1336,8 @@ watch(budgetRefreshSignature, (next, prev) => {
v-else-if="!isSearchingContracts && filteredContracts.length === 0" v-else-if="!isSearchingContracts && filteredContracts.length === 0"
class="mx-2 mb-4 rounded-2xl border border-dashed border-primary/30 bg-gradient-to-br from-primary/5 via-background to-muted/30 p-10 text-center shadow-sm" class="mx-2 mb-4 rounded-2xl border border-dashed border-primary/30 bg-gradient-to-br from-primary/5 via-background to-muted/30 p-10 text-center shadow-sm"
> >
<div class="text-lg font-semibold tracking-wide text-foreground">暂无合同卡片</div> <div class="text-lg font-semibold tracking-wide text-foreground">{{ t('ht.emptyTitle') }}</div>
<div class="mt-2 text-sm text-muted-foreground">赶紧来添加吧</div> <div class="mt-2 text-sm text-muted-foreground">{{ t('ht.emptyDesc') }}</div>
</div> </div>
<div <div
v-else v-else
@ -1389,10 +1394,10 @@ watch(budgetRefreshSignature, (next, prev) => {
ID: {{ element.id }} ID: {{ element.id }}
</span> </span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground"> <span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
预算{{ formatBudgetAmount(contractBudgetById[element.id]) }} {{ t('ht.contractBudget', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}
</span> </span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground"> <span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
创建时间{{ formatDateTime(element.createdAt) }} {{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
</span> </span>
</template> </template>
</CardTitle> </CardTitle>
@ -1411,7 +1416,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" /> <GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">拖动排序搜索时关闭</TooltipContent> <TooltipContent side="top">{{ t('ht.dragSortSearchOff') }}</TooltipContent>
</TooltipRoot> </TooltipRoot>
<TooltipRoot> <TooltipRoot>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@ -1424,7 +1429,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" /> <Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">编辑</TooltipContent> <TooltipContent side="top">{{ t('ht.edit') }}</TooltipContent>
</TooltipRoot> </TooltipRoot>
<TooltipRoot> <TooltipRoot>
<TooltipTrigger as-child> <TooltipTrigger as-child>
@ -1437,7 +1442,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" /> <Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">删除</TooltipContent> <TooltipContent side="top">{{ t('ht.remove') }}</TooltipContent>
</TooltipRoot> </TooltipRoot>
</div> </div>
</CardHeader> </CardHeader>
@ -1448,16 +1453,16 @@ watch(budgetRefreshSignature, (next, prev) => {
'space-y-1 pb-4' 'space-y-1 pb-4'
]" ]"
> >
<div class="break-all">ID{{ element.id }}</div> <div class="break-all">{{ t('ht.idLabel', { id: element.id }) }}</div>
<div>本合同预算金额{{ formatBudgetAmount(contractBudgetById[element.id]) }}</div> <div>{{ t('ht.contractBudgetLine', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}</div>
<div>创建时间{{ formatDateTime(element.createdAt) }}</div> <div>{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}</div>
</div> </div>
</Card> </Card>
<div <div
v-if="filteredContracts.length === 0" v-if="filteredContracts.length === 0"
class="col-span-full rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground" class="col-span-full rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground"
> >
未找到匹配的合同段 {{ t('ht.notFound') }}
</div> </div>
</div> </div>
</ScrollArea> </ScrollArea>
@ -1468,7 +1473,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
type="button" type="button"
aria-label="回到顶部" :aria-label="t('ht.backToTop')"
:class="[ :class="[
'fixed bottom-8 right-8 z-40 inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-black/15 bg-white text-black shadow-[0_10px_24px_rgba(0,0,0,0.16)] transition-all duration-300 hover:scale-105 hover:border-black/30 hover:bg-black hover:text-white', 'fixed bottom-8 right-8 z-40 inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-black/15 bg-white text-black shadow-[0_10px_24px_rgba(0,0,0,0.16)] transition-all duration-300 hover:scale-105 hover:border-black/30 hover:bg-black hover:text-white',
showScrollTopFab ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-3 opacity-0' showScrollTopFab ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-3 opacity-0'
@ -1478,7 +1483,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<ArrowUp class="h-5 w-5" /> <ArrowUp class="h-5 w-5" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left">回到顶部</TooltipContent> <TooltipContent side="left">{{ t('ht.backToTop') }}</TooltipContent>
</TooltipRoot> </TooltipRoot>
<div <div
@ -1494,7 +1499,7 @@ watch(budgetRefreshSignature, (next, prev) => {
@mousedown.prevent="startDrag" @mousedown.prevent="startDrag"
> >
<h4 class="text-base font-semibold"> <h4 class="text-base font-semibold">
{{ editingContractId ? '编辑合同段' : '新增合同段' }} {{ editingContractId ? t('ht.editContract') : t('ht.createContract') }}
</h4> </h4>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeCreateModal"> <Button variant="ghost" size="icon" class="h-8 w-8" @click="closeCreateModal">
<X class="h-4 w-4" /> <X class="h-4 w-4" />
@ -1502,20 +1507,20 @@ watch(budgetRefreshSignature, (next, prev) => {
</div> </div>
<div class="space-y-2 px-5 py-4"> <div class="space-y-2 px-5 py-4">
<label class="block text-sm font-medium text-foreground">合同段名称</label> <label class="block text-sm font-medium text-foreground">{{ t('ht.contractName') }}</label>
<input <input
v-model="contractNameInput" v-model="contractNameInput"
type="text" type="text"
placeholder="请输入合同段名称" :placeholder="t('ht.contractNamePlaceholder')"
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" 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="createContract" @keydown.enter="createContract"
/> />
</div> </div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-3"> <div class="flex items-center justify-end gap-2 px-5 py-3">
<Button variant="outline" @click="closeCreateModal">取消</Button> <Button variant="outline" @click="closeCreateModal">{{ t('common.cancel') }}</Button>
<Button :disabled="!contractNameInput.trim()" @click="createContract"> <Button :disabled="!contractNameInput.trim()" @click="createContract">
{{ editingContractId ? '保存' : '确定' }} {{ editingContractId ? t('ht.save') : t('ht.ok') }}
</Button> </Button>
</div> </div>
</div> </div>
@ -1525,16 +1530,51 @@ watch(budgetRefreshSignature, (next, prev) => {
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认删除合同段</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('ht.deleteSingleTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
即将删除{{ pendingDeleteContractName }}及其关联咨询服务和计价数据是否继续 {{ t('ht.deleteSingleDesc', { name: pendingDeleteContractName }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline" @click="pendingDeleteContractId = null">取消</Button> <Button variant="outline" @click="pendingDeleteContractId = null">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteContract">确认删除</Button> <Button variant="destructive" @click="confirmDeleteContract">{{ t('common.confirm') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="batchDeleteConfirmOpen" @update:open="batchDeleteConfirmOpen = $event">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ t('ht.deleteBatchTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('ht.deleteBatchDesc', { count: batchDeleteCount }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteSelectedContracts">{{ t('common.confirm') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot :open="messageDialogOpen" @update:open="messageDialogOpen = $event">
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">{{ messageDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ messageDialogDesc }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogAction as-child>
<Button @click="messageDialogOpen = false">{{ t('tab.dialog.iKnow') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -1550,11 +1590,11 @@ watch(budgetRefreshSignature, (next, prev) => {
<ToastDescription class="text-xs text-muted-foreground">{{ toastText }}</ToastDescription> <ToastDescription class="text-xs text-muted-foreground">{{ toastText }}</ToastDescription>
</div> </div>
<ToastAction <ToastAction
alt-text="知道了" :alt-text="t('tab.dialog.iKnow')"
class="ml-auto cursor-pointer inline-flex h-7 items-center rounded-md border border-border bg-muted px-2 text-xs text-foreground hover:bg-muted/80" class="ml-auto cursor-pointer inline-flex h-7 items-center rounded-md border border-border bg-muted px-2 text-xs text-foreground hover:bg-muted/80"
@click="toastOpen = false" @click="toastOpen = false"
> >
知道了 {{ t('tab.dialog.iKnow') }}
</ToastAction> </ToastAction>
</ToastRoot> </ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" /> <ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />

View File

@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue' import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { additionalWorkList } from '@/sql' import { getAdditionalWorkListEntries } from '@/sql'
const props = defineProps<{ const props = defineProps<{
contractId: string contractId: string
contractName?: string contractName?: string
}>() }>()
const { t, locale } = useI18n()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`) const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
const additionalWorkNames = computed(() => const additionalWorkNames = computed(() =>
additionalWorkList.map(item => ({id:item?.id,name:item?.name})) getAdditionalWorkListEntries(locale.value).map(item => ({ id: item?.id, name: item?.name }))
) )
</script> </script>
<template> <template>
<HtFeeMethodGrid <HtFeeMethodGrid
title="附加工作费" :title="t('htFee.additionalTitle')"
:storageKey="STORAGE_KEY" :storageKey="STORAGE_KEY"
:contract-id="props.contractId" :contract-id="props.contractId"
:contract-name="props.contractName" :contract-name="props.contractName"

View File

@ -1,111 +1,88 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { import { useI18n } from 'vue-i18n'
useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
interface HtBaseInfoState { interface HtBaseInfoState {
quality: string quality: string
duration: duration: string
string
} }
const DEFAULT_QUALITY = '造价咨询服务的综合评价应达到"较好"或综合评分90分' const { t } = useI18n()
const DEFAULT_DURATION = '' const DEFAULT_QUALITY = t('htBaseInfo.defaultQuality')
const DEFAULT_DURATION = ''
const props =
defineProps<{ const props = defineProps<{
contractId: string contractId: string
}>()
}>()
const zxFwPricingStore = useZxFwPricingStore()
const zxFwPricingStore = useZxFwPricingStore() const storageKey = () => `ht-base-info-${props.contractId}`
const storageKey = () => const quality = ref(DEFAULT_QUALITY)
`ht-base-info-${props.contractId}` const duration = ref('')
const lastSavedSnapshot = ref('')
const quality = ref(DEFAULT_QUALITY)
const duration = ref('') const saveForm = (force = false) => {
const const payload: HtBaseInfoState = {
lastSavedSnapshot = ref('') quality: quality.value,
duration: duration.value
const saveForm = (force = false) => {
const payload: HtBaseInfoState = {
quality: quality.value,
duration: duration.value
} }
const snapshot = JSON.stringify(payload) const snapshot = JSON.stringify(payload)
if (!force if (!force && snapshot === lastSavedSnapshot.value) return
&& snapshot === lastSavedSnapshot.value) return zxFwPricingStore.setKeyState(storageKey(), payload)
zxFwPricingStore.setKeyState(storageKey(), payload)
lastSavedSnapshot.value = snapshot lastSavedSnapshot.value = snapshot
} }
const loadForm = async () => { const loadForm = async () => {
const data = await const data = await zxFwPricingStore.loadKeyState<HtBaseInfoState>(storageKey())
zxFwPricingStore.loadKeyState<HtBaseInfoState>(storageKey()) const hasStoredValue = Boolean(
const hasStoredValue = Boolean( data && (Object.prototype.hasOwnProperty.call(data, 'quality') || Object.prototype.hasOwnProperty.call(data, 'duration'))
data &&
(Object.prototype.hasOwnProperty.call(data, 'quality') || Object.prototype.hasOwnProperty.call(data, 'duration'))
) )
quality.value = typeof data?.quality === 'string' && quality.value = typeof data?.quality === 'string' && data.quality ? data.quality : DEFAULT_QUALITY
data.quality ? data.quality : DEFAULT_QUALITY duration.value = typeof data?.duration === 'string' ? data.duration : (hasStoredValue ? '' : DEFAULT_DURATION)
duration.value = typeof data?.duration === 'string' lastSavedSnapshot.value = JSON.stringify({ quality: quality.value, duration: duration.value })
? data.duration if (!hasStoredValue) saveForm(true)
: (hasStoredValue ? '' : DEFAULT_DURATION)
const payload: HtBaseInfoState = { quality: quality.value, duration: duration.value }
lastSavedSnapshot.value = JSON.stringify(payload)
if (!hasStoredValue) {
saveForm(true)
}
} }
watch([quality, duration], () => { saveForm() watch([quality, duration], () => {
}) saveForm()
})
onMounted(() => { void loadForm() })
onBeforeUnmount(() => { saveForm(true) }) onMounted(() => {
</script> void loadForm()
})
<template>
<div onBeforeUnmount(() => {
class="h-full min-h-0 flex flex-col"> saveForm(true)
<div class="rounded-lg border bg-card p-5"> })
<div class="mb-4 </script>
border-b pb-3">
<h3 class="text-sm font-semibold text-foreground">基础信息</h3> <template>
</div> <div class="h-full min-h-0 flex flex-col">
<div <div class="rounded-lg border bg-card p-5">
class="grid grid-cols-1 gap-5"> <div class="mb-4 border-b pb-3">
<label class="space-y-1.5"> <h3 class="text-sm font-semibold text-foreground">{{ t('htBaseInfo.title') }}</h3>
<div class="text-xs font-medium </div>
text-muted-foreground">质量要求</div> <div class="grid grid-cols-1 gap-5">
<textarea <label class="space-y-1.5">
v-model="quality" <div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.qualityLabel') }}</div>
rows="3" <textarea
v-model="quality"
placeholder="请输入质量要求" rows="3"
class="w-full rounded-md border bg-background px-3 py-2 :placeholder="t('htBaseInfo.qualityPlaceholder')"
text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none" class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
/> />
</label>
</label> <label class="space-y-1.5">
<label class="space-y-1.5"> <div class="text-xs font-medium text-muted-foreground">{{ t('htBaseInfo.durationLabel') }}</div>
<div class="text-xs font-medium <textarea
text-muted-foreground">工期要求</div> v-model="duration"
<textarea rows="3"
v-model="duration" :placeholder="t('htBaseInfo.durationPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none"
rows="3" />
placeholder="请输入工期要求" </label>
class="w-full rounded-md border bg-background </div>
px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30 resize-none" </div>
/> </div>
</template>
</label>
</div>
</div>
</div>
</template>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue' import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql' import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue' import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
@ -25,6 +26,7 @@ type ServiceItem = {
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1') const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const projectIndustry = ref('') const projectIndustry = ref('')
const kvStore = useKvStore() const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => { const loadProjectIndustry = async () => {
try { try {
@ -57,7 +59,7 @@ onActivated(() => {
<template> <template>
<XmFactorGrid <XmFactorGrid
title="咨询分类系数明细" :title="t('htFactors.consultCategoryTitle')"
:storage-key="`ht-consult-category-factor-v1-${props.contractId}`" :storage-key="`ht-consult-category-factor-v1-${props.contractId}`"
:parent-storage-key="props.parentStorageKey || 'xm-consult-category-factor-v1'" :parent-storage-key="props.parentStorageKey || 'xm-consult-category-factor-v1'"
:dict="filteredServiceDict" :dict="filteredServiceDict"

View File

@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch, type PropType } from 'vue' import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch, type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEvent, ICellRendererParams, RowDataUpdatedEvent } from 'ag-grid-community' import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEvent, ICellRendererParams, RowDataUpdatedEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toFiniteNumberOrNull } from '@/lib/decimal' import { roundTo, toFiniteNumberOrNull } from '@/lib/decimal'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
@ -50,6 +52,7 @@ interface QuantityMethodStateLike {
const props = defineProps<{ const props = defineProps<{
contractId: string contractId: string
}>() }>()
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const gridApi = shallowRef<GridApi<SummaryRow> | null>(null) const gridApi = shallowRef<GridApi<SummaryRow> | null>(null)
@ -147,15 +150,15 @@ const buildFeeRows = async (
const summary = await loadHtMethodSummaryByRow(mainStorageKey, String(item.id)) const summary = await loadHtMethodSummaryByRow(mainStorageKey, String(item.id))
const lineParts: string[] = [] const lineParts: string[] = []
if (summary.m0) { if (summary.m0) {
lineParts.push(`按费率${summary.m0.coe}%计得${summary.m0.fee}`) lineParts.push(t('htSummary.explainByRate', { rate: summary.m0.coe, fee: summary.m0.fee }))
} }
if (summary.m4) { if (summary.m4) {
lineParts.push(`按工时法计得${summary.m4.fee}`) lineParts.push(t('htSummary.explainByHourly', { fee: summary.m4.fee }))
} }
if (summary.m5) { if (summary.m5) {
lineParts.push(`按数量单价计得${summary.m5.fee}`) lineParts.push(t('htSummary.explainByQuantity', { fee: summary.m5.fee }))
} }
const linePrefix = rowType === 'additional' ? '附加工作费' : '预备费' const linePrefix = rowType === 'additional' ? t('htSummary.additionalPrefix') : t('htSummary.reservePrefix')
const explainLine = lineParts.length > 0 ? `${linePrefix}-${item.name}${lineParts.join(';')}` : '' const explainLine = lineParts.length > 0 ? `${linePrefix}-${item.name}${lineParts.join(';')}` : ''
const row: SummaryRow = { const row: SummaryRow = {
id: `${rowType}-${item.id}`, id: `${rowType}-${item.id}`,
@ -242,7 +245,7 @@ const totalRow = computed<SummaryRow>(() => {
id: 'summary-total-row', id: 'summary-total-row',
rowType: 'total', rowType: 'total',
code: '', code: '',
name: '合计', name: t('htSummary.total'),
investScale: sumField(row => row.investScale), investScale: sumField(row => row.investScale),
landScale: sumField(row => row.landScale), landScale: sumField(row => row.landScale),
workload: sumField(row => row.workload), workload: sumField(row => row.workload),
@ -263,7 +266,7 @@ const RichCodeRenderer = defineComponent({
setup(props) { setup(props) {
return () => { return () => {
if (props.params.data?.rowType === 'total') { if (props.params.data?.rowType === 'total') {
return h('span', props.params.data.name || '合计') return h('span', props.params.data.name || t('htSummary.total'))
} }
const value = props.params.value as SummaryRow['code'] const value = props.params.value as SummaryRow['code']
if (!value || typeof value === 'string') { if (!value || typeof value === 'string') {
@ -294,19 +297,19 @@ const RichCodeRenderer = defineComponent({
const columnDefs: ColDef<SummaryRow>[] = [ const columnDefs: ColDef<SummaryRow>[] = [
{ {
headerName: '编码', headerName: t('htSummary.columns.code'),
field: 'code', field: 'code',
minWidth: 90, minWidth: 90,
maxWidth: 140, maxWidth: 140,
colSpan: params => (params.data?.rowType === 'total' ? 2 : 1), colSpan: params => (params.data?.rowType === 'total' ? 2 : 1),
valueFormatter: params => { valueFormatter: params => {
if (params.data?.rowType === 'total') return params.data.name || '合计' if (params.data?.rowType === 'total') return params.data.name || t('htSummary.total')
return typeof params.value === 'string' ? params.value : '' return typeof params.value === 'string' ? params.value : ''
}, },
cellRenderer: RichCodeRenderer cellRenderer: RichCodeRenderer
}, },
{ {
headerName: '名称', headerName: t('htSummary.columns.name'),
field: 'name', field: 'name',
minWidth: 220, minWidth: 220,
flex: 2, flex: 2,
@ -315,7 +318,7 @@ const columnDefs: ColDef<SummaryRow>[] = [
cellStyle: { 'line-height': 1.6 } cellStyle: { 'line-height': 1.6 }
}, },
{ {
headerName: '投资规模法', headerName: t('htSummary.columns.investScale'),
field: 'investScale', field: 'investScale',
minWidth: 120, minWidth: 120,
flex: 1.2, flex: 1.2,
@ -335,7 +338,7 @@ const columnDefs: ColDef<SummaryRow>[] = [
} }
}, },
{ {
headerName: '用地规模法', headerName: t('htSummary.columns.landScale'),
field: 'landScale', field: 'landScale',
minWidth: 120, minWidth: 120,
flex: 1.2, flex: 1.2,
@ -349,7 +352,7 @@ const columnDefs: ColDef<SummaryRow>[] = [
} }
}, },
{ {
headerName: '工作量法', headerName: t('htSummary.columns.workload'),
field: 'workload', field: 'workload',
minWidth: 110, minWidth: 110,
flex: 1.2, flex: 1.2,
@ -363,7 +366,7 @@ const columnDefs: ColDef<SummaryRow>[] = [
} }
}, },
{ {
headerName: '工时法', headerName: t('htSummary.columns.hourly'),
field: 'hourly', field: 'hourly',
minWidth: 110, minWidth: 110,
flex: 1.2, flex: 1.2,
@ -377,7 +380,7 @@ const columnDefs: ColDef<SummaryRow>[] = [
} }
}, },
{ {
headerName: '小计', headerName: t('htSummary.columns.subtotal'),
field: 'subtotal', field: 'subtotal',
minWidth: 120, minWidth: 120,
flex: 1.2, flex: 1.2,
@ -388,7 +391,7 @@ const columnDefs: ColDef<SummaryRow>[] = [
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2)) valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
}, },
{ {
headerName: '确认金额', headerName: t('htSummary.columns.finalFee'),
field: 'finalFee', field: 'finalFee',
minWidth: 120, minWidth: 120,
flex: 1.2, flex: 1.2,
@ -399,6 +402,7 @@ const columnDefs: ColDef<SummaryRow>[] = [
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2)) valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const summaryGridOptions: GridOptions<SummaryRow> = { const summaryGridOptions: GridOptions<SummaryRow> = {
...gridOptions, ...gridOptions,
@ -472,7 +476,7 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex flex-col overflow-hidden"> <div class="rounded-lg border bg-card xmMx flex flex-col overflow-hidden">
<div class="flex items-center justify-between border-b px-3 py-2"> <div class="flex items-center justify-between border-b px-3 py-2">
<h3 class="text-xs font-semibold text-foreground leading-none"> <h3 class="text-xs font-semibold text-foreground leading-none">
合同段汇总 {{ t('htSummary.title') }}
</h3> </h3>
</div> </div>
<div class="ag-theme-quartz w-full"> <div class="ag-theme-quartz w-full">
@ -480,7 +484,7 @@ onBeforeUnmount(() => {
:style="{ width: '100%' }" :style="{ width: '100%' }"
:rowData="rowData" :rowData="rowData"
:pinnedBottomRowData="[totalRow]" :pinnedBottomRowData="[totalRow]"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:gridOptions="summaryGridOptions" :gridOptions="summaryGridOptions"
:theme="myTheme" :theme="myTheme"
:animateRows="true" :animateRows="true"
@ -493,11 +497,11 @@ onBeforeUnmount(() => {
</div> </div>
<form class="rounded-lg border bg-card p-3 space-y-2"> <form class="rounded-lg border bg-card p-3 space-y-2">
<div class="text-xs font-semibold text-foreground">说明</div> <div class="text-xs font-semibold text-foreground">{{ t('htSummary.remark') }}</div>
<textarea <textarea
:value="explanationText" :value="explanationText"
rows="3" rows="3"
placeholder="请先填咨询服务/附加工作费/预备费的数据" :placeholder="t('htSummary.placeholder')"
readonly readonly
class="w-full rounded-md border bg-muted/40 px-3 py-2 text-sm text-foreground outline-none" class="w-full rounded-md border bg-muted/40 px-3 py-2 text-sm text-foreground outline-none"
/> />

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { roundTo } from '@/lib/decimal' import { roundTo } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
@ -19,6 +20,7 @@ const props = defineProps<{
htMethodType?: 'rate-fee' htMethodType?: 'rate-fee'
}>() }>()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const { t } = useI18n()
const rate = ref<number | null>(null) const rate = ref<number | null>(null)
const remark = ref('') const remark = ref('')
@ -50,7 +52,7 @@ const additionalWorkStateSignature = computed(() => {
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null) return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
}) })
const baseLabel = computed(() => const baseLabel = computed(() =>
isReserveFee.value ? '基数(咨询服务总计 + 附加工作费总计)' : '基数(所有服务费预算合计)' isReserveFee.value ? t('htFeeRate.reserveBaseLabel') : t('htFeeRate.baseLabel')
) )
const budgetFee = computed<number | null>(() => { const budgetFee = computed<number | null>(() => {
@ -210,22 +212,22 @@ onBeforeUnmount(() => {
</label> </label>
<label class="space-y-1.5"> <label class="space-y-1.5">
<div class="text-xs text-muted-foreground">费率%</div> <div class="text-xs text-muted-foreground">{{ t('htFeeRate.rateLabel') }}</div>
<input v-model="rateInput" type="text" inputmode="decimal" placeholder="请输入费率建议1 ~ 5" <input v-model="rateInput" type="text" inputmode="decimal" :placeholder="t('htFeeRate.ratePlaceholder')"
class="rate-input h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30" class="rate-input h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
@blur="applyRateInput" @blur="applyRateInput"
@keydown.enter.prevent="applyRateInput" /> @keydown.enter.prevent="applyRateInput" />
</label> </label>
<label class="space-y-1.5"> <label class="space-y-1.5">
<div class="text-xs text-muted-foreground">预算费用自动计算</div> <div class="text-xs text-muted-foreground">{{ t('htFeeRate.budgetFeeLabel') }}</div>
<input type="text" :value="formatAmount(budgetFee)" readonly disabled tabindex="-1" <input type="text" :value="formatAmount(budgetFee)" readonly disabled tabindex="-1"
class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" /> class="h-9 w-full cursor-not-allowed rounded-md border bg-muted/40 px-3 text-sm text-foreground select-none" />
</label> </label>
<label class="space-y-1.5 md:col-span-2"> <label class="space-y-1.5 md:col-span-2">
<div class="text-xs text-muted-foreground">说明</div> <div class="text-xs text-muted-foreground">{{ t('htFeeRate.remarkLabel') }}</div>
<textarea v-model="remark" rows="4" placeholder="请输入说明" <textarea v-model="remark" rows="4" :placeholder="t('htFeeRate.remarkPlaceholder')"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30" /> class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30" />
</label> </label>
</div> </div>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue' import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue' import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
@ -25,6 +26,7 @@ const props = defineProps<{
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1') const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const projectIndustry = ref('') const projectIndustry = ref('')
const kvStore = useKvStore() const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => { const loadProjectIndustry = async () => {
try { try {
@ -57,7 +59,7 @@ onActivated(() => {
<template> <template>
<XmFactorGrid <XmFactorGrid
title="工程专业系数明细" :title="t('htFactors.majorTitle')"
:storage-key="`ht-major-factor-v1-${props.contractId}`" :storage-key="`ht-major-factor-v1-${props.contractId}`"
:parent-storage-key="props.parentStorageKey || 'xm-major-factor-v1'" :parent-storage-key="props.parentStorageKey || 'xm-major-factor-v1'"
:dict="filteredMajorDict" :dict="filteredMajorDict"

View File

@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue' import HtFeeMethodGrid from '@/features/shared/components/HtFeeMethodGrid.vue'
import { reserveList } from '@/sql' import { getReserveListEntries } from '@/sql'
const props = defineProps<{ const props = defineProps<{
contractId: string contractId: string
contractName?: string contractName?: string
}>() }>()
const { t, locale } = useI18n()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`) const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
const reserveFeeNames = computed(() => const reserveFeeNames = computed(() =>
reserveList.map(item => ({name:item.name,id:item.id})) getReserveListEntries(locale.value).map(item => ({ name: item.name, id: item.id }))
) )
</script> </script>
<template> <template>
<HtFeeMethodGrid <HtFeeMethodGrid
title="预备费" :title="t('htFee.reserveTitle')"
:storageKey="STORAGE_KEY" :storageKey="STORAGE_KEY"
:contract-id="props.contractId" :contract-id="props.contractId"
:contract-name="props.contractName" :contract-name="props.contractName"

View File

@ -2,9 +2,9 @@
<!-- 修复模板字符串语法反引号需用 v-bind 或模板插值+ 补充属性格式 --> <!-- 修复模板字符串语法反引号需用 v-bind 或模板插值+ 补充属性格式 -->
<TypeLine <TypeLine
scene="ht-tab" scene="ht-tab"
:title="`合同段:${contractName}`" :title="t('htCard.title', { name: contractName })"
:subtitle="`合同段ID${contractId}`" :subtitle="t('htCard.subtitle', { id: contractId })"
:meta-text="`合同段预算金额:${formatBudgetAmount(contractBudget)}`" :meta-text="t('htCard.metaBudget', { amount: formatBudgetAmount(contractBudget) })"
:copy-text="contractId" :copy-text="contractId"
:storage-key="typeLineStorageKey" :storage-key="typeLineStorageKey"
default-category="base-info" default-category="base-info"
@ -14,6 +14,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue'; import { computed, markRaw, defineAsyncComponent, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type Component } from 'vue';
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue'; import TypeLine from '@/layout/typeLine.vue';
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal' import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
@ -28,6 +29,7 @@ const props = defineProps<{
projectConsultCategoryFactorKey?: string; // projectConsultCategoryFactorKey?: string; //
projectMajorFactorKey?: string; // projectMajorFactorKey?: string; //
}>(); }>();
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
interface HtFeeMainRowLike { interface HtFeeMainRowLike {
@ -65,7 +67,9 @@ const typeLineStorageKey = computed(() => `project-active-cat-${props.contractId
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
const formatBudgetAmount = (value: number | null | undefined) => const formatBudgetAmount = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)}` : '--' typeof value === 'number' && Number.isFinite(value)
? `${formatThousands(value, 2)} ${t('htCard.currencySuffix')}`
: '--'
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => { const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : [] const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
@ -326,18 +330,16 @@ const summaryView = markRaw(
) )
// 4. // 4.
const xmCategories: XmCategoryItem[] = [ const xmCategories = computed<XmCategoryItem[]>(() => [
{ key: 'base-info', label: '基础信息', component: htBaseInfoView }, { key: 'base-info', label: t('htCard.categories.baseInfo'), component: htBaseInfoView },
{ key: 'info', label: '规模信息', component: htView }, { key: 'info', label: t('htCard.categories.scaleInfo'), component: htView },
{ key: 'contract', label: '咨询服务', component: zxfwView }, { key: 'contract', label: t('htCard.categories.services'), component: zxfwView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView }, { key: 'consult-category-factor', label: t('htCard.categories.consultFactor'), component: consultCategoryFactorView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }, { key: 'major-factor', label: t('htCard.categories.majorFactor'), component: majorFactorView },
{ key: 'additional-work-fee', label: '附加工作费', component: additionalWorkFeeView }, { key: 'additional-work-fee', label: t('htCard.categories.additionalFee'), component: additionalWorkFeeView },
{ key: 'reserve-fee', label: '预备费', component: reserveFeeView }, { key: 'reserve-fee', label: t('htCard.categories.reserveFee'), component: reserveFeeView },
{ key: 'all', label: '汇总', component: summaryView }, { key: 'all', label: t('htCard.categories.summary'), component: summaryView },
]);
];
watch(budgetRefreshSignature, (next, prev) => { watch(budgetRefreshSignature, (next, prev) => {
if (next === prev) return if (next === prev) return

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue' import CommonAgGrid from '@/features/shared/components/xmCommonAgGrid.vue'
@ -14,10 +15,11 @@ const XM_DB_KEY = computed(() => {
return props.projectScaleKey || 'xm-info-v3' return props.projectScaleKey || 'xm-info-v3'
}) })
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1') const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const { t } = useI18n()
</script> </script>
<template> <template>
<CommonAgGrid title="合同规模明细" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/> <CommonAgGrid :title="t('htInfo.scaleDetailTitle')" :dbKey="DB_KEY" :xmInfoKey="XM_DB_KEY" :base-info-key="BASE_INFO_KEY"/>
</template> </template>

View File

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue' import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community' import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import type { FirstDataRenderedEvent } from 'ag-grid-community' import type { FirstDataRenderedEvent } from 'ag-grid-community'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { roundTo, sumNullableNumbers } from '@/lib/decimal' import { roundTo, sumNullableNumbers } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
@ -81,6 +83,7 @@ const props = defineProps<{
const tabStore = useTabStore() const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore() const kvStore = useKvStore()
const { t, locale } = useI18n()
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || '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_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
@ -143,7 +146,7 @@ const serviceDict = computed<ServiceItem[]>(() => {
const serviceById = computed(() => new Map(serviceDict.value.map(item => [item.id, item]))) const serviceById = computed(() => new Map(serviceDict.value.map(item => [item.id, item])))
const serviceIdByCode = computed(() => new Map(serviceDict.value.map(item => [item.code, item.id]))) const serviceIdByCode = computed(() => new Map(serviceDict.value.map(item => [item.code, item.id])))
const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id).join('|')) const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id).join('|'))
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: '', name: '小计' } const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: '', name: t('htZxFw.subtotal') }
/** 判断是否固定汇总行(小计行)。 */ /** 判断是否固定汇总行(小计行)。 */
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
@ -476,7 +479,7 @@ const openEditTab = (row: DetailRow) => {
const serviceType = serviceById.value.get(row.id)?.type const serviceType = serviceById.value.get(row.id)?.type
tabStore.openTab({ tabStore.openTab({
id: `zxfw-edit-${props.contractId}-${row.id}`, id: `zxfw-edit-${props.contractId}-${row.id}`,
title: `服务编辑-${row.code}${row.name}`, title: t('htZxFw.editTabTitle', { name: `${row.code}${row.name}` }),
componentName: 'ZxFwView', componentName: 'ZxFwView',
props: { props: {
contractId: props.contractId, contractId: props.contractId,
@ -511,15 +514,15 @@ const ActionCellRenderer = defineComponent({
h('div', { class: 'zxfw-action-group' }, [ h('div', { class: 'zxfw-action-group' }, [
h('button', { class: 'zxfw-action-btn', 'data-action': 'edit', type: 'button' }, [ h('button', { class: 'zxfw-action-btn', 'data-action': 'edit', type: 'button' }, [
h(Pencil, { size: 13, 'aria-hidden': 'true' }), h(Pencil, { size: 13, 'aria-hidden': 'true' }),
h('span', '编辑') h('span', t('htZxFw.edit'))
]), ]),
h('button', { class: 'zxfw-action-btn', 'data-action': 'clear', type: 'button' }, [ h('button', { class: 'zxfw-action-btn', 'data-action': 'clear', type: 'button' }, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }), h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', '恢复默认') h('span', t('htZxFw.resetDefault'))
]), ]),
h('button', { class: 'zxfw-action-btn zxfw-action-btn--danger', 'data-action': 'delete', type: 'button' }, [ h('button', { class: 'zxfw-action-btn zxfw-action-btn--danger', 'data-action': 'delete', type: 'button' }, [
h(Trash2, { size: 13, 'aria-hidden': 'true' }), h(Trash2, { size: 13, 'aria-hidden': 'true' }),
h('span', '删除') h('span', t('htZxFw.delete'))
]) ])
]) ])
]) ])
@ -545,8 +548,9 @@ const ProcessCellRenderer = defineComponent({
void props.params.context?.onSetProcess?.(row.id, value) void props.params.context?.onSetProcess?.(row.id, value)
} }
const radioName = `process-${row.id}` const radioName = `process-${row.id}`
return h('div', { class: 'flex items-center justify-center gap-4 w-full text-sm' }, [ const isEnglish = locale.value.startsWith('en')
h('label', { class: 'inline-flex items-center gap-1.5 cursor-pointer' }, [ return h('div', { class: ['zxfw-process-cell', isEnglish ? 'zxfw-process-cell--en' : ''] }, [
h('label', { class: ['zxfw-process-option', isEnglish ? 'zxfw-process-option--en' : ''] }, [
h('input', { h('input', {
type: 'radio', type: 'radio',
name: radioName, name: radioName,
@ -555,9 +559,9 @@ const ProcessCellRenderer = defineComponent({
onClick: (event: Event) => event.stopPropagation(), onClick: (event: Event) => event.stopPropagation(),
onChange: (event: Event) => onSelect(event, 0) onChange: (event: Event) => onSelect(event, 0)
}), }),
h('span', '编制') h('span', t('htZxFw.processDraft'))
]), ]),
h('label', { class: 'inline-flex items-center gap-1.5 cursor-pointer' }, [ h('label', { class: ['zxfw-process-option', isEnglish ? 'zxfw-process-option--en' : ''] }, [
h('input', { h('input', {
type: 'radio', type: 'radio',
name: radioName, name: radioName,
@ -566,7 +570,7 @@ const ProcessCellRenderer = defineComponent({
onClick: (event: Event) => event.stopPropagation(), onClick: (event: Event) => event.stopPropagation(),
onChange: (event: Event) => onSelect(event, 1) onChange: (event: Event) => onSelect(event, 1)
}), }),
h('span', '审核') h('span', t('htZxFw.processReview'))
]) ])
]) ])
} }
@ -592,19 +596,19 @@ const NameCellRenderer = defineComponent({
const columnDefs: ColDef<DetailRow>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{ {
headerName: '编码', headerName: t('htZxFw.columns.code'),
field: 'code', field: 'code',
minWidth: 50, minWidth: 50,
maxWidth: 100, maxWidth: 100,
valueGetter: params => { valueGetter: params => {
if (!params.data) return '' if (!params.data) return ''
if (isFixedRow(params.data)) return '小计' if (isFixedRow(params.data)) return t('htZxFw.subtotal')
return params.data.code return params.data.code
}, },
colSpan: params => (params.data && isFixedRow(params.data) ? 3 : 1) colSpan: params => (params.data && isFixedRow(params.data) ? 3 : 1)
}, },
{ {
headerName: '名称', headerName: t('htZxFw.columns.name'),
field: 'name', field: 'name',
minWidth: 150, minWidth: 150,
flex: 3, flex: 3,
@ -620,11 +624,11 @@ const columnDefs: ColDef<DetailRow>[] = [
} }
}, },
{ {
headerName: '工作环节', headerName: t('htZxFw.columns.process'),
field: 'process', field: 'process',
headerClass: 'ag-center-header zxfw-process-header', headerClass: 'ag-center-header zxfw-process-header',
minWidth: 150, minWidth: locale.value.startsWith('en') ? 118 : 150,
maxWidth: 200, maxWidth: locale.value.startsWith('en') ? 136 : 200,
flex: 1, flex: 1,
editable: false, editable: false,
sortable: false, sortable: false,
@ -641,12 +645,12 @@ const columnDefs: ColDef<DetailRow>[] = [
}, },
cellRenderer: ProcessCellRenderer cellRenderer: ProcessCellRenderer
}, },
createMethodColumn('投资规模法', 'investScale', 100), createMethodColumn(t('htZxFw.columns.investScale'), 'investScale', 100),
createMethodColumn('用地规模法', 'landScale', 100), createMethodColumn(t('htZxFw.columns.landScale'), 'landScale', 100),
createMethodColumn('工作量法', 'workload', 90), createMethodColumn(t('htZxFw.columns.workload'), 'workload', 90),
createMethodColumn('工时法', 'hourly', 90), createMethodColumn(t('htZxFw.columns.hourly'), 'hourly', 90),
{ {
headerName: '小计', headerName: t('htZxFw.columns.subtotal'),
field: 'subtotal', field: 'subtotal',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
flex: 2, flex: 2,
@ -662,10 +666,10 @@ const columnDefs: ColDef<DetailRow>[] = [
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2)) valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
}, },
{ {
headerName: '确认金额 ✎', headerName: t('htZxFw.columns.finalFee'),
field: 'finalFee', field: 'finalFee',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
headerTooltip: '该列支持手动修改,修改后会自动汇总到固定小计行', headerTooltip: t('htZxFw.columns.finalFeeTooltip'),
flex: 2, flex: 2,
minWidth: 110, minWidth: 110,
editable: params => !isFixedRow(params.data), editable: params => !isFixedRow(params.data),
@ -690,11 +694,11 @@ const columnDefs: ColDef<DetailRow>[] = [
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2)) valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
}, },
{ {
headerName: '操作', headerName: t('htZxFw.columns.actions'),
field: 'actions', field: 'actions',
minWidth: 200, minWidth: 250,
flex: 1.5, flex: 1.8,
maxWidth: 220, maxWidth: 320,
editable: false, editable: false,
sortable: false, sortable: false,
filter: false, filter: false,
@ -702,6 +706,7 @@ const columnDefs: ColDef<DetailRow>[] = [
cellRenderer: ActionCellRenderer cellRenderer: ActionCellRenderer
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const detailGridOptions: GridOptions<DetailRow> = { const detailGridOptions: GridOptions<DetailRow> = {
...gridOptions, ...gridOptions,
@ -1018,6 +1023,39 @@ watch(serviceIdSignature, () => {
} }
}) })
const relabelRowsByLocale = async () => {
const currentState = getCurrentContractState()
if (!Array.isArray(currentState.detailRows) || currentState.detailRows.length === 0) return
let changed = false
const nextRows = currentState.detailRows.map(row => {
if (isFixedRow(row)) {
const nextName = t('htZxFw.subtotal')
if (row.name === nextName) return row
changed = true
return { ...row, name: nextName }
}
const dictItem = serviceById.value.get(String(row.id))
if (!dictItem) return row
const nextCode = String(dictItem.code || '')
const nextName = String(dictItem.name || '')
if (row.code === nextCode && row.name === nextName) return row
changed = true
return { ...row, code: nextCode, name: nextName }
})
if (!changed) return
await setCurrentContractState({
...currentState,
detailRows: applyFixedRowSummary(nextRows)
})
}
watch(
() => locale.value,
() => {
void relabelRowsByLocale()
}
)
watch( watch(
() => detailRows.value.map(row => `${row.id}:${row.name}`).join('|'), () => detailRows.value.map(row => `${row.id}:${row.name}`).join('|'),
() => { () => {
@ -1116,15 +1154,15 @@ onActivated(async () => {
<div class="flex items-start justify-between gap-3 border-b px-3 py-2"> <div class="flex items-start justify-between gap-3 border-b px-3 py-2">
<div class="min-w-0 space-y-1"> <div class="min-w-0 space-y-1">
<h3 class="text-xs font-semibold text-foreground leading-none"> <h3 class="text-xs font-semibold text-foreground leading-none">
咨询服务明细 {{ t('htZxFw.title') }}
</h3> </h3>
</div> </div>
<p class="text-[11px] text-muted-foreground leading-none leading-4 text-amber-700/90"> 请注意检查并修改规范建议的限值或特殊值并在确认金额栏修改</p> <p class="text-[11px] text-muted-foreground leading-none leading-4 text-amber-700/90">{{ t('htZxFw.warning') }}</p>
</div> </div>
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows" :columnDefs="columnDefs" <AgGridVue :style="agGridStyle" :rowData="detailRows" :columnDefs="gridColumnDefs"
:gridOptions="detailGridOptions" :theme="myTheme" @cell-value-changed="handleCellValueChanged" :gridOptions="detailGridOptions" :theme="myTheme" @cell-value-changed="handleCellValueChanged"
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd" @paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd" @fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
@ -1141,16 +1179,16 @@ onActivated(async () => {
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent <AlertDialogContent
class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认恢复默认数据</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('htZxFw.dialog.resetTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
会使用合同卡片里面最新填写的规模信息以及系数自动计算默认数据覆盖{{ pendingClearServiceName }}当前数据是否继续 {{ t('htZxFw.dialog.resetDesc', { name: pendingClearServiceName }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmClearRow">确认恢复</Button> <Button variant="destructive" @click="confirmClearRow">{{ t('htZxFw.dialog.confirmReset') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -1162,16 +1200,16 @@ onActivated(async () => {
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent <AlertDialogContent
class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认删除服务</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('htZxFw.dialog.deleteTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将逻辑删除{{ pendingDeleteServiceName }}已填写的数据不会清空重新勾选后会恢复是否继续 {{ t('htZxFw.dialog.deleteDesc', { name: pendingDeleteServiceName }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button> <Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -1218,4 +1256,32 @@ onActivated(async () => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
line-height: 1.6; line-height: 1.6;
} }
:deep(.zxfw-process-cell) {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 12px;
}
:deep(.zxfw-process-cell--en) {
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 2px 0;
}
:deep(.zxfw-process-option) {
display: inline-flex;
cursor: pointer;
align-items: center;
gap: 6px;
white-space: nowrap;
}
:deep(.zxfw-process-option--en) {
width: 100%;
}
</style> </style>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue' import HourlyFeeGrid from '@/features/shared/components/HourlyFeeGrid.vue'
const props = defineProps<{ const props = defineProps<{
@ -8,11 +9,12 @@ const props = defineProps<{
}>() }>()
const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `hourlyPricing-${props.contractId}-${props.serviceId}`)
const { t } = useI18n()
</script> </script>
<template> <template>
<HourlyFeeGrid <HourlyFeeGrid
title="工时法明细" :title="t('hourlyFeeGrid.title')"
:storage-key="DB_KEY" :storage-key="DB_KEY"
:contract-id="props.contractId" :contract-id="props.contractId"
:service-id="props.serviceId" :service-id="props.serviceId"

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community' import type { ColDef, ColGroupDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { getMajorDictEntries, getServiceDictItemById, industryTypeList, isMajorIdInIndustryScope } from '@/sql' import { getIndustryDisplayName, getMajorDictEntries, getServiceDictItemById, isMajorIdInIndustryScope } from '@/sql'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync' import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
@ -145,6 +147,7 @@ const props = defineProps<{
projectInfoKey?: string projectInfoKey?: string
}>() }>()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const { t, locale } = useI18n()
const kvStore = useKvStore() const kvStore = useKvStore()
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`) const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
@ -165,12 +168,6 @@ const gridApi = ref<GridApi<DetailRow> | null>(null)
const lastAppliedConsultFactorChangeAt = ref(0) const lastAppliedConsultFactorChangeAt = ref(0)
const lastAppliedMajorFactorChangeAt = ref(0) const lastAppliedMajorFactorChangeAt = ref(0)
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
const getDefaultConsultCategoryFactor = () => const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
@ -194,8 +191,8 @@ const inferProjectCountFromRows = (rows?: Array<Partial<DetailRow>>) => {
return inferScaleProjectCountFromRows(rows, isMutipleService.value) return inferScaleProjectCountFromRows(rows, isMutipleService.value)
} }
const totalLabel = computed(() => { const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || '' const industryName = getIndustryDisplayName(activeIndustryCode.value.trim(), locale.value)
return industryName ? `${industryName}总投资` : '总投资' return industryName ? t('pricingScale.totalInvestmentByIndustry', { industryName }) : t('pricingScale.totalInvestment')
}) })
const loadFactorDefaults = async () => { const loadFactorDefaults = async () => {
@ -269,13 +266,24 @@ type majorLite = {
hasArea?: boolean hasArea?: boolean
industryId?: string | number | null industryId?: string | number | null
} }
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite]) const serviceEntries: Array<[string, majorLite]> = []
const detailDict: ScaleDictGroup[] = buildScaleDetailDict( const detailDict: ScaleDictGroup[] = []
serviceEntries, const idLabelMap = new Map<string, string>()
({ hasCost, hasArea }) => hasCost && !hasArea const rebuildScaleDictCaches = () => {
) const nextServiceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
serviceEntries.splice(0, serviceEntries.length, ...nextServiceEntries)
const idLabelMap = buildScaleIdLabelMap(detailDict) const nextDetailDict = buildScaleDetailDict(
serviceEntries,
({ hasCost, hasArea }) => hasCost && !hasArea
)
detailDict.splice(0, detailDict.length, ...nextDetailDict)
const nextIdLabelMap = buildScaleIdLabelMap(nextDetailDict)
idLabelMap.clear()
nextIdLabelMap.forEach((label, id) => {
idLabelMap.set(id, label)
})
}
rebuildScaleDictCaches()
const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRow[] => { const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRow[] => {
return buildScaleRowsFromDict({ return buildScaleRowsFromDict({
@ -522,7 +530,7 @@ const formatEditableMoney = (params: any) =>
formatScaleEditableConditionalNumber(params, { formatScaleEditableConditionalNumber(params, {
enabled: Boolean(params.data?.hasCost), enabled: Boolean(params.data?.hasCost),
precision: 3, precision: 3,
emptyText: '点击输入' emptyText: t('pricingScale.clickToInput')
}) })
const createBudgetCellRendererWithCheck = createScaleBudgetCellRendererToggleFactory( const createBudgetCellRendererWithCheck = createScaleBudgetCellRendererToggleFactory(
@ -638,12 +646,12 @@ const restoreMajorFactorColumnDefaults = async () => {
const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
createScaleValueColumn<DetailRow>({ createScaleValueColumn<DetailRow>({
headerName: '造价金额(万元)', headerName: t('pricingScale.columns.investAmount'),
field: 'amount', field: 'amount',
headerTooltip: '点击右侧↻恢复本列默认造价金额', headerTooltip: t('pricingScale.tooltip.resetInvestAmount'),
headerComponent: AgGridResetHeader, headerComponent: AgGridResetHeader,
onReset: restoreAmountColumnDefaults, onReset: restoreAmountColumnDefaults,
resetTitle: '恢复本列默认造价金额', resetTitle: t('pricingScale.tooltip.resetInvestAmount'),
minWidth: 90, minWidth: 90,
flex: 2, flex: 2,
isEditable: row => Boolean(row?.hasCost), isEditable: row => Boolean(row?.hasCost),
@ -665,6 +673,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
}), }),
createScaleRemarkColumn<DetailRow>() createScaleRemarkColumn<DetailRow>()
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef = createScaleAutoGroupColumn<DetailRow>({ const autoGroupColumnDef: ColDef = createScaleAutoGroupColumn<DetailRow>({
totalLabel: totalLabel.value, totalLabel: totalLabel.value,
@ -871,6 +880,56 @@ const applyDetailRows = (rows: DetailRow[]) => {
detailRows.value = rows detailRows.value = rows
} }
const relabelDetailRowsFromDict = async () => {
rebuildScaleDictCaches()
if (detailRows.value.length === 0) {
gridApi.value?.refreshCells({ force: true })
return
}
const majorMetaMap = new Map<string, Pick<DetailRow, 'groupCode' | 'groupName' | 'majorCode' | 'majorName'>>()
for (const group of detailDict) {
majorMetaMap.set(group.id, {
groupCode: group.code,
groupName: group.name,
majorCode: group.code,
majorName: group.name
})
for (const child of group.children) {
majorMetaMap.set(child.id, {
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name
})
}
}
let changed = false
detailRows.value = detailRows.value.map(row => {
const meta = majorMetaMap.get(resolveRowMajorDictId(row))
if (!meta) return row
if (
row.groupCode === meta.groupCode &&
row.groupName === meta.groupName &&
row.majorCode === meta.majorCode &&
row.majorName === meta.majorName
) {
return row
}
changed = true
return {
...row,
groupCode: meta.groupCode,
groupName: meta.groupName,
majorCode: meta.majorCode,
majorName: meta.majorName
}
})
gridApi.value?.refreshCells({ force: true })
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const mergeProjectExpandedRow = (defaultRow: DetailRow, existingRow: DetailRow): DetailRow => ({ const mergeProjectExpandedRow = (defaultRow: DetailRow, existingRow: DetailRow): DetailRow => ({
...defaultRow, ...defaultRow,
...existingRow, ...existingRow,
@ -1016,6 +1075,12 @@ usePricingPaneLifecycle({
}, },
saveToIndexedDB: () => saveToIndexedDB() saveToIndexedDB: () => saveToIndexedDB()
}) })
watch(
() => locale.value,
() => {
void relabelDetailRowsFromDict()
}
)
const processCellForClipboard = (params: any) => { const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) { if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // return JSON.stringify(params.value); //
@ -1050,9 +1115,9 @@ const processCellFromClipboard = (params: any) => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> <div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3"> <div class="flex items-center justify-between border-b px-4 py-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-foreground">投资规模明细</h3> <h3 class="text-sm font-semibold text-foreground">{{ t('pricingPane.investment.title') }}</h3>
<div v-if="isMutipleService" class="flex items-center gap-2"> <div v-if="isMutipleService" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">项目数量</span> <span class="text-xs text-muted-foreground">{{ t('pricingPane.projectCount') }}</span>
<NumberFieldRoot <NumberFieldRoot
v-model="projectCount" v-model="projectCount"
:min="1" :min="1"
@ -1069,21 +1134,21 @@ const processCellFromClipboard = (params: any) => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<AlertDialogRoot> <AlertDialogRoot>
<AlertDialogTrigger as-child> <AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">清空</Button> <Button type="button" variant="outline" size="sm">{{ t('common.clear') }}</Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空当前明细</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('pricingPane.clearTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空当前投资规模明细是否继续 {{ t('pricingPane.investment.clearDesc') }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="clearAllData">确认清空</Button> <Button variant="destructive" @click="clearAllData">{{ t('pricingPane.confirmClear') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -1091,21 +1156,21 @@ const processCellFromClipboard = (params: any) => {
</AlertDialogRoot> </AlertDialogRoot>
<AlertDialogRoot> <AlertDialogRoot>
<AlertDialogTrigger as-child> <AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">使用默认数据</Button> <Button type="button" variant="outline" size="sm">{{ t('pricingPane.useDefault') }}</Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认覆盖当前明细</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('pricingPane.overrideTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将使用合同默认数据覆盖当前投资规模明细是否继续 {{ t('pricingPane.investment.overrideDesc') }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button @click="importContractData">确认覆盖</Button> <Button @click="importContractData">{{ t('pricingPane.confirmOverride') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -1116,7 +1181,7 @@ const processCellFromClipboard = (params: any) => {
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData" <AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme" :columnDefs="gridColumnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
:animateRows="true" :animateRows="true"
@grid-ready="handleGridReady" @grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true" @cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community' import type { ColDef, ColGroupDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { getMajorDictEntries, getServiceDictItemById, industryTypeList, isMajorIdInIndustryScope } from '@/sql' import { getIndustryDisplayName, getMajorDictEntries, getServiceDictItemById, isMajorIdInIndustryScope } from '@/sql'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo } from '@/lib/decimal' import { decimalAggSum, roundTo } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync' import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
@ -145,6 +147,7 @@ const props = defineProps<{
projectInfoKey?: string projectInfoKey?: string
}>() }>()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const { t, locale } = useI18n()
const kvStore = useKvStore() const kvStore = useKvStore()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`) const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
@ -164,15 +167,9 @@ const paneInstanceCreatedAt = Date.now()
const gridApi = ref<GridApi<DetailRow> | null>(null) const gridApi = ref<GridApi<DetailRow> | null>(null)
const lastAppliedConsultFactorChangeAt = ref(0) const lastAppliedConsultFactorChangeAt = ref(0)
const lastAppliedMajorFactorChangeAt = ref(0) const lastAppliedMajorFactorChangeAt = ref(0)
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
const totalLabel = computed(() => { const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || '' const industryName = getIndustryDisplayName(activeIndustryCode.value.trim(), locale.value)
return industryName ? `${industryName}总投资` : '总投资' return industryName ? t('pricingScale.totalInvestmentByIndustry', { industryName }) : t('pricingScale.totalInvestment')
}) })
const isMutipleService = computed(() => { const isMutipleService = computed(() => {
@ -258,13 +255,24 @@ const shouldSkipPersist = () => {
} }
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean } type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite]) const serviceEntries: Array<[string, majorLite]> = []
const detailDict: ScaleDictGroup[] = buildScaleDetailDict( const detailDict: ScaleDictGroup[] = []
serviceEntries, const idLabelMap = new Map<string, string>()
({ hasArea }) => hasArea const rebuildScaleDictCaches = () => {
) const nextServiceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
serviceEntries.splice(0, serviceEntries.length, ...nextServiceEntries)
const idLabelMap = buildScaleIdLabelMap(detailDict) const nextDetailDict = buildScaleDetailDict(
serviceEntries,
({ hasArea }) => hasArea
)
detailDict.splice(0, detailDict.length, ...nextDetailDict)
const nextIdLabelMap = buildScaleIdLabelMap(nextDetailDict)
idLabelMap.clear()
nextIdLabelMap.forEach((label, id) => {
idLabelMap.set(id, label)
})
}
rebuildScaleDictCaches()
const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRow[] => { const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRow[] => {
return buildScaleRowsFromDict({ return buildScaleRowsFromDict({
@ -446,7 +454,7 @@ const formatEditableFlexibleNumber = (params: any) =>
formatScaleEditableConditionalNumber(params, { formatScaleEditableConditionalNumber(params, {
enabled: Boolean(params.data?.hasArea), enabled: Boolean(params.data?.hasArea),
precision: 3, precision: 3,
emptyText: '点击输入' emptyText: t('pricingScale.clickToInput')
}) })
const restoreLandAreaColumnDefaults = async () => { const restoreLandAreaColumnDefaults = async () => {
@ -512,12 +520,12 @@ const restoreMajorFactorColumnDefaults = async () => {
const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
createScaleValueColumn<DetailRow>({ createScaleValueColumn<DetailRow>({
headerName: '用地面积(亩)', headerName: t('pricingScale.columns.landArea'),
field: 'landArea', field: 'landArea',
headerTooltip: '点击右侧↻恢复本列默认用地面积', headerTooltip: t('pricingScale.tooltip.resetLandArea'),
headerComponent: AgGridResetHeader, headerComponent: AgGridResetHeader,
onReset: restoreLandAreaColumnDefaults, onReset: restoreLandAreaColumnDefaults,
resetTitle: '恢复本列默认用地面积', resetTitle: t('pricingScale.tooltip.resetLandArea'),
minWidth: 90, minWidth: 90,
flex: 2, flex: 2,
isEditable: row => Boolean(row?.hasArea), isEditable: row => Boolean(row?.hasArea),
@ -542,6 +550,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
}), }),
createScaleRemarkColumn<DetailRow>() createScaleRemarkColumn<DetailRow>()
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef = createScaleAutoGroupColumn<DetailRow>({ const autoGroupColumnDef: ColDef = createScaleAutoGroupColumn<DetailRow>({
totalLabel: totalLabel.value, totalLabel: totalLabel.value,
@ -731,6 +740,56 @@ const applyDetailRows = (rows: DetailRow[]) => {
detailRows.value = rows detailRows.value = rows
} }
const relabelDetailRowsFromDict = async () => {
rebuildScaleDictCaches()
if (detailRows.value.length === 0) {
gridApi.value?.refreshCells({ force: true })
return
}
const majorMetaMap = new Map<string, Pick<DetailRow, 'groupCode' | 'groupName' | 'majorCode' | 'majorName'>>()
for (const group of detailDict) {
majorMetaMap.set(group.id, {
groupCode: group.code,
groupName: group.name,
majorCode: group.code,
majorName: group.name
})
for (const child of group.children) {
majorMetaMap.set(child.id, {
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name
})
}
}
let changed = false
detailRows.value = detailRows.value.map(row => {
const meta = majorMetaMap.get(resolveRowMajorDictId(row))
if (!meta) return row
if (
row.groupCode === meta.groupCode &&
row.groupName === meta.groupName &&
row.majorCode === meta.majorCode &&
row.majorName === meta.majorName
) {
return row
}
changed = true
return {
...row,
groupCode: meta.groupCode,
groupName: meta.groupName,
majorCode: meta.majorCode,
majorName: meta.majorName
}
})
gridApi.value?.refreshCells({ force: true })
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const mergeProjectExpandedRow = (defaultRow: DetailRow, existingRow: DetailRow): DetailRow => ({ const mergeProjectExpandedRow = (defaultRow: DetailRow, existingRow: DetailRow): DetailRow => ({
...defaultRow, ...defaultRow,
...existingRow, ...existingRow,
@ -874,6 +933,12 @@ usePricingPaneLifecycle({
}, },
saveToIndexedDB: () => saveToIndexedDB() saveToIndexedDB: () => saveToIndexedDB()
}) })
watch(
() => locale.value,
() => {
void relabelDetailRowsFromDict()
}
)
const processCellForClipboard = (params: any) => { const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) { if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // return JSON.stringify(params.value); //
@ -908,9 +973,9 @@ const processCellFromClipboard = (params: any) => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> <div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3"> <div class="flex items-center justify-between border-b px-4 py-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3> <h3 class="text-sm font-semibold text-foreground">{{ t('pricingPane.land.title') }}</h3>
<div v-if="isMutipleService" class="flex items-center gap-2"> <div v-if="isMutipleService" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">项目数量</span> <span class="text-xs text-muted-foreground">{{ t('pricingPane.projectCount') }}</span>
<NumberFieldRoot <NumberFieldRoot
v-model="projectCount" v-model="projectCount"
:min="1" :min="1"
@ -927,22 +992,22 @@ const processCellFromClipboard = (params: any) => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<AlertDialogRoot> <AlertDialogRoot>
<AlertDialogTrigger as-child> <AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">清空</Button> <Button type="button" variant="outline" size="sm">{{ t('common.clear') }}</Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent <AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空当前明细</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('pricingPane.clearTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空当前用地规模明细是否继续 {{ t('pricingPane.land.clearDesc') }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="clearAllData">确认清空</Button> <Button variant="destructive" @click="clearAllData">{{ t('pricingPane.confirmClear') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -950,22 +1015,22 @@ const processCellFromClipboard = (params: any) => {
</AlertDialogRoot> </AlertDialogRoot>
<AlertDialogRoot> <AlertDialogRoot>
<AlertDialogTrigger as-child> <AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">使用默认数据</Button> <Button type="button" variant="outline" size="sm">{{ t('pricingPane.useDefault') }}</Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent <AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认覆盖当前明细</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('pricingPane.overrideTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将使用合同默认数据覆盖当前用地规模明细是否继续 {{ t('pricingPane.land.overrideDesc') }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button @click="importContractData">确认覆盖</Button> <Button @click="importContractData">{{ t('pricingPane.confirmOverride') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>
@ -976,7 +1041,7 @@ const processCellFromClipboard = (params: any) => {
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData" <AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme" :columnDefs="gridColumnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
:animateRows="true" :animateRows="true"
@grid-ready="handleGridReady" @grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true" @cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { taskList } from '@/sql' import { taskList } from '@/sql'
@ -7,6 +8,7 @@ import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgG
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync' import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
@ -47,6 +49,7 @@ const props = defineProps<{
serviceId: string | number serviceId: string | number
}>() }>()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const { t, locale } = useI18n()
const kvStore = useKvStore() const kvStore = useKvStore()
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`) const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
@ -127,6 +130,13 @@ type taskLite = {
desc: string | null desc: string | null
} }
const getTaskDisplayName = (task: taskLite | undefined) => {
if (!task) return ''
return String(locale.value).toLowerCase().startsWith('en')
? (task as taskLite & { nameEn?: string }).nameEn || task.name
: task.name
}
const formatTaskReferenceUnitPrice = (task: taskLite) => { const formatTaskReferenceUnitPrice = (task: taskLite) => {
const unit = task.unit || '' const unit = task.unit || ''
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice) const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
@ -160,7 +170,7 @@ const buildDefaultRows = (): DetailRow[] => {
rows.push({ rows.push({
id: rowId, id: rowId,
taskCode, taskCode,
taskName: task.name, taskName: getTaskDisplayName(task),
unit: task.unit || '', unit: task.unit || '',
conversion: typeof task.conversion === 'number' && Number.isFinite(task.conversion) ? task.conversion : null, conversion: typeof task.conversion === 'number' && Number.isFinite(task.conversion) ? task.conversion : null,
workload: null, workload: null,
@ -250,9 +260,9 @@ const calcServiceFee = (row: DetailRow | undefined) => {
} }
const formatEditableNumber = (params: any) => { const formatEditableNumber = (params: any) => {
if (isNoTaskRow(params.data)) return '无' if (isNoTaskRow(params.data)) return t('workloadPricing.none')
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return t('workloadPricing.clickToInput')
} }
if (params.value == null) return '' if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
@ -269,16 +279,16 @@ const spanRowsByTaskName = (params: any) => {
const columnDefs: ColDef<DetailRow>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{ {
headerName: '编码', headerName: t('workloadPricing.columns.code'),
field: 'taskCode', field: 'taskCode',
minWidth: 100, minWidth: 100,
width: 120, width: 120,
pinned: 'left', pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 2 : 1), colSpan: params => (params.node?.rowPinned ? 2 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '') valueFormatter: params => (params.node?.rowPinned ? t('workloadPricing.total') : params.value || '')
}, },
{ {
headerName: '名称', headerName: t('workloadPricing.columns.name'),
field: 'taskName', field: 'taskName',
minWidth: 150, minWidth: 150,
width: 220, width: 220,
@ -291,7 +301,7 @@ const columnDefs: ColDef<DetailRow>[] = [
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '') valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
}, },
{ {
headerName: '预算基数', headerName: t('workloadPricing.columns.budgetBase'),
field: 'budgetBase', field: 'budgetBase',
minWidth: 150, minWidth: 150,
autoHeight: true, autoHeight: true,
@ -302,14 +312,14 @@ const columnDefs: ColDef<DetailRow>[] = [
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '') valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
}, },
{ {
headerName: '预算参考单价', headerName: t('workloadPricing.columns.budgetReferenceUnitPrice'),
field: 'budgetReferenceUnitPrice', field: 'budgetReferenceUnitPrice',
minWidth: 170, minWidth: 170,
flex: 1, flex: 1,
valueFormatter: params => params.value || '' valueFormatter: params => params.value || ''
}, },
{ {
headerName: '预算采用单价', headerName: t('workloadPricing.columns.budgetAdoptedUnitPrice'),
field: 'budgetAdoptedUnitPrice', field: 'budgetAdoptedUnitPrice',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 170, minWidth: 170,
@ -326,9 +336,9 @@ const columnDefs: ColDef<DetailRow>[] = [
}, },
valueParser: params => parseSanitizedAdoptedPriceOrNull(params.newValue), valueParser: params => parseSanitizedAdoptedPriceOrNull(params.newValue),
valueFormatter: params => { valueFormatter: params => {
if (isNoTaskRow(params.data)) return '无' if (isNoTaskRow(params.data)) return t('workloadPricing.none')
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return t('workloadPricing.clickToInput')
} }
if (params.value == null) return '' if (params.value == null) return ''
const unit = params.data?.unit || '' const unit = params.data?.unit || ''
@ -336,7 +346,7 @@ const columnDefs: ColDef<DetailRow>[] = [
} }
}, },
{ {
headerName: '工作量', headerName: t('workloadPricing.columns.workload'),
field: 'workload', field: 'workload',
minWidth: 140, minWidth: 140,
flex: 1, flex: 1,
@ -356,7 +366,7 @@ const columnDefs: ColDef<DetailRow>[] = [
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
}, },
{ {
headerName: '咨询分类系数', headerName: t('workloadPricing.columns.consultCategoryFactor'),
field: 'consultCategoryFactor', field: 'consultCategoryFactor',
width: 80, width: 80,
minWidth: 70, minWidth: 70,
@ -376,7 +386,7 @@ const columnDefs: ColDef<DetailRow>[] = [
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
}, },
{ {
headerName: '服务费用(元)', headerName: t('workloadPricing.columns.serviceFee'),
field: 'serviceFee', field: 'serviceFee',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 150, minWidth: 150,
@ -388,13 +398,13 @@ const columnDefs: ColDef<DetailRow>[] = [
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceFee ?? null : calcServiceFee(params.data)), valueGetter: params => (params.node?.rowPinned ? params.data?.serviceFee ?? null : calcServiceFee(params.data)),
aggFunc: decimalAggSum, aggFunc: decimalAggSum,
valueFormatter: params => { valueFormatter: params => {
if (isNoTaskRow(params.data)) return '无' if (isNoTaskRow(params.data)) return t('workloadPricing.none')
if (params.value == null || params.value === '') return '' if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(roundTo(params.value, 3), 3) return formatThousandsFlexible(roundTo(params.value, 3), 3)
} }
}, },
{ {
headerName: '说明', headerName: t('workloadPricing.columns.remark'),
field: 'remark', field: 'remark',
minWidth: 180, minWidth: 180,
flex: 1.2, flex: 1.2,
@ -418,6 +428,7 @@ const columnDefs: ColDef<DetailRow>[] = [
} }
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload)) const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
const totalBasicFee = computed(() => sumByNumber(detailRows.value, row => calcBasicFee(row))) const totalBasicFee = computed(() => sumByNumber(detailRows.value, row => calcBasicFee(row)))
@ -425,7 +436,7 @@ const totalServiceFee = computed(() => sumNullableBy(detailRows.value, row => ca
const pinnedTopRowData = computed(() => const pinnedTopRowData = computed(() =>
createPinnedTopRowData({ createPinnedTopRowData({
id: 'pinned-total-row', id: 'pinned-total-row',
taskCode: '总合计', taskCode: t('workloadPricing.total'),
taskName: '', taskName: '',
unit: '', unit: '',
conversion: null, conversion: null,
@ -533,6 +544,41 @@ const loadFromIndexedDB = async () => {
} }
} }
const relabelRowsFromTaskDict = async () => {
if (!isWorkloadMethodApplicable.value || detailRows.value.length === 0) return
let changed = false
detailRows.value = detailRows.value.map(row => {
if (isNoTaskRow(row)) return row
const match = String(row.id || '').match(/^task-(\d+)-\d+$/)
if (!match) return row
const task = (taskList as Record<string, taskLite | undefined>)[match[1]]
if (!task) return row
const nextTaskName = getTaskDisplayName(task)
const nextUnit = task.unit || ''
const nextBudgetBase = task.basicParam || ''
const nextBudgetReferenceUnitPrice = formatTaskReferenceUnitPrice(task)
if (
row.taskName === nextTaskName &&
row.unit === nextUnit &&
row.budgetBase === nextBudgetBase &&
row.budgetReferenceUnitPrice === nextBudgetReferenceUnitPrice
) {
return row
}
changed = true
return {
...row,
taskName: nextTaskName,
unit: nextUnit,
budgetBase: nextBudgetBase,
budgetReferenceUnitPrice: nextBudgetReferenceUnitPrice
}
})
gridApi.value?.refreshCells({ force: true })
if (!changed) return
await saveToIndexedDB()
}
let isBulkClipboardMutation = false let isBulkClipboardMutation = false
const commitGridChanges = () => { const commitGridChanges = () => {
@ -564,6 +610,12 @@ usePricingPaneLifecycle({
linkedSourceSignature: linkedConsultFactorSignature, linkedSourceSignature: linkedConsultFactorSignature,
saveToIndexedDB saveToIndexedDB
}) })
watch(
() => locale.value,
() => {
void relabelRowsFromTaskDict()
}
)
const processCellForClipboard = (params: any) => { const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) { if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // return JSON.stringify(params.value); //
@ -608,13 +660,13 @@ const mydiyTheme = myTheme.withParams({
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> <div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3"> <div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">工作量明细</h3> <h3 class="text-sm font-semibold text-foreground">{{ t('workloadPricing.title') }}</h3>
<div class="text-xs text-muted-foreground"></div> <div class="text-xs text-muted-foreground"></div>
</div> </div>
<div v-if="isWorkloadMethodApplicable" :class="agGridWrapClass"> <div v-if="isWorkloadMethodApplicable" :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData" <AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false" :columnDefs="gridColumnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
:animateRows="true" :animateRows="true"
:enableCellSpan="true" :enableCellSpan="true"
@grid-ready="handleGridReady" @grid-ready="handleGridReady"
@ -632,8 +684,8 @@ const mydiyTheme = myTheme.withParams({
</div> </div>
<MethodUnavailableNotice <MethodUnavailableNotice
v-else v-else
title="该服务不适用工作量法" :title="t('workloadPricing.unavailableTitle')"
message="当前服务没有关联工作量法任务,无需填写此部分内容。" :message="t('workloadPricing.unavailableMessage')"
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue' import { computed, nextTick, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { import type {
ColDef, ColDef,
@ -18,6 +19,7 @@ import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricing
import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing' import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl' import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
interface DetailRow { interface DetailRow {
id: string id: string
@ -49,12 +51,13 @@ const props = withDefaults(
htMethodType?: HtFeeMethodType htMethodType?: HtFeeMethodType
}>(), }>(),
{ {
title: '工时法明细', title: undefined,
enableZxFwSync: false, enableZxFwSync: false,
syncField: 'hourly' syncField: 'hourly'
} }
) )
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const { t, locale } = useI18n()
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
@ -162,6 +165,13 @@ type ExpertLite = {
manageCoe: number | null manageCoe: number | null
} }
const getExpertDisplayName = (expert: ExpertLite | undefined) => {
if (!expert) return ''
return String(locale.value).toLowerCase().startsWith('en')
? (expert as ExpertLite & { nameEn?: string }).nameEn || expert.name
: expert.name
}
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>) const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0])) .sort((a, b) => Number(a[0]) - Number(b[0]))
.filter((entry): entry is [string, ExpertLite] => { .filter((entry): entry is [string, ExpertLite] => {
@ -210,7 +220,7 @@ const buildDefaultRows = (): DetailRow[] => {
rows.push({ rows.push({
id: rowId, id: rowId,
expertCode: expert.code, expertCode: expert.code,
expertName: expert.name, expertName: getExpertDisplayName(expert),
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice), laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert), compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert), adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
@ -255,7 +265,7 @@ const parseNonNegativeIntegerOrNull = (value: unknown) => {
const formatEditableNumber = (params: any) => { const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return t('hourlyFeeGrid.clickToInput')
} }
if (params.value == null) return '' if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
@ -263,7 +273,7 @@ const formatEditableNumber = (params: any) => {
const formatEditableInteger = (params: any) => { const formatEditableInteger = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return t('hourlyFeeGrid.clickToInput')
} }
if (params.value == null) return '' if (params.value == null) return ''
return String(Number(params.value)) return String(Number(params.value))
@ -334,9 +344,9 @@ const editableMoneyCol = <K extends keyof DetailRow>(
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '') !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}, },
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: params => { valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return t('hourlyFeeGrid.clickToInput')
} }
if (params.value == null) return '' if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
@ -360,16 +370,16 @@ const readonlyTextCol = <K extends keyof DetailRow>(
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
{ {
headerName: '编码', headerName: t('hourlyFeeGrid.columns.code'),
field: 'expertCode', field: 'expertCode',
minWidth: 90, minWidth: 90,
width: 100, width: 100,
pinned: 'left', pinned: 'left',
colSpan: params => (params.node?.rowPinned ? 2 : 1), colSpan: params => (params.node?.rowPinned ? 2 : 1),
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '') valueFormatter: params => (params.node?.rowPinned ? t('hourlyFeeGrid.total') : params.value || '')
}, },
{ {
headerName: '人员名称', headerName: t('hourlyFeeGrid.columns.name'),
field: 'expertName', field: 'expertName',
minWidth: 210, minWidth: 210,
width: 230, width: 230,
@ -382,25 +392,25 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '') valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
}, },
{ {
headerName: '预算参考单价', headerName: t('hourlyFeeGrid.columns.referenceUnitPrice'),
marryChildren: true, marryChildren: true,
children: [ children: [
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)', { readonlyTextCol('laborBudgetUnitPrice', t('hourlyFeeGrid.columns.laborBudgetUnitPrice'), {
colSpan: params => (params.node?.rowPinned ? 3 : 1), colSpan: params => (params.node?.rowPinned ? 3 : 1),
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '') valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
}), }),
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)') readonlyTextCol('compositeBudgetUnitPrice', t('hourlyFeeGrid.columns.compositeBudgetUnitPrice'))
] ]
}, },
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'), editableMoneyCol('adoptedBudgetUnitPrice', t('hourlyFeeGrid.columns.adoptedBudgetUnitPrice')),
editableNumberCol('personnelCount', '人员数量(人)', { editableNumberCol('personnelCount', t('hourlyFeeGrid.columns.personnelCount'), {
aggFunc: decimalAggSum, aggFunc: decimalAggSum,
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue), valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
valueFormatter: formatEditableInteger valueFormatter: formatEditableInteger
}), }),
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }), editableNumberCol('workdayCount', t('hourlyFeeGrid.columns.workdayCount'), { aggFunc: decimalAggSum }),
{ {
headerName: '服务预算(元)', headerName: t('hourlyFeeGrid.columns.serviceBudget'),
field: 'serviceBudget', field: 'serviceBudget',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 120, minWidth: 120,
@ -417,7 +427,7 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
} }
}, },
{ {
headerName: '说明', headerName: t('hourlyFeeGrid.columns.remark'),
field: 'remark', field: 'remark',
minWidth: 120, minWidth: 120,
flex: 1, flex: 1,
@ -427,7 +437,7 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' }, cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned, editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => { valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入' if (!params.node?.group && !params.node?.rowPinned && !params.value) return t('hourlyFeeGrid.clickToInput')
return params.value || '' return params.value || ''
}, },
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''), cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
@ -437,6 +447,7 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
} }
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount)) const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount)) const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
@ -444,7 +455,7 @@ const totalServiceBudget = computed(() => sumNullableNumbers(detailRows.value.ma
const pinnedTopRowData = computed(() => [ const pinnedTopRowData = computed(() => [
{ {
id: 'pinned-total-row', id: 'pinned-total-row',
expertCode: '总合计', expertCode: t('hourlyFeeGrid.total'),
expertName: '', expertName: '',
laborBudgetUnitPrice: '', laborBudgetUnitPrice: '',
compositeBudgetUnitPrice: '', compositeBudgetUnitPrice: '',
@ -530,6 +541,27 @@ const loadFromIndexedDB = async () => {
} }
} }
const relabelRowsFromExpertDict = async () => {
if (detailRows.value.length === 0) return
let changed = false
detailRows.value = detailRows.value.map(row => {
const match = String(row.id || '').match(/^expert-(\d+)$/)
if (!match) return row
const expert = (expertList as Record<string, ExpertLite | undefined>)[match[1]]
if (!expert) return row
const nextName = getExpertDisplayName(expert)
if (row.expertName === nextName) return row
changed = true
return {
...row,
expertName: nextName
}
})
gridApi.value?.refreshCells({ force: true })
if (!changed) return
await saveToIndexedDB()
}
let isBulkClipboardMutation = false let isBulkClipboardMutation = false
const commitGridChanges = (source: string) => { const commitGridChanges = (source: string) => {
@ -653,6 +685,13 @@ watch(
} }
) )
watch(
() => locale.value,
() => {
void relabelRowsFromExpertDict()
}
)
onDeactivated(() => { onDeactivated(() => {
gridApi.value?.stopEditing() gridApi.value?.stopEditing()
void saveToIndexedDB() void saveToIndexedDB()
@ -673,7 +712,7 @@ onBeforeUnmount(() => {
<div class="h-full min-h-0 flex flex-col"> <div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> <div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3"> <div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ props.title }}</h3> <h3 class="text-sm font-semibold text-foreground">{{ props.title || t('hourlyFeeGrid.title') }}</h3>
<div class="text-xs text-muted-foreground"></div> <div class="text-xs text-muted-foreground"></div>
</div> </div>
@ -682,7 +721,7 @@ onBeforeUnmount(() => {
:style="agGridStyle" :style="agGridStyle"
:rowData="detailRows" :rowData="detailRows"
:pinnedTopRowData="pinnedTopRowData" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:gridOptions="gridOptions" :gridOptions="gridOptions"
:theme="myTheme" :theme="myTheme"
:animateRows="true" :animateRows="true"

View File

@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue' import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community' import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toDecimal } from '@/lib/decimal' import { roundTo, toDecimal } from '@/lib/decimal'
@ -44,6 +46,7 @@ const props = defineProps<{
htMethodType?: 'quantity-unit-price-fee' htMethodType?: 'quantity-unit-price-fee'
}>() }>()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const { t } = useI18n()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed' const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
@ -60,7 +63,7 @@ const createDefaultRow = (): FeeRow => ({
const createSubtotalRow = (): FeeRow => ({ const createSubtotalRow = (): FeeRow => ({
id: SUBTOTAL_ROW_ID, id: SUBTOTAL_ROW_ID,
feeItem: '小计', feeItem: t('htFeeDetail.subtotal'),
unit: '', unit: '',
quantity: null, quantity: null,
unitPrice: null, unitPrice: null,
@ -122,7 +125,7 @@ const deleteRow = (id: string) => {
const requestDeleteRow = (id: string, name?: string) => { const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || '当前行' pendingDeleteRowName.value = String(name || '').trim() || t('htFeeDetail.currentRow')
deleteConfirmOpen.value = true deleteConfirmOpen.value = true
} }
@ -141,19 +144,19 @@ const confirmDeleteRow = () => {
const formatEditableText = (params: any) => { const formatEditableText = (params: any) => {
if (isSubtotalRow(params.data)) return '' if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return String(params.value) return String(params.value)
} }
const formatEditableQuantity = (params: any) => { const formatEditableQuantity = (params: any) => {
if (isSubtotalRow(params.data)) return '' if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
const formatEditableUnitPrice = (params: any) => { const formatEditableUnitPrice = (params: any) => {
if (isSubtotalRow(params.data)) return '' if (isSubtotalRow(params.data)) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('htFeeDetail.clickToInput')
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
@ -183,7 +186,7 @@ const syncComputedValuesToRows = () => {
} }
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row)) const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
if (subtotalRow) { if (subtotalRow) {
subtotalRow.feeItem = '小计' subtotalRow.feeItem = t('htFeeDetail.subtotal')
subtotalRow.unit = '' subtotalRow.unit = ''
subtotalRow.quantity = null subtotalRow.quantity = null
subtotalRow.unitPrice = null subtotalRow.unitPrice = null
@ -259,7 +262,7 @@ const loadFromIndexedDB = async () => {
const columnDefs: ColDef<FeeRow>[] = [ const columnDefs: ColDef<FeeRow>[] = [
{ {
headerName: '序号', headerName: t('htFeeDetail.columns.no'),
colId: 'rowNo', colId: 'rowNo',
minWidth: 68, minWidth: 68,
maxWidth: 80, maxWidth: 80,
@ -272,14 +275,14 @@ const columnDefs: ColDef<FeeRow>[] = [
params.node?.rowPinned params.node?.rowPinned
? '' ? ''
: isSubtotalRow(params.data) : isSubtotalRow(params.data)
? '小计' ? t('htFeeDetail.subtotal')
: typeof params.node?.rowIndex === 'number' : typeof params.node?.rowIndex === 'number'
? params.node.rowIndex + 1 ? params.node.rowIndex + 1
: '', : '',
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1) colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
}, },
{ {
headerName: '费用项', headerName: t('htFeeDetail.columns.feeItem'),
field: 'feeItem', field: 'feeItem',
minWidth: 140, minWidth: 140,
flex: 1.4, flex: 1.4,
@ -292,7 +295,7 @@ const columnDefs: ColDef<FeeRow>[] = [
} }
}, },
{ {
headerName: '单位', headerName: t('htFeeDetail.columns.unit'),
field: 'unit', field: 'unit',
minWidth: 90, minWidth: 90,
flex: 0.9, flex: 0.9,
@ -304,7 +307,7 @@ const columnDefs: ColDef<FeeRow>[] = [
} }
}, },
{ {
headerName: '数量', headerName: t('htFeeDetail.columns.quantity'),
field: 'quantity', field: 'quantity',
minWidth: 100, minWidth: 100,
flex: 1, flex: 1,
@ -319,7 +322,7 @@ const columnDefs: ColDef<FeeRow>[] = [
} }
}, },
{ {
headerName: '单价(元)', headerName: t('htFeeDetail.columns.unitPrice'),
field: 'unitPrice', field: 'unitPrice',
minWidth: 120, minWidth: 120,
flex: 1.1, flex: 1.1,
@ -334,7 +337,7 @@ const columnDefs: ColDef<FeeRow>[] = [
} }
}, },
{ {
headerName: '预算费用(元)', headerName: t('htFeeDetail.columns.budgetFee'),
field: 'budgetFee', field: 'budgetFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
@ -346,7 +349,7 @@ const columnDefs: ColDef<FeeRow>[] = [
valueFormatter: formatReadonlyBudgetFee valueFormatter: formatReadonlyBudgetFee
}, },
{ {
headerName: '说明', headerName: t('htFeeDetail.columns.remark'),
field: 'remark', field: 'remark',
minWidth: 170, minWidth: 170,
flex: 2, flex: 2,
@ -362,7 +365,7 @@ const columnDefs: ColDef<FeeRow>[] = [
} }
}, },
{ {
headerName: '操作', headerName: t('htFeeDetail.columns.actions'),
field: 'actions', field: 'actions',
minWidth: 92, minWidth: 92,
maxWidth: 110, maxWidth: 110,
@ -396,13 +399,14 @@ const columnDefs: ColDef<FeeRow>[] = [
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50', 'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
onClick: onDelete onClick: onDelete
}, },
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')] [h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', t('common.delete'))]
) )
} }
} }
}) })
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const detailGridOptions: GridOptions<FeeRow> = { const detailGridOptions: GridOptions<FeeRow> = {
...gridOptions, ...gridOptions,
@ -486,14 +490,14 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> <div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3"> <div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3> <h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button type="button" variant="outline" size="sm" @click="addRow">添加行</Button> <Button type="button" variant="outline" size="sm" @click="addRow">{{ t('htFeeDetail.addRow') }}</Button>
</div> </div>
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue <AgGridVue
:style="agGridStyle" :style="agGridStyle"
:rowData="detailRows" :rowData="detailRows"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:gridOptions="detailGridOptions" :gridOptions="detailGridOptions"
:theme="myTheme" :theme="myTheme"
:animateRows="true" :animateRows="true"
@ -524,16 +528,16 @@ onBeforeUnmount(() => {
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认删除行</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('htFeeDetail.dialog.deleteTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将删除{{ pendingDeleteRowName }}这条明细是否继续 {{ t('htFeeDetail.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button> <Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>

View File

@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue' import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community' import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { roundTo, sumNullableNumbers, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal' import { roundTo, sumNullableNumbers, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
@ -78,6 +80,7 @@ const props = defineProps<{
contractName?: string contractName?: string
fixedNames?: any[] fixedNames?: any[]
}>() }>()
const { t } = useI18n()
const tabStore = useTabStore() const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
@ -230,6 +233,14 @@ const fixedNames = computed(() =>
: [] : []
) )
const hasFixedNames = computed(() => fixedNames.value.length > 0) const hasFixedNames = computed(() => fixedNames.value.length > 0)
const fixedNamesSignature = computed(() =>
JSON.stringify(
fixedNames.value.map(item => ({
id: String(item?.id || ''),
name: String(item?.name || '')
}))
)
)
const detailRows = computed<FeeMethodRow[]>({ const detailRows = computed<FeeMethodRow[]>({
get: () => { get: () => {
const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows
@ -247,7 +258,7 @@ const summaryRow = computed<FeeMethodRow>(() => {
const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee) const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee)
const result: FeeMethodRow = { const result: FeeMethodRow = {
id: SUMMARY_ROW_ID, id: SUMMARY_ROW_ID,
name: '小计', name: t('htFeeGrid.subtotal'),
rateFee, rateFee,
hourlyFee, hourlyFee,
quantityUnitPriceFee quantityUnitPriceFee
@ -264,7 +275,7 @@ const lastSavedSnapshot = ref('')
const requestClearRow = (id: string, name?: string) => { const requestClearRow = (id: string, name?: string) => {
pendingClearRowId.value = id pendingClearRowId.value = id
pendingClearRowName.value = String(name || '').trim() || '当前行' pendingClearRowName.value = String(name || '').trim() || t('htFeeGrid.currentRow')
clearConfirmOpen.value = true clearConfirmOpen.value = true
} }
@ -337,13 +348,14 @@ const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
}) })
if (hasFixedNames.value) { if (hasFixedNames.value) {
const byId = new Map(rows.map(row => [String(row.id || ''), row]))
const byName = new Map(rows.map(row => [row.name, row])) const byName = new Map(rows.map(row => [row.name, row]))
return fixedNames.value.map((item, index) => { return fixedNames.value.map((item, index) => {
const fromDb = byName.get(item.name) const rowId = String(item?.id || `fee-method-fixed-${index}`)
const fromDb = byId.get(rowId) || byName.get(item.name)
return { return {
id: String(item?.id || `fee-method-fixed-${index}`), id: rowId,
name:item.name, name: item.name,
rateFee: fromDb?.rateFee ?? null, rateFee: fromDb?.rateFee ?? null,
hourlyFee: fromDb?.hourlyFee ?? null, hourlyFee: fromDb?.hourlyFee ?? null,
quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null
@ -412,7 +424,7 @@ const editRow = (id: string) => {
if (!row) return if (!row) return
tabStore.openTab({ tabStore.openTab({
id: `ht-fee-edit-${props.storageKey}-${id}`, id: `ht-fee-edit-${props.storageKey}-${id}`,
title: `费用编辑-${row.name || '未命名'}`, title: t('htFeeGrid.editTabTitle', { name: row.name || t('htFeeGrid.unnamed') }),
componentName: 'HtFeeMethodTypeLineView', componentName: 'HtFeeMethodTypeLineView',
props: { props: {
sourceTitle: props.title, sourceTitle: props.title,
@ -456,7 +468,7 @@ const ActionCellRenderer = defineComponent({
onClick: onActionClick('edit') onClick: onActionClick('edit')
}, [ }, [
h(Pencil, { size: 13, 'aria-hidden': 'true' }), h(Pencil, { size: 13, 'aria-hidden': 'true' }),
h('span', '编辑') h('span', t('htFeeGrid.edit'))
]), ]),
h('button', { h('button', {
class: 'zxfw-action-btn zxfw-action-btn--danger', class: 'zxfw-action-btn zxfw-action-btn--danger',
@ -465,7 +477,7 @@ const ActionCellRenderer = defineComponent({
onClick: onActionClick('clear') onClick: onActionClick('clear')
}, [ }, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }), h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', '清空') h('span', t('htFeeGrid.clear'))
]) ])
]) ])
]) ])
@ -475,7 +487,7 @@ const ActionCellRenderer = defineComponent({
const columnDefs: ColDef<FeeMethodRow>[] = [ const columnDefs: ColDef<FeeMethodRow>[] = [
{ {
headerName: '名字', headerName: t('htFeeGrid.columns.name'),
field: 'name', field: 'name',
minWidth: 180, minWidth: 180,
flex: 1.8, flex: 1.8,
@ -490,7 +502,7 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
} }
}, },
{ {
headerName: '费率计取', headerName: t('htFeeGrid.columns.rateFee'),
field: 'rateFee', field: 'rateFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
@ -504,7 +516,7 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
} }
}, },
{ {
headerName: '工时法', headerName: t('htFeeGrid.columns.hourlyFee'),
field: 'hourlyFee', field: 'hourlyFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
@ -518,7 +530,7 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
} }
}, },
{ {
headerName: '数量单价', headerName: t('htFeeGrid.columns.quantityUnitPriceFee'),
field: 'quantityUnitPriceFee', field: 'quantityUnitPriceFee',
minWidth: 130, minWidth: 130,
flex: 1.2, flex: 1.2,
@ -532,7 +544,7 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
} }
}, },
{ {
headerName: '小计', headerName: t('htFeeGrid.columns.subtotal'),
field: 'subtotal', field: 'subtotal',
minWidth: 140, minWidth: 140,
flex: 1.2, flex: 1.2,
@ -545,7 +557,7 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
valueFormatter: formatEditableNumber valueFormatter: formatEditableNumber
}, },
{ {
headerName: '操作', headerName: t('htFeeGrid.columns.actions'),
field: 'actions', field: 'actions',
minWidth: 220, minWidth: 220,
flex: 1.6, flex: 1.6,
@ -557,6 +569,7 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
cellRenderer: ActionCellRenderer cellRenderer: ActionCellRenderer
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const detailGridOptions: GridOptions<FeeMethodRow> = { const detailGridOptions: GridOptions<FeeMethodRow> = {
...gridOptions, ...gridOptions,
@ -676,6 +689,20 @@ watch([hasFixedNames], () => {
gridApi.value?.refreshCells({ force: true }) gridApi.value?.refreshCells({ force: true })
}) })
watch(
fixedNamesSignature,
async (nextSig, prevSig) => {
if (!hasFixedNames.value) return
if (!nextSig || nextSig === prevSig) return
const nextRows = mergeWithStoredRows(detailRows.value)
const changed = JSON.stringify(nextRows) !== JSON.stringify(detailRows.value)
if (!changed) return
detailRows.value = nextRows
await saveToIndexedDB(true)
gridApi.value?.refreshCells({ force: true })
}
)
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (reloadTimer) clearTimeout(reloadTimer) if (reloadTimer) clearTimeout(reloadTimer)
gridApi.value = null gridApi.value = null
@ -688,14 +715,14 @@ onBeforeUnmount(() => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col"> <div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3"> <div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3> <h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">新增</Button> <Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">{{ t('htFeeGrid.add') }}</Button>
</div> </div>
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue <AgGridVue
:style="agGridStyle" :style="agGridStyle"
:rowData="displayRows" :rowData="displayRows"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:gridOptions="detailGridOptions" :gridOptions="detailGridOptions"
:theme="myTheme" :theme="myTheme"
:animateRows="true" :animateRows="true"
@ -724,16 +751,16 @@ onBeforeUnmount(() => {
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('htFeeGrid.dialog.clearTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空{{ pendingClearRowName }}及其编辑页面的可填和自动计算数据是否继续 {{ t('htFeeGrid.dialog.clearDesc', { name: pendingClearRowName }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmClearRow">确认清空</Button> <Button variant="destructive" @click="confirmClearRow">{{ t('htFeeGrid.dialog.confirmClear') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>

View File

@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
title?: string title?: string
message: string message: string
}>(), }>(),
{ {
title: '该服务不适用当前计价方法' title: undefined
} }
) )
</script> </script>
@ -15,7 +17,7 @@ const props = withDefaults(
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6" class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
> >
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur"> <div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
<p class="text-lg font-semibold tracking-wide text-neutral-900">{{ props.title }}</p> <p class="text-lg font-semibold tracking-wide text-neutral-900">{{ props.title || t('methodUnavailable.defaultTitle') }}</p>
<p class="mt-2 text-sm leading-6 text-red-700">{{ props.message }}</p> <p class="mt-2 text-sm leading-6 text-red-700">{{ props.message }}</p>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
interface ServiceItem { interface ServiceItem {
id: string id: string
@ -15,6 +16,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void (e: 'update:modelValue', value: string[]): void
}>() }>()
const { t } = useI18n()
const selectedSet = computed(() => new Set(props.modelValue)) const selectedSet = computed(() => new Set(props.modelValue))
@ -33,13 +35,13 @@ const clearAll = () => {
<template> <template>
<div class="rounded-lg border bg-card p-2.5 shadow-sm"> <div class="rounded-lg border bg-card p-2.5 shadow-sm">
<div class="mb-1 flex items-center justify-between gap-2"> <div class="mb-1 flex items-center justify-between gap-2">
<label class="block text-[11px] font-medium text-foreground leading-none">选择服务</label> <label class="block text-[11px] font-medium text-foreground leading-none">{{ t('serviceSelector.title') }}</label>
<button <button
type="button" type="button"
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent" class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
@click="clearAll" @click="clearAll"
> >
清空 {{ t('serviceSelector.clear') }}
</button> </button>
</div> </div>
<div class="rounded-md border p-1.5"> <div class="rounded-md border p-1.5">
@ -60,7 +62,7 @@ const clearAll = () => {
</label> </label>
</div> </div>
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground"> <div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
暂无服务 {{ t('serviceSelector.empty') }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, PropType, ref, watch } from 'vue' import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, PropType, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { import type {
CellValueChangedEvent, CellValueChangedEvent,
@ -25,8 +26,9 @@ import {
} from 'reka-ui' } from 'reka-ui'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { agGridDefaultColDef, myTheme, agGridStyle } from '@/lib/diyAgGridOptions' import { agGridDefaultColDef, myTheme, agGridStyle } from '@/lib/diyAgGridOptions'
import { getServiceDictItemById, wholeProcessTasks, workList } from '@/sql' import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { WorkType,TYPE_LABEL_MAP } from '@/sql' import { getServiceDictItemById, getWorkListEntries, wholeProcessTasks } from '@/sql'
import { WorkType } from '@/sql'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
import { Trash2 } from 'lucide-vue-next' import { Trash2 } from 'lucide-vue-next'
@ -35,6 +37,7 @@ interface WorkContentRow {
id: string id: string
content: string content: string
type: WorkType type: WorkType
dictOrder?: number
serviceGroup?: string serviceGroup?: string
serviceid?: number | null serviceid?: number | null
remark: string remark: string
@ -57,7 +60,7 @@ const props = withDefaults(defineProps<{
projectInfoKey?: string projectInfoKey?: string
dictMode?: 'service' | 'additional' | 'none' dictMode?: 'service' | 'additional' | 'none'
}>(), { }>(), {
title: '工作内容', title: '',
projectInfoKey: 'xm-base-info-v1', projectInfoKey: 'xm-base-info-v1',
dictMode: 'none' dictMode: 'none'
}) })
@ -65,6 +68,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
checkedChange: [value: string[]] checkedChange: [value: string[]]
}>() }>()
const { t, locale } = useI18n()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore() const kvStore = useKvStore()
@ -106,9 +110,21 @@ const toServiceId = (value: unknown): number | null => {
return parsed return parsed
} }
const getDictRowMergeKey = (row: Pick<WorkContentRow, 'content' | 'serviceid' | 'serviceGroup'>) => { const getStableDictRowKeyFromId = (idRaw: unknown) => {
const id = String(idRaw || '').trim()
if (!id.startsWith('dict-')) return ''
const matched = /^dict-(-?\d+)-(\d+)(?:-|$)/.exec(id)
if (!matched) return ''
return `sid:${matched[1]}|order:${matched[2]}`
}
const getDictRowMergeKey = (row: Pick<WorkContentRow, 'id' | 'dictOrder' | 'content' | 'serviceid' | 'serviceGroup'>) => {
const fromId = getStableDictRowKeyFromId(row.id)
if (fromId) return fromId
const content = String(row.content || '').trim() const content = String(row.content || '').trim()
const serviceid = toServiceId(row.serviceid) const serviceid = toServiceId(row.serviceid)
const dictOrder = Number(row.dictOrder)
if (serviceid != null && Number.isFinite(dictOrder)) return `sid:${serviceid}|order:${dictOrder}`
if (serviceid != null) return `sid:${serviceid}|content:${content}` if (serviceid != null) return `sid:${serviceid}|content:${content}`
const groupName = String(row.serviceGroup || '').trim() const groupName = String(row.serviceGroup || '').trim()
if (groupName) return `group:${groupName}|content:${content}` if (groupName) return `group:${groupName}|content:${content}`
@ -133,7 +149,7 @@ const loadProjectIndustryId = async () => {
const buildDefaultRowsFromDict = async (): Promise<WorkContentRow[]> => { const buildDefaultRowsFromDict = async (): Promise<WorkContentRow[]> => {
const rows: WorkContentRow[] = [] const rows: WorkContentRow[] = []
const entries = Object.values(workList) as Array<{ text: string; serviceid: number; order: number; type: number }> const entries = getWorkListEntries(locale.value) as Array<{ text: string; serviceid: number; order: number; type: number }>
let filtered: typeof entries = [] let filtered: typeof entries = []
let groupedServiceIds: number[] = [] let groupedServiceIds: number[] = []
@ -193,17 +209,22 @@ const buildDefaultRowsFromDict = async (): Promise<WorkContentRow[]> => {
for (const entry of filtered) { for (const entry of filtered) {
const content = String(entry.text || '').trim() const content = String(entry.text || '').trim()
if (!content) continue if (!content) continue
const typeLabel = TYPE_LABEL_MAP[entry.type] ?? '基本工作' const typeLabel = ((): WorkType => {
if (entry.type === 1) return t('workContent.type.optional') as WorkType
if (entry.type === 2) return t('workContent.type.daily') as WorkType
if (entry.type === 3) return t('workContent.type.special') as WorkType
if (entry.type === 4) return t('workContent.type.additional') as WorkType
return t('workContent.type.basic') as WorkType
})()
const serviceItem = getServiceDictItemById(entry.serviceid) as { code?: string; name?: string } | undefined const serviceItem = getServiceDictItemById(entry.serviceid) as { code?: string; name?: string } | undefined
const serviceGroup = serviceItem const serviceGroup = serviceItem
? `${String(serviceItem.code || '').trim()} ${String(serviceItem.name || '').trim()}`.trim() ? `${String(serviceItem.code || '').trim()} ${String(serviceItem.name || '').trim()}`.trim()
: '' : ''
rows.push({ rows.push({
id: isWholeProcessGroupedMode.value id: `dict-${entry.serviceid}-${entry.order}`,
? `dict-${entry.serviceid}-${entry.order}-${content}`
: `dict-${entry.order}`,
content, content,
type: typeLabel, type: typeLabel,
dictOrder: entry.order,
serviceGroup, serviceGroup,
serviceid: toServiceId(entry.serviceid), serviceid: toServiceId(entry.serviceid),
remark: '', remark: '',
@ -221,7 +242,7 @@ const pendingDeleteRowId = ref<string | null>(null)
const pendingDeleteRowName = ref('') const pendingDeleteRowName = ref('')
const requestDeleteRow = (id: string, name?: string) => { const requestDeleteRow = (id: string, name?: string) => {
pendingDeleteRowId.value = id pendingDeleteRowId.value = id
pendingDeleteRowName.value = String(name || '').trim() || '当前行' pendingDeleteRowName.value = String(name || '').trim() || t('workContent.currentRow')
deleteConfirmOpen.value = true deleteConfirmOpen.value = true
} }
const checkedIds = computed(() => const checkedIds = computed(() =>
@ -259,10 +280,10 @@ const loadFromStore = async () => {
if (Array.isArray(state?.detailRows) && state.detailRows.length > 0) { if (Array.isArray(state?.detailRows) && state.detailRows.length > 0) {
const persistedRows = state.detailRows.map(item => ({ const persistedRows = state.detailRows.map(item => ({
...item, ...item,
type: item.custom ? '自定义' : (item.type || '基本工作'), type: item.custom ? t('workContent.type.custom') : (item.type || t('workContent.type.basic')),
checked: item.custom ? false : item.checked !== false, checked: item.custom ? false : item.checked !== false,
serviceid: toServiceId(item.serviceid), serviceid: toServiceId(item.serviceid),
path: Array.isArray(item.path) && item.path.length ? item.path : ['自定义', item.content || '未命名'] path: Array.isArray(item.path) && item.path.length ? item.path : [t('workContent.type.custom'), item.content || t('workContent.unnamed')]
})) as WorkContentRow[] })) as WorkContentRow[]
const defaultGroupServiceIdMap = new Map<string, number>() const defaultGroupServiceIdMap = new Map<string, number>()
@ -296,8 +317,7 @@ const loadFromStore = async () => {
return { return {
...item, ...item,
checked: old.checked !== false, checked: old.checked !== false,
remark: String(old.remark || ''), remark: String(old.remark || '')
type: old.type || item.type
} }
}) })
rowData.value = withAddTriggerRows([...mergedDictRows, ...persistedCustomRows]) rowData.value = withAddTriggerRows([...mergedDictRows, ...persistedCustomRows])
@ -391,7 +411,7 @@ const contentCellRenderer = (params: ICellRendererParams<WorkContentRow>) => {
if (isAddTriggerRow(data)) { if (isAddTriggerRow(data)) {
const label = document.createElement('span') const label = document.createElement('span')
label.className = 'work-content-placeholder' label.className = 'work-content-placeholder'
label.textContent = String(data.content || '点击添加自定义内容') label.textContent = String(data.content || t('workContent.addCustom'))
wrapper.appendChild(label) wrapper.appendChild(label)
return wrapper return wrapper
} }
@ -400,7 +420,7 @@ const contentCellRenderer = (params: ICellRendererParams<WorkContentRow>) => {
const label = document.createElement('span') const label = document.createElement('span')
if (!data.content) { if (!data.content) {
label.className = 'work-content-placeholder' label.className = 'work-content-placeholder'
label.textContent = '点击输入工作内容' label.textContent = t('workContent.clickToInputContent')
} else { } else {
label.className = 'work-content-text' label.className = 'work-content-text'
label.textContent = data.content label.textContent = data.content
@ -425,7 +445,7 @@ const contentCellRenderer = (params: ICellRendererParams<WorkContentRow>) => {
const columnDefs: ColDef<WorkContentRow>[] = [ const columnDefs: ColDef<WorkContentRow>[] = [
{ {
headerName: '序号', headerName: t('workContent.columns.no'),
minWidth: 60, minWidth: 60,
width: 70, width: 70,
suppressMovable: true, suppressMovable: true,
@ -446,7 +466,7 @@ const columnDefs: ColDef<WorkContentRow>[] = [
button.type = 'button' button.type = 'button'
button.className = button.className =
'inline-flex h-full w-full cursor-pointer items-center justify-center rounded-none border-0 bg-transparent px-3 py-3 text-sm font-medium text-blue-700 hover:bg-transparent focus:outline-none' 'inline-flex h-full w-full cursor-pointer items-center justify-center rounded-none border-0 bg-transparent px-3 py-3 text-sm font-medium text-blue-700 hover:bg-transparent focus:outline-none'
button.textContent = ' 添加自定义内容' button.textContent = ` ${t('workContent.addCustom')}`
button.addEventListener('click', event => { button.addEventListener('click', event => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@ -456,7 +476,7 @@ const columnDefs: ColDef<WorkContentRow>[] = [
} }
}, },
{ {
headerName: '工作内容', headerName: t('workContent.columns.content'),
field: 'content', field: 'content',
minWidth: 320, minWidth: 320,
flex: 2, flex: 2,
@ -471,7 +491,7 @@ const columnDefs: ColDef<WorkContentRow>[] = [
cellRenderer: contentCellRenderer cellRenderer: contentCellRenderer
}, },
{ {
headerName: '工作类型', headerName: t('workContent.columns.type'),
field: 'type', field: 'type',
minWidth: 100, minWidth: 100,
width: 120, width: 120,
@ -480,7 +500,7 @@ const columnDefs: ColDef<WorkContentRow>[] = [
isAddTriggerRow(params.data) ? '' : String(params.value || '') isAddTriggerRow(params.data) ? '' : String(params.value || '')
}, },
{ {
headerName: '备注', headerName: t('workContent.columns.remark'),
field: 'remark', field: 'remark',
minWidth: 180, minWidth: 180,
flex: 1.2, flex: 1.2,
@ -493,10 +513,10 @@ const columnDefs: ColDef<WorkContentRow>[] = [
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
}, },
valueFormatter: params => (isAddTriggerRow(params.data) ? '' : (params.value || '点击输入')) valueFormatter: params => (isAddTriggerRow(params.data) ? '' : (params.value || t('workContent.clickToInput')))
}, },
{ {
headerName: '操作', headerName: t('workContent.columns.actions'),
colId: 'actions', colId: 'actions',
minWidth: 92, minWidth: 92,
maxWidth: 110, maxWidth: 110,
@ -530,13 +550,14 @@ const columnDefs: ColDef<WorkContentRow>[] = [
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50', 'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
onClick: onDelete onClick: onDelete
}, },
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')] [h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', t('common.delete'))]
) )
} }
} }
}) })
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const isAddTriggerRow = (row?: WorkContentRow | null) => Boolean(row?.isAddTrigger) const isAddTriggerRow = (row?: WorkContentRow | null) => Boolean(row?.isAddTrigger)
const getPersistableRows = (rows: WorkContentRow[]) => rows.filter(item => !isAddTriggerRow(item)) const getPersistableRows = (rows: WorkContentRow[]) => rows.filter(item => !isAddTriggerRow(item))
@ -545,8 +566,8 @@ const createAddTriggerRow = (groupName?: string): WorkContentRow => {
const suffix = groupName ? String(groupName).trim() : 'root' const suffix = groupName ? String(groupName).trim() : 'root'
return { return {
id: `add-trigger-${suffix}`, id: `add-trigger-${suffix}`,
content: '点击添加自定义内容', content: t('workContent.addCustom'),
type: '自定义' as WorkType, type: t('workContent.type.custom') as WorkType,
serviceGroup: groupName || '', serviceGroup: groupName || '',
serviceid: null, serviceid: null,
remark: '', remark: '',
@ -562,7 +583,7 @@ const withAddTriggerRows = (rows: WorkContentRow[]) => {
if (isWholeProcessGroupedMode.value) { if (isWholeProcessGroupedMode.value) {
const groupedMap = new Map<string, WorkContentRow[]>() const groupedMap = new Map<string, WorkContentRow[]>()
for (const row of pureRows) { for (const row of pureRows) {
const groupName = String(row.serviceGroup || '').trim() || '未分组' const groupName = String(row.serviceGroup || '').trim() || t('workContent.ungrouped')
if (!groupedMap.has(groupName)) groupedMap.set(groupName, []) if (!groupedMap.has(groupName)) groupedMap.set(groupName, [])
groupedMap.get(groupName)?.push(row) groupedMap.get(groupName)?.push(row)
} }
@ -610,15 +631,15 @@ const addCustomRow = (groupName?: string) => {
const nextRow: WorkContentRow = { const nextRow: WorkContentRow = {
id: `custom-${ts}`, id: `custom-${ts}`,
content: '', content: '',
type: '自定义' as WorkType, type: t('workContent.type.custom') as WorkType,
serviceGroup: finalGroupName, serviceGroup: finalGroupName,
serviceid: finalServiceId, serviceid: finalServiceId,
remark: '', remark: '',
checked: false, checked: false,
custom: true, custom: true,
path: isWholeProcessGroupedMode.value && finalGroupName path: isWholeProcessGroupedMode.value && finalGroupName
? [finalGroupName, `自定义-${ts}`] ? [finalGroupName, `${t('workContent.type.custom')}-${ts}`]
: ['自定义', `自定义-${ts}`] : [t('workContent.type.custom'), `${t('workContent.type.custom')}-${ts}`]
} }
const pureRows = getPersistableRows(rowData.value) const pureRows = getPersistableRows(rowData.value)
pureRows.push(nextRow) pureRows.push(nextRow)
@ -645,11 +666,11 @@ const onCellValueChanged = (event: CellValueChangedEvent<WorkContentRow>) => {
if (event.colDef.field === 'content' && row.custom) { if (event.colDef.field === 'content' && row.custom) {
const groupName = String(row.serviceGroup || '').trim() const groupName = String(row.serviceGroup || '').trim()
row.path = isWholeProcessGroupedMode.value && groupName row.path = isWholeProcessGroupedMode.value && groupName
? [groupName, row.content || `自定义-${row.id}`] ? [groupName, row.content || `${t('workContent.type.custom')}-${row.id}`]
: ['自定义', row.content || `自定义-${row.id}`] : [t('workContent.type.custom'), row.content || `${t('workContent.type.custom')}-${row.id}`]
} }
if (event.colDef.field === 'type' && row.custom) { if (event.colDef.field === 'type' && row.custom) {
row.type = '自定义' row.type = t('workContent.type.custom') as WorkType
} }
saveToStore() saveToStore()
} }
@ -669,6 +690,10 @@ watch(
} }
) )
watch(locale, () => {
void loadFromStore()
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
gridApi.value?.stopEditing() gridApi.value?.stopEditing()
saveToStore() saveToStore()
@ -697,13 +722,13 @@ const confirmDeleteRow = () => {
<div class="h-full min-h-0 xmMx"> <div class="h-full min-h-0 xmMx">
<div class="h-full min-h-0 rounded-2xl border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm"> <div class="h-full min-h-0 rounded-2xl border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm">
<div class="flex items-center justify-between border-b border-border/60 px-4 py-3"> <div class="flex items-center justify-between border-b border-border/60 px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ props.title }}</h3> <h3 class="text-sm font-semibold text-foreground">{{ props.title || t('workContent.title') }}</h3>
</div> </div>
<div class="ag-theme-quartz h-[calc(100%-56px)] min-h-0 w-full"> <div class="ag-theme-quartz h-[calc(100%-56px)] min-h-0 w-full">
<AgGridVue <AgGridVue
:style="agGridStyle" :style="agGridStyle"
:rowData="rowData" :rowData="rowData"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:theme="myTheme" :theme="myTheme"
:getRowId="(params: { data: WorkContentRow }) => params.data.id" :getRowId="(params: { data: WorkContentRow }) => params.data.id"
:treeData="isWholeProcessGroupedMode" :treeData="isWholeProcessGroupedMode"
@ -731,16 +756,16 @@ const confirmDeleteRow = () => {
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认删除行</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">{{ t('workContent.dialog.deleteTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将删除{{ pendingDeleteRowName }}这条明细是否继续 {{ t('workContent.dialog.deleteDesc', { name: pendingDeleteRowName }) }}
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteRow">确认删除</Button> <Button variant="destructive" @click="confirmDeleteRow">{{ t('common.delete') }}</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community' import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
@ -8,6 +9,7 @@ import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing' import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
import { syncContractFactorsToPricing } from '@/lib/zxFwPricingSync' import { syncContractFactorsToPricing } from '@/lib/zxFwPricingSync'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
interface DictItem { interface DictItem {
code: string code: string
@ -48,6 +50,7 @@ const props = defineProps<{
excludeNotshowByZxflxs?: boolean excludeNotshowByZxflxs?: boolean
initBudgetValueFromStandard?: boolean initBudgetValueFromStandard?: boolean
}>() }>()
const { t } = useI18n()
const zxFwPricingStore = useZxFwPricingStore() const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore() const kvStore = useKvStore()
@ -63,7 +66,7 @@ const formatReadonlyFactor = (value: unknown) => {
} }
const formatEditableFactor = (params: any) => { const formatEditableFactor = (params: any) => {
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('xmFactorGrid.clickToInput')
const parsed = parseNumberOrNull(params.value, { precision: 3 }) const parsed = parseNumberOrNull(params.value, { precision: 3 })
if (parsed == null) return '' if (parsed == null) return ''
return String(Number(parsed)) return String(Number(parsed))
@ -162,7 +165,7 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] =>
const columnDefs: ColDef<FactorRow>[] = [ const columnDefs: ColDef<FactorRow>[] = [
{ {
headerName: '标准系数', headerName: t('xmFactorGrid.columns.standardFactor'),
field: 'standardFactor', field: 'standardFactor',
minWidth: 86, minWidth: 86,
maxWidth: 100, maxWidth: 100,
@ -174,7 +177,7 @@ const columnDefs: ColDef<FactorRow>[] = [
valueFormatter: params => formatReadonlyFactor(params.value) valueFormatter: params => formatReadonlyFactor(params.value)
}, },
{ {
headerName: '预算取值', headerName: t('xmFactorGrid.columns.budgetValue'),
field: 'budgetValue', field: 'budgetValue',
minWidth: 86, minWidth: 86,
maxWidth: 100, maxWidth: 100,
@ -203,7 +206,7 @@ const columnDefs: ColDef<FactorRow>[] = [
} }
}, },
{ {
headerName: '说明', headerName: t('xmFactorGrid.columns.remark'),
field: 'remark', field: 'remark',
minWidth: 170, minWidth: 170,
flex: 2.4, flex: 2.4,
@ -212,16 +215,17 @@ const columnDefs: ColDef<FactorRow>[] = [
autoHeight: true, autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' }, cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: true, editable: true,
valueFormatter: params => params.value || '点击输入', valueFormatter: params => params.value || t('xmFactorGrid.clickToInput'),
cellClass: ' remark-wrap-cell', cellClass: ' remark-wrap-cell',
cellClassRules: { cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === '' 'editable-cell-empty': params => params.value == null || params.value === ''
} }
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef<FactorRow> = { const autoGroupColumnDef: ColDef<FactorRow> = {
headerName: '专业编码以及工程专业名称', headerName: t('xmFactorGrid.columns.groupName'),
minWidth: 220, minWidth: 220,
flex: 2.2, flex: 2.2,
cellRendererParams: { cellRendererParams: {
@ -459,7 +463,7 @@ onBeforeUnmount(() => {
<AgGridVue <AgGridVue
:style="agGridStyle" :style="agGridStyle"
:rowData="detailRows" :rowData="detailRows"
:columnDefs="columnDefs" :columnDefs="gridColumnDefs"
:autoGroupColumnDef="autoGroupColumnDef" :autoGroupColumnDef="autoGroupColumnDef"
:gridOptions="gridOptions" :gridOptions="gridOptions"
:theme="myTheme" :theme="myTheme"

View File

@ -1,13 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3' import { AgGridVue } from 'ag-grid-vue3'
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community' import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions' import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousandsFlexible } from '@/lib/numberFormat'
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' import { getIndustryDisplayName, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import { syncContractScaleToPricing, type ContractScaleSyncResult } from '@/lib/zxFwPricingSync' import { syncContractScaleToPricing, type ContractScaleSyncResult } from '@/lib/zxFwPricingSync'
import { SwitchRoot, SwitchThumb } from 'reka-ui' import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
@ -56,12 +58,19 @@ const CONTRACT_SCALE_KEY_PREFIX = 'ht-info-v3-'
const CONTRACT_SCALE_CHANGE_KEY_PREFIX = 'ht-info-scale-change-v1-' const CONTRACT_SCALE_CHANGE_KEY_PREFIX = 'ht-info-scale-change-v1-'
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean } type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
const kvStore = useKvStore() const kvStore = useKvStore()
const { t, locale } = useI18n()
const detailRows = ref<DetailRow[]>([]) const detailRows = ref<DetailRow[]>([])
const detailDict = ref<DictGroup[]>([]) const detailDict = ref<DictGroup[]>([])
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite]) const majorEntries = ref<Array<[string, MajorLite]>>([])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id])) const majorIdAliasMap = ref<Map<string, string>>(new Map())
const refreshMajorDictCaches = () => {
const entries = getMajorDictEntries()
majorEntries.value = entries.map(({ id, item }) => [id, item] as [string, MajorLite])
majorIdAliasMap.value = new Map(entries.map(({ rawId, id }) => [rawId, id]))
}
refreshMajorDictCaches()
const buildDetailDict = (entries: Array<[string, MajorLite]>) => { const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
const groupMap = new Map<string, DictGroup>() const groupMap = new Map<string, DictGroup>()
@ -135,7 +144,7 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
if (row?.isGroupRow === true) continue if (row?.isGroupRow === true) continue
const rowId = String(row.id) const rowId = String(row.id)
dbValueMap.set(rowId, row) dbValueMap.set(rowId, row)
const aliasId = majorIdAliasMap.get(rowId) const aliasId = majorIdAliasMap.value.get(rowId)
if (aliasId && !dbValueMap.has(aliasId)) { if (aliasId && !dbValueMap.has(aliasId)) {
dbValueMap.set(aliasId, row) dbValueMap.set(aliasId, row)
} }
@ -227,7 +236,7 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
return return
} }
const filteredEntries = majorEntries.filter(([id]) => const filteredEntries = majorEntries.value.filter(([id]) =>
isMajorIdInIndustryScope(id, activeIndustryId.value) isMajorIdInIndustryScope(id, activeIndustryId.value)
) )
detailDict.value = buildDetailDict(filteredEntries) detailDict.value = buildDetailDict(filteredEntries)
@ -325,15 +334,9 @@ const props = defineProps<{
let persistTimer: ReturnType<typeof setTimeout> | null = null let persistTimer: ReturnType<typeof setTimeout> | null = null
const gridApi = ref<GridApi<DetailRow> | null>(null) const gridApi = ref<GridApi<DetailRow> | null>(null)
const activeIndustryId = ref('') const activeIndustryId = ref('')
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
const totalLabel = computed(() => { const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryId.value.trim()) || '' const industryName = getIndustryDisplayName(activeIndustryId.value.trim(), locale.value)
return industryName ? `${industryName}总投资` : '总投资' return industryName ? t('pricingScale.totalInvestmentByIndustry', { industryName }) : t('pricingScale.totalInvestment')
}) })
const roughCalcEnabled = ref(false) const roughCalcEnabled = ref(false)
const visibleRowData = computed(() => { return detailRows.value.filter(row => !row.hide) }) const visibleRowData = computed(() => { return detailRows.value.filter(row => !row.hide) })
@ -350,7 +353,7 @@ const refreshPinnedTotalLabelCell = () => {
const columnDefs: ColDef<DetailRow>[] = [ const columnDefs: ColDef<DetailRow>[] = [
{ {
headerName: '造价金额(万元)', headerName: t('pricingScale.columns.investAmount'),
field: 'amount', field: 'amount',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 100, minWidth: 100,
@ -377,21 +380,21 @@ const columnDefs: ColDef<DetailRow>[] = [
valueFormatter: params => { valueFormatter: params => {
if (roughCalcEnabled.value) { if (roughCalcEnabled.value) {
if (!params.node?.rowPinned) return '' if (!params.node?.rowPinned) return ''
if (params.value == null || params.value === '') return '点击输入' if (params.value == null || params.value === '') return t('pricingScale.clickToInput')
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) { if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
return '' return ''
} }
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return t('pricingScale.clickToInput')
} }
if (params.value == null) return '' if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
}, },
{ {
headerName: '用地面积(亩)', headerName: t('pricingScale.columns.landArea'),
field: 'landArea', field: 'landArea',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 100, minWidth: 100,
@ -415,16 +418,17 @@ const columnDefs: ColDef<DetailRow>[] = [
return '' return ''
} }
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入' return t('pricingScale.clickToInput')
} }
if (params.value == null) return '' if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3) return formatThousandsFlexible(params.value, 3)
} }
} }
] ]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef = { const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称', headerName: t('pricingScale.columns.majorGroup'),
minWidth: 200, minWidth: 200,
flex: 2, flex: 2,
cellRendererParams: { cellRendererParams: {
@ -484,7 +488,11 @@ const getChangedScaleRowIds = (previousLeafRows: DetailRow[], nextLeafRows: Deta
const showScaleSyncToast = (result: ContractScaleSyncResult) => { const showScaleSyncToast = (result: ContractScaleSyncResult) => {
if (result.updatedMethodCount <= 0) return if (result.updatedMethodCount <= 0) return
syncToastText.value = `规模信息已同步到咨询服务(${result.updatedServiceCount} 项服务,${result.updatedMethodCount} 个计价页,${result.updatedRowCount} 行)` syncToastText.value = t('xmScaleGrid.syncToastDesc', {
serviceCount: result.updatedServiceCount,
methodCount: result.updatedMethodCount,
rowCount: result.updatedRowCount
})
syncToastOpen.value = false syncToastOpen.value = false
requestAnimationFrame(() => { requestAnimationFrame(() => {
syncToastOpen.value = true syncToastOpen.value = true
@ -667,10 +675,45 @@ const processCellFromClipboard = (params: any) => {
const relabelRowsFromMajorDict = async () => {
refreshMajorDictCaches()
if (!activeIndustryId.value) return
const filteredEntries = majorEntries.value.filter(([id]) =>
isMajorIdInIndustryScope(id, activeIndustryId.value)
)
detailDict.value = buildDetailDict(filteredEntries)
if (detailRows.value.length === 0) {
gridApi.value?.refreshCells({ force: true })
return
}
const nextRows = mergeWithDictRows(detailRows.value)
const changed = nextRows.some((row, index) => {
const current = detailRows.value[index]
return (
current?.groupCode !== row.groupCode ||
current?.groupName !== row.groupName ||
current?.majorCode !== row.majorCode ||
current?.majorName !== row.majorName
)
})
detailRows.value = nextRows
gridApi.value?.refreshCells({ force: true })
void refreshPinnedTotalLabelCell()
if (!changed) return
await saveToIndexedDB()
}
watch(totalLabel, () => { watch(totalLabel, () => {
refreshPinnedTotalLabelCell() refreshPinnedTotalLabelCell()
}) })
watch(
() => locale.value,
() => {
void relabelRowsFromMajorDict()
}
)
const syncPinnedTotalForNormalMode = () => { const syncPinnedTotalForNormalMode = () => {
if (roughCalcEnabled.value) return if (roughCalcEnabled.value) return
@ -730,7 +773,7 @@ onMounted(() => {
<div :class="agGridWrapClass"> <div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData" <AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme" :columnDefs="gridColumnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
:animateRows="true" :animateRows="true"
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true" @grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd" @paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
@ -747,12 +790,12 @@ onMounted(() => {
> >
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="space-y-1"> <div class="space-y-1">
<ToastTitle class="text-sm font-semibold text-foreground">已同步咨询服务</ToastTitle> <ToastTitle class="text-sm font-semibold text-foreground">{{ t('xmScaleGrid.syncToastTitle') }}</ToastTitle>
<ToastDescription class="text-xs text-muted-foreground">{{ syncToastText }}</ToastDescription> <ToastDescription class="text-xs text-muted-foreground">{{ syncToastText }}</ToastDescription>
</div> </div>
<ToastAction as-child alt-text="关闭"> <ToastAction as-child :alt-text="t('common.close')">
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="syncToastOpen = false"> <Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="syncToastOpen = false">
关闭 {{ t('common.close') }}
</Button> </Button>
</ToastAction> </ToastAction>
</div> </div>

View File

@ -1,4 +1,5 @@
import type localforage from 'localforage' import type localforage from 'localforage'
import { i18n } from '@/i18n'
export interface DataEntry { export interface DataEntry {
key: string key: string
@ -105,7 +106,7 @@ export const sanitizeFileNamePart = (value: string): string => {
.replace(/[\\/:*?"<>|]/g, '_') .replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim() .trim()
return cleaned || '造价项目' return cleaned || i18n.global.t('tab.messages.defaultProjectName')
} }
export const getExportProjectName = (entries: DataEntry[], projectInfoDbKey: string, legacyProjectDbKey: string) => { export const getExportProjectName = (entries: DataEntry[], projectInfoDbKey: string, legacyProjectDbKey: string) => {
@ -113,7 +114,9 @@ export const getExportProjectName = (entries: DataEntry[], projectInfoDbKey: str
entries.find(item => item.key === projectInfoDbKey) || entries.find(item => item.key === projectInfoDbKey) ||
entries.find(item => item.key === legacyProjectDbKey) entries.find(item => item.key === legacyProjectDbKey)
const data = (target?.value || {}) as XmInfoLike const data = (target?.value || {}) as XmInfoLike
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目' return typeof data.projectName === 'string'
? sanitizeFileNamePart(data.projectName)
: i18n.global.t('tab.messages.defaultProjectName')
} }
export const isDataPackageLike = (value: unknown): value is DataPackage => { export const isDataPackageLike = (value: unknown): value is DataPackage => {

View File

@ -5,10 +5,12 @@ import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/ca
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTabStore } from '@/pinia/tab' import { useTabStore } from '@/pinia/tab'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
import { useUiPrefsStore } from '@/pinia/uiPrefs'
import { import {
Calculator, Calculator,
Check, Check,
ChevronDown, ChevronDown,
Languages,
X X
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { getIndustryDisplayName, industryTypeList } from '@/sql' import { getIndustryDisplayName, industryTypeList } from '@/sql'
@ -71,12 +73,12 @@ interface ProjectInfoState {
const PROJECT_INFO_KEY = 'xm-base-info-v1' const PROJECT_INFO_KEY = 'xm-base-info-v1'
const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1' const PROJECT_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const PROJECT_MAJOR_FACTOR_KEY = 'xm-major-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 PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
const getActiveProjectId = () => readCurrentProjectId() const getActiveProjectId = () => readCurrentProjectId()
const tabStore = useTabStore() const tabStore = useTabStore()
const kvStore = useKvStore() const kvStore = useKvStore()
const uiPrefsStore = useUiPrefsStore()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const projectDialogOpen = ref(false) const projectDialogOpen = ref(false)
const projectIndustry = ref(String(industryTypeList[0]?.id || '')) const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
@ -93,6 +95,11 @@ const projectIndustryLabel = computed(() => {
if (!target) return '' if (!target) return ''
return getIndustryDisplayName(target, locale.value) || '' return getIndustryDisplayName(target, locale.value) || ''
}) })
const localeBadge = computed(() => (locale.value === 'en-US' ? 'EN' : '中'))
const toggleLocale = () => {
const next = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
uiPrefsStore.setLocale(next as 'zh-CN' | 'en-US')
}
const getTodayDateString = () => { const getTodayDateString = () => {
const now = new Date() const now = new Date()
@ -104,12 +111,12 @@ const getTodayDateString = () => {
const enterProjectCalc = () => { const enterProjectCalc = () => {
const projectId = getActiveProjectId() const projectId = getActiveProjectId()
upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? '默认项目' : undefined) upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? t('tab.messages.defaultProjectLabel') : undefined)
writeProjectIdToUrl(projectId) writeProjectIdToUrl(projectId)
writeWorkspaceMode('project') writeWorkspaceMode('project')
tabStore.enterWorkspace({ tabStore.enterWorkspace({
id: PROJECT_TAB_ID, id: PROJECT_TAB_ID,
title: '项目计算', title: t('home.projectCalcTab'),
componentName: 'ProjectCalcView' componentName: 'ProjectCalcView'
}) })
tabStore.hasCompletedSetup = true tabStore.hasCompletedSetup = true
@ -140,7 +147,7 @@ const confirmProjectCalc = async () => {
try { try {
await kvStore.setItem<ProjectInfoState>(PROJECT_INFO_KEY, { await kvStore.setItem<ProjectInfoState>(PROJECT_INFO_KEY, {
projectIndustry: industry, projectIndustry: industry,
projectName: DEFAULT_PROJECT_NAME, projectName: t('xmInfo.defaultProjectName'),
preparedBy: '', preparedBy: '',
reviewedBy: '', reviewedBy: '',
preparedCompany: '', preparedCompany: '',
@ -206,7 +213,7 @@ const openQuickCalc = async () => {
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, { await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
...currentInfo, ...currentInfo,
projectIndustry: industry, projectIndustry: industry,
projectName: '快速计算' projectName: t('quickCalc.projectName')
}) })
await kvStore.setItem(QUICK_CONTRACT_META_KEY, { await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
id: QUICK_CONTRACT_ID, id: QUICK_CONTRACT_ID,
@ -255,12 +262,12 @@ const confirmHomeImport = () => {
detail: { file } detail: { file }
})) }))
const projectId = getActiveProjectId() const projectId = getActiveProjectId()
upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? '默认项目' : undefined) upsertProject(projectId, projectId === DEFAULT_PROJECT_ID ? t('tab.messages.defaultProjectLabel') : undefined)
writeProjectIdToUrl(projectId) writeProjectIdToUrl(projectId)
writeWorkspaceMode('project') writeWorkspaceMode('project')
tabStore.enterWorkspace({ tabStore.enterWorkspace({
id: PROJECT_TAB_ID, id: PROJECT_TAB_ID,
title: '项目计算', title: t('home.projectCalcTab'),
componentName: 'ProjectCalcView' componentName: 'ProjectCalcView'
}) })
tabStore.hasCompletedSetup = true tabStore.hasCompletedSetup = true
@ -293,6 +300,17 @@ onMounted(() => {
<div class="home-entry relative flex min-h-full items-center justify-center px-4 py-8 lg:py-10"> <div class="home-entry relative flex min-h-full items-center justify-center px-4 py-8 lg:py-10">
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,rgba(59,130,246,0.06),transparent_70%)]" /> <div class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,rgba(59,130,246,0.06),transparent_70%)]" />
<div class="relative w-full max-w-[1240px]"> <div class="relative w-full max-w-[1240px]">
<div class="absolute right-0 top-0 z-10">
<Button
variant="outline"
size="sm"
class="h-8 cursor-pointer gap-1.5 rounded-full border-slate-200/80 bg-white/85 px-3 text-xs text-slate-600 shadow-sm backdrop-blur transition hover:bg-white"
@click="toggleLocale"
>
<Languages class="h-3.5 w-3.5" />
<span>{{ localeBadge }}</span>
</Button>
</div>
<div class="home-title text-center"> <div class="home-title text-center">
<h1 class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl">{{ t('home.title') }}</h1> <h1 class="text-2xl font-semibold tracking-tight text-slate-900 lg:text-3xl">{{ t('home.title') }}</h1>
<p class="mt-1.5 text-sm text-slate-500">{{ t('home.subtitle') }}</p> <p class="mt-1.5 text-sm text-slate-500">{{ t('home.subtitle') }}</p>

View File

@ -2,7 +2,7 @@
<TypeLine <TypeLine
scene="ht-fee-method-type-line" scene="ht-fee-method-type-line"
:title="titleText" :title="titleText"
:subtitle="`合同ID${contractIdText}`" :subtitle="t('htFeeMethodTypeLine.contractId', { id: contractIdText })"
:copy-text="contractIdText" :copy-text="contractIdText"
:storage-key="activeTypeStorageKey" :storage-key="activeTypeStorageKey"
default-category="rate-fee" default-category="rate-fee"
@ -12,6 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineComponent, h, markRaw, defineAsyncComponent, type Component } from 'vue' import { computed, defineComponent, h, markRaw, defineAsyncComponent, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue' import TypeLine from '@/layout/typeLine.vue'
import HtFeeGrid from '@/features/shared/components/HtFeeGrid.vue' import HtFeeGrid from '@/features/shared/components/HtFeeGrid.vue'
import HtFeeRateMethodForm from '@/features/ht/components/HtFeeRateMethodForm.vue' import HtFeeRateMethodForm from '@/features/ht/components/HtFeeRateMethodForm.vue'
@ -31,12 +32,16 @@ const props = defineProps<{
contractId?: string contractId?: string
contractName?: string contractName?: string
}>() }>()
const sourceTitleText = computed(() => props.sourceTitle || '费用明细') const { t } = useI18n()
const rowNameText = computed(() => props.rowName || '未命名') const sourceTitleText = computed(() => props.sourceTitle || t('htFeeMethodTypeLine.feeDetail'))
const rowNameText = computed(() => props.rowName || t('htFeeMethodTypeLine.unnamed'))
const rowIdText = computed(() => String(props.rowId || '').trim()) const rowIdText = computed(() => String(props.rowId || '').trim())
const contractIdText = computed(() => String(props.contractId || '').trim()) const contractIdText = computed(() => String(props.contractId || '').trim())
const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-') const contractNameText = computed(() => String(props.contractName || '').trim() || contractIdText.value || '-')
const titleText = computed(() => `合同段:${contractNameText.value} · ${rowNameText.value || sourceTitleText.value}`) const titleText = computed(() => t('htFeeMethodTypeLine.title', {
contractName: contractNameText.value,
rowName: rowNameText.value || sourceTitleText.value
}))
const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${rowIdText.value}`) const activeTypeStorageKey = computed(() => `ht-fee-type-active-cat-${props.storageKey}-${rowIdText.value}`)
const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') => const buildMethodStorageKey = (method: 'rate-fee' | 'hourly-fee' | 'quantity-unit-price-fee') =>
`${props.storageKey}-${rowIdText.value}-${method}` `${props.storageKey}-${rowIdText.value}-${method}`
@ -48,7 +53,7 @@ const quantityUnitPricePane = markRaw(
const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee')) const quantityStorageKey = computed(() => buildMethodStorageKey('quantity-unit-price-fee'))
return () => return () =>
h(HtFeeGrid, { h(HtFeeGrid, {
title: '数量单价', title: t('htFeeMethodTypeLine.quantityUnitPrice'),
storageKey: quantityStorageKey.value, storageKey: quantityStorageKey.value,
htMainStorageKey: props.storageKey, htMainStorageKey: props.storageKey,
htRowId: rowIdText.value, htRowId: rowIdText.value,
@ -82,7 +87,7 @@ const hourlyFeePane = markRaw(
const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee')) const hourlyStorageKey = computed(() => buildMethodStorageKey('hourly-fee'))
return () => return () =>
h(HourlyFeeGrid, { h(HourlyFeeGrid, {
title: '工时法明细', title: t('hourlyFeeGrid.title'),
storageKey: hourlyStorageKey.value, storageKey: hourlyStorageKey.value,
htMainStorageKey: props.storageKey, htMainStorageKey: props.storageKey,
htRowId: rowIdText.value, htRowId: rowIdText.value,
@ -92,7 +97,7 @@ const hourlyFeePane = markRaw(
}) })
) )
const isReserveFee = computed(() => props.sourceTitle === '预备费') const isReserveFee = computed(() => props.sourceTitle === t('htSummary.reservePrefix'))
const showWorkContent = computed(() => { const showWorkContent = computed(() => {
if (isReserveFee.value) return false if (isReserveFee.value) return false
return true return true
@ -109,7 +114,7 @@ const workContentPane = markRaw(
} }
}) })
return () => h(AsyncWorkContentGrid, { return () => h(AsyncWorkContentGrid, {
title: '工作内容', title: t('workContent.title'),
storageKey: `work-content-${props.storageKey}-${rowIdText.value}`, storageKey: `work-content-${props.storageKey}-${rowIdText.value}`,
dictMode: 'additional' dictMode: 'additional'
}) })
@ -119,12 +124,12 @@ const workContentPane = markRaw(
const categories = computed<TypeLineCategoryItem[]>(() => { const categories = computed<TypeLineCategoryItem[]>(() => {
const base: TypeLineCategoryItem[] = [ const base: TypeLineCategoryItem[] = [
{ key: 'rate-fee', label: '费率计取', component: rateFeePane }, { key: 'rate-fee', label: t('htFeeGrid.columns.rateFee'), component: rateFeePane },
{ key: 'hourly-fee', label: '工时法', component: hourlyFeePane }, { key: 'hourly-fee', label: t('htFeeGrid.columns.hourlyFee'), component: hourlyFeePane },
{ key: 'quantity-unit-price-fee', label: '数量单价', component: quantityUnitPricePane }, { key: 'quantity-unit-price-fee', label: t('htFeeGrid.columns.quantityUnitPriceFee'), component: quantityUnitPricePane },
] ]
if (showWorkContent.value) { if (showWorkContent.value) {
base.push({ key: 'work-content', label: '工作内容', component: workContentPane }) base.push({ key: 'work-content', label: t('workContent.title'), component: workContentPane })
} }
return base return base
}) })

View File

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref, watch } from 'vue' import { computed, onActivated, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, ChevronDown, Circle, CircleDot } from 'lucide-vue-next' import { Check, ChevronDown, Circle, CircleDot } from 'lucide-vue-next'
import { import {
getQuickCalcGroups, getQuickCalcGroups,
getMajorDictItemById, getMajorDictItemById,
getServiceDictItemById, getServiceDictItemById,
getIndustryDisplayName,
industryTypeList industryTypeList
} from '@/sql' } from '@/sql'
import { roundTo } from '@/lib/decimal' import { roundTo } from '@/lib/decimal'
@ -53,6 +55,7 @@ type DictFactorItem = {
type QuickCalcScaleMode = 'cost' | 'area' type QuickCalcScaleMode = 'cost' | 'area'
const kvStore = useKvStore() const kvStore = useKvStore()
const { t, locale } = useI18n()
const consultFactorMap = ref<Map<string, number | null>>(new Map()) const consultFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map()) const majorFactorMap = ref<Map<string, number | null>>(new Map())
const quickCalcGroups = getQuickCalcGroups() const quickCalcGroups = getQuickCalcGroups()
@ -132,8 +135,9 @@ const visibleGroups = computed(() => {
}) })
const industryLabel = computed(() => { const industryLabel = computed(() => {
const current = industryTypeList.find(item => item.id === projectIndustry.value.trim()) const target = projectIndustry.value.trim()
return current?.name || '未选择' if (!target) return t('quickCalc.notSelected')
return getIndustryDisplayName(target, locale.value) || t('quickCalc.notSelected')
}) })
const selectedConsultOption = computed(() => const selectedConsultOption = computed(() =>
@ -211,20 +215,20 @@ const canUseLandScale = computed(() =>
majorSupportsLandScale.value majorSupportsLandScale.value
) )
const investScalePlaceholder = computed(() => { const investScalePlaceholder = computed(() => {
if (!selectedConsultLabel.value) return '请先选择咨询类别' if (!selectedConsultLabel.value) return t('quickCalc.placeholder.selectConsultFirst')
if (!consultSupportsScale.value) return '当前分类不适用规模法' if (!consultSupportsScale.value) return t('quickCalc.placeholder.scaleUnavailable')
if (!hasResolvedMajor.value) return '请先选择工程专业' if (!hasResolvedMajor.value) return t('quickCalc.placeholder.selectMajorFirst')
if (preferLandScaleForDualMajor.value) return '当前专业按用地规模计价' if (preferLandScaleForDualMajor.value) return t('quickCalc.placeholder.preferLandScale')
if (!consultOnlySupportsCostScale.value && !majorSupportsCostScale.value) return '当前专业不适用投资规模' if (!consultOnlySupportsCostScale.value && !majorSupportsCostScale.value) return t('quickCalc.placeholder.investUnavailable')
return '请输入' return t('quickCalc.placeholder.input')
}) })
const landScalePlaceholder = computed(() => { const landScalePlaceholder = computed(() => {
if (!selectedConsultLabel.value) return '请先选择咨询类别' if (!selectedConsultLabel.value) return t('quickCalc.placeholder.selectConsultFirst')
if (!consultSupportsScale.value) return '当前分类不适用规模法' if (!consultSupportsScale.value) return t('quickCalc.placeholder.scaleUnavailable')
if (!hasResolvedMajor.value) return '请先选择工程专业' if (!hasResolvedMajor.value) return t('quickCalc.placeholder.selectMajorFirst')
if (consultOnlySupportsCostScale.value) return '当前分类仅支持投资规模' if (consultOnlySupportsCostScale.value) return t('quickCalc.placeholder.consultCostOnly')
if (!majorSupportsLandScale.value) return '当前专业不适用用地规模' if (!majorSupportsLandScale.value) return t('quickCalc.placeholder.landUnavailable')
return '请输入' return t('quickCalc.placeholder.input')
}) })
const activeScaleMode = computed<QuickCalcScaleMode | null>(() => { const activeScaleMode = computed<QuickCalcScaleMode | null>(() => {
const hasInvestValue = investScale.value.trim() !== '' const hasInvestValue = investScale.value.trim() !== ''
@ -277,19 +281,19 @@ const scaleBudgetPreview = computed(() => {
}) })
const formulaText = computed(() => { const formulaText = computed(() => {
const preview = scaleBudgetPreview.value const preview = scaleBudgetPreview.value
if (!preview) return '请先选择输入对应规模' if (!preview) return t('quickCalc.placeholder.selectScaleFirst')
const parts = [preview.basicFormula, preview.optionalFormula].filter(Boolean) const parts = [preview.basicFormula, preview.optionalFormula].filter(Boolean)
return parts.join(' + ') || '--' return parts.join(' + ') || '--'
}) })
const benchmarkAmountText = computed(() => { const benchmarkAmountText = computed(() => {
const total = scaleBudgetPreview.value?.benchmarkBudget const total = scaleBudgetPreview.value?.benchmarkBudget
if (total == null) return '--' if (total == null) return '--'
return total.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return total.toLocaleString(locale.value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}) })
const computedBudgetAmount = computed(() => { const computedBudgetAmount = computed(() => {
const total = scaleBudgetPreview.value?.budgetFeeTotal const total = scaleBudgetPreview.value?.budgetFeeTotal
if (total == null) return '--' if (total == null) return '--'
return total.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return total.toLocaleString(locale.value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}) })
const applyScaleInput = (field: 'invest' | 'land') => { const applyScaleInput = (field: 'invest' | 'land') => {
@ -360,7 +364,7 @@ const persistQuickIndustry = async (industry: string) => {
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, { await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
...currentInfo, ...currentInfo,
projectIndustry: industry, projectIndustry: industry,
projectName: '快速计算' projectName: t('quickCalc.projectName')
}) })
} }
@ -435,22 +439,22 @@ watch(canUseLandScale, enabled => {
<div class="quick-calc-layout"> <div class="quick-calc-layout">
<section class="quick-calc-panel quick-calc-panel--catalog"> <section class="quick-calc-panel quick-calc-panel--catalog">
<header class="quick-calc-panel__header"> <header class="quick-calc-panel__header">
<div class="quick-calc-panel__title-wrap"> <div class="quick-calc-panel__title-wrap">
<div class="quick-calc-panel__eyebrow">分类清单</div> <div class="quick-calc-panel__eyebrow">{{ t('quickCalc.catalogEyebrow') }}</div>
<h2 class="quick-calc-panel__title">快速计算选项</h2> <h2 class="quick-calc-panel__title">{{ t('quickCalc.catalogTitle') }}</h2>
</div> </div>
<div class="quick-calc-status"> <div class="quick-calc-status">
<span class="quick-calc-status__item">行业 {{ industryLabel }}</span> <span class="quick-calc-status__item">{{ t('quickCalc.industryLabel', { name: industryLabel }) }}</span>
</div> </div>
</header> </header>
<div class="quick-calc-toolbar"> <div class="quick-calc-toolbar">
<label class="quick-calc-toolbar__field"> <label class="quick-calc-toolbar__field">
<span class="quick-calc-field__label">工程行业</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.industry') }}</span>
<SelectRoot v-model="projectIndustry"> <SelectRoot v-model="projectIndustry">
<SelectTrigger class="quick-calc-toolbar__trigger"> <SelectTrigger class="quick-calc-toolbar__trigger">
<SelectValue placeholder="请选择工程行业" /> <SelectValue :placeholder="t('quickCalc.selectIndustry')" />
<SelectIcon as-child> <SelectIcon as-child>
<ChevronDown class="h-4 w-4 text-[var(--qc-muted)]" /> <ChevronDown class="h-4 w-4 text-[var(--qc-muted)]" />
</SelectIcon> </SelectIcon>
@ -468,7 +472,7 @@ watch(canUseLandScale, enabled => {
:value="String(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" 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> <SelectItemText>{{ getIndustryDisplayName(item.id, locale) }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700"> <SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
<Check class="h-4 w-4" /> <Check class="h-4 w-4" />
</SelectItemIndicator> </SelectItemIndicator>
@ -480,24 +484,24 @@ watch(canUseLandScale, enabled => {
</label> </label>
<span class="quick-calc-toolbar__meta"> <span class="quick-calc-toolbar__meta">
{{ industrySaving ? '保存中...' : hasSelectedIndustry ? '已同步行业' : '未选择行业' }} {{ industrySaving ? t('quickCalc.saving') : hasSelectedIndustry ? t('quickCalc.synced') : t('quickCalc.notSelectedIndustry') }}
</span> </span>
</div> </div>
<div v-if="!hasSelectedIndustry" class="quick-calc-empty-state"> <div v-if="!hasSelectedIndustry" class="quick-calc-empty-state">
请选择工程行业选中后可先选择咨询类别再显示对应专业分类 {{ t('quickCalc.empty.selectIndustry') }}
</div> </div>
<div v-else-if="!hasSelectedConsult" class="quick-calc-empty-state"> <div v-else-if="!hasSelectedConsult" class="quick-calc-empty-state">
请先选择咨询类别选中后才会显示匹配的通用专业和工程专业分类 {{ t('quickCalc.empty.selectConsult') }}
</div> </div>
<div v-else-if="!consultSupportsScale" class="quick-calc-empty-state"> <div v-else-if="!consultSupportsScale" class="quick-calc-empty-state">
当前咨询类别不适用规模法因此不显示专业分类 {{ t('quickCalc.empty.scaleUnavailable') }}
</div> </div>
<div v-else-if="consultOnlySupportsCostScale" class="quick-calc-empty-state"> <div v-else-if="consultOnlySupportsCostScale" class="quick-calc-empty-state">
当前咨询类别按行业汇总计价工程专业系数已按所选行业自动带入不再显示内部互补专业行 {{ t('quickCalc.empty.consultCostOnly') }}
</div> </div>
<div class="quick-calc-catalog"> <div class="quick-calc-catalog">
@ -511,7 +515,7 @@ watch(canUseLandScale, enabled => {
}" }"
> >
<div class="quick-calc-group__side"> <div class="quick-calc-group__side">
<div class="quick-calc-group__eyebrow">{{ group.key === 'consult' ? '咨询类别' : '工程专业' }}</div> <div class="quick-calc-group__eyebrow">{{ group.key === 'consult' ? t('quickCalc.consultCategory') : t('quickCalc.majorCategory') }}</div>
<h3 class="quick-calc-group__title">{{ group.label }}</h3> <h3 class="quick-calc-group__title">{{ group.label }}</h3>
</div> </div>
@ -554,9 +558,9 @@ watch(canUseLandScale, enabled => {
<aside class="quick-calc-panel quick-calc-panel--form"> <aside class="quick-calc-panel quick-calc-panel--form">
<header class="quick-calc-panel__header"> <header class="quick-calc-panel__header">
<div class="quick-calc-panel__title-wrap"> <div class="quick-calc-panel__title-wrap">
<div class="quick-calc-panel__eyebrow">参数表单</div> <div class="quick-calc-panel__eyebrow">{{ t('quickCalc.formEyebrow') }}</div>
<h2 class="quick-calc-panel__title">计算参数</h2> <h2 class="quick-calc-panel__title">{{ t('quickCalc.formTitle') }}</h2>
</div> </div>
@ -566,18 +570,18 @@ watch(canUseLandScale, enabled => {
<div class="quick-calc-form-stack"> <div class="quick-calc-form-stack">
<section class="quick-calc-form-section quick-calc-form-section--summary"> <section class="quick-calc-form-section quick-calc-form-section--summary">
<header class="quick-calc-form-section__header"> <header class="quick-calc-form-section__header">
<div class="quick-calc-form-section__eyebrow">当前选项</div> <div class="quick-calc-form-section__eyebrow">{{ t('quickCalc.sections.currentSelection') }}</div>
<h3 class="quick-calc-form-section__title">基础信息</h3> <h3 class="quick-calc-form-section__title">{{ t('quickCalc.sections.basicInfo') }}</h3>
</header> </header>
<div class="quick-calc-form-grid quick-calc-form-grid--summary"> <div class="quick-calc-form-grid quick-calc-form-grid--summary">
<label class="quick-calc-field"> <label class="quick-calc-field">
<span class="quick-calc-field__label">咨询类别</span> <span class="quick-calc-field__label">{{ t('quickCalc.consultCategory') }}</span>
<div class="quick-calc-field__readonly">{{ selectedConsultLabel || '未选择' }}</div> <div class="quick-calc-field__readonly">{{ selectedConsultLabel || t('quickCalc.notSelected') }}</div>
</label> </label>
<label class="quick-calc-field"> <label class="quick-calc-field">
<span class="quick-calc-field__label">编码</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.code') }}</span>
<div class="quick-calc-field__readonly">{{ selectedConsultCode || '--' }}</div> <div class="quick-calc-field__readonly">{{ selectedConsultCode || '--' }}</div>
</label> </label>
</div> </div>
@ -585,13 +589,13 @@ watch(canUseLandScale, enabled => {
<section class="quick-calc-form-section"> <section class="quick-calc-form-section">
<header class="quick-calc-form-section__header"> <header class="quick-calc-form-section__header">
<div class="quick-calc-form-section__eyebrow">计算基数</div> <div class="quick-calc-form-section__eyebrow">{{ t('quickCalc.sections.scaleBase') }}</div>
<h3 class="quick-calc-form-section__title">规模参数</h3> <h3 class="quick-calc-form-section__title">{{ t('quickCalc.sections.scaleParams') }}</h3>
</header> </header>
<div class="quick-calc-form-grid"> <div class="quick-calc-form-grid">
<label class="quick-calc-field"> <label class="quick-calc-field">
<span class="quick-calc-field__label">投资规模万元</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.investScale') }}</span>
<input <input
v-model="investScale" v-model="investScale"
type="text" type="text"
@ -606,7 +610,7 @@ watch(canUseLandScale, enabled => {
</label> </label>
<label class="quick-calc-field"> <label class="quick-calc-field">
<span class="quick-calc-field__label">用地规模</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.landScale') }}</span>
<input <input
v-model="landScale" v-model="landScale"
type="text" type="text"
@ -624,18 +628,18 @@ watch(canUseLandScale, enabled => {
<section class="quick-calc-form-section"> <section class="quick-calc-form-section">
<header class="quick-calc-form-section__header"> <header class="quick-calc-form-section__header">
<div class="quick-calc-form-section__eyebrow">基准预算</div> <div class="quick-calc-form-section__eyebrow">{{ t('quickCalc.sections.benchmarkBudget') }}</div>
<h3 class="quick-calc-form-section__title">预算基础值</h3> <h3 class="quick-calc-form-section__title">{{ t('quickCalc.sections.budgetBase') }}</h3>
</header> </header>
<div class="quick-calc-form-grid"> <div class="quick-calc-form-grid">
<label class="quick-calc-field quick-calc-field--wide"> <label class="quick-calc-field quick-calc-field--wide">
<span class="quick-calc-field__label">计算式</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.formula') }}</span>
<div class="quick-calc-field__readonly quick-calc-field__readonly--multiline">{{ formulaText }}</div> <div class="quick-calc-field__readonly quick-calc-field__readonly--multiline">{{ formulaText }}</div>
</label> </label>
<label class="quick-calc-field"> <label class="quick-calc-field">
<span class="quick-calc-field__label">金额</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.amount') }}</span>
<div class="quick-calc-field__readonly">{{ benchmarkAmountText }}</div> <div class="quick-calc-field__readonly">{{ benchmarkAmountText }}</div>
</label> </label>
</div> </div>
@ -643,38 +647,38 @@ watch(canUseLandScale, enabled => {
<section class="quick-calc-form-section"> <section class="quick-calc-form-section">
<header class="quick-calc-form-section__header"> <header class="quick-calc-form-section__header">
<div class="quick-calc-form-section__eyebrow">服务预算</div> <div class="quick-calc-form-section__eyebrow">{{ t('quickCalc.sections.serviceBudget') }}</div>
<h3 class="quick-calc-form-section__title">系数与结果</h3> <h3 class="quick-calc-form-section__title">{{ t('quickCalc.sections.factorsAndResult') }}</h3>
</header> </header>
<div class="quick-calc-form-grid"> <div class="quick-calc-form-grid">
<label class="quick-calc-field"> <label class="quick-calc-field">
<span class="quick-calc-field__label">咨询分类系数</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.consultFactor') }}</span>
<div class="quick-calc-field__readonly"> <div class="quick-calc-field__readonly">
{{ formatFactorValue(consultCategoryFactor) }} {{ formatFactorValue(consultCategoryFactor) }}
</div> </div>
</label> </label>
<label class="quick-calc-field"> <label class="quick-calc-field">
<span class="quick-calc-field__label">工程专业系数</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.majorFactor') }}</span>
<div class="quick-calc-field__readonly"> <div class="quick-calc-field__readonly">
{{ formatFactorValue(engineeringMajorFactor) }} {{ formatFactorValue(engineeringMajorFactor) }}
</div> </div>
</label> </label>
<div class="quick-calc-field"> <div class="quick-calc-field">
<span class="quick-calc-field__label">工作环境系数</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.workEnvFactor') }}</span>
<input <input
v-model="workEnvFactor" v-model="workEnvFactor"
class="quick-calc-field__input" class="quick-calc-field__input"
placeholder="默认 1" :placeholder="t('quickCalc.fields.workEnvFactorPlaceholder')"
@blur="applyWorkEnvFactorInput" @blur="applyWorkEnvFactorInput"
@keydown.enter.prevent="applyWorkEnvFactorInput" @keydown.enter.prevent="applyWorkEnvFactorInput"
> >
</div> </div>
<label class="quick-calc-field"> <label class="quick-calc-field">
<span class="quick-calc-field__label">预算金额</span> <span class="quick-calc-field__label">{{ t('quickCalc.fields.budgetAmount') }}</span>
<div class="quick-calc-field__readonly quick-calc-field__readonly--emphasis"> <div class="quick-calc-field__readonly quick-calc-field__readonly--emphasis">
{{ computedBudgetAmount || '--' }} {{ computedBudgetAmount || '--' }}
</div> </div>

View File

@ -1,8 +1,8 @@
<template> <template>
<TypeLine <TypeLine
scene="zxfw-pricing-tab" scene="zxfw-pricing-tab"
:title="`${contractName ? `合同段:${contractName} · ` : ''}${fwName}计算`" :title="`${contractName ? `${t('zxFwView.contractPrefix', { name: contractName })} · ` : ''}${fwName}${t('zxFwView.calcSuffix')}`"
:subtitle="`合同ID${contractId}`" :subtitle="t('zxFwView.contractId', { id: contractId })"
:copy-text="contractId" :copy-text="contractId"
:storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`" :storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`"
:default-category="defaultCategory" :default-category="defaultCategory"
@ -12,6 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue' import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue' import TypeLine from '@/layout/typeLine.vue'
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue' import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
@ -30,6 +31,7 @@ const props = defineProps<{
type?: ServiceMethodType type?: ServiceMethodType
projectInfoKey?: string projectInfoKey?: string
}>() }>()
const { t } = useI18n()
interface PricingCategoryItem { interface PricingCategoryItem {
key: string key: string
@ -61,7 +63,7 @@ const createPricingPane = (name: string) =>
const AsyncPricingView = defineAsyncComponent({ const AsyncPricingView = defineAsyncComponent({
loader: () => import(`@/features/pricing/components/${name}.vue`), loader: () => import(`@/features/pricing/components/${name}.vue`),
onError: err => { onError: err => {
console.error('加载 PricingMethodView 组件失败:', err) console.error('load PricingMethodView failed:', err)
} }
}) })
@ -96,12 +98,12 @@ const workContentPane = markRaw(
const AsyncWorkContentGrid = defineAsyncComponent({ const AsyncWorkContentGrid = defineAsyncComponent({
loader: () => import('@/features/shared/components/WorkContentGrid.vue'), loader: () => import('@/features/shared/components/WorkContentGrid.vue'),
onError: err => { onError: err => {
console.error('加载 WorkContentGrid 组件失败:', err) console.error('load WorkContentGrid failed:', err)
} }
}) })
return () => h(AsyncWorkContentGrid, { return () => h(AsyncWorkContentGrid, {
title: '工作内容', title: t('zxFwView.workContentTitle'),
storageKey: `work-content-${props.contractId}-${props.serviceId}`, storageKey: `work-content-${props.contractId}-${props.serviceId}`,
contractId: props.contractId, contractId: props.contractId,
projectInfoKey: props.projectInfoKey, projectInfoKey: props.projectInfoKey,
@ -113,47 +115,47 @@ const workContentPane = markRaw(
) )
const investmentScaleUnavailableView = createMethodUnavailablePane( const investmentScaleUnavailableView = createMethodUnavailablePane(
'该服务不适用投资规模法', t('zxFwView.unavailable.investmentScaleTitle'),
'当前服务未启用规模法,投资规模法不可编辑。' t('zxFwView.unavailable.investmentScaleMessage')
) )
const landScaleUnavailableView = createMethodUnavailablePane( const landScaleUnavailableView = createMethodUnavailablePane(
'该服务不适用用地规模法', t('zxFwView.unavailable.landScaleTitle'),
'当前服务仅支持投资规模法,用地规模法不可编辑。' t('zxFwView.unavailable.landScaleMessage')
) )
const workloadUnavailableView = createMethodUnavailablePane( const workloadUnavailableView = createMethodUnavailablePane(
'该服务不适用工作量法', t('zxFwView.unavailable.workloadTitle'),
'当前服务未启用工作量法,工作量法不可编辑。' t('zxFwView.unavailable.workloadMessage')
) )
const hourlyUnavailableView = createMethodUnavailablePane( const hourlyUnavailableView = createMethodUnavailablePane(
'该服务不适用工时法', t('zxFwView.unavailable.hourlyTitle'),
'当前服务未启用工时法,工时法不可编辑。' t('zxFwView.unavailable.hourlyMessage')
) )
const pricingCategories = computed<PricingCategoryItem[]>(() => [ const pricingCategories = computed<PricingCategoryItem[]>(() => [
{ {
key: 'investment-scale-method', key: 'investment-scale-method',
label: '投资规模法', label: t('zxFwView.categories.investmentScale'),
component: methodAvailability.value.investmentScale ? investmentScaleView : investmentScaleUnavailableView component: methodAvailability.value.investmentScale ? investmentScaleView : investmentScaleUnavailableView
}, },
{ {
key: 'land-scale-method', key: 'land-scale-method',
label: '用地规模法', label: t('zxFwView.categories.landScale'),
component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView
}, },
{ {
key: 'workload-method', key: 'workload-method',
label: '工作量法', label: t('zxFwView.categories.workload'),
component: methodAvailability.value.workload ? workloadView : workloadUnavailableView component: methodAvailability.value.workload ? workloadView : workloadUnavailableView
}, },
{ {
key: 'hourly-method', key: 'hourly-method',
label: '工时法', label: t('zxFwView.categories.hourly'),
component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView
}, },
{ {
key: 'work-content', key: 'work-content',
label: '工作内容', label: t('zxFwView.categories.workContent'),
component: workContentPane component: workContentPane
}, },
]) ])

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue' import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql' import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue' import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
@ -19,6 +20,7 @@ type ServiceItem = {
const PROJECT_INFO_KEY = 'xm-base-info-v1' const PROJECT_INFO_KEY = 'xm-base-info-v1'
const projectIndustry = ref('') const projectIndustry = ref('')
const kvStore = useKvStore() const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => { const loadProjectIndustry = async () => {
try { try {
@ -51,7 +53,7 @@ onActivated(() => {
</script> </script>
<template> <template>
<XmFactorGrid title="咨询分类系数明细" storage-key="xm-consult-category-factor-v1" :dict="filteredServiceDict" <XmFactorGrid :title="t('htFactors.consultCategoryTitle')" storage-key="xm-consult-category-factor-v1" :dict="filteredServiceDict"
:disable-budget-edit-when-standard-null="true" :exclude-notshow-by-zxflxs="true" :disable-budget-edit-when-standard-null="true" :exclude-notshow-by-zxflxs="true"
:init-budget-value-from-standard="true" /> :init-budget-value-from-standard="true" />
</template> </template>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue' import { computed, onActivated, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue' import XmFactorGrid from '@/features/shared/components/XmFactorGrid.vue'
import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue' import MethodUnavailableNotice from '@/features/shared/components/MethodUnavailableNotice.vue'
@ -21,6 +22,7 @@ const PROJECT_INFO_KEY = 'xm-base-info-v1'
const projectIndustry = ref('') const projectIndustry = ref('')
const hasProjectBaseInfo = ref(false) const hasProjectBaseInfo = ref(false)
const kvStore = useKvStore() const kvStore = useKvStore()
const { t } = useI18n()
const loadProjectIndustry = async () => { const loadProjectIndustry = async () => {
try { try {
@ -56,7 +58,7 @@ onActivated(() => {
<template> <template>
<XmFactorGrid <XmFactorGrid
title="工程专业系数明细" :title="t('htFactors.majorTitle')"
storage-key="xm-major-factor-v1" storage-key="xm-major-factor-v1"
:dict="filteredMajorDict" :dict="filteredMajorDict"
:disable-budget-edit-when-standard-null="true" :disable-budget-edit-when-standard-null="true"

View File

@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { parseDate } from '@internationalized/date' import { parseDate } from '@internationalized/date'
import { onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { import {
getIndustryDisplayName,
industryTypeList, industryTypeList,
} from '@/sql' } from '@/sql'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
@ -44,9 +46,10 @@ interface XmInfoState {
type MajorParentNode = { id: string; name: string } type MajorParentNode = { id: string; name: string }
const DB_KEY = 'xm-base-info-v1' const DB_KEY = 'xm-base-info-v1'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务' const { t, locale } = useI18n()
const DEFAULT_DESC = '在履行造价咨询服务时宜根据咨询服务质量情况分级确定相应的处罚金额。其中考评得分在大于及等于85和小于90分时处罚金额为预算费用的10%其中考评得分在大于及等于80和小于85分时处罚金额为预算费用的20%其中考评得分在大于及等于75和小于80分时处罚金额为预算费用的30%其中考评得分在大于及等于70和小于75分时处罚金额为预算费用的40%其中考评得分小于70分时处罚金额为预算费用的50%以上。' const DEFAULT_PROJECT_NAME = t('xmInfo.defaultProjectName')
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择' const DEFAULT_DESC = t('xmInfo.defaultDesc')
const INDUSTRY_HINT_TEXT = computed(() => t('xmInfo.industryHint'))
const getTodayDateString = () => { const getTodayDateString = () => {
const now = new Date() const now = new Date()
const year = String(now.getFullYear()) const year = String(now.getFullYear())
@ -97,12 +100,14 @@ const handlePreparedDateSelect = (date: any) => {
preparedDate.value = date?.toString() ?? '' preparedDate.value = date?.toString() ?? ''
} }
const majorParentNodes: MajorParentNode[] = industryTypeList.map(item => ({ const majorParentNodes = computed<MajorParentNode[]>(() =>
id: item.id, industryTypeList.map(item => ({
name: item.name id: item.id,
})) name: getIndustryDisplayName(item.id, locale.value) || String(item.name || '')
const majorParentCodeSet = new Set(majorParentNodes.map(item => item.id)) }))
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.id || '' )
const majorParentCodeSet = new Set<string>(industryTypeList.map(item => String(item.id)))
const DEFAULT_PROJECT_INDUSTRY = String(industryTypeList[0]?.id || '')
const kvStore = useKvStore() const kvStore = useKvStore()
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
@ -200,18 +205,18 @@ onMounted(async () => {
v-if="!isProjectInitialized" v-if="!isProjectInitialized"
class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground" class="rounded-xl border bg-card p-10 h-full flex items-center justify-center text-sm text-muted-foreground"
> >
请从首页先新建项目后再进入此页面 {{ t('xmInfo.createFromHomeFirst') }}
</div> </div>
<div v-else class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5"> <div v-else class="rounded-xl border bg-card p-4 shadow-sm shrink-0 md:p-5">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="md:col-span-2 xl:col-span-4"> <div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">项目名称</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.projectName') }}</label>
<input <input
v-model="projectName" v-model="projectName"
type="text" type="text"
required required
placeholder="xxx造价咨询服务" :placeholder="t('xmInfo.defaultProjectName')"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring" class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
@blur="handleProjectNameBlur" @blur="handleProjectNameBlur"
/> />
@ -219,12 +224,12 @@ onMounted(async () => {
<div class="md:col-span-2 xl:col-span-4"> <div class="md:col-span-2 xl:col-span-4">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<label class="block text-sm font-medium text-foreground">工程行业</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.projectIndustry') }}</label>
<TooltipRoot> <TooltipRoot>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
type="button" type="button"
aria-label="工程行业提示" :aria-label="t('xmInfo.industryHintAria')"
class="inline-flex h-5 w-5 items-center justify-center text-muted-foreground/90 transition hover:text-foreground" class="inline-flex h-5 w-5 items-center justify-center text-muted-foreground/90 transition hover:text-foreground"
> >
<CircleHelp class="h-5 w-5" /> <CircleHelp class="h-5 w-5" />
@ -251,46 +256,46 @@ onMounted(async () => {
</div> </div>
</div> </div>
<div class="md:col-span-2 xl:col-span-4"> <div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">项目概况</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.overview') }}</label>
<textarea <textarea
v-model="overview" v-model="overview"
rows="3" rows="3"
placeholder="请输入项目概况" :placeholder="t('xmInfo.placeholders.overview')"
class="mt-2 w-full rounded-lg border bg-background px-4 py-2 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring resize-none" class="mt-2 w-full rounded-lg border bg-background px-4 py-2 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring resize-none"
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-foreground">编制人</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedBy') }}</label>
<input <input
v-model="preparedBy" v-model="preparedBy"
type="text" type="text"
placeholder="请输入编制人" :placeholder="t('xmInfo.placeholders.preparedBy')"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring" class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-foreground">复核人</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.reviewedBy') }}</label>
<input <input
v-model="reviewedBy" v-model="reviewedBy"
type="text" type="text"
placeholder="请输入复核人" :placeholder="t('xmInfo.placeholders.reviewedBy')"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring" class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-foreground">编制单位</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedCompany') }}</label>
<input <input
v-model="preparedCompany" v-model="preparedCompany"
type="text" type="text"
placeholder="请输入编制单位" :placeholder="t('xmInfo.placeholders.preparedCompany')"
class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring" class="mt-2 h-10 w-full rounded-lg border bg-background px-4 text-sm outline-none ring-offset-background shadow-sm transition placeholder:text-muted-foreground/70 focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-ring"
/> />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-foreground">编制日期</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.preparedDate') }}</label>
<DatePickerRoot <DatePickerRoot
locale="en-CA" locale="en-CA"
:model-value="preparedDatePickerValue" :model-value="preparedDatePickerValue"
@ -386,12 +391,12 @@ onMounted(async () => {
</div> </div>
</DatePickerCalendar> </DatePickerCalendar>
<div class="mt-2 flex justify-end"> <div class="mt-2 flex justify-end">
<DatePickerClose as-child> <DatePickerClose as-child>
<button <button
type="button" type="button"
class="cursor-pointer h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted mr-2" class="cursor-pointer h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted mr-2"
> >
确认 {{ t('common.confirm') }}
</button> </button>
</DatePickerClose> </DatePickerClose>
@ -402,7 +407,7 @@ onMounted(async () => {
<div class="md:col-span-2 xl:col-span-4"> <div class="md:col-span-2 xl:col-span-4">
<label class="block text-sm font-medium text-foreground">其他说明</label> <label class="block text-sm font-medium text-foreground">{{ t('xmInfo.fields.desc') }}</label>
<textarea <textarea
v-model="desc" v-model="desc"
rows="4" rows="4"

View File

@ -9,8 +9,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { defineAsyncComponent, markRaw } from 'vue' import { computed, defineAsyncComponent, markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeLine from '@/layout/typeLine.vue' import TypeLine from '@/layout/typeLine.vue'
const { t } = useI18n()
const infoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/info.vue'))) const infoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/info.vue')))
const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmInfo.vue'))) const scaleInfoView = markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmInfo.vue')))
const htView = markRaw(defineAsyncComponent(() => import('@/features/ht/components/Ht.vue'))) const htView = markRaw(defineAsyncComponent(() => import('@/features/ht/components/Ht.vue')))
@ -21,11 +23,11 @@ const majorFactorView = markRaw(
defineAsyncComponent(() => import('@/features/xm/components/XmMajorFactor.vue')) defineAsyncComponent(() => import('@/features/xm/components/XmMajorFactor.vue'))
) )
const xmCategories = [ const xmCategories = computed(() => [
{ key: 'info', label: '基础信息', component: infoView }, { key: 'info', label: t('xmCard.categories.info'), component: infoView },
{ key: 'scale-info', label: '规模信息', component: scaleInfoView }, { key: 'scale-info', label: t('xmCard.categories.scaleInfo'), component: scaleInfoView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView }, { key: 'consult-category-factor', label: t('xmCard.categories.consultCategoryFactor'), component: consultCategoryFactorView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }, { key: 'major-factor', label: t('xmCard.categories.majorFactor'), component: majorFactorView },
{ key: 'contract', label: '合同段管理', component: htView } { key: 'contract', label: t('xmCard.categories.contract'), component: htView }
] ])
</script> </script>

View File

@ -3,7 +3,8 @@ export const enUS = {
cancel: 'Cancel', cancel: 'Cancel',
confirm: 'Confirm', confirm: 'Confirm',
delete: 'Delete', delete: 'Delete',
close: 'Close' close: 'Close',
clear: 'Clear'
}, },
app: { app: {
projectConflict: { projectConflict: {
@ -19,6 +20,7 @@ export const enUS = {
home: { home: {
title: 'Calculation Entry', title: 'Calculation Entry',
subtitle: 'Project Budget · Quick Calc · Import Data', subtitle: 'Project Budget · Quick Calc · Import Data',
projectCalcTab: 'Project Calculation',
cards: { cards: {
heroTitle: 'One-Click Smart Budget', heroTitle: 'One-Click Smart Budget',
heroSubTitle: 'Accelerate standards adoption', heroSubTitle: 'Accelerate standards adoption',
@ -26,7 +28,7 @@ export const enUS = {
projectBudget: 'Project Budget', projectBudget: 'Project Budget',
projectBudgetDesc: 'For full project-level calculation across multiple contracts with import/export support', projectBudgetDesc: 'For full project-level calculation across multiple contracts with import/export support',
quickCalc: 'Quick Calc', quickCalc: 'Quick Calc',
quickCalcDesc: 'Pick industry and consulting type, input scale values, and get results instantly', quickCalcDesc: 'Suitable for single service trial, select industry, consultation type, engineering specialty, input base number and get results in seconds',
importData: 'Import Data', importData: 'Import Data',
importDataDesc: 'Import ".zw" package to restore project state and continue work quickly', importDataDesc: 'Import ".zw" package to restore project state and continue work quickly',
enter: 'Enter', enter: 'Enter',
@ -92,13 +94,553 @@ export const enUS = {
later: 'Later', later: 'Later',
prev: 'Prev', prev: 'Prev',
next: 'Next', next: 'Next',
finish: 'Finish and Disable Auto Popup' finish: 'Finish and Disable Auto Popup',
jumpToStep: 'Jump to step {index}',
steps: {
step1: {
title: 'Welcome',
description: 'This guide covers major features and the full workflow. It is recommended to go through it in order.',
point1: 'The top area is the tab bar, for quick switching between project, segment, and pricing pages.',
point2: 'Tables and forms on the page auto-save locally. No manual save is needed.',
point3: 'You can reopen this tutorial anytime from the top-right "Guide" button.'
},
step2: {
title: 'Project Card and Four Modules',
description: 'The default "Project Card" tab is the entry. The left flow line contains four project-level modules.',
point1: 'Basic Info: fill project name and project scale details.',
point2: 'Contract Segment Management: create, sort, search, import/export segments.',
point3: 'Consult Category Factor / Major Factor: maintain budget values and remarks.'
},
step3: {
title: 'Fill Basic Info',
description: 'Complete project-level data in "Basic Info" first, then continue to segment-level calculations.',
point1: 'Project name is used in export file names and page display.',
point2: 'Project detail table supports direct edit, copy/paste, and undo/redo.',
point3: 'Group rows auto-summarize, and the pinned top row shows grand total.'
},
step4: {
title: 'Contract Segment Management',
description: 'Manage the full lifecycle of contract segments in this module.',
point1: '"Add Segment" creates a new one; top-right actions on each card support edit/delete.',
point2: 'Search and grid/list switch are supported; drag-sort is available when not searching.',
point3: 'The more menu supports import/export; click a card to enter segment details.'
},
step5: {
title: 'Contract Segment Detail',
description: 'Inside a segment, the left flow line includes scale info and consulting services.',
point1: 'Scale Info: fill segment scale data by engineering major.',
point2: 'Consulting Services: choose services from dictionary and generate fee details.',
point3: 'Each segment page has isolated cache and does not interfere with others.'
},
step6: {
title: 'Service and Pricing Pages',
description: 'The consulting service page manages service details and opens specific pricing method pages.',
point1: 'Click "Browse" to select services, then confirm to generate detail rows.',
point2: 'In detail table, "Edit" opens service pricing page, and "Clear" resets that service calculation.',
point3: 'Pricing page includes investment scale, land scale, workload, and hourly methods.'
},
step7: {
title: 'Factor Maintenance',
description: 'Project-level factors adjust budget values and can be maintained on two factor pages.',
point1: 'Consult Category Factor page: maintain budget values and notes by consult category.',
point2: 'Major Factor page: maintain budget values and notes by major tree.',
point3: 'Batch paste and undo/redo are supported for efficient multi-row updates.'
},
step8: {
title: 'Data Management and Recovery',
description: 'Top toolbar handles full import/export and reset initialization.',
point1: '"Import/Export" operates on full-project data package.',
point2: '"Reset" clears all local data and restores default page.',
point3: 'It is recommended to export a backup before major changes.'
}
}
}, },
toast: { toast: {
export: 'Export Report', export: 'Export Report',
success: 'Export Success', success: 'Export Success',
failed: 'Export Failed' failed: 'Export Failed'
},
messages: {
defaultProjectLabel: 'Default Project',
defaultProjectName: 'Cost Project',
projectNamePrefix: 'Project-{id}',
contractFallbackName: 'Contract-{index}',
reportFileSuffix: 'Budget Report',
reportGenerating: 'Generating report file...',
reportExportDone: 'Report export completed',
reportExportFailedRetry: 'Report export failed, please retry',
importFailedTitle: 'Import Failed',
importProjectIdMissing: 'This package does not contain project ID (legacy export). Import is blocked to avoid cross-project overwrite.',
importProjectMismatch: 'This package belongs to another project and cannot override current project.',
importInvalidFile: 'File is invalid, corrupted, or modified.',
importWriteError: 'An error occurred while writing local data.',
openFile: 'Open file'
}
},
typeLine: {
copy: 'Copy',
copied: 'Copied',
copyFailed: 'Copy failed',
brandAlt: 'Zhongwei',
supportText: 'This website is supported by Zhongwei Engineering Consulting Co., Ltd.',
aboutTitle: 'About Us',
companyName: 'Zhongwei Engineering Consulting Co., Ltd.',
openOfficialSiteAria: 'Open official website',
officialSiteTitle: 'Official Website',
aboutParagraph1: 'Zhongwei Engineering Consulting Co., Ltd. was founded in 2009, focusing on whole-process consulting for project cost and cost control. It is a preferred audit vendor for Guangdong government. The company serves multi-domain and diverse clients, with cumulative project investment over one trillion CNY, deep participation in major national projects such as the Hong Kong-Zhuhai-Macao Bridge and Hengqin Campus of the University of Macau, and participation in over 30 national/provincial/municipal standards.',
aboutParagraph2: 'Based in the Greater Bay Area and expanding globally, the company has offices in Macau and Sri Lanka, with cross-border and overseas delivery capabilities. With 15 years of expertise and trillion-level project experience, it provides precise and reliable engineering consulting services.'
},
agGrid: {
resetDefault: 'Reset to default'
},
ht: {
title: 'Contract Segments',
projectTotalBudget: 'Project Total Budget: {amount}',
budgetLoading: 'Calculating...',
selectedCount: '{count} selected',
exportSelected: 'Export Selected',
deleteSelected: 'Delete Selected',
cancelSelect: 'Cancel',
addContract: 'Add Segment',
batchDelete: 'Batch Delete',
exportContracts: 'Export Segments',
importContracts: 'Import Segments',
searchPlaceholder: 'Search by segment name or ID',
clearFilter: 'Clear Filter',
searchingHint: 'Searching ({filtered} / {total}), drag sorting is disabled',
selectModeExportHint: 'Export mode: select segments and click "Export Selected"',
selectModeDeleteHint: 'Delete mode: select segments and click "Delete Selected"',
setupRequiredHint: 'Set project industry in "Basic Info" before adding or importing segments',
listLayout: 'List',
gridLayout: 'Grid',
dragSort: 'Drag to Sort',
dragSortSearchOff: 'Drag Sort (Disabled in Search)',
edit: 'Edit',
remove: 'Delete',
idLabel: 'ID: {id}',
contractBudget: 'Budget: {amount}',
contractBudgetLine: 'Segment Budget: {amount}',
createdAt: 'Created: {time}',
emptyTitle: 'No Contract Segments',
emptyDesc: 'Add one to get started',
notFound: 'No matching contract segment',
backToTop: 'Back to Top',
editContract: 'Edit Segment',
createContract: 'New Segment',
contractTabTitle: 'Segment {name}',
contractName: 'Segment Name',
contractNamePlaceholder: 'Enter segment name',
save: 'Save',
ok: 'OK',
toastSuccessTitle: 'Success',
createSuccess: 'Created successfully',
editSuccess: 'Updated successfully',
deleteSuccess: 'Deleted successfully',
sortDone: 'Sort completed',
exportSuccess: 'Exported successfully ({count} segments)',
importSuccess: 'Imported successfully ({count} segments)',
deleteBatchSuccess: 'Deleted successfully ({count} segments)',
tipTitle: 'Notice',
exportFailedTitle: 'Export Failed',
importFailedTitle: 'Import Failed',
batchDeleteFailedTitle: 'Batch Delete Failed',
retry: 'Please try again.',
selectAtLeastOne: 'Please select at least one contract segment.',
noContractsToDelete: 'No contract segment found to delete.',
industryMissingForExport: 'Project industry is missing. Please set it in "Basic Info" first.',
importIndustryMismatch: 'Industry mismatch (package: {importIndustry}, current: {currentIndustry}).',
importCurrentIndustryMissing: 'Current project industry is not set. Please set it in "Basic Info" first.',
importPackageIndustryMissing: 'Import package missing industry info. Re-export with latest version and try again.',
importFileInvalid: 'Invalid or corrupted file, or not a contract-segment package.',
deleteSingleTitle: 'Confirm Delete Segment',
deleteSingleDesc: 'Delete "{name}" and all related service/pricing data. Continue?',
deleteBatchTitle: 'Confirm Batch Delete',
deleteBatchDesc: 'Delete {count} segments and related service/pricing data. Continue?'
},
htCard: {
title: 'Segment: {name}',
subtitle: 'Segment ID: {id}',
metaBudget: 'Segment Budget: {amount}',
currencySuffix: 'CNY',
categories: {
baseInfo: 'Basic Info',
scaleInfo: 'Scale Info',
services: 'Consulting Services',
consultFactor: 'Consult Category Factor',
majorFactor: 'Major Factor',
additionalFee: 'Additional Fee',
reserveFee: 'Reserve Fee',
summary: 'Summary'
}
},
htBaseInfo: {
title: 'Basic Info',
defaultQuality: 'The comprehensive evaluation of cost consulting services should reach "Good" or a score of 90.',
qualityLabel: 'Quality Requirement',
qualityPlaceholder: 'Enter quality requirement',
durationLabel: 'Duration Requirement',
durationPlaceholder: 'Enter duration requirement'
},
htFactors: {
consultCategoryTitle: 'Consult Category Factor Details',
majorTitle: 'Major Factor Details'
},
htFee: {
additionalTitle: 'Additional Work Fee',
reserveTitle: 'Reserve Fee'
},
htInfo: {
scaleDetailTitle: 'Contract Scale Details'
},
htFeeRate: {
baseLabel: 'Base (total budget of all service fees)',
reserveBaseLabel: 'Base (consulting services total + additional work fee total)',
rateLabel: 'Rate (%)',
ratePlaceholder: 'Enter rate, suggested 1 ~ 5',
budgetFeeLabel: 'Budget Fee (Auto)',
remarkLabel: 'Remark',
remarkPlaceholder: 'Enter remark'
},
htZxFw: {
title: 'Consulting Service Details',
warning: 'Please review and adjust recommended limits/special values in the specification, then update final fee if needed.',
editTabTitle: 'Service Edit-{name}',
subtotal: 'Subtotal',
edit: 'Edit',
resetDefault: 'Reset',
delete: 'Remove',
processDraft: 'Draft',
processReview: 'Review',
columns: {
code: 'Code',
name: 'Name',
process: 'Process',
investScale: 'Investment Scale',
landScale: 'Land Scale',
workload: 'Workload',
hourly: 'Hourly',
subtotal: 'Subtotal',
finalFee: 'Final Fee ✎',
finalFeeTooltip: 'This column supports manual edits and will auto-sync to the fixed subtotal row.',
actions: 'Actions'
},
dialog: {
resetTitle: 'Confirm Reset to Default',
resetDesc: 'This will recalculate default data from latest scale/factor values and overwrite current data for "{name}". Continue?',
confirmReset: 'Confirm Reset',
deleteTitle: 'Confirm Delete Service',
deleteDesc: 'This will logically remove "{name}". Existing entered data is kept and will restore if re-selected. Continue?'
}
},
htSummary: {
title: 'Contract Summary',
total: 'Total',
remark: 'Remark',
placeholder: 'Fill consulting services / additional work fee / reserve fee first',
additionalPrefix: 'Additional Work Fee',
reservePrefix: 'Reserve Fee',
explainByRate: 'By rate {rate}%, calculated {fee} CNY',
explainByHourly: 'By hourly method, calculated {fee} CNY',
explainByQuantity: 'By quantity-unit-price method, calculated {fee} CNY',
columns: {
code: 'Code',
name: 'Name',
investScale: 'Investment Scale',
landScale: 'Land Scale',
workload: 'Workload',
hourly: 'Hourly',
subtotal: 'Subtotal',
finalFee: 'Final Fee'
}
},
htFeeGrid: {
subtotal: 'Subtotal',
currentRow: 'Current Row',
unnamed: 'Unnamed',
edit: 'Edit',
clear: 'Clear',
add: 'Add',
editTabTitle: 'Fee Edit-{name}',
columns: {
name: 'Name',
rateFee: 'Rate Fee',
hourlyFee: 'Hourly',
quantityUnitPriceFee: 'Quantity Unit Price',
subtotal: 'Subtotal',
actions: 'Actions'
},
dialog: {
clearTitle: 'Confirm Clear',
clearDesc: 'This will clear editable and auto-calculated data for "{name}" and its edit page. Continue?',
confirmClear: 'Confirm Clear'
}
},
xmFactorGrid: {
clickToInput: 'Click to input',
columns: {
standardFactor: 'Standard Factor',
budgetValue: 'Budget Value',
remark: 'Remark',
groupName: 'Major Code and Major Name'
}
},
serviceSelector: {
title: 'Select Services',
clear: 'Clear',
empty: 'No services'
},
zxFwView: {
contractPrefix: 'Contract: {name}',
calcSuffix: ' Calculation',
contractId: 'Contract ID: {id}',
workContentTitle: 'Work Content',
categories: {
investmentScale: 'Investment Scale',
landScale: 'Land Scale',
workload: 'Workload',
hourly: 'Hourly',
workContent: 'Work Content'
},
unavailable: {
investmentScaleTitle: 'Investment Scale Not Applicable',
investmentScaleMessage: 'Scale method is not enabled for this service, so Investment Scale is not editable.',
landScaleTitle: 'Land Scale Not Applicable',
landScaleMessage: 'This service only supports Investment Scale, so Land Scale is not editable.',
workloadTitle: 'Workload Not Applicable',
workloadMessage: 'Workload method is not enabled for this service, so Workload is not editable.',
hourlyTitle: 'Hourly Not Applicable',
hourlyMessage: 'Hourly method is not enabled for this service, so Hourly is not editable.'
}
},
htFeeDetail: {
subtotal: 'Subtotal',
currentRow: 'Current Row',
clickToInput: 'Click to input',
addRow: 'Add Row',
columns: {
no: 'No.',
feeItem: 'Fee Item',
unit: 'Unit',
quantity: 'Quantity',
unitPrice: 'Unit Price (CNY)',
budgetFee: 'Budget Fee (CNY)',
remark: 'Remark',
actions: 'Actions'
},
dialog: {
deleteTitle: 'Confirm Delete Row',
deleteDesc: 'Delete row "{name}"?'
}
},
workContent: {
title: 'Work Content',
addCustom: 'Add Custom Content',
clickToInput: 'Click to input',
clickToInputContent: 'Click to input work content',
currentRow: 'Current Row',
unnamed: 'Unnamed',
ungrouped: 'Ungrouped',
type: {
basic: 'Basic Work',
optional: 'Optional Work',
daily: 'Daily Advisory',
special: 'Special Advisory',
additional: 'Additional Work',
custom: 'Custom'
},
columns: {
no: 'No.',
content: 'Content',
type: 'Type',
remark: 'Remark',
actions: 'Actions'
},
dialog: {
deleteTitle: 'Confirm Delete Row',
deleteDesc: 'Delete row "{name}"?'
}
},
quickCalc: {
projectName: 'Quick Calculation',
catalogEyebrow: 'Category List',
catalogTitle: 'Quick Calc Options',
formEyebrow: 'Parameter Form',
formTitle: 'Calculation Parameters',
industryLabel: 'Industry {name}',
selectIndustry: 'Select industry',
saving: 'Saving...',
synced: 'Industry synced',
notSelectedIndustry: 'Industry not selected',
notSelected: 'Not selected',
consultCategory: 'Consult Category',
majorCategory: 'Major',
fields: {
industry: 'Industry',
code: 'Code',
investScale: 'Investment Scale (10k CNY)',
landScale: 'Land Scale (mu)',
formula: 'Formula',
amount: 'Amount (CNY)',
consultFactor: 'Consult Category Factor',
majorFactor: 'Major Factor',
workEnvFactor: 'Work Environment Factor',
workEnvFactorPlaceholder: 'Default 1',
budgetAmount: 'Budget Amount (CNY)'
},
sections: {
currentSelection: 'Current Selection',
basicInfo: 'Basic Info',
scaleBase: 'Scale Base',
scaleParams: 'Scale Parameters',
benchmarkBudget: 'Benchmark Budget',
budgetBase: 'Budget Base',
serviceBudget: 'Service Budget',
factorsAndResult: 'Factors and Result'
},
empty: {
selectIndustry: 'Select an industry first. Then choose consult category and matched majors will appear.',
selectConsult: 'Select a consult category first. Matched general and major categories will then appear.',
scaleUnavailable: 'The selected consult category does not support scale method, so major categories are hidden.',
consultCostOnly: 'The selected consult category is priced by industry summary. Major factor is auto-applied by industry.'
},
placeholder: {
selectConsultFirst: 'Select consult category first',
scaleUnavailable: 'Current category does not support scale method',
selectMajorFirst: 'Select major first',
preferLandScale: 'Current major is priced by land scale',
investUnavailable: 'Current major does not support investment scale',
consultCostOnly: 'Current category supports investment scale only',
landUnavailable: 'Current major does not support land scale',
input: 'Please input',
selectScaleFirst: 'Select and input a scale value first'
}
},
methodUnavailable: {
defaultTitle: 'This Service Is Not Applicable to Current Pricing Method'
},
xmCard: {
categories: {
info: 'Basic Info',
scaleInfo: 'Scale Info',
consultCategoryFactor: 'Consult Category Factor',
majorFactor: 'Major Factor',
contract: 'Contract Segment Management'
}
},
htFeeMethodTypeLine: {
feeDetail: 'Fee Details',
unnamed: 'Unnamed',
title: 'Segment: {contractName} · {rowName}',
contractId: 'Contract ID: {id}',
quantityUnitPrice: 'Quantity Unit Price'
},
pricingScale: {
totalInvestmentByIndustry: '{industryName} Total Investment',
totalInvestment: 'Total Investment',
clickToInput: 'Click to input',
projectLabel: 'Project {index}',
columns: {
investAmount: 'Cost Amount (10k CNY)',
landArea: 'Land Area (mu)',
benchmarkBudget: 'Benchmark Budget (CNY)',
basicWork: 'Basic Work',
optionalWork: 'Optional Work',
subtotal: 'Subtotal',
budgetFee: 'Budget Fee',
consultCategoryFactor: 'Consult Category Factor',
majorFactor: 'Major Factor',
workStageFactor: 'Work Stage Factor (Draft/Review)',
workRatio: 'Work Ratio (%)',
total: 'Total',
remark: 'Remark',
majorGroup: 'Major Code and Major Name'
},
tooltip: {
resetInvestAmount: 'Click ↻ to restore default cost amount for this column',
resetLandArea: 'Click ↻ to restore default land area for this column',
resetConsultCategoryFactor: 'Click ↻ to restore default consult category factor for this column',
resetMajorFactor: 'Click ↻ to restore default major factor for this column'
}
},
pricingPane: {
projectCount: 'Project Count',
clearTitle: 'Confirm Clear Current Details',
confirmClear: 'Confirm Clear',
useDefault: 'Use Default Data',
overrideTitle: 'Confirm Override Current Details',
confirmOverride: 'Confirm Override',
investment: {
title: 'Investment Scale Details',
clearDesc: 'This will clear current investment scale details. Continue?',
overrideDesc: 'Use contract default data to override current investment scale details. Continue?'
},
land: {
title: 'Land Scale Details',
clearDesc: 'This will clear current land scale details. Continue?',
overrideDesc: 'Use contract default data to override current land scale details. Continue?'
}
},
workloadPricing: {
title: 'Workload Details',
unavailableTitle: 'Workload Method Not Applicable',
unavailableMessage: 'No workload tasks are associated with this service. No input is needed.',
clickToInput: 'Click to input',
none: 'N/A',
total: 'Grand Total',
columns: {
code: 'Code',
name: 'Name',
budgetBase: 'Budget Base',
budgetReferenceUnitPrice: 'Budget Reference Unit Price',
budgetAdoptedUnitPrice: 'Budget Adopted Unit Price',
workload: 'Workload',
consultCategoryFactor: 'Consult Category Factor',
serviceFee: 'Service Fee (CNY)',
remark: 'Remark'
}
},
hourlyFeeGrid: {
title: 'Hourly Method Details',
clickToInput: 'Click to input',
total: 'Grand Total',
columns: {
code: 'Code',
name: 'Personnel Name',
referenceUnitPrice: 'Budget Reference Unit Price',
laborBudgetUnitPrice: 'Labor Budget Unit Price (CNY/workday)',
compositeBudgetUnitPrice: 'Composite Budget Unit Price (CNY/workday)',
adoptedBudgetUnitPrice: 'Adopted Budget Unit Price (CNY/workday)',
personnelCount: 'Personnel Count',
workdayCount: 'Workday Count',
serviceBudget: 'Service Budget (CNY)',
remark: 'Remark'
}
},
xmScaleGrid: {
syncToastTitle: 'Consulting Services Synced',
syncToastDesc: 'Scale info synced to consulting services ({serviceCount} services, {methodCount} pricing pages, {rowCount} rows)'
},
xmInfo: {
defaultProjectName: 'xxx Cost Consulting Service',
defaultDesc: 'When providing cost consulting services, penalties should be graded by service quality. For scores >=85 and <90, penalty is 10% of budget fee; >=80 and <85: 20%; >=75 and <80: 30%; >=70 and <75: 40%; <70: 50% or above.',
industryHint: 'Changing industry requires reset and re-selection',
industryHintAria: 'Industry hint',
createFromHomeFirst: 'Please create a project from Home before entering this page.',
fields: {
projectName: 'Project Name',
projectIndustry: 'Industry',
overview: 'Project Overview',
preparedBy: 'Prepared By',
reviewedBy: 'Reviewed By',
preparedCompany: 'Prepared Company',
preparedDate: 'Prepared Date',
desc: 'Other Notes'
},
placeholders: {
overview: 'Enter project overview',
preparedBy: 'Enter preparer',
reviewedBy: 'Enter reviewer',
preparedCompany: 'Enter prepared company'
} }
} }
} as const } as const

View File

@ -3,7 +3,8 @@ export const zhCN = {
cancel: '取消', cancel: '取消',
confirm: '确认', confirm: '确认',
delete: '删除', delete: '删除',
close: '关闭' close: '关闭',
clear: '清空'
}, },
app: { app: {
projectConflict: { projectConflict: {
@ -19,6 +20,7 @@ export const zhCN = {
home: { home: {
title: '计算入口', title: '计算入口',
subtitle: '项目计算 · 单项速算 · 导入数据', subtitle: '项目计算 · 单项速算 · 导入数据',
projectCalcTab: '项目计算',
cards: { cards: {
heroTitle: '智能预算一键生成', heroTitle: '智能预算一键生成',
heroSubTitle: '助力《规范》高效落地', heroSubTitle: '助力《规范》高效落地',
@ -26,7 +28,7 @@ export const zhCN = {
projectBudget: '项目预算', projectBudget: '项目预算',
projectBudgetDesc: '适用于多合同段、项目级整体计算,支持导出/导入完整项目数据', projectBudgetDesc: '适用于多合同段、项目级整体计算,支持导出/导入完整项目数据',
quickCalc: '单项速算', quickCalc: '单项速算',
quickCalcDesc: '单项速算,选择行业与咨询类型,输入基数秒出结果', quickCalcDesc: '适用于单项服务试算,选择行业、咨询类型、工程专业,输入基数秒出结果',
importData: '导入数据', importData: '导入数据',
importDataDesc: '导入".zw"数据包,快速恢复项目计算状态,续未完成工作', importDataDesc: '导入".zw"数据包,快速恢复项目计算状态,续未完成工作',
enter: '进入计算', enter: '进入计算',
@ -92,13 +94,553 @@ export const zhCN = {
later: '稍后再看', later: '稍后再看',
prev: '上一步', prev: '上一步',
next: '下一步', next: '下一步',
finish: '完成并不再自动弹出' finish: '完成并不再自动弹出',
jumpToStep: '跳转到第 {index} 步',
steps: {
step1: {
title: '欢迎使用',
description: '这个引导会覆盖系统主要功能和完整使用路径,建议按顺序走一遍。',
point1: '顶部是标签页区,可在项目、合同段、服务计算页之间快速切换。',
point2: '页面里的表格与表单会自动保存到本地,无需手动点击保存。',
point3: '你可以随时点击右上角“使用引导”重新打开本教程。'
},
step2: {
title: '项目卡片与四个模块',
description: '默认标签“项目卡片”是入口,左侧流程线包含四个项目级功能。',
point1: '基础信息:填写项目名称与项目规模明细。',
point2: '合同段管理:新建、排序、搜索、导入/导出合同段。',
point3: '咨询分类系数 / 工程专业系数:维护系数预算取值和备注。'
},
step3: {
title: '基础信息填写',
description: '先在“基础信息”中补齐项目级数据,再进入后续合同段计算。',
point1: '项目名称会用于导出文件名和页面展示。',
point2: '项目明细表支持直接编辑、复制粘贴、撤销重做。',
point3: '分组行自动汇总,顶部固定行显示总合计。'
},
step4: {
title: '合同段管理',
description: '在“合同段管理”中完成合同段生命周期操作。',
point1: '“添加合同段”用于新增,卡片右上角可编辑或删除。',
point2: '支持搜索、网格/列表切换,非搜索状态可拖拽排序。',
point3: '更多菜单可导入/导出合同段;点击卡片进入该合同段详情。'
},
step5: {
title: '合同段详情',
description: '进入合同段后,左侧同样是流程线,包含规模信息和咨询服务两部分。',
point1: '规模信息:按工程专业填写当前合同段的规模数据。',
point2: '咨询服务:选择服务词典并生成服务费用明细。',
point3: '合同段页面会独立缓存,不同合同段互不干扰。'
},
step6: {
title: '咨询服务与计算页',
description: '咨询服务页面用于管理服务明细,并进入具体计费方法页面。',
point1: '先点击“浏览”选择服务,再确认生成明细行。',
point2: '明细表“编辑”可打开服务计算页,“清空”会重置该服务计算结果。',
point3: '服务计算页包含投资规模法、用地规模法、工作量法、工时法四种方法。'
},
step7: {
title: '系数维护',
description: '项目级系数用于调节预算取值,可在两个系数页分别维护。',
point1: '咨询分类系数页:按咨询分类维护预算取值与说明。',
point2: '工程专业系数页:按专业树维护预算取值与说明。',
point3: '支持批量粘贴、撤销重做,便于一次性维护多行数据。'
},
step8: {
title: '数据管理与恢复',
description: '顶部工具栏负责全量数据导入导出与初始化重置。',
point1: '“导入/导出”是整项目级别的数据包操作。',
point2: '“重置”会清空本地全部数据并恢复默认页面。',
point3: '建议在重要调整前先导出备份。'
}
}
}, },
toast: { toast: {
export: '导出报表', export: '导出报表',
success: '导出成功', success: '导出成功',
failed: '导出失败' failed: '导出失败'
},
messages: {
defaultProjectLabel: '默认项目',
defaultProjectName: '造价项目',
projectNamePrefix: '项目-{id}',
contractFallbackName: '合同段-{index}',
reportFileSuffix: '预算文件',
reportGenerating: '正在生成报表文件...',
reportExportDone: '报表导出完成',
reportExportFailedRetry: '报表导出失败,请重试',
importFailedTitle: '导入失败',
importProjectIdMissing: '该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。',
importProjectMismatch: '该数据包属于其他项目,不能覆盖当前项目。',
importInvalidFile: '文件无效、已损坏或被修改。',
importWriteError: '写入本地数据时发生错误。',
openFile: '打开文件'
}
},
typeLine: {
copy: '复制',
copied: '已复制',
copyFailed: '复制失败',
brandAlt: '众为咨询',
supportText: '本网站由众为工程咨询有限公司提供免费技术支持',
aboutTitle: '关于我们',
companyName: '众为工程咨询有限公司',
openOfficialSiteAria: '跳转到官网首页',
officialSiteTitle: '官网首页',
aboutParagraph1: '众为工程咨询有限公司 2009 年成立,专注工程造价与工程成本管控全过程咨询,是广东省政府审计入库优选单位。公司服务覆盖多领域、全类型客户,累计服务投资额超万亿元,深度参与港珠澳大桥、澳门大学横琴校区等国家级重点工程,参编三十余项国家及省市行业标准。',
aboutParagraph2: '公司立足大湾区,布局全球,设有澳门公司、斯里兰卡分公司,具备跨境与海外项目服务能力,以十五年专业沉淀、万亿级项目经验,为客户提供精准、可靠的工程咨询服务。'
},
agGrid: {
resetDefault: '恢复默认值'
},
ht: {
title: '合同段列表',
projectTotalBudget: '项目总预算金额:{amount}',
budgetLoading: '计算中...',
selectedCount: '已选 {count} 个',
exportSelected: '导出已选',
deleteSelected: '删除已选',
cancelSelect: '取消',
addContract: '添加合同段',
batchDelete: '批量删除',
exportContracts: '导出合同段',
importContracts: '导入合同段',
searchPlaceholder: '搜索合同段名称或ID',
clearFilter: '清空筛选',
searchingHint: '搜索中({filtered} / {total}),已关闭拖拽排序',
selectModeExportHint: '导出选择模式:勾选合同段后点击“导出已选”',
selectModeDeleteHint: '删除选择模式:勾选合同段后点击“删除已选”',
setupRequiredHint: '请先在“基础信息”里新建项目并选择工程行业后,再新增或导入合同段',
listLayout: '列表布局',
gridLayout: '网格布局',
dragSort: '拖动排序',
dragSortSearchOff: '拖动排序(搜索时关闭)',
edit: '编辑',
remove: '删除',
idLabel: 'ID{id}',
contractBudget: '预算:{amount}',
contractBudgetLine: '本合同预算金额:{amount}',
createdAt: '创建时间:{time}',
emptyTitle: '暂无合同卡片',
emptyDesc: '赶紧来添加吧',
notFound: '未找到匹配的合同段',
backToTop: '回到顶部',
editContract: '编辑合同段',
createContract: '新增合同段',
contractTabTitle: '合同段{name}',
contractName: '合同段名称',
contractNamePlaceholder: '请输入合同段名称',
save: '保存',
ok: '确定',
toastSuccessTitle: '操作成功',
createSuccess: '新建成功',
editSuccess: '编辑成功',
deleteSuccess: '删除成功',
sortDone: '排序完成',
exportSuccess: '导出成功({count} 个合同段)',
importSuccess: '导入成功({count} 个合同段)',
deleteBatchSuccess: '删除成功({count} 个合同段)',
tipTitle: '提示',
exportFailedTitle: '导出失败',
importFailedTitle: '导入失败',
batchDeleteFailedTitle: '批量删除失败',
retry: '请重试。',
selectAtLeastOne: '请先勾选至少一个合同段。',
noContractsToDelete: '未找到可删除的合同段。',
industryMissingForExport: '未读取到当前项目工程行业,请先在“基础信息”里新建项目。',
importIndustryMismatch: '工程行业不一致(导入包:{importIndustry},当前项目:{currentIndustry})。',
importCurrentIndustryMissing: '当前项目未设置工程行业,请先在“基础信息”里新建项目。',
importPackageIndustryMissing: '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。',
importFileInvalid: '文件无效、已损坏或不是合同段导出文件。',
deleteSingleTitle: '确认删除合同段',
deleteSingleDesc: '即将删除“{name}”及其关联咨询服务和计价数据,是否继续?',
deleteBatchTitle: '确认批量删除',
deleteBatchDesc: '即将删除 {count} 个合同段及其关联咨询服务和计价数据,是否继续?'
},
htCard: {
title: '合同段:{name}',
subtitle: '合同段ID{id}',
metaBudget: '合同段预算金额:{amount}',
currencySuffix: '元',
categories: {
baseInfo: '基础信息',
scaleInfo: '规模信息',
services: '咨询服务',
consultFactor: '咨询分类系数',
majorFactor: '工程专业系数',
additionalFee: '附加工作费',
reserveFee: '预备费',
summary: '汇总'
}
},
htBaseInfo: {
title: '基础信息',
defaultQuality: '造价咨询服务的综合评价应达到"较好"或综合评分90分',
qualityLabel: '质量要求',
qualityPlaceholder: '请输入质量要求',
durationLabel: '工期要求',
durationPlaceholder: '请输入工期要求'
},
htFactors: {
consultCategoryTitle: '咨询分类系数明细',
majorTitle: '工程专业系数明细'
},
htFee: {
additionalTitle: '附加工作费',
reserveTitle: '预备费'
},
htInfo: {
scaleDetailTitle: '合同规模明细'
},
htFeeRate: {
baseLabel: '基数(所有服务费预算合计)',
reserveBaseLabel: '基数(咨询服务总计 + 附加工作费总计)',
rateLabel: '费率(%',
ratePlaceholder: '请输入费率建议1 ~ 5',
budgetFeeLabel: '预算费用(自动计算)',
remarkLabel: '说明',
remarkPlaceholder: '请输入说明'
},
htZxFw: {
title: '咨询服务明细',
warning: '※ 请注意检查并修改《规范》建议的限值或特殊值,并在确认金额栏修改',
editTabTitle: '服务编辑-{name}',
subtotal: '小计',
edit: '编辑',
resetDefault: '恢复默认',
delete: '删除',
processDraft: '编制',
processReview: '审核',
columns: {
code: '编码',
name: '名称',
process: '工作环节',
investScale: '投资规模法',
landScale: '用地规模法',
workload: '工作量法',
hourly: '工时法',
subtotal: '小计',
finalFee: '确认金额 ✎',
finalFeeTooltip: '该列支持手动修改,修改后会自动汇总到固定小计行',
actions: '操作'
},
dialog: {
resetTitle: '确认恢复默认数据',
resetDesc: '会使用合同卡片里面最新填写的规模信息以及系数,自动计算默认数据,覆盖“{name}”当前数据,是否继续?',
confirmReset: '确认恢复',
deleteTitle: '确认删除服务',
deleteDesc: '将逻辑删除“{name}”,已填写的数据不会清空,重新勾选后会恢复,是否继续?'
}
},
htSummary: {
title: '合同段汇总',
total: '合计',
remark: '说明',
placeholder: '请先填咨询服务/附加工作费/预备费的数据',
additionalPrefix: '附加工作费',
reservePrefix: '预备费',
explainByRate: '按费率{rate}%计得{fee}元',
explainByHourly: '按工时法计得{fee}元',
explainByQuantity: '按数量单价计得{fee}元',
columns: {
code: '编码',
name: '名称',
investScale: '投资规模法',
landScale: '用地规模法',
workload: '工作量法',
hourly: '工时法',
subtotal: '小计',
finalFee: '确认金额'
}
},
htFeeGrid: {
subtotal: '小计',
currentRow: '当前行',
unnamed: '未命名',
edit: '编辑',
clear: '清空',
add: '新增',
editTabTitle: '费用编辑-{name}',
columns: {
name: '名字',
rateFee: '费率计取',
hourlyFee: '工时法',
quantityUnitPriceFee: '数量单价',
subtotal: '小计',
actions: '操作'
},
dialog: {
clearTitle: '确认清空',
clearDesc: '将清空“{name}”及其编辑页面的可填和自动计算数据,是否继续?',
confirmClear: '确认清空'
}
},
xmFactorGrid: {
clickToInput: '点击输入',
columns: {
standardFactor: '标准系数',
budgetValue: '预算取值',
remark: '说明',
groupName: '专业编码以及工程专业名称'
}
},
serviceSelector: {
title: '选择服务',
clear: '清空',
empty: '暂无服务'
},
zxFwView: {
contractPrefix: '合同段:{name}',
calcSuffix: '计算',
contractId: '合同ID{id}',
workContentTitle: '工作内容',
categories: {
investmentScale: '投资规模法',
landScale: '用地规模法',
workload: '工作量法',
hourly: '工时法',
workContent: '工作内容'
},
unavailable: {
investmentScaleTitle: '该服务不适用投资规模法',
investmentScaleMessage: '当前服务未启用规模法,投资规模法不可编辑。',
landScaleTitle: '该服务不适用用地规模法',
landScaleMessage: '当前服务仅支持投资规模法,用地规模法不可编辑。',
workloadTitle: '该服务不适用工作量法',
workloadMessage: '当前服务未启用工作量法,工作量法不可编辑。',
hourlyTitle: '该服务不适用工时法',
hourlyMessage: '当前服务未启用工时法,工时法不可编辑。'
}
},
htFeeDetail: {
subtotal: '小计',
currentRow: '当前行',
clickToInput: '点击输入',
addRow: '添加行',
columns: {
no: '序号',
feeItem: '费用项',
unit: '单位',
quantity: '数量',
unitPrice: '单价(元)',
budgetFee: '预算费用(元)',
remark: '说明',
actions: '操作'
},
dialog: {
deleteTitle: '确认删除行',
deleteDesc: '将删除“{name}”这条明细,是否继续?'
}
},
workContent: {
title: '工作内容',
addCustom: '添加自定义内容',
clickToInput: '点击输入',
clickToInputContent: '点击输入工作内容',
currentRow: '当前行',
unnamed: '未命名',
ungrouped: '未分组',
type: {
basic: '基本工作',
optional: '可选工作',
daily: '日常顾问',
special: '专项顾问',
additional: '附加工作',
custom: '自定义'
},
columns: {
no: '序号',
content: '工作内容',
type: '工作类型',
remark: '备注',
actions: '操作'
},
dialog: {
deleteTitle: '确认删除行',
deleteDesc: '将删除“{name}”这条明细,是否继续?'
}
},
quickCalc: {
projectName: '快速计算',
catalogEyebrow: '分类清单',
catalogTitle: '快速计算选项',
formEyebrow: '参数表单',
formTitle: '计算参数',
industryLabel: '行业 {name}',
selectIndustry: '请选择工程行业',
saving: '保存中...',
synced: '已同步行业',
notSelectedIndustry: '未选择行业',
notSelected: '未选择',
consultCategory: '咨询类别',
majorCategory: '工程专业',
fields: {
industry: '工程行业',
code: '编码',
investScale: '投资规模(万元)',
landScale: '用地规模(亩)',
formula: '计算式',
amount: '金额(元)',
consultFactor: '咨询分类系数',
majorFactor: '工程专业系数',
workEnvFactor: '工作环境系数',
workEnvFactorPlaceholder: '默认 1',
budgetAmount: '预算金额(元)'
},
sections: {
currentSelection: '当前选项',
basicInfo: '基础信息',
scaleBase: '计算基数',
scaleParams: '规模参数',
benchmarkBudget: '基准预算',
budgetBase: '预算基础值',
serviceBudget: '服务预算',
factorsAndResult: '系数与结果'
},
empty: {
selectIndustry: '请选择工程行业。选中后可先选择咨询类别,再显示对应专业分类。',
selectConsult: '请先选择咨询类别。选中后才会显示匹配的通用专业和工程专业分类。',
scaleUnavailable: '当前咨询类别不适用规模法,因此不显示专业分类。',
consultCostOnly: '当前咨询类别按行业汇总计价,工程专业系数已按所选行业自动带入,不再显示内部互补专业行。'
},
placeholder: {
selectConsultFirst: '请先选择咨询类别',
scaleUnavailable: '当前分类不适用规模法',
selectMajorFirst: '请先选择工程专业',
preferLandScale: '当前专业按用地规模计价',
investUnavailable: '当前专业不适用投资规模',
consultCostOnly: '当前分类仅支持投资规模',
landUnavailable: '当前专业不适用用地规模',
input: '请输入',
selectScaleFirst: '请先选择输入对应规模'
}
},
methodUnavailable: {
defaultTitle: '该服务不适用当前计价方法'
},
xmCard: {
categories: {
info: '基础信息',
scaleInfo: '规模信息',
consultCategoryFactor: '咨询分类系数',
majorFactor: '工程专业系数',
contract: '合同段管理'
}
},
htFeeMethodTypeLine: {
feeDetail: '费用明细',
unnamed: '未命名',
title: '合同段:{contractName} · {rowName}',
contractId: '合同ID{id}',
quantityUnitPrice: '数量单价'
},
pricingScale: {
totalInvestmentByIndustry: '{industryName}总投资',
totalInvestment: '总投资',
clickToInput: '点击输入',
projectLabel: '项目{index}',
columns: {
investAmount: '造价金额(万元)',
landArea: '用地面积(亩)',
benchmarkBudget: '基准预算(元)',
basicWork: '基本工作',
optionalWork: '可选工作',
subtotal: '小计',
budgetFee: '预算费用',
consultCategoryFactor: '咨询分类系数',
majorFactor: '专业系数',
workStageFactor: '工作环节系数(编审系数)',
workRatio: '工作占比(%)',
total: '合计',
remark: '说明',
majorGroup: '专业编码以及工程专业名称'
},
tooltip: {
resetInvestAmount: '点击右侧↻恢复本列默认造价金额',
resetLandArea: '点击右侧↻恢复本列默认用地面积',
resetConsultCategoryFactor: '点击右侧↻恢复本列默认咨询分类系数',
resetMajorFactor: '点击右侧↻恢复本列默认专业系数'
}
},
pricingPane: {
projectCount: '项目数量',
clearTitle: '确认清空当前明细',
confirmClear: '确认清空',
useDefault: '使用默认数据',
overrideTitle: '确认覆盖当前明细',
confirmOverride: '确认覆盖',
investment: {
title: '投资规模明细',
clearDesc: '将清空当前投资规模明细,是否继续?',
overrideDesc: '将使用合同默认数据覆盖当前投资规模明细,是否继续?'
},
land: {
title: '用地规模明细',
clearDesc: '将清空当前用地规模明细,是否继续?',
overrideDesc: '将使用合同默认数据覆盖当前用地规模明细,是否继续?'
}
},
workloadPricing: {
title: '工作量明细',
unavailableTitle: '该服务不适用工作量法',
unavailableMessage: '当前服务没有关联工作量法任务,无需填写此部分内容。',
clickToInput: '点击输入',
none: '无',
total: '总合计',
columns: {
code: '编码',
name: '名称',
budgetBase: '预算基数',
budgetReferenceUnitPrice: '预算参考单价',
budgetAdoptedUnitPrice: '预算采用单价',
workload: '工作量',
consultCategoryFactor: '咨询分类系数',
serviceFee: '服务费用(元)',
remark: '说明'
}
},
hourlyFeeGrid: {
title: '工时法明细',
clickToInput: '点击输入',
total: '总合计',
columns: {
code: '编码',
name: '人员名称',
referenceUnitPrice: '预算参考单价',
laborBudgetUnitPrice: '人工预算单价(元/工日)',
compositeBudgetUnitPrice: '综合预算单价(元/工日)',
adoptedBudgetUnitPrice: '预算采用单价(元/工日)',
personnelCount: '人员数量(人)',
workdayCount: '工日数量(工日)',
serviceBudget: '服务预算(元)',
remark: '说明'
}
},
xmScaleGrid: {
syncToastTitle: '已同步咨询服务',
syncToastDesc: '规模信息已同步到咨询服务({serviceCount} 项服务,{methodCount} 个计价页,{rowCount} 行)'
},
xmInfo: {
defaultProjectName: 'xxx造价咨询服务',
defaultDesc: '在履行造价咨询服务时宜根据咨询服务质量情况分级确定相应的处罚金额。其中考评得分在大于及等于85和小于90分时处罚金额为预算费用的10%其中考评得分在大于及等于80和小于85分时处罚金额为预算费用的20%其中考评得分在大于及等于75和小于80分时处罚金额为预算费用的30%其中考评得分在大于及等于70和小于75分时处罚金额为预算费用的40%其中考评得分小于70分时处罚金额为预算费用的50%以上。',
industryHint: '变更需要重置后重新选择',
industryHintAria: '工程行业提示',
createFromHomeFirst: '请从首页先新建项目后再进入此页面。',
fields: {
projectName: '项目名称',
projectIndustry: '工程行业',
overview: '项目概况',
preparedBy: '编制人',
reviewedBy: '复核人',
preparedCompany: '编制单位',
preparedDate: '编制日期',
desc: '其他说明'
},
placeholders: {
overview: '请输入项目概况',
preparedBy: '请输入编制人',
reviewedBy: '请输入复核人',
preparedCompany: '请输入编制单位'
} }
} }
} as const } as const

View File

@ -9,6 +9,7 @@ import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys' import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee' import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
import { useKvStore } from '@/pinia/kv' import { useKvStore } from '@/pinia/kv'
import { UI_PREFS_STORAGE_KEY, useUiPrefsStore } from '@/pinia/uiPrefs'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
@ -140,9 +141,16 @@ import {
toMoney toMoney
} from '@/lib/reportExportBuilders' } from '@/lib/reportExportBuilders'
import { exportFile } from '@/sql' import { exportFile } from '@/sql'
import { getIndustryDisplayName, industryTypeList } from '@/sql' import {
getAdditionalWorkListEntries,
getIndustryDisplayName,
getReserveListEntries,
getServiceDictItemById,
industryTypeList
} from '@/sql'
import { initializeProjectFactorStates } from '@/lib/projectWorkspace' import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
import { setAppLocale, type AppLocale } from '@/i18n' import { I18N_LOCALE_KEY, type AppLocale } from '@/i18n'
const { t, locale } = useI18n()
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1' const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
const THEME_PREFERENCE_KEY = 'jgjs-theme-dark-v1' const THEME_PREFERENCE_KEY = 'jgjs-theme-dark-v1'
@ -150,86 +158,24 @@ const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3' const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1' const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-v1' const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-v1'
const DEFAULT_PROJECT_NAME = 'xxx 造价咨询服务' const DEFAULT_PROJECT_NAME = t('xmInfo.defaultProjectName')
const MAX_PROJECT_COUNT = 10 const MAX_PROJECT_COUNT = 10
const PINIA_PERSIST_DB_NAME = getProjectDbName(readCurrentProjectId()) const PINIA_PERSIST_DB_NAME = getProjectDbName(readCurrentProjectId())
const PINIA_PERSIST_BASE_STORE_NAME = 'pinia' const PINIA_PERSIST_BASE_STORE_NAME = 'pinia'
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'zxFwPricingKeys', 'zxFwPricingHtFee', 'kv'] as const const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'zxFwPricingKeys', 'zxFwPricingHtFee', 'kv'] as const
const RESET_MIN_LOADING_MS = 1000 const RESET_MIN_LOADING_MS = 1000
const userGuideSteps: UserGuideStep[] = [ const userGuideSteps = computed<UserGuideStep[]>(() => {
{ const stepKeys = ['step1', 'step2', 'step3', 'step4', 'step5', 'step6', 'step7', 'step8']
title: '欢迎使用', return stepKeys.map(step => ({
description: '这个引导会覆盖系统主要功能和完整使用路径,建议按顺序走一遍。', title: t(`tab.guide.steps.${step}.title`),
description: t(`tab.guide.steps.${step}.description`),
points: [ points: [
'顶部是标签页区,可在项目、合同段、服务计算页之间快速切换。', t(`tab.guide.steps.${step}.point1`),
'页面里的表格与表单会自动保存到本地,无需手动点击保存。', t(`tab.guide.steps.${step}.point2`),
'你可以随时点击右上角“使用引导”重新打开本教程。' t(`tab.guide.steps.${step}.point3`)
] ]
}, }))
{ })
title: '项目卡片与四个模块',
description: '默认标签“项目卡片”是入口,左侧流程线包含四个项目级功能。',
points: [
'基础信息:填写项目名称与项目规模明细。',
'合同段管理:新建、排序、搜索、导入/导出合同段。',
'咨询分类系数 / 工程专业系数:维护系数预算取值和备注。'
]
},
{
title: '基础信息填写',
description: '先在“基础信息”中补齐项目级数据,再进入后续合同段计算。',
points: [
'项目名称会用于导出文件名和页面展示。',
'项目明细表支持直接编辑、复制粘贴、撤销重做。',
'分组行自动汇总,顶部固定行显示总合计。'
]
},
{
title: '合同段管理',
description: '在“合同段管理”中完成合同段生命周期操作。',
points: [
'“添加合同段”用于新增,卡片右上角可编辑或删除。',
'支持搜索、网格/列表切换,非搜索状态可拖拽排序。',
'更多菜单可导入/导出合同段;点击卡片进入该合同段详情。'
]
},
{
title: '合同段详情',
description: '进入合同段后,左侧同样是流程线,包含规模信息和咨询服务两部分。',
points: [
'规模信息:按工程专业填写当前合同段的规模数据。',
'咨询服务:选择服务词典并生成服务费用明细。',
'合同段页面会独立缓存,不同合同段互不干扰。'
]
},
{
title: '咨询服务与计算页',
description: '咨询服务页面用于管理服务明细,并进入具体计费方法页面。',
points: [
'先点击“浏览”选择服务,再确认生成明细行。',
'明细表“编辑”可打开服务计算页,“清空”会重置该服务计算结果。',
'服务计算页包含投资规模法、用地规模法、工作量法、工时法四种方法。'
]
},
{
title: '系数维护',
description: '项目级系数用于调节预算取值,可在两个系数页分别维护。',
points: [
'咨询分类系数页:按咨询分类维护预算取值与说明。',
'工程专业系数页:按专业树维护预算取值与说明。',
'支持批量粘贴、撤销重做,便于一次性维护多行数据。'
]
},
{
title: '数据管理与恢复',
description: '顶部工具栏负责全量数据导入导出与初始化重置。',
points: [
'“导入/导出”是整项目级别的数据包操作。',
'“重置”会清空本地全部数据并恢复默认页面。',
'建议在重要调整前先导出备份。'
]
}
]
const componentMap: Record<string, any> = { const componentMap: Record<string, any> = {
ProjectCalcView: markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmCard.vue'))), ProjectCalcView: markRaw(defineAsyncComponent(() => import('@/features/xm/components/xmCard.vue'))),
@ -244,7 +190,7 @@ const zxFwPricingStore = useZxFwPricingStore()
const zxFwPricingKeysStore = useZxFwPricingKeysStore() const zxFwPricingKeysStore = useZxFwPricingKeysStore()
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore() const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
const kvStore = useKvStore() const kvStore = useKvStore()
const { t, locale } = useI18n() const uiPrefsStore = useUiPrefsStore()
const isDark = useDark({ const isDark = useDark({
selector: 'html', selector: 'html',
attribute: 'class', attribute: 'class',
@ -369,11 +315,11 @@ const hasClosableTabs = computed(() => {
return tabStore.tabs.some((t: any) => t.componentName !== fixedId) return tabStore.tabs.some((t: any) => t.componentName !== fixedId)
}) })
const activeGuideStep = computed( const activeGuideStep = computed(
() => userGuideSteps[userGuideStepIndex.value] || userGuideSteps[0] () => userGuideSteps.value[userGuideStepIndex.value] || userGuideSteps.value[0]
) )
const isFirstGuideStep = computed(() => userGuideStepIndex.value === 0) const isFirstGuideStep = computed(() => userGuideStepIndex.value === 0)
const isLastGuideStep = computed(() => userGuideStepIndex.value >= userGuideSteps.length - 1) const isLastGuideStep = computed(() => userGuideStepIndex.value >= userGuideSteps.value.length - 1)
const guideProgressText = computed(() => `${userGuideStepIndex.value + 1} / ${userGuideSteps.length}`) const guideProgressText = computed(() => `${userGuideStepIndex.value + 1} / ${userGuideSteps.value.length}`)
const canCloseLeft = computed(() => { const canCloseLeft = computed(() => {
if (contextTabIndex.value <= 0) return false if (contextTabIndex.value <= 0) return false
return tabStore.tabs.slice(1, contextTabIndex.value).length > 0 return tabStore.tabs.slice(1, contextTabIndex.value).length > 0
@ -395,8 +341,104 @@ const newProjectIndustryLabel = computed(() => {
const toggleLocale = () => { const toggleLocale = () => {
const nextLocale: AppLocale = locale.value === 'en-US' ? 'zh-CN' : 'en-US' const nextLocale: AppLocale = locale.value === 'en-US' ? 'zh-CN' : 'en-US'
setAppLocale(nextLocale) uiPrefsStore.setLocale(nextLocale)
window.location.reload() }
const resolveHtFeeMethodRowNameByDict = (
storageKeyRaw: string,
rowIdRaw: string,
fallbackRaw: string
) => {
const storageKey = String(storageKeyRaw || '').trim()
const rowId = String(rowIdRaw || '').trim()
const fallback = String(fallbackRaw || '').trim()
if (!storageKey || !rowId) return fallback
if (storageKey.includes('-additional-work')) {
const item = getAdditionalWorkListEntries(locale.value).find(entry => String(entry?.id || '').trim() === rowId)
return String(item?.name || '').trim() || fallback
}
if (storageKey.includes('-reserve')) {
const item = getReserveListEntries(locale.value).find(entry => String(entry?.id || '').trim() === rowId)
return String(item?.name || '').trim() || fallback
}
return fallback
}
const syncTabLabelsByLocale = () => {
const nextProjectTitle = t('home.projectCalcTab')
const nextQuickTitle = t('home.quickCalcTab')
let changed = false
tabStore.tabs = tabStore.tabs.map((tab: any) => {
const currentProps = tab?.props && typeof tab.props === 'object' ? { ...tab.props } : undefined
if (tab.id === PROJECT_TAB_ID) {
if (tab.title === nextProjectTitle) return tab
changed = true
return { ...tab, title: nextProjectTitle }
}
if (tab.id === QUICK_TAB_ID) {
if (tab.title === nextQuickTitle) return tab
changed = true
return { ...tab, title: nextQuickTitle }
}
if (tab.componentName === 'QuickCalcView') {
const contractName = String(currentProps?.contractName || '').trim()
const nextTitle = t('ht.contractTabTitle', { name: contractName || '-' })
if (tab.title === nextTitle) return tab
changed = true
return { ...tab, title: nextTitle }
}
if (tab.componentName === 'ZxFwView') {
const serviceId = String(currentProps?.serviceId || '').trim()
const dict = serviceId ? (getServiceDictItemById(serviceId) as { code?: string; name?: string } | undefined) : undefined
const serviceName = dict
? `${String(dict.code || '').trim()}${String(dict.name || '').trim()}`
: String(currentProps?.fwName || '').trim()
const nextTitle = t('htZxFw.editTabTitle', { name: serviceName || '-' })
const nextProps = {
...(currentProps || {}),
fwName: serviceName || String(currentProps?.fwName || '')
}
if (tab.title === nextTitle && String(currentProps?.fwName || '') === String(nextProps.fwName || '')) return tab
changed = true
return { ...tab, title: nextTitle, props: nextProps }
}
if (tab.componentName === 'HtFeeMethodTypeLineView') {
const storageKey = String(currentProps?.storageKey || '').trim()
const rowId = String(currentProps?.rowId || '').trim()
const nextSourceTitle = storageKey.includes('-reserve')
? t('htSummary.reservePrefix')
: storageKey.includes('-additional-work')
? t('htSummary.additionalPrefix')
: String(currentProps?.sourceTitle || '')
const rows = storageKey
? (zxFwPricingStore.getHtFeeMainState<any>(storageKey)?.detailRows || [])
: []
const fromStoreName = Array.isArray(rows)
? String(rows.find((row: any) => String(row?.id || '') === rowId)?.name || '').trim()
: ''
const rowName = resolveHtFeeMethodRowNameByDict(
storageKey,
rowId,
fromStoreName || String(currentProps?.rowName || '').trim()
)
const nextTitle = t('htFeeGrid.editTabTitle', { name: rowName || t('htFeeGrid.unnamed') })
const nextProps = {
...(currentProps || {}),
sourceTitle: nextSourceTitle,
rowName
}
if (
tab.title === nextTitle
&& String(currentProps?.rowName || '') === String(nextProps.rowName || '')
&& String(currentProps?.sourceTitle || '') === String(nextProps.sourceTitle || '')
) return tab
changed = true
return { ...tab, title: nextTitle, props: nextProps }
}
return tab
})
if (!changed) return
scheduleUpdateTabTitleOverflow()
} }
const closeMenus = () => { const closeMenus = () => {
@ -705,7 +747,7 @@ const shouldAutoOpenGuide = async () => {
const openUserGuide = (startAt = 0) => { const openUserGuide = (startAt = 0) => {
closeMenus() closeMenus()
userGuideStepIndex.value = Math.min(Math.max(startAt, 0), userGuideSteps.length - 1) userGuideStepIndex.value = Math.min(Math.max(startAt, 0), userGuideSteps.value.length - 1)
userGuideOpen.value = true userGuideOpen.value = true
} }
@ -728,7 +770,7 @@ const nextUserGuideStep = () => {
} }
const jumpToGuideStep = (stepIndex: number) => { const jumpToGuideStep = (stepIndex: number) => {
userGuideStepIndex.value = Math.min(Math.max(stepIndex, 0), userGuideSteps.length - 1) userGuideStepIndex.value = Math.min(Math.max(stepIndex, 0), userGuideSteps.value.length - 1)
} }
const openTabContextMenu = (event: MouseEvent, tabId: string) => { const openTabContextMenu = (event: MouseEvent, tabId: string) => {
@ -1083,7 +1125,7 @@ const buildAdditionalExport = async (contractId: string): Promise<ExportAddition
return { return {
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] }, code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
name: '附加工作', name: t('htSummary.additionalPrefix'),
fee: toMoney(sumNumbers(det.map(item => item.fee))), fee: toMoney(sumNumbers(det.map(item => item.fee))),
det det
} }
@ -1100,7 +1142,7 @@ const buildReserveExport = async (contractId: string): Promise<ExportReserve | n
const rowSubtotal = getHtMainRowSubtotal(row) const rowSubtotal = getHtMainRowSubtotal(row)
if (!methodPayload && rowSubtotal == null) continue if (!methodPayload && rowSubtotal == null) continue
const reserve: ExportReserve = { const reserve: ExportReserve = {
name: row.name || '预备费', name: row.name || t('htSummary.reservePrefix'),
fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0), fee: toMoney(rowSubtotal ?? methodPayload?.fee ?? 0),
tasks: [] tasks: []
} }
@ -1134,7 +1176,9 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorState.resolved?.detailRows) const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorState.resolved?.detailRows)
const projectMajorCoes = buildProjectMajorCoes(majorFactorState.resolved?.detailRows) const projectMajorCoes = buildProjectMajorCoes(majorFactorState.resolved?.detailRows)
const projectName = isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目' const projectName = isNonEmptyString(projectInfo.projectName)
? projectInfo.projectName.trim()
: t('tab.messages.defaultProjectName')
const writer = isNonEmptyString(projectInfo.preparedBy) ? projectInfo.preparedBy.trim() : '' const writer = isNonEmptyString(projectInfo.preparedBy) ? projectInfo.preparedBy.trim() : ''
const reviewer = isNonEmptyString(projectInfo.reviewedBy) ? projectInfo.reviewedBy.trim() : '' const reviewer = isNonEmptyString(projectInfo.reviewedBy) ? projectInfo.reviewedBy.trim() : ''
const company = isNonEmptyString(projectInfo.preparedCompany) ? projectInfo.preparedCompany.trim() : '' const company = isNonEmptyString(projectInfo.preparedCompany) ? projectInfo.preparedCompany.trim() : ''
@ -1274,7 +1318,9 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
}) })
contracts.push({ contracts.push({
name: isNonEmptyString(contract.name) ? contract.name : `合同段-${index + 1}`, name: isNonEmptyString(contract.name)
? contract.name
: t('tab.messages.contractFallbackName', { index: index + 1 }),
serviceFee, serviceFee,
addtionalFee, addtionalFee,
reserveFee, reserveFee,
@ -1345,7 +1391,7 @@ const exportData = async () => {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch (error) { } catch (error) {
console.error('export failed:', error) console.error('export failed:', error)
showMessageDialog('导出失败', '请重试。') showMessageDialog(t('tab.toast.failed'), t('ht.retry'))
} finally { } finally {
dataMenuOpen.value = false dataMenuOpen.value = false
} }
@ -1355,15 +1401,17 @@ const exportReport = async () => {
try { try {
const now = new Date() const now = new Date()
const projectInfoRaw = await kvStore.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY) const projectInfoRaw = await kvStore.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY)
const projectName = isNonEmptyString(projectInfoRaw?.projectName) ? sanitizeFileNamePart(projectInfoRaw.projectName) : '造价项目' const projectName = isNonEmptyString(projectInfoRaw?.projectName)
const fileName = `${formatExportTimestamp(now)}-${projectName}预算文件` ? sanitizeFileNamePart(projectInfoRaw.projectName)
: t('tab.messages.defaultProjectName')
const fileName = `${formatExportTimestamp(now)}-${projectName}${t('tab.messages.reportFileSuffix')}`
const blobUrl = await exportFile(fileName, () => buildExportReportPayload(), () => { const blobUrl = await exportFile(fileName, () => buildExportReportPayload(), () => {
showReportExportProgress(30, '正在生成报表文件...') showReportExportProgress(30, t('tab.messages.reportGenerating'))
}) })
finishReportExportProgress(true, '报表导出完成', blobUrl) finishReportExportProgress(true, t('tab.messages.reportExportDone'), blobUrl)
} catch (error) { } catch (error) {
console.error('export report failed:', error) console.error('export report failed:', error)
finishReportExportProgress(false, '报表导出失败,请重试') finishReportExportProgress(false, t('tab.messages.reportExportFailedRetry'))
} finally { } finally {
dataMenuOpen.value = false dataMenuOpen.value = false
@ -1412,14 +1460,14 @@ const importData = async (event: Event) => {
} catch (error) { } catch (error) {
console.error('import failed:', error) console.error('import failed:', error)
if (error instanceof Error && error.message === 'PROJECT_ID_MISSING') { if (error instanceof Error && error.message === 'PROJECT_ID_MISSING') {
showMessageDialog('导入失败', '该数据包不包含项目标识(旧版本导出包),为避免串项目已禁止导入。') showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importProjectIdMissing'))
return return
} }
if (error instanceof Error && error.message.startsWith('PROJECT_ID_MISMATCH:')) { if (error instanceof Error && error.message.startsWith('PROJECT_ID_MISMATCH:')) {
showMessageDialog('导入失败', '该数据包属于其他项目,不能覆盖当前项目。') showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importProjectMismatch'))
return return
} }
showMessageDialog('导入失败', '文件无效、已损坏或被修改。') showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile'))
} finally { } finally {
input.value = '' input.value = ''
} }
@ -1479,6 +1527,9 @@ const confirmImportOverride = async () => {
kvStore.$patch(kvState as any) kvStore.$patch(kvState as any)
} }
// locale
syncTabLabelsByLocale()
await Promise.all([ await Promise.all([
tabStore.$persistNow?.(), tabStore.$persistNow?.(),
zxFwPricingStore.$persistNow?.(), zxFwPricingStore.$persistNow?.(),
@ -1490,7 +1541,7 @@ const confirmImportOverride = async () => {
window.location.reload() window.location.reload()
} catch (error) { } catch (error) {
console.error('import apply failed:', error) console.error('import apply failed:', error)
showMessageDialog('导入失败', '写入本地数据时发生错误。') showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importWriteError'))
} finally { } finally {
cancelImportConfirm() cancelImportConfirm()
} }
@ -1532,12 +1583,22 @@ const handleReset = async () => {
window.dispatchEvent(new CustomEvent('jgjs-release-project-lock')) window.dispatchEvent(new CustomEvent('jgjs-release-project-lock'))
const themePreference = const themePreference =
typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_PREFERENCE_KEY) : null typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_PREFERENCE_KEY) : null
const localePreference =
typeof localStorage !== 'undefined' ? localStorage.getItem(I18N_LOCALE_KEY) : null
const uiPrefsSnapshot =
typeof localStorage !== 'undefined' ? localStorage.getItem(UI_PREFS_STORAGE_KEY) : null
// 1) // 1)
localStorage.clear() localStorage.clear()
if (themePreference != null) { if (themePreference != null) {
localStorage.setItem(THEME_PREFERENCE_KEY, themePreference) localStorage.setItem(THEME_PREFERENCE_KEY, themePreference)
} }
if (localePreference != null) {
localStorage.setItem(I18N_LOCALE_KEY, localePreference)
}
if (uiPrefsSnapshot != null) {
localStorage.setItem(UI_PREFS_STORAGE_KEY, uiPrefsSnapshot)
}
sessionStorage.clear() sessionStorage.clear()
await projectDefaultForage.clear() await projectDefaultForage.clear()
@ -1602,7 +1663,7 @@ onMounted(() => {
if (pendingHomeImportFile) { if (pendingHomeImportFile) {
void prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => { void prepareImportPayloadFromFile(pendingHomeImportFile, { skipConfirm: skipWorkspaceImportConfirm }).catch(error => {
console.error('home import failed:', error) console.error('home import failed:', error)
showMessageDialog('导入失败', '文件无效、已损坏或被修改。') showMessageDialog(t('tab.messages.importFailedTitle'), t('tab.messages.importInvalidFile'))
}) })
} }
void (async () => { void (async () => {
@ -1687,6 +1748,14 @@ watch(
}) })
} }
) )
watch(
() => locale.value,
() => {
syncTabLabelsByLocale()
},
{ immediate: true }
)
</script> </script>
<template> <template>
@ -1815,6 +1884,18 @@ watch(
<CircleHelp class="h-4 w-4 mr-1" /> <CircleHelp class="h-4 w-4 mr-1" />
{{ t('tab.toolbar.userGuide') }} {{ t('tab.toolbar.userGuide') }}
</Button> </Button>
<Button
v-if="workspaceModeForUi === 'quick'"
variant="ghost"
size="sm"
class="h-9 min-w-9 px-2 shrink-0 cursor-pointer text-muted-foreground transition-all duration-200 hover:text-foreground"
:disabled="isResetting"
:title="t('tab.toolbar.language')"
:aria-label="t('tab.toolbar.language')"
@click="toggleLocale"
>
{{ localeLabel }}
</Button>
<Button <Button
v-if="workspaceModeForUi === 'quick'" v-if="workspaceModeForUi === 'quick'"
variant="destructive" variant="destructive"
@ -2052,7 +2133,7 @@ watch(
<div class="flex-1 overflow-auto relative"> <div class="flex-1 overflow-auto relative">
<div <div
v-if="activeTab" v-if="activeTab"
:key="activeTab.id" :key="`${activeTab.id}-${locale}`"
:ref="el => setTabPanelRef(activeTabId, el)" :ref="el => setTabPanelRef(activeTabId, el)"
class="h-full w-full animate-in fade-in duration-300" class="h-full w-full animate-in fade-in duration-300"
> >
@ -2111,7 +2192,7 @@ watch(
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<button v-for="(_step, index) in userGuideSteps" :key="`guide-dot-${index}`" <button v-for="(_step, index) in userGuideSteps" :key="`guide-dot-${index}`"
class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors" class="h-2.5 w-2.5 cursor-pointer rounded-full transition-colors"
:class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'" :aria-label="`跳转到第 ${index + 1} 步`" :class="index === userGuideStepIndex ? 'bg-primary' : 'bg-muted'" :aria-label="t('tab.guide.jumpToStep', { index: index + 1 })"
@click="jumpToGuideStep(index)" /> @click="jumpToGuideStep(index)" />
</div> </div>
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
@ -2145,10 +2226,10 @@ watch(
<ToastDescription class="mt-1 text-xs text-muted-foreground">{{ reportExportText }}</ToastDescription> <ToastDescription class="mt-1 text-xs text-muted-foreground">{{ reportExportText }}</ToastDescription>
<!-- <div v-if="reportExportStatus === 'success' && reportExportBlobUrl" class="mt-2 flex items-center gap-2"> <!-- <div v-if="reportExportStatus === 'success' && reportExportBlobUrl" class="mt-2 flex items-center gap-2">
<Button size="sm" class="h-7 rounded-md px-3 text-xs" @click="openExportedReport"> <Button size="sm" class="h-7 rounded-md px-3 text-xs" @click="openExportedReport">
打开文件 {{ t('tab.messages.openFile') }}
</Button> </Button>
<Button variant="ghost" size="sm" class="h-7 rounded-md px-2 text-xs text-muted-foreground" @click="dismissReportToast"> <Button variant="ghost" size="sm" class="h-7 rounded-md px-2 text-xs text-muted-foreground" @click="dismissReportToast">
关闭 {{ t('common.close') }}
</Button> </Button>
</div> --> </div> -->
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-2">

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch, type Component, onMounted } from 'vue' import { computed, onBeforeUnmount, ref, watch, type Component, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip' import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
@ -35,7 +36,7 @@ const props = withDefaults(
}>(), }>(),
{ {
scene: 'default', scene: 'default',
title: '配置', title: undefined,
subtitle: '', subtitle: '',
metaText: '', metaText: '',
copyText: '', copyText: '',
@ -44,6 +45,7 @@ const props = withDefaults(
persistActiveCategory: true persistActiveCategory: true
} }
) )
const { t } = useI18n()
const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`) const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
const scopedCacheKey = computed(() => `project:${readCurrentProjectId()}:${cacheKey.value}`) const scopedCacheKey = computed(() => `project:${readCurrentProjectId()}:${cacheKey.value}`)
@ -99,7 +101,7 @@ const dotInnerStyle = computed(() => ({ width: 'var(--app-typeline-dot-inner)',
const labelStyle = computed(() => ({ fontSize: 'var(--app-typeline-label-font)', lineHeight: 'var(--app-typeline-label-line)' })) const labelStyle = computed(() => ({ fontSize: 'var(--app-typeline-label-font)', lineHeight: 'var(--app-typeline-label-line)' }))
const lineStyle = computed(() => ({ left: 'var(--app-typeline-line-left)' })) const lineStyle = computed(() => ({ left: 'var(--app-typeline-line-left)' }))
const copyBtnText = ref('复制') const copyBtnText = ref(t('typeLine.copy'))
const sheetOpen = ref(false) const sheetOpen = ref(false)
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
@ -117,15 +119,15 @@ const handleCopySubtitle = async () => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
copyBtnText.value = '已复制' copyBtnText.value = t('typeLine.copied')
} catch (error) { } catch (error) {
console.error('copy failed:', error) console.error('copy failed:', error)
copyBtnText.value = '复制失败' copyBtnText.value = t('typeLine.copyFailed')
} }
if (copyBtnTimer) clearTimeout(copyBtnTimer) if (copyBtnTimer) clearTimeout(copyBtnTimer)
copyBtnTimer = setTimeout(() => { copyBtnTimer = setTimeout(() => {
copyBtnText.value = '复制' copyBtnText.value = t('typeLine.copy')
}, 1200) }, 1200)
} }
@ -282,8 +284,8 @@ useMotionValueEvent(
<DialogTrigger as-child> <DialogTrigger as-child>
<button type="button" <button type="button"
class="cursor-pointer absolute left-3 right-3 bottom-3 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[11px] leading-4 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"> class="cursor-pointer absolute left-3 right-3 bottom-3 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[11px] leading-4 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground">
<img src="/favicon.ico" alt="众为咨询" class="h-5 w-5 shrink-0 rounded-sm" /> <img src="/favicon.ico" :alt="t('typeLine.brandAlt')" class="h-5 w-5 shrink-0 rounded-sm" />
<span>本网站由众为工程咨询有限公司提供免费技术支持</span> <span>{{ t('typeLine.supportText') }}</span>
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogPortal> <DialogPortal>
@ -322,8 +324,8 @@ useMotionValueEvent(
</div> </div>
<DialogTitle class="mt-2"> <DialogTitle class="mt-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src="/favicon.ico" alt="众为咨询" class="h-7 w-7 shrink-0 rounded-sm" /> <img src="/favicon.ico" :alt="t('typeLine.brandAlt')" class="h-7 w-7 shrink-0 rounded-sm" />
<span class="text-2xl font-semibold leading-none">关于我们</span> <span class="text-2xl font-semibold leading-none">{{ t('typeLine.aboutTitle') }}</span>
</div> </div>
</DialogTitle> </DialogTitle>
</div> </div>
@ -332,11 +334,11 @@ useMotionValueEvent(
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer" <a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline"> class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline">
众为工程咨询有限公司 {{ t('typeLine.companyName') }}
</a> </a>
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer" <a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground" class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
aria-label="跳转到官网首页" title="官网首页"> :aria-label="t('typeLine.openOfficialSiteAria')" :title="t('typeLine.officialSiteTitle')">
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true"> <svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M7 7h10v10M7 17L17 7" /> stroke-width="2" d="M7 7h10v10M7 17L17 7" />
@ -347,11 +349,10 @@ useMotionValueEvent(
<div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7"> <div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
<p> <p>
众为工程咨询有限公司 2009 {{ t('typeLine.aboutParagraph1') }}
年成立专注工程造价与工程成本管控全过程咨询是广东省政府审计入库优选单位公司服务覆盖多领域全类型客户累计服务投资额超万亿元深度参与港珠澳大桥澳门大学横琴校区等国家级重点工程参编三十余项国家及省市行业标准
</p> </p>
<p> <p>
公司立足大湾区布局全球设有澳门公司斯里兰卡分公司具备跨境与海外项目服务能力以十五年专业沉淀万亿级项目经验为客户提供精准可靠的工程咨询服务 {{ t('typeLine.aboutParagraph2') }}
</p> </p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,55 @@
import type { ColDef, ColGroupDef } from 'ag-grid-community'
type AnyColDef<TRow> = ColDef<TRow> | ColGroupDef<TRow>
const mergeCellStyle = (cellStyle: ColDef['cellStyle']): ColDef['cellStyle'] => {
const baseStyle = { whiteSpace: 'normal', lineHeight: '1.4' }
if (!cellStyle) return baseStyle
if (typeof cellStyle === 'function') {
return params => {
const next = cellStyle(params)
if (next && typeof next === 'object') {
return {
...next,
...baseStyle
}
}
return baseStyle
}
}
if (typeof cellStyle === 'object') {
return {
...cellStyle,
...baseStyle
}
}
return cellStyle
}
const enhanceLeafColumn = <TRow>(col: ColDef<TRow>): ColDef<TRow> => {
const editable = col.editable
const isReadonlyColumn = editable == null || editable === false
if (!isReadonlyColumn) return { ...col }
return {
...col,
wrapText: true,
autoHeight: true,
cellStyle: mergeCellStyle(col.cellStyle)
}
}
const enhanceColumn = <TRow>(col: AnyColDef<TRow>): AnyColDef<TRow> => {
const maybeGroup = col as ColGroupDef<TRow>
if (Array.isArray(maybeGroup.children)) {
return {
...maybeGroup,
children: maybeGroup.children.map(child => enhanceColumn(child as AnyColDef<TRow>))
}
}
return enhanceLeafColumn(col as ColDef<TRow>)
}
export const withReadonlyAutoHeight = <TRow>(
defs: Array<AnyColDef<TRow>>
): Array<AnyColDef<TRow>> => defs.map(def => enhanceColumn(def))

View File

@ -1,4 +1,5 @@
import type { IHeaderComp, IHeaderParams } from 'ag-grid-community' import type { IHeaderComp, IHeaderParams } from 'ag-grid-community'
import { i18n } from '@/i18n'
export type ResetHeaderParams = IHeaderParams & { export type ResetHeaderParams = IHeaderParams & {
onReset?: () => void | Promise<void> onReset?: () => void | Promise<void>
@ -35,8 +36,9 @@ export class AgGridResetHeader implements IHeaderComp {
const eButton = document.createElement('button') const eButton = document.createElement('button')
eButton.type = 'button' eButton.type = 'button'
eButton.textContent = '↻' eButton.textContent = '↻'
eButton.title = params.resetTitle || '恢复默认值' const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
eButton.setAttribute('aria-label', params.resetTitle || '恢复默认值') eButton.title = params.resetTitle || fallbackResetTitle
eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
eButton.style.display = 'inline-flex' eButton.style.display = 'inline-flex'
eButton.style.alignItems = 'center' eButton.style.alignItems = 'center'
eButton.style.justifyContent = 'center' eButton.style.justifyContent = 'center'
@ -67,8 +69,9 @@ export class AgGridResetHeader implements IHeaderComp {
refresh(params: ResetHeaderParams) { refresh(params: ResetHeaderParams) {
this.params = params this.params = params
this.eLabel.textContent = params.displayName || params.column?.getColDef().headerName || '' this.eLabel.textContent = params.displayName || params.column?.getColDef().headerName || ''
this.eButton.title = params.resetTitle || '恢复默认值' const fallbackResetTitle = i18n.global.t('agGrid.resetDefault')
this.eButton.setAttribute('aria-label', params.resetTitle || '恢复默认值') this.eButton.title = params.resetTitle || fallbackResetTitle
this.eButton.setAttribute('aria-label', params.resetTitle || fallbackResetTitle)
this.eButton.style.visibility = params.onReset ? 'visible' : 'hidden' this.eButton.style.visibility = params.onReset ? 'visible' : 'hidden'
return true return true
} }

View File

@ -1,6 +1,11 @@
import type { import type {
CellPosition, CellPosition,
ColDef, ColDef,
GridApi,
GridSizeChangedEvent,
FirstDataRenderedEvent,
RowDataUpdatedEvent,
ColumnResizedEvent,
GridOptions, GridOptions,
SuppressKeyboardEventParams SuppressKeyboardEventParams
} from 'ag-grid-community' } from 'ag-grid-community'
@ -87,6 +92,17 @@ const suppressExcelLikeEnter = (params: SuppressKeyboardEventParams) => {
return true return true
} }
const syncRowHeightsWithJs = (api: GridApi | null | undefined) => {
if (!api || api.isDestroyed?.()) return
// 统一使用 JS 重算,规避 wrapText/居中样式组合导致的高度滞后。
setTimeout(() => {
if (!api || api.isDestroyed?.()) return
api.onRowHeightChanged()
api.refreshCells({ force: true })
api.redrawRows()
}, 0)
}
export const agGridDefaultColDef: ColDef = { export const agGridDefaultColDef: ColDef = {
resizable: true, resizable: true,
sortable: false, sortable: false,
@ -137,5 +153,17 @@ export const gridOptions: GridOptions = {
defaultColGroupDef: { defaultColGroupDef: {
wrapHeaderText: true, wrapHeaderText: true,
autoHeaderHeight: true autoHeaderHeight: true
},
onFirstDataRendered: (event: FirstDataRenderedEvent) => {
syncRowHeightsWithJs(event.api)
},
onRowDataUpdated: (event: RowDataUpdatedEvent) => {
syncRowHeightsWithJs(event.api)
},
onGridSizeChanged: (event: GridSizeChangedEvent) => {
syncRowHeightsWithJs(event.api)
},
onColumnResized: (event: ColumnResizedEvent) => {
syncRowHeightsWithJs(event.api)
} }
} }

View File

@ -4,8 +4,11 @@ import {
formatScaleReadonlyMoney, formatScaleReadonlyMoney,
getScaleMergeColSpanBeforeTotal getScaleMergeColSpanBeforeTotal
} from '@/lib/pricingScaleGrid' } from '@/lib/pricingScaleGrid'
import { i18n } from '@/i18n'
type ScaleColumnField<TRow> = Extract<keyof TRow, string> | string type ScaleColumnField<TRow> = Extract<keyof TRow, string> | string
const scaleT = (key: string, params?: Record<string, unknown>) =>
params ? i18n.global.t(`pricingScale.${key}`, params) : i18n.global.t(`pricingScale.${key}`)
export const createScaleValueColumn = <TRow>(options: { export const createScaleValueColumn = <TRow>(options: {
headerName: string headerName: string
@ -50,11 +53,11 @@ export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
getCheckedSplit: (row: TRow | undefined) => { basic?: number | null; optional?: number | null; total?: number | null } | null getCheckedSplit: (row: TRow | undefined) => { basic?: number | null; optional?: number | null; total?: number | null } | null
createBudgetCellRendererWithCheck: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any createBudgetCellRendererWithCheck: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
}) : ColGroupDef<TRow> => ({ }) : ColGroupDef<TRow> => ({
headerName: '基准预算(元)', headerName: scaleT('columns.benchmarkBudget'),
marryChildren: true, marryChildren: true,
children: [ children: [
{ {
headerName: '基本工作', headerName: scaleT('columns.basicWork'),
field: 'benchmarkBudgetBasic' as any, field: 'benchmarkBudgetBasic' as any,
colId: 'benchmarkBudgetBasic', colId: 'benchmarkBudgetBasic',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
@ -71,7 +74,7 @@ export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
valueFormatter: formatScaleReadonlyMoney valueFormatter: formatScaleReadonlyMoney
}, },
{ {
headerName: '可选工作', headerName: scaleT('columns.optionalWork'),
field: 'benchmarkBudgetOptional' as any, field: 'benchmarkBudgetOptional' as any,
colId: 'benchmarkBudgetOptional', colId: 'benchmarkBudgetOptional',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
@ -88,7 +91,7 @@ export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
valueFormatter: formatScaleReadonlyMoney valueFormatter: formatScaleReadonlyMoney
}, },
{ {
headerName: '小计', headerName: scaleT('columns.subtotal'),
field: 'benchmarkBudget' as any, field: 'benchmarkBudget' as any,
colId: 'benchmarkBudgetTotal', colId: 'benchmarkBudgetTotal',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
@ -111,18 +114,18 @@ export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
getBudgetFee: (row: TRow | undefined) => number | null getBudgetFee: (row: TRow | undefined) => number | null
aggFunc: any aggFunc: any
}) : ColGroupDef<TRow> => ({ }) : ColGroupDef<TRow> => ({
headerName: '预算费用', headerName: scaleT('columns.budgetFee'),
marryChildren: true, marryChildren: true,
children: [ children: [
{ {
headerName: '咨询分类系数', headerName: scaleT('columns.consultCategoryFactor'),
field: 'consultCategoryFactor' as any, field: 'consultCategoryFactor' as any,
colId: 'consultCategoryFactor', colId: 'consultCategoryFactor',
headerTooltip: '点击右侧↻恢复本列默认咨询分类系数', headerTooltip: scaleT('tooltip.resetConsultCategoryFactor'),
headerComponent: options.headerComponent, headerComponent: options.headerComponent,
headerComponentParams: { headerComponentParams: {
onReset: options.restoreConsultCategoryFactorColumnDefaults, onReset: options.restoreConsultCategoryFactorColumnDefaults,
resetTitle: '恢复本列默认咨询分类系数' resetTitle: scaleT('tooltip.resetConsultCategoryFactor')
}, },
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 80, minWidth: 80,
@ -141,14 +144,14 @@ export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
valueFormatter: params => formatScaleEditableNumber(params) valueFormatter: params => formatScaleEditableNumber(params)
}, },
{ {
headerName: '专业系数', headerName: scaleT('columns.majorFactor'),
field: 'majorFactor' as any, field: 'majorFactor' as any,
colId: 'majorFactor', colId: 'majorFactor',
headerTooltip: '点击右侧↻恢复本列默认专业系数', headerTooltip: scaleT('tooltip.resetMajorFactor'),
headerComponent: options.headerComponent, headerComponent: options.headerComponent,
headerComponentParams: { headerComponentParams: {
onReset: options.restoreMajorFactorColumnDefaults, onReset: options.restoreMajorFactorColumnDefaults,
resetTitle: '恢复本列默认专业系数' resetTitle: scaleT('tooltip.resetMajorFactor')
}, },
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 80, minWidth: 80,
@ -167,7 +170,7 @@ export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
valueFormatter: params => formatScaleEditableNumber(params) valueFormatter: params => formatScaleEditableNumber(params)
}, },
{ {
headerName: '工作环节系数(编审系数)', headerName: scaleT('columns.workStageFactor'),
field: 'workStageFactor' as any, field: 'workStageFactor' as any,
colId: 'workStageFactor', colId: 'workStageFactor',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
@ -187,7 +190,7 @@ export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
valueFormatter: params => formatScaleEditableNumber(params) valueFormatter: params => formatScaleEditableNumber(params)
}, },
{ {
headerName: '工作占比(%)', headerName: scaleT('columns.workRatio'),
field: 'workRatio' as any, field: 'workRatio' as any,
colId: 'workRatio', colId: 'workRatio',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
@ -207,7 +210,7 @@ export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
valueFormatter: params => formatScaleEditableNumber(params, 2) valueFormatter: params => formatScaleEditableNumber(params, 2)
}, },
{ {
headerName: '合计', headerName: scaleT('columns.total'),
field: 'budgetFee' as any, field: 'budgetFee' as any,
colId: 'budgetFeeTotal', colId: 'budgetFeeTotal',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
@ -224,7 +227,7 @@ export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
}) })
export const createScaleRemarkColumn = <TRow>() : ColDef<TRow> => ({ export const createScaleRemarkColumn = <TRow>() : ColDef<TRow> => ({
headerName: '说明', headerName: scaleT('columns.remark'),
field: 'remark' as any, field: 'remark' as any,
minWidth: 100, minWidth: 100,
flex: 1.2, flex: 1.2,
@ -234,7 +237,7 @@ export const createScaleRemarkColumn = <TRow>() : ColDef<TRow> => ({
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' }, cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned, editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => { valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入' if (!params.node?.group && !params.node?.rowPinned && !params.value) return scaleT('clickToInput')
return params.value || '' return params.value || ''
}, },
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''), cellClass: params => (!params.node?.group && !params.node?.rowPinned ? ' remark-wrap-cell' : ''),
@ -249,9 +252,15 @@ export const createScaleAutoGroupColumn = <TRow>(options: {
idLabelMap: Map<string, string> idLabelMap: Map<string, string>
parseProjectIndexFromPathKey: (key: string) => number | null parseProjectIndexFromPathKey: (key: string) => number | null
}) : ColDef<TRow> => ({ }) : ColDef<TRow> => ({
headerName: '专业编码以及工程专业名称', headerName: scaleT('columns.majorGroup'),
minWidth: 250, minWidth: 250,
flex: 2, flex: 2,
wrapText: true,
autoHeight: true,
cellStyle: {
whiteSpace: 'normal',
lineHeight: '1.4'
},
cellRendererParams: { cellRendererParams: {
suppressCount: true suppressCount: true
}, },
@ -266,7 +275,7 @@ export const createScaleAutoGroupColumn = <TRow>(options: {
} }
const nodeId = String(params.value || '') const nodeId = String(params.value || '')
const projectIndex = options.parseProjectIndexFromPathKey(nodeId) const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return `项目${projectIndex}` if (projectIndex != null) return scaleT('projectLabel', { index: projectIndex })
return options.idLabelMap.get(nodeId) || nodeId return options.idLabelMap.get(nodeId) || nodeId
}, },
tooltipValueGetter: params => { tooltipValueGetter: params => {
@ -277,7 +286,7 @@ export const createScaleAutoGroupColumn = <TRow>(options: {
} }
const nodeId = String(params.value || '') const nodeId = String(params.value || '')
const projectIndex = options.parseProjectIndexFromPathKey(nodeId) const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return `项目${projectIndex}` if (projectIndex != null) return scaleT('projectLabel', { index: projectIndex })
return options.idLabelMap.get(nodeId) || nodeId return options.idLabelMap.get(nodeId) || nodeId
} }
}) })

View File

@ -1,4 +1,5 @@
import { QUICK_PROJECT_ID, normalizeProjectId } from '@/lib/workspace' import { QUICK_PROJECT_ID, normalizeProjectId } from '@/lib/workspace'
import { i18n } from '@/i18n'
const PROJECT_REGISTRY_KEY = 'jgjs-project-registry-v1' const PROJECT_REGISTRY_KEY = 'jgjs-project-registry-v1'
@ -24,7 +25,11 @@ const defaultProjects = (): ProjectMeta[] => {
const sanitizeProject = (item: Partial<ProjectMeta> | null | undefined): ProjectMeta | null => { const sanitizeProject = (item: Partial<ProjectMeta> | null | undefined): ProjectMeta | null => {
if (!item || typeof item !== 'object') return null if (!item || typeof item !== 'object') return null
const id = normalizeProjectId(item.id) const id = normalizeProjectId(item.id)
const name = String(item.name || '').trim() || (id === QUICK_PROJECT_ID ? '快速计算' : `项目-${id}`) const name = String(item.name || '').trim() || (
id === QUICK_PROJECT_ID
? i18n.global.t('quickCalc.projectName')
: i18n.global.t('tab.messages.projectNamePrefix', { id })
)
const createdAt = typeof item.createdAt === 'string' && item.createdAt ? item.createdAt : nowIso() const createdAt = typeof item.createdAt === 'string' && item.createdAt ? item.createdAt : nowIso()
const updatedAt = typeof item.updatedAt === 'string' && item.updatedAt ? item.updatedAt : createdAt const updatedAt = typeof item.updatedAt === 'string' && item.updatedAt ? item.updatedAt : createdAt
const lastOpenedAt = const lastOpenedAt =
@ -78,7 +83,11 @@ export const upsertProject = (
if (index < 0) { if (index < 0) {
payload.projects.push({ payload.projects.push({
id, id,
name: name || (id === QUICK_PROJECT_ID ? '快速计算' : `项目-${id}`), name: name || (
id === QUICK_PROJECT_ID
? i18n.global.t('quickCalc.projectName')
: i18n.global.t('tab.messages.projectNamePrefix', { id })
),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
lastOpenedAt: now lastOpenedAt: now
@ -130,7 +139,7 @@ export const createProject = (nameRaw?: string) => {
do { do {
id = normalizeProjectId(`p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`) id = normalizeProjectId(`p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
} while (payload.projects.some(item => item.id === id)) } while (payload.projects.some(item => item.id === id))
const name = String(nameRaw || '').trim() || `项目-${payload.projects.length + 1}` const name = String(nameRaw || '').trim() || i18n.global.t('tab.messages.projectNamePrefix', { id: payload.projects.length + 1 })
const project: ProjectMeta = { id, name, createdAt: now, updatedAt: now, lastOpenedAt: now } const project: ProjectMeta = { id, name, createdAt: now, updatedAt: now, lastOpenedAt: now }
payload.projects.push(project) payload.projects.push(project)
writePayload(payload) writePayload(payload)

View File

@ -1,3 +1,5 @@
import { i18n } from '@/i18n'
export type WorkspaceMode = 'project' | 'quick' export type WorkspaceMode = 'project' | 'quick'
export const PROJECT_TAB_ID = 'ProjectCalcView' export const PROJECT_TAB_ID = 'ProjectCalcView'
@ -16,7 +18,7 @@ export const PROJECT_DB_NAME_PREFIX = 'DB'
export const QUICK_CONTRACT_ID = 'quick-contract-default' export const QUICK_CONTRACT_ID = 'quick-contract-default'
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1' export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
export const QUICK_CONTRACT_FALLBACK_NAME = '快速计算' export const QUICK_CONTRACT_FALLBACK_NAME = i18n.global.t('quickCalc.projectName')
export const QUICK_CONTRACT_TAB_ID = `contract-${QUICK_CONTRACT_ID}` 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_INFO_KEY = 'quick-xm-base-info-v1'

View File

@ -20,6 +20,7 @@ RenderApiModule ,ColumnApiModule ,CellSpanModule ,RowStyleModule ,RowSelectionM
} from 'ag-grid-enterprise' } from 'ag-grid-enterprise'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import piniaPersistedstate from '@/pinia/Plugin/indexdb' import piniaPersistedstate from '@/pinia/Plugin/indexdb'
import { useUiPrefsStore } from '@/pinia/uiPrefs'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './style.css' import './style.css'
@ -76,6 +77,8 @@ pinia.use(
mode: 'multiple' mode: 'multiple'
}) })
) )
const uiPrefsStore = useUiPrefsStore(pinia)
uiPrefsStore.initFromStorage()
// 在应用启动时一次性注册 AG Grid 运行所需模块。 // 在应用启动时一次性注册 AG Grid 运行所需模块。
ModuleRegistry.registerModules(AG_GRID_MODULES) ModuleRegistry.registerModules(AG_GRID_MODULES)

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { i18n } from '@/i18n'
import { import {
PROJECT_TAB_ID, PROJECT_TAB_ID,
QUICK_CONTRACT_TAB_ID, QUICK_CONTRACT_TAB_ID,
@ -16,7 +17,7 @@ export interface TabItem<TProps = Record<string, unknown>> {
const DEFAULT_PROJECT_TAB: TabItem = { const DEFAULT_PROJECT_TAB: TabItem = {
id: PROJECT_TAB_ID, id: PROJECT_TAB_ID,
title: '项目卡片', title: i18n.global.t('home.projectCalcTab'),
componentName: 'ProjectCalcView' componentName: 'ProjectCalcView'
} }

57
src/pinia/uiPrefs.ts Normal file
View File

@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { setAppLocale, type AppLocale } from '@/i18n'
export const UI_PREFS_STORAGE_KEY = 'jgjs-ui-prefs-v1'
type UiPrefsState = {
locale: AppLocale | null
hasLocaleOverride: boolean
}
const isAppLocale = (value: unknown): value is AppLocale =>
value === 'zh-CN' || value === 'en-US'
const readPersistedPrefs = (): Partial<UiPrefsState> | null => {
if (typeof window === 'undefined') return null
const raw = localStorage.getItem(UI_PREFS_STORAGE_KEY)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as Partial<UiPrefsState>
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
const writePersistedPrefs = (state: UiPrefsState) => {
if (typeof window === 'undefined') return
localStorage.setItem(UI_PREFS_STORAGE_KEY, JSON.stringify(state))
}
export const useUiPrefsStore = defineStore('uiPrefs', {
state: (): UiPrefsState => ({
locale: null,
hasLocaleOverride: false
}),
actions: {
initFromStorage() {
const persisted = readPersistedPrefs()
if (!persisted) return
if (persisted.hasLocaleOverride === true && isAppLocale(persisted.locale)) {
this.locale = persisted.locale
this.hasLocaleOverride = true
setAppLocale(persisted.locale)
}
},
setLocale(locale: AppLocale) {
this.locale = locale
this.hasLocaleOverride = true
setAppLocale(locale)
writePersistedPrefs({
locale: this.locale,
hasLocaleOverride: this.hasLocaleOverride
})
}
}
})

View File

@ -1,6 +1,7 @@
// @ts-nocheck // @ts-nocheck
import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal' import { addNumbers, roundTo, toDecimal, toFiniteNumberOrZero } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat' import { formatThousands } from '@/lib/numberFormat'
import { DEFAULT_LOCALE, i18n, I18N_LOCALE_KEY } from '@/i18n'
import { import {
EXPERT_NAME_EN_BY_CODE, EXPERT_NAME_EN_BY_CODE,
MAJOR_NAME_EN_BY_CODE, MAJOR_NAME_EN_BY_CODE,
@ -61,6 +62,15 @@ const normalizeServiceTaskGroups = (tasks: unknown): Array<{ serviceid?: number;
}); });
return groups; return groups;
} }
const resolveRuntimeLocale = () => {
const i18nLocale = String(i18n?.global?.locale?.value || '').trim()
if (i18nLocale) return i18nLocale
if (typeof window === 'undefined') return DEFAULT_LOCALE
const stored = String(localStorage.getItem(I18N_LOCALE_KEY) || '').trim()
if (stored) return stored
const language = String(navigator.language || '').toLowerCase()
return language.startsWith('en') ? 'en-US' : DEFAULT_LOCALE
}
export type WorkType = '基本工作' | '可选工作' | '日常顾问' | '专项顾问' | '附加工作' | '自定义' export type WorkType = '基本工作' | '可选工作' | '日常顾问' | '专项顾问' | '附加工作' | '自定义'
export const TYPE_LABEL_MAP: Record<number, WorkType> = { export const TYPE_LABEL_MAP: Record<number, WorkType> = {
@ -76,16 +86,6 @@ export const industryTypeList = [
{ id: '1', name: '铁路工程', nameEn: 'Railway Engineering', type: 'isRailway' }, { id: '1', name: '铁路工程', nameEn: 'Railway Engineering', type: 'isRailway' },
{ id: '2', name: '水运工程', nameEn: 'Waterway Engineering', type: 'isWaterway' } { id: '2', name: '水运工程', nameEn: 'Waterway Engineering', type: 'isWaterway' }
] as const ] as const
const runtimeLocale = typeof window === 'undefined'
? 'zh-cn'
: String(localStorage.getItem('jgjs-locale-v1') || 'zh-CN').toLowerCase()
if (runtimeLocale.startsWith('en')) {
;(industryTypeList as unknown as Array<Record<string, any>>).forEach((item) => {
if (typeof item?.nameEn === 'string' && item.nameEn.trim()) {
item.name = item.nameEn
}
})
}
export const getIndustryDisplayName = ( export const getIndustryDisplayName = (
industryId: string | number, industryId: string | number,
@ -113,8 +113,7 @@ const attachNameEnByCode = (source: Record<string, any>, nameMap: Record<string,
} }
const getCurrentLocale = () => { const getCurrentLocale = () => {
if (typeof window === 'undefined') return 'zh-CN' return resolveRuntimeLocale()
return String(localStorage.getItem('jgjs-locale-v1') || 'zh-CN')
} }
const isEnglishLocale = (locale: string) => String(locale || '').toLowerCase().startsWith('en') const isEnglishLocale = (locale: string) => String(locale || '').toLowerCase().startsWith('en')
@ -132,19 +131,6 @@ const localizeDictItem = (item: Record<string, any>) => {
} }
} }
const applyLocaleToDictionary = (source: Record<string, any>) => {
const locale = getCurrentLocale()
if (!isEnglishLocale(locale)) return
Object.values(source).forEach((item) => {
if (!item || typeof item !== 'object') return
const target = item as Record<string, any>
if (typeof target.nameEn === 'string' && target.nameEn.trim()) target.name = target.nameEn
if (typeof target.quickLabelEn === 'string' && target.quickLabelEn.trim()) target.quickLabel = target.quickLabelEn
if (typeof target.basicParamEn === 'string' && target.basicParamEn.trim()) target.basicParam = target.basicParamEn
if (typeof target.unitEn === 'string' && target.unitEn.trim()) target.unit = target.unitEn
if (typeof target.descEn === 'string' && target.descEn.trim()) target.desc = target.descEn
})
}
export const majorList = { export const majorList = {
0: { code: 'E1', name: '交通运输工程通用专业', hideInIndustrySelector: true, maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 1, hasCost: false, hasArea: false }, 0: { code: 'E1', name: '交通运输工程通用专业', hideInIndustrySelector: true, maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 1, hasCost: false, hasArea: false },
1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 2, hasCost: true, hasArea: true }, 1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 2, hasCost: true, hasArea: true },
@ -283,10 +269,6 @@ attachNameEnByCode(majorList as Record<string, any>, MAJOR_NAME_EN_BY_CODE)
attachNameEnByCode(serviceList as Record<string, any>, SERVICE_NAME_EN_BY_CODE) attachNameEnByCode(serviceList as Record<string, any>, SERVICE_NAME_EN_BY_CODE)
attachNameEnByCode(taskList as Record<string, any>, TASK_NAME_EN_BY_CODE) attachNameEnByCode(taskList as Record<string, any>, TASK_NAME_EN_BY_CODE)
attachNameEnByCode(expertList as Record<string, any>, EXPERT_NAME_EN_BY_CODE) attachNameEnByCode(expertList as Record<string, any>, EXPERT_NAME_EN_BY_CODE)
applyLocaleToDictionary(majorList as Record<string, any>)
applyLocaleToDictionary(serviceList as Record<string, any>)
applyLocaleToDictionary(taskList as Record<string, any>)
applyLocaleToDictionary(expertList as Record<string, any>)
export const additionalWorkList = [ export const additionalWorkList = [
{ {
@ -297,7 +279,8 @@ export const additionalWorkList = [
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' } { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }
] ]
}, },
name: '人员驻场服务及其他附加工作' name: '人员驻场服务及其他附加工作',
nameEn: 'On-site Personnel Service and Other Additional Work'
}, },
{ {
id: '2', id: '2',
@ -307,7 +290,8 @@ export const additionalWorkList = [
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'X' } { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'X' }
] ]
}, },
name: '咨询服务协调工作' name: '咨询服务协调工作',
nameEn: 'Consulting Service Coordination'
} }
] ]
@ -320,172 +304,208 @@ export const reserveList = [
{ font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' } { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }
] ]
}, },
name: '预备费' name: '预备费',
nameEn: 'Reserve Fee'
} }
] ]
export const getAdditionalWorkListEntries = (locale: string = getCurrentLocale()) => {
const useEnglish = isEnglishLocale(locale)
return additionalWorkList.map(item => ({
...item,
name: useEnglish ? String((item as Record<string, any>).nameEn || item.name || '') : String(item.name || '')
}))
}
export const getReserveListEntries = (locale: string = getCurrentLocale()) => {
const useEnglish = isEnglishLocale(locale)
return reserveList.map(item => ({
...item,
name: useEnglish ? String((item as Record<string, any>).nameEn || item.name || '') : String(item.name || '')
}))
}
//工作内容词典 //工作内容词典
export const workList = { export const workList = {
0: { text: '完成投资估算文件及主要技术经济指标,完成与相关单位核对投资估算', serviceid: 6, order: 1, type: 0 }, 0: { text: '完成投资估算文件及主要技术经济指标,完成与相关单位核对投资估算', textEn: 'Complete the investment estimation documents and main technical and economic indicators, and complete the investment estimation with the relevant units.', serviceid: 6, order: 1, type: 0 },
1: { text: '根据项目建议书或可行性研究报告的变化,动态调整投资估算和技术经济指标,并对比各环节投资估算变化,完成项目建议书阶段到可行性研究阶段的量、价对比及原因分析', serviceid: 6, order: 2, type: 0 }, 1: { text: '根据项目建议书或可行性研究报告的变化,动态调整投资估算和技术经济指标,并对比各环节投资估算变化,完成项目建议书阶段到可行性研究阶段的量、价对比及原因分析', textEn: 'According to the changes in the project proposal or feasibility study report, dynamically adjust the investment estimate and technical and economic indicators, compare the changes in the investment estimate of each link, and complete the quantitative, price comparison and reason analysis from the project proposal stage to the feasibility study stage.', serviceid: 6, order: 2, type: 0 },
2: { text: '参加项目建议书或可行性研究的技术论证会议、评审会议和与确定投资估算相关的会议', serviceid: 6, order: 3, type: 0 }, 2: { text: '参加项目建议书或可行性研究的技术论证会议、评审会议和与确定投资估算相关的会议', textEn: 'Participate in technical demonstration meetings, review meetings, and meetings related to determining investment estimates for a project proposal or feasibility study', serviceid: 6, order: 3, type: 0 },
3: { text: '对比类似项目的技术经济指标,分析技术经济指标差异原因,提供造价差异分析报告', serviceid: 6, order: 4, type: 1 }, 3: { text: '对比类似项目的技术经济指标,分析技术经济指标差异原因,提供造价差异分析报告', textEn: 'Compare technical and economic indicators of similar projects, analyze the reasons for differences in technical and economic indicators, and provide cost difference analysis reports', serviceid: 6, order: 4, type: 1 },
4: { text: '根据造价差异分析结果,提出调整工程方案的咨询意见供委托人或关联单位决策', serviceid: 6, order: 5, type: 1 }, 4: { text: '根据造价差异分析结果,提出调整工程方案的咨询意见供委托人或关联单位决策', textEn: 'Based on the results of the cost variance analysis, advise on adjusting the project plan for the decision of the client or affiliated unit', serviceid: 6, order: 5, type: 1 },
5: { text: '依据投资估算和项目建设计划,编制项目资金计划,供委托人决策', serviceid: 6, order: 6, type: 1 }, 5: { text: '依据投资估算和项目建设计划,编制项目资金计划,供委托人决策', textEn: 'Prepare the project funding plan based on the investment estimate and the project construction plan for the client to decide', serviceid: 6, order: 6, type: 1 },
6: { text: '完成设计概算文件及主要技术经济指标,完成与相关单位核对设计概算', serviceid: 7, order: 7, type: 0 }, 6: { text: '完成设计概算文件及主要技术经济指标,完成与相关单位核对设计概算', textEn: 'Complete the design budget proposal document and the main technical and economic indicators, and complete the verification of the design budget estimate with the relevant units.', serviceid: 7, order: 7, type: 0 },
7: { text: '依据各版初步设计动态调整设计概算及经济指标,并对比各版设计概算结果,完成可行性研究阶段到初步设计阶段的造价对比,提供造价差异分析报告', serviceid: 7, order: 8, type: 0 }, 7: { text: '依据各版初步设计动态调整设计概算及经济指标,并对比各版设计概算结果,完成可行性研究阶段到初步设计阶段的造价对比,提供造价差异分析报告', textEn: 'Dynamically adjust the design estimate and economic indicators according to each version of the preliminary design, compare the results of each version of the design estimate, complete the cost comparison from the feasibility study stage to the preliminary design stage, and provide a cost difference analysis report.', serviceid: 7, order: 8, type: 0 },
8: { text: '参加初步设计的技术论证会议、评审会议和与设计概算相关的会议', serviceid: 7, order: 9, type: 0 }, 8: { text: '参加初步设计的技术论证会议、评审会议和与设计概算相关的会议', textEn: 'Attend preliminary design technical demonstration meetings, review meetings, and meetings related to design budget proposals', serviceid: 7, order: 9, type: 0 },
9: { text: '对比类似项目的技术经济指标,分析技术经济指标差异原因,提供造价差异分析报告', serviceid: 7, order: 10, type: 1 }, 9: { text: '对比类似项目的技术经济指标,分析技术经济指标差异原因,提供造价差异分析报告', textEn: 'Compare technical and economic indicators of similar projects, analyze the reasons for differences in technical and economic indicators, and provide cost difference analysis reports', serviceid: 7, order: 10, type: 1 },
10: { text: '根据造价差异分析结果,提出调整工程设计方案的咨询意见供委托人或关联单位决策', serviceid: 7, order: 11, type: 1 }, 10: { text: '根据造价差异分析结果,提出调整工程设计方案的咨询意见供委托人或关联单位决策', textEn: 'According to the results of the cost difference analysis, advise on adjusting the engineering design plan for the decision of the client or affiliated unit', serviceid: 7, order: 11, type: 1 },
11: { text: '依据设计概算和项目建设计划,编制或调整项目资金计划,供委托人决策', serviceid: 7, order: 12, type: 1 }, 11: { text: '依据设计概算和项目建设计划,编制或调整项目资金计划,供委托人决策', textEn: 'Prepare or adjust the project funding plan according to the design estimate and the project construction plan for the client to decide', serviceid: 7, order: 12, type: 1 },
12: { text: '完成施工图预算文件及主要技术经济指标,完成与相关单位核对施工图预算', serviceid: 8, order: 13, type: 0 }, 12: { text: '完成施工图预算文件及主要技术经济指标,完成与相关单位核对施工图预算', textEn: 'Complete the construction drawing budget document and main technical and economic indicators, and complete checking the construction drawing budget with the relevant units', serviceid: 8, order: 13, type: 0 },
13: { text: '依据各版施工图动态调整施工图预算,并对比各版施工图预算结果,完成施工图预算与设计概算的造价对比(如有必要应完成与投资估算的造价对比),提供造价差异分析报告', serviceid: 8, order: 14, type: 0 }, 13: { text: '依据各版施工图动态调整施工图预算,并对比各版施工图预算结果,完成施工图预算与设计概算的造价对比(如有必要应完成与投资估算的造价对比),提供造价差异分析报告', textEn: 'Dynamically adjust the construction drawing budget according to each version of the construction drawing, compare the budget results of each version of the construction drawing, complete the cost comparison between the construction drawing budget and the design estimate (if necessary, complete the cost comparison with the investment estimate), and provide a cost difference analysis report', serviceid: 8, order: 14, type: 0 },
14: { text: '参加施工图设计的技术论证会议、评审会议和与施工图预算相关的会议', serviceid: 8, order: 15, type: 0 }, 14: { text: '参加施工图设计的技术论证会议、评审会议和与施工图预算相关的会议', textEn: 'Participate in technical demonstration meetings, review meetings, and meetings related to construction drawing budgets for construction drawing designs', serviceid: 8, order: 15, type: 0 },
15: { text: '对比类似项目的技术经济指标,分析技术经济指标差异原因,提供造价差异分析报告', serviceid: 8, order: 16, type: 1 }, 15: { text: '对比类似项目的技术经济指标,分析技术经济指标差异原因,提供造价差异分析报告', textEn: 'Compare technical and economic indicators of similar projects, analyze the reasons for differences in technical and economic indicators, and provide cost difference analysis reports', serviceid: 8, order: 16, type: 1 },
16: { text: '根据造价差异分析结果,提出调整工程设计方案的咨询意见供委托人或关联单位决策', serviceid: 8, order: 17, type: 1 }, 16: { text: '根据造价差异分析结果,提出调整工程设计方案的咨询意见供委托人或关联单位决策', textEn: 'According to the results of the cost difference analysis, advise on adjusting the engineering design plan for the decision of the client or affiliated unit', serviceid: 8, order: 17, type: 1 },
17: { text: '依据施工图预算和项目建设计划,编制或调整项目资金计划,供委托人决策', serviceid: 8, order: 18, type: 1 }, 17: { text: '依据施工图预算和项目建设计划,编制或调整项目资金计划,供委托人决策', textEn: 'Prepare or adjust the project funding plan according to the construction drawing budget and the project construction plan for the client to decide', serviceid: 8, order: 18, type: 1 },
18: { text: '分析专项设计方案的经济合理性,提出咨询意见供委托人决策', serviceid: 8, order: 19, type: 1 }, 18: { text: '分析专项设计方案的经济合理性,提出咨询意见供委托人决策', textEn: 'Analyze the economic rationality of the special design plan, and provide advice for the client to decide', serviceid: 8, order: 19, type: 1 },
19: { text: '分析施工组织设计和交通组织方案的经济合理性,提出咨询意见供委托人决策', serviceid: 8, order: 20, type: 1 }, 19: { text: '分析施工组织设计和交通组织方案的经济合理性,提出咨询意见供委托人决策', textEn: 'Analyze the economic rationality of the construction organization design and transportation organization plan, and provide advice for the client to make decisions', serviceid: 8, order: 20, type: 1 },
20: { text: '完成招标工程量清单及工程量清单预算,与相关单位核对工程量清单及工程量清单预算,并完成修改与调整', serviceid: 9, order: 21, type: 0 }, 20: { text: '完成招标工程量清单及工程量清单预算,与相关单位核对工程量清单及工程量清单预算,并完成修改与调整', textEn: 'Complete the bidding bill of quantities and the bill of quantities budget, check the bill of quantities and the bill of quantities budget with the relevant units, and complete the revision and adjustment', serviceid: 9, order: 21, type: 0 },
21: { text: '对比工程量清单预算和施工图预算,提供造价差异分析报告', serviceid: 9, order: 22, type: 0 }, 21: { text: '对比工程量清单预算和施工图预算,提供造价差异分析报告', textEn: 'Compare the bill of quantities budget and construction drawing budget, and provide a cost variance analysis report', serviceid: 9, order: 22, type: 0 },
22: { text: '审核计量与支付条款,对招标文件和合同文件涉及合同价款、工程变更费用咨询、结算费用等与造价相关条款提供咨询意见', serviceid: 9, order: 23, type: 0 }, 22: { text: '审核计量与支付条款,对招标文件和合同文件涉及合同价款、工程变更费用咨询、结算费用等与造价相关条款提供咨询意见', textEn: 'Review the measurement and payment clauses, and provide advice on the bidding documents and contract documents related to the contract price, engineering change cost consultation, settlement costs, and other cost-related clauses', serviceid: 9, order: 23, type: 0 },
23: { text: '协助招标人完成招标答疑涉及的造价问题的解答与回复', serviceid: 9, order: 24, type: 0 }, 23: { text: '协助招标人完成招标答疑涉及的造价问题的解答与回复', textEn: 'Assist the tenderer to complete the answer and reply to the cost issues involved in the bidding question', serviceid: 9, order: 24, type: 0 },
24: { text: '参加招投标阶段与造价确定相关的会议', serviceid: 9, order: 25, type: 0 }, 24: { text: '参加招投标阶段与造价确定相关的会议', textEn: 'Participate in meetings related to cost determination during the bidding phase', serviceid: 9, order: 25, type: 0 },
25: { text: '对比中标价与工程量清单预算,并分析差异原因,协助招标人完成不平衡报价调整', serviceid: 9, order: 26, type: 1 }, 25: { text: '对比中标价与工程量清单预算,并分析差异原因,协助招标人完成不平衡报价调整', textEn: 'Compare the winning bid price with the BOQ budget and analyze the reason for the difference to assist the tenderer in completing the unbalanced bid adjustment', serviceid: 9, order: 26, type: 1 },
26: { text: '参与合同谈判,协助招标人合同谈判过程中相关造价分析', serviceid: 9, order: 27, type: 1 }, 26: { text: '参与合同谈判,协助招标人合同谈判过程中相关造价分析', textEn: 'Participate in contract negotiation to assist the tenderer in the process of contract negotiation related cost analysis', serviceid: 9, order: 27, type: 1 },
27: { text: '清理施工承包合同费用、征地拆迁费用、各项价差、其他类费用及费用依据、计算资料等', serviceid: 10, order: 28, type: 0 }, 27: { text: '清理施工承包合同费用、征地拆迁费用、各项价差、其他类费用及费用依据、计算资料等', textEn: 'Clear construction contract costs, land acquisition and demolition costs, various price differences, other types of costs and cost bases, calculation data, etc.', serviceid: 10, order: 28, type: 0 },
28: { text: '清理征地拆迁费用,包括清理建设项目的用地、青苗补偿、房屋拆迁等费用,清理管线迁改、改路、改河、改沟以及构筑物迁改等费用,土地复垦费用及征拆各项规费的清理等', serviceid: 10, order: 29, type: 0 }, 28: { text: '清理征地拆迁费用,包括清理建设项目的用地、青苗补偿、房屋拆迁等费用,清理管线迁改、改路、改河、改沟以及构筑物迁改等费用,土地复垦费用及征拆各项规费的清理等', textEn: 'Clearance of land requisition and demolition costs, including clearance of land for construction projects, compensation for seedlings, house demolition, etc., clearance of pipeline relocation, alteration of roads, alteration of rivers, alteration of ditches, and relocation of structures, land reclamation costs and clearance of various fees for demolition, etc.', serviceid: 10, order: 29, type: 0 },
29: { text: '清理材料价差,包括调整甲供材料设备价差、政策性文件规定的其他材料价差等', serviceid: 10, order: 30, type: 0 }, 29: { text: '清理材料价差,包括调整甲供材料设备价差、政策性文件规定的其他材料价差等', textEn: 'Clean up the price difference of materials, including adjusting the price difference of materials and equipment supplied by Party A, and other material price differences stipulated in policy documents, etc.', serviceid: 10, order: 30, type: 0 },
30: { text: '清理政策性调整费用', serviceid: 10, order: 31, type: 0 }, 30: { text: '清理政策性调整费用', textEn: 'Cleanup Policy Adjustment Fee', serviceid: 10, order: 31, type: 0 },
31: { text: '清理基本预备费和招标降造费,包括分析降造费的使用情况', serviceid: 10, order: 32, type: 0 }, 31: { text: '清理基本预备费和招标降造费,包括分析降造费的使用情况', textEn: 'Clean up the basic reserve fee and the bidding reduction fee, including analyzing the use of the reduction fee', serviceid: 10, order: 32, type: 0 },
32: { text: '对未进行初步验收的剩余工程及投资情况进行清理', serviceid: 10, order: 33, type: 0 }, 32: { text: '对未进行初步验收的剩余工程及投资情况进行清理', textEn: 'Clean up remaining projects and investments that have not undergone preliminary acceptance', serviceid: 10, order: 33, type: 0 },
33: { text: '梳理和清理批复的概算费用,分析费用变化的原因', serviceid: 10, order: 34, type: 0 }, 33: { text: '梳理和清理批复的概算费用,分析费用变化的原因', textEn: 'Sort and clean up the estimated cost of the approval and analyze the reasons for the change in cost', serviceid: 10, order: 34, type: 0 },
34: { text: '根据收集的资料和费用分析结果,编制清理概算文件,包括清理概算申请报告、相关批复文件、设计图纸、施工组织设计、合同、验工计价、设计变更和新增工程等支撑性资料、审计报告(如有)、第三方审价报告及其他相关依据', serviceid: 10, order: 35, type: 0 }, 34: { text: '根据收集的资料和费用分析结果,编制清理概算文件,包括清理概算申请报告、相关批复文件、设计图纸、施工组织设计、合同、验工计价、设计变更和新增工程等支撑性资料、审计报告(如有)、第三方审价报告及其他相关依据', textEn: 'Based on the collected information and the results of the cost analysis, prepare a clearance budget proposal document, including clearance budget proposal application report, relevant approval documents, design drawings, construction organization design, contract, work inspection pricing, design changes and new projects and other supporting information, audit reports (if any), third-party audit reports and other relevant bases.', serviceid: 10, order: 35, type: 0 },
35: { text: '招标图纸与实际施工图纸进行验核,核实工程数量,针对分析概算费用变化的原因', serviceid: 10, order: 36, type: 1 }, 35: { text: '招标图纸与实际施工图纸进行验核,核实工程数量,针对分析概算费用变化的原因', textEn: 'Verify the bidding drawings with the actual construction drawings, verify the number of projects, and analyze the reasons for the change in the estimated budget cost', serviceid: 10, order: 36, type: 1 },
36: { text: '参加与清理概算相关的会议,协助委托人完成清理概算文件预评审工作', serviceid: 10, order: 37, type: 0 }, 36: { text: '参加与清理概算相关的会议,协助委托人完成清理概算文件预评审工作', textEn: 'Participate in meetings related to clearing the budget proposal and assist the client in completing the pre-screening of the budget proposal document', serviceid: 10, order: 37, type: 0 },
37: { text: '完成工程变更费用的测算,或提出性价比更优的替代方案、材料等意见', serviceid: 11, order: 38, type: 0 }, 37: { text: '完成工程变更费用的测算,或提出性价比更优的替代方案、材料等意见', textEn: 'Complete the estimation of the cost of engineering changes, or suggest cost-effective alternatives, materials, etc.', serviceid: 11, order: 38, type: 0 },
38: { text: '完成新增预算单价或合同单价的计算与核定', serviceid: 11, order: 39, type: 0 }, 38: { text: '完成新增预算单价或合同单价的计算与核定', textEn: 'Complete the calculation and approval of the new budget unit price or contract unit price', serviceid: 11, order: 39, type: 0 },
39: { text: '按施工图预算或合同清单方式完成工程变更费用的计算', serviceid: 11, order: 40, type: 0 }, 39: { text: '按施工图预算或合同清单方式完成工程变更费用的计算', textEn: 'Calculate the cost of engineering changes according to the construction drawing budget or contract list', serviceid: 11, order: 40, type: 0 },
40: { text: '完成过程结算,包括建立过程结算多维度清单体系,如:项目清单、工程量清单、分项清单;划分过程结算清单单元;依据经确认的实际完工工程量完成计价计费,完成过程结算文件', serviceid: 11, order: 41, type: 0 }, 40: { text: '完成过程结算,包括建立过程结算多维度清单体系,如:项目清单、工程量清单、分项清单;划分过程结算清单单元;依据经确认的实际完工工程量完成计价计费,完成过程结算文件', textEn: 'Complete the process settlement, including establishing a process settlement multi-dimensional list system, such as: project list, project quantity list, sub-item list; dividing the process settlement list unit; completing the pricing and billing based on the confirmed actual completed project quantity, and completing the process settlement documents', serviceid: 11, order: 41, type: 0 },
41: { text: '完成价格波动费用的计算或审核', serviceid: 11, order: 42, type: 0 }, 41: { text: '完成价格波动费用的计算或审核', textEn: 'Complete calculation or review of price fluctuation charges', serviceid: 11, order: 42, type: 0 },
42: { text: '完成索赔与补偿费用的计算或审核', serviceid: 11, order: 43, type: 0 }, 42: { text: '完成索赔与补偿费用的计算或审核', textEn: 'Completing claims and reimbursement calculations or reviews', serviceid: 11, order: 43, type: 0 },
43: { text: '完成结算文件及统计技术经济指标,与相关单位核对合同(工程)结算', serviceid: 11, order: 44, type: 0 }, 43: { text: '完成结算文件及统计技术经济指标,与相关单位核对合同(工程)结算', textEn: 'Complete settlement documents and statistical technical and economic indicators, and check the contract (project) settlement with the relevant units', serviceid: 11, order: 44, type: 0 },
44: { text: '完成合同、变更和结算三环节费用的对比,分析合同费用各环节变化原因,提供对比报表和分析报告', serviceid: 11, order: 45, type: 0 }, 44: { text: '完成合同、变更和结算三环节费用的对比,分析合同费用各环节变化原因,提供对比报表和分析报告', textEn: 'Complete the comparison of the costs of the contract, change and settlement, analyze the reasons for the changes in the contract costs, and provide comparative statements and analysis reports', serviceid: 11, order: 45, type: 0 },
45: { text: '参加与过程结算、工程变更费用确定和合同(工程)结算相关的会议', serviceid: 11, order: 46, type: 0 }, 45: { text: '参加与过程结算、工程变更费用确定和合同(工程)结算相关的会议', textEn: 'Participate in meetings related to process settlement, engineering change cost determination, and contract (engineering) settlement', serviceid: 11, order: 46, type: 0 },
46: { text: '现场勘查与测量实际完工工程量', serviceid: 11, order: 47, type: 1 }, 46: { text: '现场勘查与测量实际完工工程量', textEn: 'On-site survey and measurement of actual completed works', serviceid: 11, order: 47, type: 1 },
47: { text: '复核竣工图纸数量与结算数量的差异,提交数量差异报告', serviceid: 11, order: 48, type: 1 }, 47: { text: '复核竣工图纸数量与结算数量的差异,提交数量差异报告', textEn: 'Review the discrepancy between the quantity of as-built drawings and the settlement quantity, and submit a report on the discrepancy in the quantity', serviceid: 11, order: 48, type: 1 },
48: { text: '协助委托人对项目工程变更费用审批情况进行清理,编制交工验收造价文件', serviceid: 11, order: 49, type: 1 }, 48: { text: '协助委托人对项目工程变更费用审批情况进行清理,编制交工验收造价文件', textEn: 'Assist the client to clean up the project engineering change cost approval, and prepare the delivery acceptance cost document', serviceid: 11, order: 49, type: 1 },
49: { text: '协助委托人处理工期索赔、造价费用等费用的争议与纠纷、仲裁与诉讼', serviceid: 11, order: 50, type: 1 }, 49: { text: '协助委托人处理工期索赔、造价费用等费用的争议与纠纷、仲裁与诉讼', textEn: 'Assist the client in handling disputes and disputes, arbitration and litigation over construction period claims, construction costs and other expenses', serviceid: 11, order: 50, type: 1 },
50: { text: '完成工程变更费用的测算,或提出性价比更优的替代方案、材料等意见', serviceid: 12, order: 51, type: 0 }, 50: { text: '完成工程变更费用的测算,或提出性价比更优的替代方案、材料等意见', textEn: 'Complete the estimation of the cost of engineering changes, or suggest cost-effective alternatives, materials, etc.', serviceid: 12, order: 51, type: 0 },
51: { text: '完成新增预算单价或合同单价的计算与核定', serviceid: 12, order: 52, type: 0 }, 51: { text: '完成新增预算单价或合同单价的计算与核定', textEn: 'Complete the calculation and approval of the new budget unit price or contract unit price', serviceid: 12, order: 52, type: 0 },
52: { text: '按施工图预算或合同清单方式完成工程变更费用的计算', serviceid: 12, order: 53, type: 0 }, 52: { text: '按施工图预算或合同清单方式完成工程变更费用的计算', textEn: 'Calculate the cost of engineering changes according to the construction drawing budget or contract list', serviceid: 12, order: 53, type: 0 },
53: { text: '完成过程结算,包括建立过程结算多维度清单体系,如:项目清单、工程量清单、分项清单;划分过程结算清单单元;依据经确认的实际完工工程量完成计价计费,完成过程结算文件', serviceid: 12, order: 54, type: 0 }, 53: { text: '完成过程结算,包括建立过程结算多维度清单体系,如:项目清单、工程量清单、分项清单;划分过程结算清单单元;依据经确认的实际完工工程量完成计价计费,完成过程结算文件', textEn: 'Complete the process settlement, including establishing a process settlement multi-dimensional list system, such as: project list, project quantity list, sub-item list; dividing the process settlement list unit; completing the pricing and billing based on the confirmed actual completed project quantity, and completing the process settlement documents', serviceid: 12, order: 54, type: 0 },
54: { text: '完成价格波动费用的计算或审核', serviceid: 12, order: 55, type: 0 }, 54: { text: '完成价格波动费用的计算或审核', textEn: 'Complete calculation or review of price fluctuation charges', serviceid: 12, order: 55, type: 0 },
55: { text: '完成索赔与补偿费用的计算或审核', serviceid: 12, order: 56, type: 0 }, 55: { text: '完成索赔与补偿费用的计算或审核', textEn: 'Completing claims and reimbursement calculations or reviews', serviceid: 12, order: 56, type: 0 },
56: { text: '完成结算文件及统计技术经济指标,与相关单位核对合同(工程)结算', serviceid: 12, order: 57, type: 0 }, 56: { text: '完成结算文件及统计技术经济指标,与相关单位核对合同(工程)结算', textEn: 'Complete settlement documents and statistical technical and economic indicators, and check the contract (project) settlement with the relevant units', serviceid: 12, order: 57, type: 0 },
57: { text: '完成合同、变更和结算三环节费用的对比,分析合同费用各环节变化原因,提供对比报表和分析报告', serviceid: 12, order: 58, type: 0 }, 57: { text: '完成合同、变更和结算三环节费用的对比,分析合同费用各环节变化原因,提供对比报表和分析报告', textEn: 'Complete the comparison of the costs of the contract, change and settlement, analyze the reasons for the changes in the contract costs, and provide comparative statements and analysis reports', serviceid: 12, order: 58, type: 0 },
58: { text: '参加与过程结算、工程变更费用确定和合同(工程)结算相关的会议', serviceid: 12, order: 59, type: 0 }, 58: { text: '参加与过程结算、工程变更费用确定和合同(工程)结算相关的会议', textEn: 'Participate in meetings related to process settlement, engineering change cost determination, and contract (engineering) settlement', serviceid: 12, order: 59, type: 0 },
59: { text: '现场勘查与测量实际完工工程量', serviceid: 12, order: 60, type: 1 }, 59: { text: '现场勘查与测量实际完工工程量', textEn: 'On-site survey and measurement of actual completed works', serviceid: 12, order: 60, type: 1 },
60: { text: '复核竣工图纸数量与结算数量的差异,提交数量差异报告', serviceid: 12, order: 61, type: 1 }, 60: { text: '复核竣工图纸数量与结算数量的差异,提交数量差异报告', textEn: 'Review the discrepancy between the quantity of as-built drawings and the settlement quantity, and submit a report on the discrepancy in the quantity', serviceid: 12, order: 61, type: 1 },
61: { text: '协助委托人对项目工程变更费用审批情况进行清理,编制交工验收造价文件', serviceid: 12, order: 62, type: 1 }, 61: { text: '协助委托人对项目工程变更费用审批情况进行清理,编制交工验收造价文件', textEn: 'Assist the client to clean up the project engineering change cost approval, and prepare the delivery acceptance cost document', serviceid: 12, order: 62, type: 1 },
62: { text: '协助委托人处理工期索赔、造价费用等费用的争议与纠纷、仲裁与诉讼', serviceid: 12, order: 63, type: 1 }, 62: { text: '协助委托人处理工期索赔、造价费用等费用的争议与纠纷、仲裁与诉讼', textEn: 'Assist the client in handling disputes and disputes, arbitration and litigation over construction period claims, construction costs and other expenses', serviceid: 12, order: 63, type: 1 },
63: { text: '编制项目竣工决算文件', serviceid: 13, order: 64, type: 0 }, 63: { text: '编制项目竣工决算文件', textEn: 'Preparation of project completion settlement documents', serviceid: 13, order: 64, type: 0 },
64: { text: '编制竣工决算备案送审文件', serviceid: 13, order: 65, type: 0 }, 64: { text: '编制竣工决算备案送审文件', textEn: 'Prepare final account filing and submission documents for review', serviceid: 13, order: 65, type: 0 },
65: { text: '参与竣工决算相关的会议', serviceid: 13, order: 66, type: 0 }, 65: { text: '参与竣工决算相关的会议', textEn: 'Participate in meetings related to the final settlement of construction projects', serviceid: 13, order: 66, type: 0 },
66: { text: '根据审计意见和决算备案意见调整竣工决算文件', serviceid: 13, order: 67, type: 0 }, 66: { text: '根据审计意见和决算备案意见调整竣工决算文件', textEn: 'Adjust the Completion Final Accounts Document according to the Audit Opinion and the Final Accounts Filing Opinion', serviceid: 13, order: 67, type: 0 },
67: { text: '协助委托人编制建设项目造价执行情况报告', serviceid: 13, order: 68, type: 1 }, 67: { text: '协助委托人编制建设项目造价执行情况报告', textEn: 'Assist the client in preparing the construction project cost implementation report', serviceid: 13, order: 68, type: 1 },
68: { text: '对比类似项目的技术经济指标,分析技术经济指标差异原因,提供造价差异分析报告', serviceid: 13, order: 69, type: 1 }, 68: { text: '对比类似项目的技术经济指标,分析技术经济指标差异原因,提供造价差异分析报告', textEn: 'Compare technical and economic indicators of similar projects, analyze the reasons for differences in technical and economic indicators, and provide cost difference analysis reports', serviceid: 13, order: 69, type: 1 },
69: { text: '根据造价差异分析结果,提出调整工程方案的咨询意见供委托人或关联单位决策', serviceid: 13, order: 70, type: 1 }, 69: { text: '根据造价差异分析结果,提出调整工程方案的咨询意见供委托人或关联单位决策', textEn: 'Based on the results of the cost variance analysis, advise on adjusting the project plan for the decision of the client or affiliated unit', serviceid: 13, order: 70, type: 1 },
70: { text: '协助委托人指导参建单位完成与决算相关辅助文件的编制', serviceid: 13, order: 71, type: 1 }, 70: { text: '协助委托人指导参建单位完成与决算相关辅助文件的编制', textEn: 'Assist the client to guide the participating units to complete the preparation of auxiliary documents related to the settlement of accounts', serviceid: 13, order: 71, type: 1 },
71: { text: '协助委托人开展项目造价管理及投资效益后评估', serviceid: 13, order: 72, type: 1 }, 71: { text: '协助委托人开展项目造价管理及投资效益后评估', textEn: 'Assist the client in project cost management and post-assessment of investment benefits', serviceid: 13, order: 72, type: 1 },
72: { text: '定期向委托人介绍并解读国家及地方最新发布的关于造价管理有关的法律法规、政策文件和技术标准等信息,针对风险控制方面,提供专业的造价管理建议及实施对策', serviceid: 15, order: 73, type: 2 }, 72: { text: '定期向委托人介绍并解读国家及地方最新发布的关于造价管理有关的法律法规、政策文件和技术标准等信息,针对风险控制方面,提供专业的造价管理建议及实施对策', textEn: 'Regularly introduce and interpret the latest national and local laws and regulations, policy documents and technical standards on cost management to the client, provide professional cost management advice and implement countermeasures for risk control', serviceid: 15, order: 73, type: 2 },
73: { text: '为委托人在日常建设、运营及经营管理中遇到的造价编制与审核问题,提供专业咨询建议', serviceid: 15, order: 74, type: 2 }, 73: { text: '为委托人在日常建设、运营及经营管理中遇到的造价编制与审核问题,提供专业咨询建议', textEn: 'Provide professional advice on cost preparation and review issues encountered by the client in daily construction, operation and management', serviceid: 15, order: 74, type: 2 },
74: { text: '对委托人起草的工程勘察设计、工程监理、施工、物资采购和技术服务等方面的合同与协议文本,从造价控制与投资风险角度提供审阅与修改建议', serviceid: 15, order: 75, type: 2 }, 74: { text: '对委托人起草的工程勘察设计、工程监理、施工、物资采购和技术服务等方面的合同与协议文本,从造价控制与投资风险角度提供审阅与修改建议', textEn: 'Provide review and revision suggestions from the perspective of cost control and investment risk for the contract and agreement text drafted by the client in engineering survey and design, engineering supervision, construction, material procurement and technical services, etc.', serviceid: 15, order: 75, type: 2 },
75: { text: '审查各类合同履行情况时,对识别出的潜在漏洞或风险,提出相应的修改建议或补救措施', serviceid: 15, order: 76, type: 2 }, 75: { text: '审查各类合同履行情况时,对识别出的潜在漏洞或风险,提出相应的修改建议或补救措施', textEn: 'When reviewing the performance of various types of contracts, suggest modifications or remedies for potential vulnerabilities or risks identified', serviceid: 15, order: 76, type: 2 },
76: { text: '对委托人提供的造价管理相关的制度,进行审阅与修改,或提供专业咨询建议', serviceid: 15, order: 77, type: 2 }, 76: { text: '对委托人提供的造价管理相关的制度,进行审阅与修改,或提供专业咨询建议', textEn: 'Review and modify the system related to cost management provided by the client, or provide professional advice', serviceid: 15, order: 77, type: 2 },
77: { text: '针对具体建设工程项目的重大投资决策,出具独立的造价专业意见;或根据委托人的要求, 对造价争议事项提供专业依据并出具顾问报告', serviceid: 15, order: 78, type: 3 }, 77: { text: '针对具体建设工程项目的重大投资决策,出具独立的造价专业意见;或根据委托人的要求, 对造价争议事项提供专业依据并出具顾问报告', textEn: 'Provide independent cost professional opinions for major investment decisions of specific construction projects; or provide professional basis for cost disputes and issue consultant reports according to the client\'s requirements', serviceid: 15, order: 78, type: 3 },
78: { text: '为委托人起草与工程建设相关的合同、协议等文书,包括且不限于工程勘察设计、监理、施 工、采购及技术服务等领域', serviceid: 15, order: 79, type: 3 }, 78: { text: '为委托人起草与工程建设相关的合同、协议等文书,包括且不限于工程勘察设计、监理、施 工、采购及技术服务等领域', textEn: 'Draft contracts, agreements and other documents related to engineering construction for the client, including but not limited to engineering survey and design, supervision, construction, procurement and technical services', serviceid: 15, order: 79, type: 3 },
79: { text: '独立对各类合同的履行情况进行审查,识别潜在漏洞或风险,并提出系统性的修改建议或补 救方案', serviceid: 15, order: 80, type: 3 }, 79: { text: '独立对各类合同的履行情况进行审查,识别潜在漏洞或风险,并提出系统性的修改建议或补 救方案', textEn: 'Independently review the performance of various types of contracts, identify potential vulnerabilities or risks, and propose systematic amendments or remedies', serviceid: 15, order: 80, type: 3 },
80: { text: '为委托人制订或修订与造价管理相关的制度', serviceid: 15, order: 81, type: 3 }, 80: { text: '为委托人制订或修订与造价管理相关的制度', textEn: 'Develop or revise systems related to cost management for the Principal', serviceid: 15, order: 81, type: 3 },
81: { text: '受托处理涉及委托人的造价纠纷、仲裁及诉讼案件,提供专业意见与支持,并出具咨询顾问 报告', serviceid: 15, order: 82, type: 3 }, 81: { text: '受托处理涉及委托人的造价纠纷、仲裁及诉讼案件,提供专业意见与支持,并出具咨询顾问 报告', textEn: 'Entrusted to handle cost disputes, arbitration and litigation cases involving the client, provide professional advice and support, and issue consultant reports', serviceid: 15, order: 82, type: 3 },
82: { text: '组织并完成调研工作,包括制定调研计划、实施调研和撰写调研报告', serviceid: 16, order: 83, type: 0 }, 82: { text: '组织并完成调研工作,包括制定调研计划、实施调研和撰写调研报告', textEn: 'Organize and complete research work, including developing research plans, conducting research, and writing research reports', serviceid: 16, order: 83, type: 0 },
83: { text: '完成各阶段文件的起草、修订、调整与校对工作;整理并分析各阶段的 反馈意见、完成反馈意见的采纳情况,编制必要的采纳情况报告或说明;完成工作报告', serviceid: 16, order: 84, type: 0 }, 83: { text: '完成各阶段文件的起草、修订、调整与校对工作;整理并分析各阶段的 反馈意见、完成反馈意见的采纳情况,编制必要的采纳情况报告或说明;完成工作报告', textEn: 'Complete the drafting, revision, adjustment and proofreading of documents at each stage; organize and analyze the feedback at each stage, complete the adoption of feedback, and prepare the necessary adoption reports or explanations; complete the work report', serviceid: 16, order: 84, type: 0 },
84: { text: '完成评审汇报材料,编写报告;整理并汇总评审意见、复核评审意见,完成评审 意见的采纳及反馈工作', serviceid: 16, order: 85, type: 0 }, 84: { text: '完成评审汇报材料,编写报告;整理并汇总评审意见、复核评审意见,完成评审 意见的采纳及反馈工作', textEn: 'Complete the review report materials and prepare the report; collate and summarize the review comments, review the review comments, and complete the adoption and feedback of the review comments.', serviceid: 16, order: 85, type: 0 },
85: { text: '负责评审会议的全过程组织工作, 包括准备会议材料、发出通知、组织召开并主持评审会。', serviceid: 16, order: 86, type: 1 }, 85: { text: '负责评审会议的全过程组织工作, 包括准备会议材料、发出通知、组织召开并主持评审会。', textEn: 'Responsible for organizing the entire process of the review meeting, including preparing meeting materials, issuing notices, organizing the convening and chairing the review meeting.', serviceid: 16, order: 86, type: 1 },
86: { text: '组织并完成调研工作,包括制定调研计划、实施调研和撰写调研报告', serviceid: 17, order: 87, type: 0 }, 86: { text: '组织并完成调研工作,包括制定调研计划、实施调研和撰写调研报告', textEn: 'Organize and complete research work, including developing research plans, conducting research, and writing research reports', serviceid: 17, order: 87, type: 0 },
87: { text: '完成工作大纲和编写大纲;开展专项研究工作,完成各阶段研究报告的起 草、修订、调整与校对工作;分研究阶段进行技术报告的编写、修改、调整以及相应的报审 报批工作,包括大纲与成果的评审及验收等;整理并分析各阶段的反馈意见、完成反馈意见 的采纳情况,编制必要的采纳情况报告或说明;编制工作报告', serviceid: 17, order: 88, type: 0 }, 87: { text: '完成工作大纲和编写大纲;开展专项研究工作,完成各阶段研究报告的起 草、修订、调整与校对工作;分研究阶段进行技术报告的编写、修改、调整以及相应的报审 报批工作,包括大纲与成果的评审及验收等;整理并分析各阶段的反馈意见、完成反馈意见 的采纳情况,编制必要的采纳情况报告或说明;编制工作报告', textEn: 'Complete the work outline and prepare the outline; carry out special research work, complete the drafting, revision, adjustment and proofreading of the research report at each stage; prepare, modify, adjust the technical report and the corresponding report and approval work at each stage of the research, including the review and acceptance of the outline and results; collate and analyze the feedback at each stage, complete the adoption of feedback, prepare the necessary adoption report or explanation; prepare the work report', serviceid: 17, order: 88, type: 0 },
88: { text: '确定测试与验证项目,完成测试与验证工作,完成测试与验证报告', serviceid: 17, order: 89, type: 0 }, 88: { text: '确定测试与验证项目,完成测试与验证工作,完成测试与验证报告', textEn: 'Determine the test and verification items, complete the test and verification work, and complete the test and verification report', serviceid: 17, order: 89, type: 0 },
89: { text: '完成评审与验收所需汇报材料,编写报告;整理并汇总评审意见、复核评审意见,完成评审意见的采纳及反馈工作', serviceid: 17, order: 90, type: 0 }, 89: { text: '完成评审与验收所需汇报材料,编写报告;整理并汇总评审意见、复核评审意见,完成评审意见的采纳及反馈工作', textEn: 'Complete the reporting materials required for review and acceptance, prepare the report; collate and summarize the review comments, review the review comments, and complete the adoption and feedback of the review comments.', serviceid: 17, order: 90, type: 0 },
90: { text: '编写培训与宣贯相关的材料,组织研究成果的宣贯与推广工作', serviceid: 17, order: 91, type: 1 }, 90: { text: '编写培训与宣贯相关的材料,组织研究成果的宣贯与推广工作', textEn: 'Prepare materials related to training and dissemination, and organize the dissemination and promotion of research results', serviceid: 17, order: 91, type: 1 },
91: { text: '负责评审与验收会议的全过程组织工作,包括准备会议材料、发出通知、 组织召开并主持评审与验收会议', serviceid: 17, order: 92, type: 1 }, 91: { text: '负责评审与验收会议的全过程组织工作,包括准备会议材料、发出通知、 组织召开并主持评审与验收会议', textEn: 'Responsible for organizing the entire process of review and acceptance meetings, including preparing meeting materials, issuing notices, organizing and chairing review and acceptance meetings', serviceid: 17, order: 92, type: 1 },
92: { text: '组织并完成调研工作,包括制定调研计划、实施调研和撰写调研报告', serviceid: 18, order: 93, type: 0 }, 92: { text: '组织并完成调研工作,包括制定调研计划、实施调研和撰写调研报告', textEn: 'Organize and complete research work, including developing research plans, conducting research, and writing research reports', serviceid: 18, order: 93, type: 0 },
93: { text: '制定工作计划并完成编制大纲,编制大纲包括工作大纲和编写大纲', serviceid: 18, order: 94, type: 0 }, 93: { text: '制定工作计划并完成编制大纲,编制大纲包括工作大纲和编写大纲', textEn: 'Develop the work plan and complete the outline, including the work outline and the outline', serviceid: 18, order: 94, type: 0 },
94: { text: '完成数据采集与测定工作,编制相应的数据采集与测定报告', serviceid: 18, order: 95, type: 0 }, 94: { text: '完成数据采集与测定工作,编制相应的数据采集与测定报告', textEn: 'Complete the data collection and measurement work, and prepare the corresponding data collection and measurement report.', serviceid: 18, order: 95, type: 0 },
95: { text: '完成数据整理与分析工作,编制相应的数据分析报告,整理支撑性资料', serviceid: 18, order: 96, type: 0 }, 95: { text: '完成数据整理与分析工作,编制相应的数据分析报告,整理支撑性资料', textEn: 'Complete data collation and analysis work, prepare corresponding data analysis reports, and organize supporting materials', serviceid: 18, order: 96, type: 0 },
96: { text: '完成定额文本的起草、修改及调整工作;完成各阶段研究报告的起草、修订、 调整与校对工作,编制必要的采纳情况报告或说明;编制工作报告或报批报告', serviceid: 18, order: 97, type: 0 }, 96: { text: '完成定额文本的起草、修改及调整工作;完成各阶段研究报告的起草、修订、 调整与校对工作,编制必要的采纳情况报告或说明;编制工作报告或报批报告', textEn: 'Complete the drafting, revision and adjustment of the quota text; complete the drafting, revision, adjustment and proofreading of the research report at each stage, and prepare the necessary adoption report or explanation; prepare the work report or approval report', serviceid: 18, order: 97, type: 0 },
97: { text: '确定测试与验证项目,完成测试与验证工作,完成测试与验证报告', serviceid: 18, order: 98, type: 0 }, 97: { text: '确定测试与验证项目,完成测试与验证工作,完成测试与验证报告', textEn: 'Determine the test and verification items, complete the test and verification work, and complete the test and verification report', serviceid: 18, order: 98, type: 0 },
98: { text: '完成评审与验收所需汇报材料,编写报告;整理并汇总评审意见、复核评 审意见,完成评审意见的采纳及反馈工作', serviceid: 18, order: 99, type: 0 }, 98: { text: '完成评审与验收所需汇报材料,编写报告;整理并汇总评审意见、复核评 审意见,完成评审意见的采纳及反馈工作', textEn: 'Complete the reporting materials required for review and acceptance, prepare the report; collate and summarize the review comments, review the review comments, and complete the adoption and feedback of the review comments.', serviceid: 18, order: 99, type: 0 },
99: { text: '编写培训与宣贯相关的材料,组织定额成果的培训与宣贯工作', serviceid: 18, order: 100, type: 1 }, 99: { text: '编写培训与宣贯相关的材料,组织定额成果的培训与宣贯工作', textEn: 'Prepare materials related to training and dissemination, and organize training and dissemination of quota results', serviceid: 18, order: 100, type: 1 },
100: { text: '负责评审与验收会议的全过程组织工作,包括准备会议材料、发出通知、 组织召开并主持评审与验收会议', serviceid: 18, order: 101, type: 1 }, 100: { text: '负责评审与验收会议的全过程组织工作,包括准备会议材料、发出通知、 组织召开并主持评审与验收会议', textEn: 'Responsible for organizing the entire process of review and acceptance meetings, including preparing meeting materials, issuing notices, organizing and chairing review and acceptance meetings', serviceid: 18, order: 101, type: 1 },
101: { text: '开展各类造价信息的搜集、筛选与整理工作', serviceid: 19, order: 102, type: 0 }, 101: { text: '开展各类造价信息的搜集、筛选与整理工作', textEn: 'Carry out the collection, screening and collation of various types of cost information', serviceid: 19, order: 102, type: 0 },
102: { text: '对整理后的信息与数据进行对比与分析', serviceid: 19, order: 103, type: 0 }, 102: { text: '对整理后的信息与数据进行对比与分析', textEn: 'Compare and analyze the collated information and data', serviceid: 19, order: 103, type: 0 },
103: { text: '依据分析结果,对特定价格或费用进行评估与论证,编制咨询报告', serviceid: 19, order: 104, type: 0 }, 103: { text: '依据分析结果,对特定价格或费用进行评估与论证,编制咨询报告', textEn: 'Based on the results of the analysis, evaluate and demonstrate the specific price or cost, and prepare a consulting report', serviceid: 19, order: 104, type: 0 },
104: { text: '预测未来特定时期的价格或费用,编制预测报告', serviceid: 19, order: 105, type: 1 }, 104: { text: '预测未来特定时期的价格或费用,编制预测报告', textEn: 'Forecast prices or expenses for a specific period in the future and prepare forecast reports', serviceid: 19, order: 105, type: 1 },
105: { text: '研究价格或费用的长期变动规律,编制趋势分析报告', serviceid: 19, order: 106, type: 1 }, 105: { text: '研究价格或费用的长期变动规律,编制趋势分析报告', textEn: 'Study the long-term dynamics of prices or fees and prepare trend analysis reports', serviceid: 19, order: 106, type: 1 },
106: { text: '根据委托人的目标,确定鉴定工作范围', serviceid: 20, order: 107, type: 0 }, 106: { text: '根据委托人的目标,确定鉴定工作范围', textEn: 'Determine the scope of work of the appraisal according to the objectives of the client', serviceid: 20, order: 107, type: 0 },
107: { text: '收集与复核鉴定资料', serviceid: 20, order: 108, type: 0 }, 107: { text: '收集与复核鉴定资料', textEn: 'Collect and review appraisal data', serviceid: 20, order: 108, type: 0 },
108: { text: '组织或参与必要的现场勘查工作', serviceid: 20, order: 109, type: 0 }, 108: { text: '组织或参与必要的现场勘查工作', textEn: 'Organize or participate in necessary site surveys', serviceid: 20, order: 109, type: 0 },
109: { text: '工程量的计算与工程费用的计价或估价', serviceid: 20, order: 110, type: 0 }, 109: { text: '工程量的计算与工程费用的计价或估价', textEn: 'Calculation of project quantities and valuation or valuation of project costs', serviceid: 20, order: 110, type: 0 },
110: { text: '对争议焦点进行鉴定,提出解决争议的意见', serviceid: 20, order: 111, type: 0 }, 110: { text: '对争议焦点进行鉴定,提出解决争议的意见', textEn: 'Identify the focus of the dispute and put forward suggestions for resolving the dispute', serviceid: 20, order: 111, type: 0 },
111: { text: '回复各方当事人的质证意见', serviceid: 20, order: 112, type: 0 }, 111: { text: '回复各方当事人的质证意见', textEn: 'Respond to the parties\' cross-examination comments', serviceid: 20, order: 112, type: 0 },
112: { text: '编制并出具造价鉴定报告', serviceid: 20, order: 113, type: 0 }, 112: { text: '编制并出具造价鉴定报告', textEn: 'Prepare and issue cost appraisal report', serviceid: 20, order: 113, type: 0 },
113: { text: '组织或参与现场数据复测或特殊鉴定', serviceid: 20, order: 114, type: 1 }, 113: { text: '组织或参与现场数据复测或特殊鉴定', textEn: 'Organize or participate in on-site data retesting or special identification', serviceid: 20, order: 114, type: 1 },
114: { text: '与当事人相关方进行必要的造价数据核对', serviceid: 20, order: 115, type: 1 }, 114: { text: '与当事人相关方进行必要的造价数据核对', textEn: 'Conduct necessary cost data checks with the parties concerned', serviceid: 20, order: 115, type: 1 },
115: { text: '出庭就鉴定报告及相关专业问题接受质证', serviceid: 20, order: 116, type: 1 }, 115: { text: '出庭就鉴定报告及相关专业问题接受质证', textEn: 'Appear in court to be cross-examined on the appraisal report and related professional issues', serviceid: 20, order: 116, type: 1 },
116: { text: '编制影响工程成本的资源要素清单', serviceid: 21, order: 117, type: 0 }, 116: { text: '编制影响工程成本的资源要素清单', textEn: 'Compile a list of resource elements that affect the cost of the project', serviceid: 21, order: 117, type: 0 },
117: { text: '调查人工、材料、设备和施工机械的市场供应情况,主要是价格情况', serviceid: 21, order: 118, type: 0 }, 117: { text: '调查人工、材料、设备和施工机械的市场供应情况,主要是价格情况', textEn: 'Investigate the market availability of labor, materials, equipment and construction machinery, mainly in terms of price', serviceid: 21, order: 118, type: 0 },
118: { text: '测算、分析、计算工程成本费用,结合其他相关因素,编制并出具工程成本测算报告', serviceid: 21, order: 119, type: 0 }, 118: { text: '测算、分析、计算工程成本费用,结合其他相关因素,编制并出具工程成本测算报告', textEn: 'Calculate, analyze, and calculate the cost of the project, and prepare and issue a project cost estimation report combined with other relevant factors', serviceid: 21, order: 119, type: 0 },
119: { text: '参加与工程成本测算相关的会议', serviceid: 21, order: 120, type: 0 }, 119: { text: '参加与工程成本测算相关的会议', textEn: 'Participate in meetings related to engineering cost estimation', serviceid: 21, order: 120, type: 0 },
120: { text: '审核及分析资源配置、施工措施、施工管理方案的经济合理性', serviceid: 21, order: 121, type: 1 }, 120: { text: '审核及分析资源配置、施工措施、施工管理方案的经济合理性', textEn: 'Review and analyze the economic rationality of resource allocation, construction measures, and construction management schemes', serviceid: 21, order: 121, type: 1 },
121: { text: '对比工程成本与投资估算、设计概算、施工图预算、工程量清单预算、合同价,并分析费用差异,提交分析报告', serviceid: 21, order: 122, type: 1 }, 121: { text: '对比工程成本与投资估算、设计概算、施工图预算、工程量清单预算、合同价,并分析费用差异,提交分析报告', textEn: 'Compare project cost and investment estimates, design estimates, construction drawing budgets, bill of quantities budgets, and contract prices, analyze cost differences, and submit analysis reports', serviceid: 21, order: 122, type: 1 },
122: { text: '编制影响工程成本的资源要素清单或成本组成科目', serviceid: 22, order: 123, type: 0 }, 122: { text: '编制影响工程成本的资源要素清单或成本组成科目', textEn: 'Prepare a list of resource elements or cost components that affect the cost of the project', serviceid: 22, order: 123, type: 0 },
123: { text: '收集人工、材料、设备、施工机械、措施、税费等实际发生的费用', serviceid: 22, order: 124, type: 0 }, 123: { text: '收集人工、材料、设备、施工机械、措施、税费等实际发生的费用', textEn: 'Actual expenses incurred for collecting labor, materials, equipment, construction machinery, measures, taxes, etc.', serviceid: 22, order: 124, type: 0 },
124: { text: '统计与汇总、拆解与合并、对比与分析工程成本各项费用,编制并出具工程成本核算报告', serviceid: 22, order: 125, type: 0 }, 124: { text: '统计与汇总、拆解与合并、对比与分析工程成本各项费用,编制并出具工程成本核算报告', textEn: 'Statistics and summarization, dismantling and consolidation, comparison and analysis of project costs, preparation and issuance of project cost accounting reports', serviceid: 22, order: 125, type: 0 },
125: { text: '参加与工程成本核算相关的会议', serviceid: 22, order: 126, type: 0 }, 125: { text: '参加与工程成本核算相关的会议', textEn: 'Participate in meetings related to engineering costing', serviceid: 22, order: 126, type: 0 },
126: { text: '分析实际工程成本与测算工程成本差异的合理性,提交分析报告', serviceid: 22, order: 127, type: 1 }, 126: { text: '分析实际工程成本与测算工程成本差异的合理性,提交分析报告', textEn: 'Analyze the rationality of the difference between the actual project cost and the calculated project cost, and submit an analysis report', serviceid: 22, order: 127, type: 1 },
127: { text: '归纳与总结实际工程成本与测算工程成本的差异原因,提出改进建议', serviceid: 22, order: 128, type: 1 }, 127: { text: '归纳与总结实际工程成本与测算工程成本的差异原因,提出改进建议', textEn: 'Summarize and summarize the reasons for the difference between the actual project cost and the calculated project cost, and propose improvement suggestions', serviceid: 22, order: 128, type: 1 },
128: { text: '依据本项目在招标阶段确认的设计图纸工程量,结合工程量清单计量支付规则,对设计工程量进行复核,包括构件工程量、明细表工程量和汇总表工程量,同时与相关方核对工程量', serviceid: 23, order: 129, type: 0 }, 128: { text: '依据本项目在招标阶段确认的设计图纸工程量,结合工程量清单计量支付规则,对设计工程量进行复核,包括构件工程量、明细表工程量和汇总表工程量,同时与相关方核对工程量', textEn: 'Review the design quantity of the project according to the design drawings confirmed in the bidding stage of the project, combined with the bill of quantities measurement and payment rules, including the component quantity of the project, the schedule quantity of the project and the summary table quantity of the project, and check the quantity of the project with the relevant parties', serviceid: 23, order: 129, type: 0 },
129: { text: '依据核对后确认的设计图纸数量,细化与合并招标工程量清单或合同工程量清单,建立各维度清单间的数据链接,与相关单位完成核对与确认', serviceid: 23, order: 130, type: 0 }, 129: { text: '依据核对后确认的设计图纸数量,细化与合并招标工程量清单或合同工程量清单,建立各维度清单间的数据链接,与相关单位完成核对与确认', textEn: 'Refine and consolidate the bill of quantities or contract bill of quantities according to the number of design drawings confirmed after checking, establish data links between each dimension list, and complete check and confirmation with relevant units', serviceid: 23, order: 130, type: 0 },
130: { text: '依据确定的招标工程量清单或合同工程量清单,拆解与合并相应的清单费用,与相关单位完成核对与确认', serviceid: 23, order: 131, type: 1 }, 130: { text: '依据确定的招标工程量清单或合同工程量清单,拆解与合并相应的清单费用,与相关单位完成核对与确认', textEn: 'Dismantle and consolidate the corresponding bill of quantities according to the determined bidding bill of quantities or contract bill of quantities, and complete the check and confirmation with the relevant units', serviceid: 23, order: 131, type: 1 },
131: { text: '现场勘查与测量现场实施工程量', serviceid: 23, order: 132, type: 1 }, 131: { text: '现场勘查与测量现场实施工程量', textEn: 'On-site Survey and Measurement On-site Implementation Quantity', serviceid: 23, order: 132, type: 1 },
132: { text: '完成设计图纸所载数量与复核后的数量进行对比与分析,提供对比分析报告', serviceid: 23, order: 133, type: 1 }, 132: { text: '完成设计图纸所载数量与复核后的数量进行对比与分析,提供对比分析报告', textEn: 'Complete the comparison and analysis of the quantity contained in the design drawings and the reviewed quantity, and provide a comparative analysis report', serviceid: 23, order: 133, type: 1 },
133: { text: '参加计算工程量相关的会议', serviceid: 23, order: 134, type: 0 }, 133: { text: '参加计算工程量相关的会议', textEn: 'Attend meetings related to the calculation of workloads', serviceid: 23, order: 134, type: 0 },
134: { text: '协助委托人处理涉及工程数量的争议、纠纷、仲裁或诉讼事务', serviceid: 23, order: 135, type: 1 }, 134: { text: '协助委托人处理涉及工程数量的争议、纠纷、仲裁或诉讼事务', textEn: 'Assisting the Client in disputes, disputes, arbitrations or litigation involving the number of works', serviceid: 23, order: 135, type: 1 },
135: { text: '完成工程变更费用的测算', serviceid: 24, order: 136, type: 0 }, 135: { text: '完成工程变更费用的测算', textEn: 'Calculation of engineering change costs completed', serviceid: 24, order: 136, type: 0 },
136: { text: '完成新增预算单价或合同单价的计算与核定', serviceid: 24, order: 137, type: 0 }, 136: { text: '完成新增预算单价或合同单价的计算与核定', textEn: 'Complete the calculation and approval of the new budget unit price or contract unit price', serviceid: 24, order: 137, type: 0 },
137: { text: '按施工图预算或合同清单方式完成工程变更费用的计算', serviceid: 24, order: 138, type: 0 }, 137: { text: '按施工图预算或合同清单方式完成工程变更费用的计算', textEn: 'Calculate the cost of engineering changes according to the construction drawing budget or contract list', serviceid: 24, order: 138, type: 0 },
138: { text: '完成价格波动引起的价差费用的计算', serviceid: 24, order: 139, type: 1 }, 138: { text: '完成价格波动引起的价差费用的计算', textEn: 'Complete the calculation of spread costs due to price fluctuations', serviceid: 24, order: 139, type: 1 },
139: { text: '完成索赔与补偿费用的计算,如加速施工费用、暂停施工补偿等', serviceid: 24, order: 140, type: 1 }, 139: { text: '完成索赔与补偿费用的计算,如加速施工费用、暂停施工补偿等', textEn: 'Complete the calculation of claims and reimbursement costs, such as accelerated construction costs, suspended construction reimbursement, etc.', serviceid: 24, order: 140, type: 1 },
140: { text: '协助委托人处理涉及工程变更费用的争议与纠纷、仲裁与诉讼', serviceid: 24, order: 141, type: 1 }, 140: { text: '协助委托人处理涉及工程变更费用的争议与纠纷、仲裁与诉讼', textEn: 'Assisting the Client in disputes and disputes, arbitration and litigation involving the cost of engineering changes', serviceid: 24, order: 141, type: 1 },
141: { text: '分析、论证及评估影响投资估算的主要因素', serviceid: 25, order: 142, type: 0 }, 141: { text: '分析、论证及评估影响投资估算的主要因素', textEn: 'Analyze, justify and evaluate the main factors affecting investment estimates', serviceid: 25, order: 142, type: 0 },
142: { text: '根据分析论证结果,完成调整后投资估算文件及主要技术经济指标,与相关单位核对数据', serviceid: 25, order: 143, type: 0 }, 142: { text: '根据分析论证结果,完成调整后投资估算文件及主要技术经济指标,与相关单位核对数据', textEn: 'According to the analysis and demonstration results, complete the adjusted investment estimation documents and main technical and economic indicators, and check the data with the relevant units.', serviceid: 25, order: 143, type: 0 },
143: { text: '完成调整后投资估算与原批复估算的对比及原因分析', serviceid: 25, order: 144, type: 0 }, 143: { text: '完成调整后投资估算与原批复估算的对比及原因分析', textEn: 'Completion of Comparison and Reason Analysis of Adjusted Investment Estimates and Original Approved Estimates', serviceid: 25, order: 144, type: 0 },
144: { text: '出席与投资估算调整工作相关的会议', serviceid: 25, order: 145, type: 0 }, 144: { text: '出席与投资估算调整工作相关的会议', textEn: 'Attend meetings related to investment estimation adjustment work', serviceid: 25, order: 145, type: 0 },
145: { text: '协助调规报告编制单位完成报告的造价专业部分的内容', serviceid: 25, order: 146, type: 1 }, 145: { text: '协助调规报告编制单位完成报告的造价专业部分的内容', textEn: 'Assist the preparation unit of the regulation report to complete the contents of the cost professional section of the report', serviceid: 25, order: 146, type: 1 },
146: { text: '分析、论证及评估影响设计概算的主要因素', serviceid: 26, order: 147, type: 0 }, 146: { text: '分析、论证及评估影响设计概算的主要因素', textEn: 'Analyze, justify and evaluate the main factors influencing the design estimate', serviceid: 26, order: 147, type: 0 },
147: { text: '根据分析论证结果,编制调整后设计概算文件及主要技术经济指标,与相关单位核对数据', serviceid: 26, order: 148, type: 0 }, 147: { text: '根据分析论证结果,编制调整后设计概算文件及主要技术经济指标,与相关单位核对数据', textEn: 'Based on the analysis and demonstration results, prepare the adjusted design budget proposal document and the main technical and economic indicators, and check the data with the relevant units', serviceid: 26, order: 148, type: 0 },
148: { text: '完成调整后概算与原批复概算的对比及原因分析,完成调整概算报告', serviceid: 26, order: 149, type: 0 }, 148: { text: '完成调整后概算与原批复概算的对比及原因分析,完成调整概算报告', textEn: 'Completion of the comparison and reason analysis between the adjusted budget estimate and the original approved budget estimate, and completion of the adjustment budget estimate report', serviceid: 26, order: 149, type: 0 },
149: { text: '出席与概算调整工作相关的会议', serviceid: 26, order: 150, type: 0 }, 149: { text: '出席与概算调整工作相关的会议', textEn: 'Attend meetings related to budget adjustment work', serviceid: 26, order: 150, type: 0 },
150: { text: '协助委托人开展项目预估决算费用的测算、估算工作', serviceid: 26, order: 151, type: 1 }, 150: { text: '协助委托人开展项目预估决算费用的测算、估算工作', textEn: 'Assist the client in estimating and estimating the final cost of the project', serviceid: 26, order: 151, type: 1 },
151: { text: '检查相关单位对工程造价管理法律法规、规章制度以及工程造价依据的执行情况', serviceid: 27, order: 152, type: 0 }, 151: { text: '检查相关单位对工程造价管理法律法规、规章制度以及工程造价依据的执行情况', textEn: 'Check the implementation of engineering cost management laws and regulations, rules and regulations, and engineering cost basis by relevant units', serviceid: 27, order: 152, type: 0 },
152: { text: '检查各阶段造价文件编制、审查(批)或备案以及对批复意见的落实情况', serviceid: 27, order: 153, type: 0 }, 152: { text: '检查各阶段造价文件编制、审查(批)或备案以及对批复意见的落实情况', textEn: 'Check the preparation, review (batch) or filing of the cost documents at each stage and the implementation of the approval comments', serviceid: 27, order: 153, type: 0 },
153: { text: '检查造价管理台账和计量支付制度的建立与执行、造价全过程管理与控制情况', serviceid: 27, order: 154, type: 0 }, 153: { text: '检查造价管理台账和计量支付制度的建立与执行、造价全过程管理与控制情况', textEn: 'Check the establishment and implementation of the cost management ledger and the measurement and payment system, and the management and control of the entire cost process', serviceid: 27, order: 154, type: 0 },
154: { text: '检查工程变更管理情况', serviceid: 27, order: 155, type: 0 }, 154: { text: '检查工程变更管理情况', textEn: 'Check engineering change management', serviceid: 27, order: 155, type: 0 },
155: { text: '检查项目造价信息的收集、分析及报送情况', serviceid: 27, order: 156, type: 0 }, 155: { text: '检查项目造价信息的收集、分析及报送情况', textEn: 'Check the collection, analysis and submission of project cost information', serviceid: 27, order: 156, type: 0 },
156: { text: '检查项目从业单位的造价人员执业情况', serviceid: 27, order: 157, type: 0 }, 156: { text: '检查项目从业单位的造价人员执业情况', textEn: 'Inspection of the practice of the cost personnel of the project employer', serviceid: 27, order: 157, type: 0 },
157: { text: '协助委托人进行现场核查、资料抽检和台账复核工作', serviceid: 27, order: 158, type: 0 }, 157: { text: '协助委托人进行现场核查、资料抽检和台账复核工作', textEn: 'Assist the client in on-site verification, data sampling and ledger review', serviceid: 27, order: 158, type: 0 },
158: { text: '协助委托人整理检查结果和起草检查报告等工作', serviceid: 27, order: 159, type: 0 }, 158: { text: '协助委托人整理检查结果和起草检查报告等工作', textEn: 'Assist the client in collating inspection results and drafting inspection reports, etc.', serviceid: 27, order: 159, type: 0 },
159: { text: '作为造价咨询服务总体协调单位,依据造价技术标准的具体条款或委托方的个性化需求,进一步细化各项工作的具体要求,检查其他服务单位的造价文件的组成完整性、电子文件格式是否符合要求、电子版与纸质版是否对应、造价文件报表的规范性', serviceid: -1, order: 160, type: 4 }, 159: { text: '作为造价咨询服务总体协调单位,依据造价技术标准的具体条款或委托方的个性化需求,进一步细化各项工作的具体要求,检查其他服务单位的造价文件的组成完整性、电子文件格式是否符合要求、电子版与纸质版是否对应、造价文件报表的规范性', textEn: 'As the overall coordination unit of cost consulting services, according to the specific terms of the cost technical standards or the personalized needs of the commissioning party, further refine the specific requirements of each work, check the composition integrity of the cost documents of other service units, whether the electronic file format meets the requirements, whether the electronic version corresponds to the paper version, and the standardization of the cost document report.', serviceid: -1, order: 160, type: 4 },
160: { text: '作为造价咨询服务总体协调单位,负责总体协调其他咨询人或专家团队的工作,确保各方在项目服务中的沟通顺畅,监控造价咨询服务的进展情况,确保各咨询人按时完成工作', serviceid: -1, order: 161, type: 4 }, 160: { text: '作为造价咨询服务总体协调单位,负责总体协调其他咨询人或专家团队的工作,确保各方在项目服务中的沟通顺畅,监控造价咨询服务的进展情况,确保各咨询人按时完成工作', textEn: 'As the overall coordination unit of the cost consulting services, it is responsible for the overall coordination of the work of other consultants or expert teams to ensure smooth communication between all parties in the project services, monitor the progress of the cost consulting services, and ensure that each consultant completes the work on time.', serviceid: -1, order: 161, type: 4 },
}
export type WorkListItem = {
text: string
textEn?: string
serviceid: number
order: number
type: number
}
export const getWorkListEntries = (locale: string = getCurrentLocale()): WorkListItem[] => {
const useEnglish = isEnglishLocale(locale)
return Object.values(workList).map(item => {
const row = item as WorkListItem
return {
...row,
text: useEnglish ? String(row.textEn || row.text || '') : String(row.text || '')
}
})
} }
//工作内容树形关系表 //工作内容树形关系表

View File

@ -234,11 +234,19 @@ input[inputmode='numeric'] {
} }
.ag-theme-quartz .ag-cell.ag-cell-auto-height { .ag-theme-quartz .ag-cell.ag-cell-auto-height {
display: block; display: flex;
align-items: center;
}
.ag-theme-quartz .ag-cell .ag-cell-wrapper,
.ag-theme-quartz .ag-cell .ag-cell-value {
display: flex;
align-items: center;
} }
.zxfw-action-group { .zxfw-action-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
white-space: nowrap;
} }
.ag-theme-quartz .zxfw-action-btn { .ag-theme-quartz .zxfw-action-btn {
@ -258,6 +266,7 @@ input[inputmode='numeric'] {
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 90ms ease; transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 90ms ease;
position: relative; position: relative;
user-select: none; user-select: none;
white-space: nowrap;
} }
.zxfw-action-group .zxfw-action-btn + .zxfw-action-btn { .zxfw-action-group .zxfw-action-btn + .zxfw-action-btn {

View File

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