This commit is contained in:
2026-03-25 17:18:35 +08:00
parent 9a6462f22a
commit 1d016f8c51
48 changed files with 2822 additions and 986 deletions
+128 -88
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import draggable from 'vuedraggable'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -53,7 +54,7 @@ import {
SERVICE_KEY_PREFIX,
type ContractSegmentPackage
} from '@/lib/contractSegment'
import { industryTypeList } from '@/sql'
import { getIndustryDisplayName, industryTypeList } from '@/sql'
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import {
@@ -79,6 +80,7 @@ const zxFwPricingStore = useZxFwPricingStore()
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
const kvStore = useKvStore()
const { t, locale } = useI18n()
@@ -95,7 +97,7 @@ const showCreateModal = ref(false)
const contractNameInput = ref('')
const editingContractId = ref<string | null>(null)
const toastOpen = ref(false)
const toastTitle = ref('操作成功')
const toastTitle = ref(t('ht.toastSuccessTitle'))
const toastText = ref('')
const deleteConfirmOpen = ref(false)
const pendingDeleteContractId = ref<string | null>(null)
@@ -182,7 +184,7 @@ const budgetRefreshSignature = computed(() => {
})
const notify = (text: string) => {
toastTitle.value = '操作成功'
toastTitle.value = t('ht.toastSuccessTitle')
toastText.value = text
toastOpen.value = false
requestAnimationFrame(() => {
@@ -202,6 +204,11 @@ const pendingDeleteContractName = computed(() => {
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) => {
deleteConfirmOpen.value = open
}
@@ -239,7 +246,9 @@ const getCurrentProjectIndustry = async (): Promise<string> => {
}
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 rows = Array.isArray(state?.detailRows) ? state.detailRows : []
@@ -362,17 +371,10 @@ const scheduleRefreshContractBudgets = () => {
}, 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 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
}
@@ -529,7 +531,7 @@ const initializeContractScaleData = async (contractId: string) => {
const exportSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) {
showMessageDialog('提示', '请先勾选至少一个合同段。')
showMessageDialog(t('ht.tipTitle'), t('ht.selectAtLeastOne'))
return
}
@@ -554,7 +556,7 @@ const exportSelectedContracts = async () => {
const projectIndustry = await getCurrentProjectIndustry()
if (!projectIndustry) {
showMessageDialog('导出失败', '未读取到当前项目工程行业,请先在“基础信息”里新建项目。')
showMessageDialog(t('ht.exportFailedTitle'), t('ht.industryMissingForExport'))
return
}
@@ -583,17 +585,17 @@ const exportSelectedContracts = async () => {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
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)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
notify(`导出成功(${selectedContracts.length} 个合同段)`)
notify(t('ht.exportSuccess', { count: selectedContracts.length }))
exitContractSelectionMode()
} catch (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?.()
])
await refreshContractBudgets()
notify(`导入成功(${nextContracts.length} 个合同段)`)
notify(t('ht.importSuccess', { count: nextContracts.length }))
await nextTick()
scrollContractsToBottom()
} catch (error) {
@@ -700,15 +702,18 @@ const importContractSegments = async (event: Event) => {
if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) {
const [, importIndustry = '', currentIndustry = ''] = message.split(':')
showMessageDialog(
'导入失败',
`工程行业不一致(导入包:${formatIndustryLabel(importIndustry)},当前项目:${formatIndustryLabel(currentIndustry)})。`
t('ht.importFailedTitle'),
t('ht.importIndustryMismatch', {
importIndustry: formatIndustryLabel(importIndustry),
currentIndustry: formatIndustryLabel(currentIndustry)
})
)
} else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') {
showMessageDialog('导入失败', '当前项目未设置工程行业,请先在“基础信息”里新建项目。')
showMessageDialog(t('ht.importFailedTitle'), t('ht.importCurrentIndustryMissing'))
} else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') {
showMessageDialog('导入失败', '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。')
showMessageDialog(t('ht.importFailedTitle'), t('ht.importPackageIndustryMissing'))
} else {
showMessageDialog('导入失败', '文件无效、已损坏或不是合同段导出文件。')
showMessageDialog(t('ht.importFailedTitle'), t('ht.importFileInvalid'))
}
} finally {
input.value = ''
@@ -811,7 +816,7 @@ const createContract = async () => {
item.id === editingContractId.value ? { ...item, name } : item
)
await saveContracts()
notify('编辑成功')
notify(t('ht.editSuccess'))
closeCreateModal()
return
}
@@ -831,7 +836,7 @@ const createContract = async () => {
console.error('initialize contract scale failed:', error)
}
await refreshContractBudgets()
notify('新建成功')
notify(t('ht.createSuccess'))
closeCreateModal()
await nextTick()
scrollContractsToBottom()
@@ -850,19 +855,19 @@ const deleteContract = async (id: string) => {
selectedContractIds.value = selectedContractIds.value.filter(item => item !== id)
await saveContracts()
await refreshContractBudgets()
notify('删除成功')
notify(t('ht.deleteSuccess'))
}
const deleteSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) {
showMessageDialog('提示', '请先勾选至少一个合同段。')
showMessageDialog(t('ht.tipTitle'), t('ht.selectAtLeastOne'))
return
}
const selectedSet = new Set(selectedContractIds.value)
const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) {
showMessageDialog('提示', '未找到可删除的合同段。')
showMessageDialog(t('ht.tipTitle'), t('ht.noContractsToDelete'))
return
}
batchDeleteConfirmOpen.value = true
@@ -873,7 +878,7 @@ const confirmDeleteSelectedContracts = async () => {
const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) {
batchDeleteConfirmOpen.value = false
showMessageDialog('提示', '未找到可删除的合同段。')
showMessageDialog(t('ht.tipTitle'), t('ht.noContractsToDelete'))
return
}
@@ -895,11 +900,11 @@ const confirmDeleteSelectedContracts = async () => {
selectedContractIds.value = selectedContractIds.value.filter(item => !selectedSet.has(item))
await saveContracts()
await refreshContractBudgets()
notify(`删除成功(${targetIds.length} 个合同段)`)
notify(t('ht.deleteBatchSuccess', { count: targetIds.length }))
exitContractSelectionMode()
} catch (error) {
console.error('delete selected contracts failed:', error)
showMessageDialog('批量删除失败', '请重试。')
showMessageDialog(t('ht.batchDeleteFailedTitle'), t('ht.retry'))
} finally {
batchDeleteConfirmOpen.value = false
}
@@ -916,7 +921,7 @@ const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) =>
}
await saveContracts()
notify('排序完成')
notify(t('ht.sortDone'))
}
const updateDragPointerPosition = (event: MouseEvent | DragEvent) => {
@@ -990,7 +995,7 @@ const handleCardClick = (item: ContractItem) => {
}
tabStore.openTab({
id: `contract-${item.id}`,
title: `合同段${item.name}`,
title: t('ht.contractTabTitle', { name: item.name }),
componentName: 'QuickCalcView',
props: { contractId: item.id, contractName: item.name }
})
@@ -1064,32 +1069,32 @@ watch(budgetRefreshSignature, (next, prev) => {
<ToastProvider>
<TooltipProvider>
<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="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">
项目总预算金额{{ contractBudgetLoading ? '计算中...' : formatBudgetAmount(projectTotalBudget) }}
{{ t('ht.projectTotalBudget', { amount: contractBudgetLoading ? t('ht.budgetLoading') : formatBudgetAmount(projectTotalBudget) }) }}
</div>
</div>
<div class="flex items-center gap-2">
<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
variant="outline"
:disabled="selectedContractCount === 0"
@click="selectionMode === 'export' ? exportSelectedContracts() : deleteSelectedContracts()"
>
{{ selectionMode === 'export' ? '导出已选' : '删除已选' }}
{{ selectionMode === 'export' ? t('ht.exportSelected') : t('ht.deleteSelected') }}
</Button>
<Button variant="ghost" @click="exitContractSelectionMode">
取消
{{ t('ht.cancelSelect') }}
</Button>
</template>
<template v-else>
<Button :disabled="!canManageContracts" @click="openCreateModal">
<Button class="whitespace-nowrap" :disabled="!canManageContracts" @click="openCreateModal">
<Plus class="mr-2 h-4 w-4" />
添加合同段
{{ t('ht.addContract') }}
</Button>
<div ref="contractDataMenuRef" class="relative">
<Button
@@ -1102,32 +1107,32 @@ watch(budgetRefreshSignature, (next, prev) => {
</Button>
<div
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
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'"
:disabled="!hasContracts"
@click="enterContractDeleteMode"
>
批量删除
{{ t('ht.batchDelete') }}
</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'"
:disabled="!hasContracts"
@click="enterContractExportMode"
>
导出合同段
{{ t('ht.exportContracts') }}
</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'"
:disabled="!canManageContracts"
@click="triggerContractImport"
>
导入合同段
{{ t('ht.importContracts') }}
</button>
</div>
<input
@@ -1148,7 +1153,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<input
v-model="contractSearchKeyword"
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"
/>
<Button
@@ -1158,22 +1163,22 @@ watch(budgetRefreshSignature, (next, prev) => {
class="h-10 shrink-0 px-3"
@click="contractSearchKeyword = ''"
>
清空筛选
{{ t('ht.clearFilter') }}
</Button>
</div>
<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 v-if="isSelectingContracts" class="mt-1 text-xs text-muted-foreground">
{{ selectionMode === 'export' ? '导出选择模式勾选合同段后点击导出已选' : '删除选择模式勾选合同段后点击删除已选' }}
{{ selectionMode === 'export' ? t('ht.selectModeExportHint') : t('ht.selectModeDeleteHint') }}
</div>
<div v-if="!canManageContracts" class="mt-1 text-xs text-muted-foreground">
请先在基础信息里新建项目并选择工程行业后再新增或导入合同段
{{ t('ht.setupRequiredHint') }}
</div>
</div>
<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">
<span>{{ isListLayout ? '列表布局' : '网格布局' }}</span>
<span>{{ isListLayout ? t('ht.listLayout') : t('ht.gridLayout') }}</span>
<button
type="button"
role="switch"
@@ -1259,10 +1264,10 @@ watch(budgetRefreshSignature, (next, prev) => {
ID: {{ element.id }}
</span>
<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 class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
创建时间{{ formatDateTime(element.createdAt) }}
{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
</span>
</template>
</CardTitle>
@@ -1283,7 +1288,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</button>
</TooltipTrigger>
<TooltipContent side="top">拖动排序</TooltipContent>
<TooltipContent side="top">{{ t('ht.dragSort') }}</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
@@ -1296,7 +1301,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">编辑</TooltipContent>
<TooltipContent side="top">{{ t('ht.edit') }}</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
@@ -1309,7 +1314,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">删除</TooltipContent>
<TooltipContent side="top">{{ t('ht.remove') }}</TooltipContent>
</TooltipRoot>
</div>
</CardHeader>
@@ -1320,9 +1325,9 @@ watch(budgetRefreshSignature, (next, prev) => {
'space-y-1 pb-1'
]"
>
<div class="break-all">ID{{ element.id }}</div>
<div>本合同预算金额{{ formatBudgetAmount(contractBudgetById[element.id]) }}</div>
<div>创建时间{{ formatDateTime(element.createdAt) }}</div>
<div class="break-all">{{ t('ht.idLabel', { id: element.id }) }}</div>
<div>{{ t('ht.contractBudgetLine', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}</div>
<div>{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}</div>
</div>
</Card>
</template>
@@ -1331,8 +1336,8 @@ watch(budgetRefreshSignature, (next, prev) => {
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"
>
<div class="text-lg font-semibold tracking-wide text-foreground">暂无合同卡片</div>
<div class="mt-2 text-sm text-muted-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">{{ t('ht.emptyDesc') }}</div>
</div>
<div
v-else
@@ -1389,10 +1394,10 @@ watch(budgetRefreshSignature, (next, prev) => {
ID: {{ element.id }}
</span>
<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 class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
创建时间{{ formatDateTime(element.createdAt) }}
{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}
</span>
</template>
</CardTitle>
@@ -1411,7 +1416,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</span>
</TooltipTrigger>
<TooltipContent side="top">拖动排序搜索时关闭</TooltipContent>
<TooltipContent side="top">{{ t('ht.dragSortSearchOff') }}</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
@@ -1424,7 +1429,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">编辑</TooltipContent>
<TooltipContent side="top">{{ t('ht.edit') }}</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
@@ -1437,7 +1442,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">删除</TooltipContent>
<TooltipContent side="top">{{ t('ht.remove') }}</TooltipContent>
</TooltipRoot>
</div>
</CardHeader>
@@ -1448,16 +1453,16 @@ watch(budgetRefreshSignature, (next, prev) => {
'space-y-1 pb-4'
]"
>
<div class="break-all">ID{{ element.id }}</div>
<div>本合同预算金额{{ formatBudgetAmount(contractBudgetById[element.id]) }}</div>
<div>创建时间{{ formatDateTime(element.createdAt) }}</div>
<div class="break-all">{{ t('ht.idLabel', { id: element.id }) }}</div>
<div>{{ t('ht.contractBudgetLine', { amount: formatBudgetAmount(contractBudgetById[element.id]) }) }}</div>
<div>{{ t('ht.createdAt', { time: formatDateTime(element.createdAt) }) }}</div>
</div>
</Card>
<div
v-if="filteredContracts.length === 0"
class="col-span-full rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground"
>
未找到匹配的合同段
{{ t('ht.notFound') }}
</div>
</div>
</ScrollArea>
@@ -1468,7 +1473,7 @@ watch(budgetRefreshSignature, (next, prev) => {
<TooltipTrigger as-child>
<button
type="button"
aria-label="回到顶部"
:aria-label="t('ht.backToTop')"
: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',
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" />
</button>
</TooltipTrigger>
<TooltipContent side="left">回到顶部</TooltipContent>
<TooltipContent side="left">{{ t('ht.backToTop') }}</TooltipContent>
</TooltipRoot>
<div
@@ -1494,7 +1499,7 @@ watch(budgetRefreshSignature, (next, prev) => {
@mousedown.prevent="startDrag"
>
<h4 class="text-base font-semibold">
{{ editingContractId ? '编辑合同段' : '新增合同段' }}
{{ editingContractId ? t('ht.editContract') : t('ht.createContract') }}
</h4>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeCreateModal">
<X class="h-4 w-4" />
@@ -1502,20 +1507,20 @@ watch(budgetRefreshSignature, (next, prev) => {
</div>
<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
v-model="contractNameInput"
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"
@keydown.enter="createContract"
/>
</div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
<Button variant="outline" @click="closeCreateModal">取消</Button>
<div class="flex items-center justify-end gap-2 px-5 py-3">
<Button variant="outline" @click="closeCreateModal">{{ t('common.cancel') }}</Button>
<Button :disabled="!contractNameInput.trim()" @click="createContract">
{{ editingContractId ? '保存' : '确定' }}
{{ editingContractId ? t('ht.save') : t('ht.ok') }}
</Button>
</div>
</div>
@@ -1525,16 +1530,51 @@ watch(budgetRefreshSignature, (next, prev) => {
<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">确认删除合同段</AlertDialogTitle>
<AlertDialogTitle class="text-base font-semibold">{{ t('ht.deleteSingleTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
即将删除{{ pendingDeleteContractName }}及其关联咨询服务和计价数据是否继续
{{ t('ht.deleteSingleDesc', { name: pendingDeleteContractName }) }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline" @click="pendingDeleteContractId = null">取消</Button>
<Button variant="outline" @click="pendingDeleteContractId = null">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<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>
</div>
</AlertDialogContent>
@@ -1550,11 +1590,11 @@ watch(budgetRefreshSignature, (next, prev) => {
<ToastDescription class="text-xs text-muted-foreground">{{ toastText }}</ToastDescription>
</div>
<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"
@click="toastOpen = false"
>
知道了
{{ t('tab.dialog.iKnow') }}
</ToastAction>
</ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />