2026-03-25 14:31:01 +08:00

1567 lines
56 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import draggable from 'vuedraggable'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
import { useKvStore } from '@/pinia/kv'
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
import {
formatDateTime,
isEntryRelatedToAnyContract,
normalizeContractsFromPayload,
normalizeOrder,
type ContractItem
} from '@/features/ht/contracts'
import {
applyImportedContractPiniaPayload,
buildContractPiniaPayload,
readContractRelatedForageEntries,
readContractRelatedKeyedEntries
} from '@/features/ht/importExport'
import type {
HourlyMethodStateLike,
HtFeeMainRowLike,
QuantityMethodStateLike,
RateMethodStateLike,
XmBaseInfoState,
XmScaleState
} from '@/features/ht/types'
import {
cloneJson,
CONTRACT_CONSULT_FACTOR_KEY_PREFIX,
CONTRACT_KEY_PREFIX,
CONTRACT_MAJOR_FACTOR_KEY_PREFIX,
CONTRACT_SEGMENT_FILE_EXTENSION,
CONTRACT_SEGMENT_VERSION,
formatExportTimestamp,
generateContractId,
isContractRelatedForageKey,
isContractRelatedKeyedStateKey,
isContractSegmentPackage,
normalizeContractSegmentPackage,
PROJECT_INFO_KEY,
PROJECT_SCALE_KEY,
PRICING_KEY_PREFIXES,
rewriteKeyWithContractId,
SERVICE_KEY_PREFIX,
type ContractSegmentPackage
} from '@/lib/contractSegment'
import { industryTypeList } from '@/sql'
import { roundTo, sumNullableNumbers, toFiniteNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
ToastAction,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport
} from 'reka-ui'
const STORAGE_KEY = 'ht-card-v1'
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
const kvStore = useKvStore()
const contracts = ref<ContractItem[]>([])
const contractSearchKeyword = ref('')
const isListLayout = ref(false)
const contractDataMenuOpen = ref(false)
const contractDataMenuRef = ref<HTMLElement | null>(null)
const contractImportFileRef = ref<HTMLInputElement | null>(null)
const selectionMode = ref<'none' | 'export' | 'delete'>('none')
const selectedContractIds = ref<string[]>([])
const showCreateModal = ref(false)
const contractNameInput = ref('')
const editingContractId = ref<string | null>(null)
const toastOpen = ref(false)
const toastTitle = ref('操作成功')
const toastText = ref('')
const deleteConfirmOpen = ref(false)
const pendingDeleteContractId = ref<string | null>(null)
const messageDialogOpen = ref(false)
const messageDialogTitle = ref('')
const messageDialogDesc = ref('')
const batchDeleteConfirmOpen = ref(false)
const modalOffset = ref({ x: 0, y: 0 })
let dragStartX = 0
let dragStartY = 0
let baseOffsetX = 0
let baseOffsetY = 0
const contractListScrollWrapRef = ref<HTMLElement | null>(null)
const contractListViewportRef = ref<HTMLElement | null>(null)
const showScrollTopFab = ref(false)
const isDraggingContracts = ref(false)
const cardMotionState = ref<'enter' | 'ready'>('ready')
const canManageContracts = ref(true)
const contractBudgetById = ref<Record<string, number | null>>({})
const contractBudgetLoading = ref(false)
let contractBudgetRefreshSeq = 0
let contractAutoScrollRaf = 0
let dragPointerClientY: number | null = null
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
let contractListScrollBoundEl: HTMLElement | null = null
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
const CARD_ENTER_STEP_MS = 58
const CARD_ENTER_DURATION_MS = 560
const CARD_ENTER_MAX_INDEX = 24
const CARD_ENTER_TOTAL_MS = CARD_ENTER_DURATION_MS + CARD_ENTER_STEP_MS * CARD_ENTER_MAX_INDEX + 80
const SCROLL_TOP_FAB_THRESHOLD = 220
const buildDefaultContracts = (): ContractItem[] => [
]
const normalizedSearchKeyword = computed(() => contractSearchKeyword.value.trim().toLowerCase())
const isSelectingContracts = computed(() => selectionMode.value !== 'none')
const selectedContractCount = computed(() => selectedContractIds.value.length)
const hasContracts = computed(() => contracts.value.length > 0)
const filteredContracts = computed(() => {
if (!normalizedSearchKeyword.value) return contracts.value
return contracts.value.filter(item => {
const name = item.name.toLowerCase()
const id = item.id.toLowerCase()
return name.includes(normalizedSearchKeyword.value) || id.includes(normalizedSearchKeyword.value)
})
})
const isSearchingContracts = computed(() => Boolean(normalizedSearchKeyword.value))
const projectTotalBudget = computed(() => {
let hasValid = false
let total = 0
for (const contract of contracts.value) {
const fee = contractBudgetById.value[contract.id]
if (typeof fee !== 'number' || !Number.isFinite(fee)) continue
total += fee
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
})
const budgetRefreshSignature = computed(() => {
const ids = contracts.value.map(item => String(item.id || '').trim()).filter(Boolean)
if (ids.length === 0) return ''
return ids
.map(id => {
const additionalMainKey = `htExtraFee-${id}-additional-work`
const reserveMainKey = `htExtraFee-${id}-reserve`
const contractState = zxFwPricingStore.contracts[id] || null
const addMain = zxFwPricingStore.htFeeMainStates[additionalMainKey] || null
const reserveMain = zxFwPricingStore.htFeeMainStates[reserveMainKey] || null
const addMethods = zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null
const reserveMethods = zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
return JSON.stringify({
id,
contractState,
addMain,
reserveMain,
addMethods,
reserveMethods
})
})
.join('|')
})
const notify = (text: string) => {
toastTitle.value = '操作成功'
toastText.value = text
toastOpen.value = false
requestAnimationFrame(() => {
toastOpen.value = true
})
}
const showMessageDialog = (title: string, description: string) => {
messageDialogTitle.value = title
messageDialogDesc.value = description
messageDialogOpen.value = true
}
const pendingDeleteContractName = computed(() => {
if (!pendingDeleteContractId.value) return ''
const target = contracts.value.find(item => item.id === pendingDeleteContractId.value)
return target?.name || pendingDeleteContractId.value
})
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.value = open
}
const requestDeleteContract = (id: string) => {
pendingDeleteContractId.value = id
deleteConfirmOpen.value = true
}
const confirmDeleteContract = async () => {
const id = pendingDeleteContractId.value
if (!id) return
await deleteContract(id)
deleteConfirmOpen.value = false
pendingDeleteContractId.value = null
}
const closeContractDataMenu = () => {
contractDataMenuOpen.value = false
}
const loadProjectBaseState = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
canManageContracts.value = Boolean(typeof data?.projectIndustry === 'string' && data.projectIndustry.trim())
} catch (error) {
console.error('load project base state failed:', error)
canManageContracts.value = false
}
}
const getCurrentProjectIndustry = async (): Promise<string> => {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
return typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
}
const formatBudgetAmount = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)}` : '--'
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
let hasValid = false
let total = 0
for (const row of rows) {
const serviceBudget = toFiniteNumber(row?.serviceBudget)
if (serviceBudget != null) {
total += serviceBudget
hasValid = true
continue
}
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
const personnel = toFiniteNumber(row?.personnelCount)
const workday = toFiniteNumber(row?.workdayCount)
if (adopted == null || personnel == null || workday == null) continue
total += adopted * personnel * workday
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
}
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
if (rows.length === 0) return null
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
if (subtotal != null) return roundTo(subtotal, 2)
let hasValid = false
let total = 0
for (const row of rows) {
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
const budget = toFiniteNumber(row?.budgetFee)
if (budget != null) {
total += budget
hasValid = true
continue
}
const quantity = toFiniteNumber(row?.quantity)
const unitPrice = toFiniteNumber(row?.unitPrice)
if (quantity == null || unitPrice == null) continue
total += quantity * unitPrice
hasValid = true
}
return hasValid ? roundTo(total, 2) : null
}
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
const [rateState, hourlyState, quantityState] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
])
const parts = [
toFiniteNumber(rateState?.budgetFee),
sumHourlyMethodFee(hourlyState),
sumQuantityMethodFee(quantityState)
]
const total = sumNullableNumbers(parts)
return total == null ? null : roundTo(total, 2)
}
const loadHtMainTotalFee = async (mainStorageKey: string) => {
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
const rowIds = rows
.map(row => String(row?.id || '').trim())
.filter(Boolean)
if (rowIds.length === 0) return null
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
const total = sumNullableNumbers(rowTotals)
return total == null ? null : roundTo(total, 2)
}
const loadContractBudgetFee = async (contractId: string) => {
await zxFwPricingStore.loadContract(contractId)
const serviceFee = zxFwPricingStore.getBaseSubtotal(contractId)
const [additionalFee, reserveFee] = await Promise.all([
loadHtMainTotalFee(`htExtraFee-${contractId}-additional-work`),
loadHtMainTotalFee(`htExtraFee-${contractId}-reserve`)
])
const parts = [serviceFee, additionalFee, reserveFee]
const total = sumNullableNumbers(parts)
return total == null ? null : roundTo(total, 2)
}
const refreshContractBudgets = async () => {
const seq = ++contractBudgetRefreshSeq
const ids = contracts.value.map(item => String(item.id || '').trim()).filter(Boolean)
if (ids.length === 0) {
contractBudgetById.value = {}
contractBudgetLoading.value = false
return
}
contractBudgetLoading.value = true
try {
const nextPairs = await Promise.all(
ids.map(async id => {
const fee = await loadContractBudgetFee(id)
return [id, fee] as const
})
)
if (seq !== contractBudgetRefreshSeq) return
contractBudgetById.value = Object.fromEntries(nextPairs)
} catch (error) {
console.error('refresh contract budgets failed:', error)
} finally {
if (seq === contractBudgetRefreshSeq) {
contractBudgetLoading.value = false
}
}
}
const scheduleRefreshContractBudgets = () => {
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
budgetRefreshTimer = setTimeout(() => {
void refreshContractBudgets()
}, 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)
return name ? `${trimmed} ${name}` : trimmed
}
const isContractSelected = (contractId: string) =>
selectedContractIds.value.includes(contractId)
const toggleContractSelection = (contractId: string) => {
if (!isSelectingContracts.value) return
if (isContractSelected(contractId)) {
selectedContractIds.value = selectedContractIds.value.filter(id => id !== contractId)
return
}
selectedContractIds.value = [...selectedContractIds.value, contractId]
}
const exitContractSelectionMode = () => {
selectionMode.value = 'none'
selectedContractIds.value = []
}
const enterContractExportMode = () => {
if (!hasContracts.value) return
closeContractDataMenu()
selectionMode.value = 'export'
selectedContractIds.value = []
}
const enterContractDeleteMode = () => {
if (!hasContracts.value) return
closeContractDataMenu()
selectionMode.value = 'delete'
selectedContractIds.value = []
}
const triggerContractImport = () => {
if (!canManageContracts.value) return
closeContractDataMenu()
contractImportFileRef.value?.click()
}
const getContractListViewport = () => {
const viewport = contractListScrollWrapRef.value?.querySelector<HTMLElement>(
'[data-slot="scroll-area-viewport"]'
) || null
contractListViewportRef.value = viewport
return viewport
}
const scrollContractsToBottom = (behavior: ScrollBehavior = 'smooth') => {
const viewport = getContractListViewport()
if (!viewport) return
viewport.scrollTo({
top: viewport.scrollHeight,
behavior
})
}
const scrollContractsToTop = (behavior: ScrollBehavior = 'smooth') => {
const viewport = getContractListViewport()
if (!viewport) return
viewport.scrollTo({
top: 0,
behavior
})
}
const updateScrollTopFabVisible = () => {
const viewport = contractListViewportRef.value
showScrollTopFab.value = Boolean(viewport && viewport.scrollTop > SCROLL_TOP_FAB_THRESHOLD)
}
const handleContractListScroll = () => {
updateScrollTopFabVisible()
}
const bindContractListScroll = () => {
const viewport = getContractListViewport()
if (contractListScrollBoundEl === viewport) {
updateScrollTopFabVisible()
return
}
if (contractListScrollBoundEl) {
contractListScrollBoundEl.removeEventListener('scroll', handleContractListScroll)
contractListScrollBoundEl = null
}
if (!viewport) {
showScrollTopFab.value = false
return
}
contractListScrollBoundEl = viewport
contractListScrollBoundEl.addEventListener('scroll', handleContractListScroll, { passive: true })
updateScrollTopFabVisible()
}
const unbindContractListScroll = () => {
if (contractListScrollBoundEl) {
contractListScrollBoundEl.removeEventListener('scroll', handleContractListScroll)
contractListScrollBoundEl = null
}
showScrollTopFab.value = false
}
const triggerCardEnterAnimation = () => {
if (cardEnterTimer) {
clearTimeout(cardEnterTimer)
cardEnterTimer = null
}
cardMotionState.value = 'enter'
cardEnterTimer = setTimeout(() => {
cardMotionState.value = 'ready'
cardEnterTimer = null
}, CARD_ENTER_TOTAL_MS)
}
const getCardEnterStyle = (index: number) => ({
'--ht-card-enter-delay': `${Math.min(index, CARD_ENTER_MAX_INDEX) * CARD_ENTER_STEP_MS}ms`
})
const getCardSelectStyle = (index: number) => ({
'--ht-card-select-delay': `${(index % 10) * 70}ms`
})
const getContractCardStyle = (index: number) => {
if (isSelectingContracts.value) {
return getCardSelectStyle(index)
}
if (cardMotionState.value === 'enter') {
return getCardEnterStyle(index)
}
return undefined
}
const saveContracts = async () => {
try {
contracts.value = normalizeOrder(contracts.value)
await kvStore.setItem(STORAGE_KEY, JSON.parse(JSON.stringify(contracts.value)))
} catch (error) {
console.error('save contracts failed:', error)
}
}
const initializeContractScaleData = async (contractId: string) => {
const source = await kvStore.getItem<XmScaleState>(PROJECT_SCALE_KEY)
const payload: XmScaleState = {
detailRows: Array.isArray(source?.detailRows) ? cloneJson(source.detailRows) : [],
roughCalcEnabled: Boolean(source?.roughCalcEnabled),
totalAmount:
typeof source?.totalAmount === 'number' && Number.isFinite(source.totalAmount)
? source.totalAmount
: null
}
await kvStore.setItem(`${CONTRACT_KEY_PREFIX}${contractId}`, payload)
}
const exportSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) {
showMessageDialog('提示', '请先勾选至少一个合同段。')
return
}
try {
const selectedSet = new Set(selectedContractIds.value)
const selectedContracts = contracts.value
.filter(item => selectedSet.has(item.id))
.map((item, index) => ({
...item,
order: index
}))
const localforageEntries = await readContractRelatedForageEntries(
kvStore,
selectedContracts.map(item => item.id)
)
const keyedEntries = readContractRelatedKeyedEntries(
zxFwPricingStore,
selectedContracts.map(item => item.id)
)
const piniaPayload = await buildContractPiniaPayload(zxFwPricingStore, selectedContracts.map(item => item.id))
const projectIndustry = await getCurrentProjectIndustry()
if (!projectIndustry) {
showMessageDialog('导出失败', '未读取到当前项目工程行业,请先在“基础信息”里新建项目。')
return
}
const now = new Date()
const payload: ContractSegmentPackage = {
version: CONTRACT_SEGMENT_VERSION,
packageType: 'contract-segments',
exportedAt: now.toISOString(),
project: {
industry: projectIndustry
},
contracts: selectedContracts,
storage: {
localforageEntries,
keyedEntries
},
pinia: {
zxFwPricing: piniaPayload
}
}
const content = await encodeZwArchive(payload)
const binary = new Uint8Array(content.length)
binary.set(content)
const blob = new Blob([binary], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `合同段导出-${formatExportTimestamp(now)}${CONTRACT_SEGMENT_FILE_EXTENSION}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
notify(`导出成功(${selectedContracts.length} 个合同段)`)
exitContractSelectionMode()
} catch (error) {
console.error('export selected contracts failed:', error)
showMessageDialog('导出失败', '请重试。')
}
}
const importContractSegments = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const name = file.name.toLowerCase()
if (!name.endsWith(CONTRACT_SEGMENT_FILE_EXTENSION) && !name.endsWith('.zw')) {
throw new Error('INVALID_FILE_EXTENSION')
}
const buffer = await file.arrayBuffer()
const payload = await decodeZwArchive<ContractSegmentPackage>(buffer)
if (!isContractSegmentPackage(payload)) {
throw new Error('INVALID_CONTRACT_SEGMENT_PAYLOAD')
}
const normalizedPackage = normalizeContractSegmentPackage(payload)
const currentProjectIndustry = await getCurrentProjectIndustry()
if (!currentProjectIndustry) {
throw new Error('CURRENT_PROJECT_INDUSTRY_MISSING')
}
if (!normalizedPackage.projectIndustry) {
throw new Error('IMPORT_PACKAGE_INDUSTRY_MISSING')
}
if (normalizedPackage.projectIndustry !== currentProjectIndustry) {
throw new Error(`PROJECT_INDUSTRY_MISMATCH:${normalizedPackage.projectIndustry}:${currentProjectIndustry}`)
}
const importedContracts = normalizeContractsFromPayload(payload.contracts)
if (importedContracts.length === 0) {
throw new Error('EMPTY_CONTRACTS')
}
const importedEntries = normalizedPackage.localforageEntries
const importedKeyedEntries = normalizedPackage.keyedEntries
const importedContractIdSet = new Set(importedContracts.map(item => String(item.id || '').trim()).filter(Boolean))
const filteredImportedEntries = importedEntries.filter(entry =>
isEntryRelatedToAnyContract(entry.key, importedContractIdSet, isContractRelatedForageKey)
)
const filteredImportedKeyedEntries = importedKeyedEntries.filter(entry =>
isEntryRelatedToAnyContract(entry.key, importedContractIdSet, isContractRelatedKeyedStateKey)
)
const usedIds = new Set(contracts.value.map(item => item.id))
const oldToNewIdMap = new Map<string, string>()
const nextContracts: ContractItem[] = importedContracts.map((item, index) => {
const nextId = generateContractId(usedIds)
oldToNewIdMap.set(item.id, nextId)
return {
...item,
id: nextId,
order: contracts.value.length + index,
createdAt: item.createdAt || new Date().toISOString()
}
})
const rewrittenEntries = filteredImportedEntries.map(entry => {
let nextKey = entry.key
for (const [oldId, newId] of oldToNewIdMap.entries()) {
if (!nextKey.includes(oldId)) continue
nextKey = rewriteKeyWithContractId(nextKey, oldId, newId)
}
return {
key: nextKey,
value: entry.value
}
})
const rewrittenKeyedEntries = filteredImportedKeyedEntries.map(entry => {
let nextKey = entry.key
for (const [oldId, newId] of oldToNewIdMap.entries()) {
if (!nextKey.includes(oldId)) continue
nextKey = rewriteKeyWithContractId(nextKey, oldId, newId)
}
return {
key: nextKey,
value: entry.value
}
})
await Promise.all(rewrittenEntries.map(entry => kvStore.setItem(entry.key, entry.value)))
for (const entry of rewrittenKeyedEntries) {
zxFwPricingStore.setKeyState(entry.key, cloneJson(entry.value), { force: true })
}
await applyImportedContractPiniaPayload(zxFwPricingStore, normalizedPackage.piniaState, oldToNewIdMap)
contracts.value = [...contracts.value, ...nextContracts]
await saveContracts()
await Promise.all([
zxFwPricingStore.$persistNow?.(),
zxFwPricingKeysStore.$persistNow?.(),
zxFwPricingHtFeeStore.$persistNow?.()
])
await refreshContractBudgets()
notify(`导入成功(${nextContracts.length} 个合同段)`)
await nextTick()
scrollContractsToBottom()
} catch (error) {
console.error('import contract segments failed:', error)
const message = error instanceof Error ? error.message : ''
if (message.startsWith('PROJECT_INDUSTRY_MISMATCH:')) {
const [, importIndustry = '', currentIndustry = ''] = message.split(':')
showMessageDialog(
'导入失败',
`工程行业不一致(导入包:${formatIndustryLabel(importIndustry)},当前项目:${formatIndustryLabel(currentIndustry)})。`
)
} else if (message === 'CURRENT_PROJECT_INDUSTRY_MISSING') {
showMessageDialog('导入失败', '当前项目未设置工程行业,请先在“基础信息”里新建项目。')
} else if (message === 'IMPORT_PACKAGE_INDUSTRY_MISSING') {
showMessageDialog('导入失败', '导入包缺少工程行业信息,请使用最新版本重新导出后再导入。')
} else {
showMessageDialog('导入失败', '文件无效、已损坏或不是合同段导出文件。')
}
} finally {
input.value = ''
}
}
const removeForageKeysByContractId = async (store: typeof kvStore, contractId: string) => {
try {
const keys = await store.keys()
const targetKeys = keys.filter(key => key.includes(contractId))
await Promise.all(targetKeys.map(key => store.removeItem(key)))
} catch (error) {
console.error('remove forage keys by contract id failed:', contractId, error)
}
}
const removeRelatedTabsByContractId = (contractId: string) => {
const relatedTabIds = tabStore.tabs
.filter(tab => {
const propsContractId = tab?.props?.contractId
return (
tab.id === `contract-${contractId}` ||
tab.id.startsWith(`zxfw-edit-${contractId}-`) ||
propsContractId === contractId
)
})
.map(tab => tab.id)
for (const tabId of relatedTabIds) {
tabStore.removeTab(tabId)
}
}
const cleanupContractRelatedData = async (contractId: string) => {
zxFwPricingStore.removeContractData(contractId)
await Promise.all([
removeForageKeysByContractId(kvStore, contractId),
])
await Promise.all([
zxFwPricingStore.$persistNow?.(),
zxFwPricingKeysStore.$persistNow?.(),
zxFwPricingHtFeeStore.$persistNow?.()
])
}
const loadContracts = async () => {
try {
const saved = await kvStore.getItem<ContractItem[]>(STORAGE_KEY)
if (!saved || saved.length === 0) {
contracts.value = buildDefaultContracts()
await saveContracts()
return
}
const sorted = [...saved].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
contracts.value = normalizeOrder(sorted)
} catch (error) {
console.error('load contracts failed:', error)
contracts.value = buildDefaultContracts()
}
}
const openCreateModal = () => {
if (!canManageContracts.value) return
closeContractDataMenu()
editingContractId.value = null
contractNameInput.value = ''
modalOffset.value = { x: 0, y: 0 }
showCreateModal.value = true
}
const openEditModal = (item: ContractItem) => {
closeContractDataMenu()
editingContractId.value = item.id
contractNameInput.value = item.name
modalOffset.value = { x: 0, y: 0 }
showCreateModal.value = true
}
const closeCreateModal = () => {
showCreateModal.value = false
editingContractId.value = null
contractNameInput.value = ''
modalOffset.value = { x: 0, y: 0 }
}
const createContract = async () => {
const name = contractNameInput.value.trim()
if (!name) return
if (editingContractId.value) {
const current = contracts.value.find(item => item.id === editingContractId.value)
if (!current) return
if (current.name === name) {
closeCreateModal()
return
}
contracts.value = contracts.value.map(item =>
item.id === editingContractId.value ? { ...item, name } : item
)
await saveContracts()
notify('编辑成功')
closeCreateModal()
return
}
const newContract: ContractItem = {
id: `ct-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
name,
order: contracts.value.length,
createdAt: new Date().toISOString()
}
contracts.value = [...contracts.value, newContract]
await saveContracts()
try {
await initializeContractScaleData(newContract.id)
} catch (error) {
console.error('initialize contract scale failed:', error)
}
await refreshContractBudgets()
notify('新建成功')
closeCreateModal()
await nextTick()
scrollContractsToBottom()
}
const deleteContract = async (id: string) => {
// 先关闭合同段及其咨询服务相关 tab避免页面仍在运行时继续写回缓存
removeRelatedTabsByContractId(id)
await nextTick()
await cleanupContractRelatedData(id)
// 组件卸载时会异步保存一次,延迟再清一遍避免数据回写
await new Promise(resolve => setTimeout(resolve, 80))
await cleanupContractRelatedData(id)
contracts.value = contracts.value.filter(item => item.id !== id)
selectedContractIds.value = selectedContractIds.value.filter(item => item !== id)
await saveContracts()
await refreshContractBudgets()
notify('删除成功')
}
const deleteSelectedContracts = async () => {
if (selectedContractIds.value.length === 0) {
showMessageDialog('提示', '请先勾选至少一个合同段。')
return
}
const selectedSet = new Set(selectedContractIds.value)
const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) {
showMessageDialog('提示', '未找到可删除的合同段。')
return
}
batchDeleteConfirmOpen.value = true
}
const confirmDeleteSelectedContracts = async () => {
const selectedSet = new Set(selectedContractIds.value)
const targets = contracts.value.filter(item => selectedSet.has(item.id))
if (targets.length === 0) {
batchDeleteConfirmOpen.value = false
showMessageDialog('提示', '未找到可删除的合同段。')
return
}
try {
const targetIds = targets.map(item => item.id)
for (const id of targetIds) {
removeRelatedTabsByContractId(id)
}
await nextTick()
for (const id of targetIds) {
await cleanupContractRelatedData(id)
}
await new Promise(resolve => setTimeout(resolve, 80))
for (const id of targetIds) {
await cleanupContractRelatedData(id)
}
contracts.value = contracts.value.filter(item => !selectedSet.has(item.id))
selectedContractIds.value = selectedContractIds.value.filter(item => !selectedSet.has(item))
await saveContracts()
await refreshContractBudgets()
notify(`删除成功(${targetIds.length} 个合同段)`)
exitContractSelectionMode()
} catch (error) {
console.error('delete selected contracts failed:', error)
showMessageDialog('批量删除失败', '请重试。')
} finally {
batchDeleteConfirmOpen.value = false
}
}
const handleDragEnd = async (event: { oldIndex?: number; newIndex?: number }) => {
stopContractAutoScroll()
if (
event.oldIndex == null ||
event.newIndex == null ||
event.oldIndex === event.newIndex
) {
return
}
await saveContracts()
notify('排序完成')
}
const updateDragPointerPosition = (event: MouseEvent | DragEvent) => {
if (!isDraggingContracts.value) return
dragPointerClientY = event.clientY
}
const contractAutoScrollTick = () => {
if (!isDraggingContracts.value) {
contractAutoScrollRaf = 0
return
}
const viewport = contractListViewportRef.value || getContractListViewport()
const clientY = dragPointerClientY
if (viewport && clientY != null) {
const rect = viewport.getBoundingClientRect()
const edge = 88
const maxStep = 22
let delta = 0
if (clientY < rect.top + edge) {
const ratio = Math.max(0, Math.min(1, (rect.top + edge - clientY) / edge))
delta = -Math.ceil(maxStep * ratio)
} else if (clientY > rect.bottom - edge) {
const ratio = Math.max(0, Math.min(1, (clientY - (rect.bottom - edge)) / edge))
delta = Math.ceil(maxStep * ratio)
}
if (delta !== 0) {
viewport.scrollTop = Math.max(
0,
Math.min(viewport.scrollTop + delta, viewport.scrollHeight - viewport.clientHeight)
)
}
}
contractAutoScrollRaf = window.requestAnimationFrame(contractAutoScrollTick)
}
const startContractAutoScroll = () => {
if (cardEnterTimer) {
clearTimeout(cardEnterTimer)
cardEnterTimer = null
}
cardMotionState.value = 'ready'
getContractListViewport()
isDraggingContracts.value = true
dragPointerClientY = null
window.addEventListener('pointermove', updateDragPointerPosition as EventListener, { passive: true })
window.addEventListener('dragover', updateDragPointerPosition as EventListener, { passive: true })
if (contractAutoScrollRaf) cancelAnimationFrame(contractAutoScrollRaf)
contractAutoScrollRaf = window.requestAnimationFrame(contractAutoScrollTick)
}
const stopContractAutoScroll = () => {
isDraggingContracts.value = false
dragPointerClientY = null
window.removeEventListener('pointermove', updateDragPointerPosition as EventListener)
window.removeEventListener('dragover', updateDragPointerPosition as EventListener)
if (contractAutoScrollRaf) {
cancelAnimationFrame(contractAutoScrollRaf)
contractAutoScrollRaf = 0
}
}
const handleCardClick = (item: ContractItem) => {
if (isSelectingContracts.value) {
toggleContractSelection(item.id)
return
}
tabStore.openTab({
id: `contract-${item.id}`,
title: `合同段${item.name}`,
componentName: 'QuickCalcView',
props: { contractId: item.id, contractName: item.name }
})
}
const onDragMove = (event: MouseEvent) => {
modalOffset.value = {
x: baseOffsetX + (event.clientX - dragStartX),
y: baseOffsetY + (event.clientY - dragStartY)
}
}
const stopDrag = () => {
window.removeEventListener('mousemove', onDragMove)
window.removeEventListener('mouseup', stopDrag)
}
const startDrag = (event: MouseEvent) => {
dragStartX = event.clientX
dragStartY = event.clientY
baseOffsetX = modalOffset.value.x
baseOffsetY = modalOffset.value.y
window.addEventListener('mousemove', onDragMove)
window.addEventListener('mouseup', stopDrag)
}
const handleGlobalMouseDown = (event: MouseEvent) => {
if (!contractDataMenuOpen.value || !contractDataMenuRef.value) return
const target = event.target as Node
if (!contractDataMenuRef.value.contains(target)) {
contractDataMenuOpen.value = false
}
}
onMounted(async () => {
await loadProjectBaseState()
await loadContracts()
await refreshContractBudgets()
triggerCardEnterAnimation()
await nextTick()
bindContractListScroll()
window.addEventListener('mousedown', handleGlobalMouseDown)
})
onActivated(() => {
void loadProjectBaseState()
void refreshContractBudgets()
triggerCardEnterAnimation()
void nextTick(() => {
bindContractListScroll()
})
})
onBeforeUnmount(() => {
stopDrag()
stopContractAutoScroll()
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
unbindContractListScroll()
window.removeEventListener('mousedown', handleGlobalMouseDown)
if (cardEnterTimer) clearTimeout(cardEnterTimer)
void saveContracts()
})
watch(budgetRefreshSignature, (next, prev) => {
if (next === prev) return
scheduleRefreshContractBudgets()
})
</script>
<template>
<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="mb-6 flex items-center justify-between pt-1">
<div class="space-y-1">
<h3 class="text-lg font-bold">合同段列表</h3>
<div class="text-xs text-muted-foreground">
项目总预算金额{{ contractBudgetLoading ? '计算中...' : formatBudgetAmount(projectTotalBudget) }}
</div>
</div>
<div class="flex items-center gap-2">
<template v-if="isSelectingContracts">
<div class="text-xs text-muted-foreground">已选 {{ selectedContractCount }} 个</div>
<Button
variant="outline"
:disabled="selectedContractCount === 0"
@click="selectionMode === 'export' ? exportSelectedContracts() : deleteSelectedContracts()"
>
{{ selectionMode === 'export' ? '导出已选' : '删除已选' }}
</Button>
<Button variant="ghost" @click="exitContractSelectionMode">
取消
</Button>
</template>
<template v-else>
<Button :disabled="!canManageContracts" @click="openCreateModal">
<Plus class="mr-2 h-4 w-4" />
添加合同段
</Button>
<div ref="contractDataMenuRef" class="relative">
<Button
variant="outline"
size="icon"
class="h-10 w-10"
@click="contractDataMenuOpen = !contractDataMenuOpen"
>
<MoreHorizontal class="h-4 w-4" />
</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"
>
<button
class="w-full 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"
>
批量删除
</button>
<button
class="w-full 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"
>
导出合同段
</button>
<button
class="w-full 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"
>
导入合同段
</button>
</div>
<input
ref="contractImportFileRef"
type="file"
class="hidden"
accept=".htzw,.zw"
@change="importContractSegments"
/>
</div>
</template>
</div>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-start">
<div class="w-full md:max-w-md">
<div class="flex items-center gap-2">
<input
v-model="contractSearchKeyword"
type="text"
placeholder="搜索合同段名称或ID"
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
v-if="contractSearchKeyword"
variant="outline"
size="sm"
class="h-10 shrink-0 px-3"
@click="contractSearchKeyword = ''"
>
清空筛选
</Button>
</div>
<div v-if="isSearchingContracts" class="mt-1 text-xs text-muted-foreground">
搜索中({{ filteredContracts.length }} / {{ contracts.length }}),已关闭拖拽排序
</div>
<div v-if="isSelectingContracts" class="mt-1 text-xs text-muted-foreground">
{{ selectionMode === 'export' ? '导出选择模式:勾选合同段后点击“导出已选”' : '删除选择模式:勾选合同段后点击“删除已选”' }}
</div>
<div v-if="!canManageContracts" class="mt-1 text-xs text-muted-foreground">
请先在“基础信息”里新建项目并选择工程行业后,再新增或导入合同段
</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>
<button
type="button"
role="switch"
:aria-checked="isListLayout"
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors active:scale-95"
:class="isListLayout ? 'bg-primary border-primary' : 'bg-muted border-border'"
@click="isListLayout = !isListLayout"
>
<span
class="pointer-events-none absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-background shadow-sm transition-all"
:class="isListLayout ? 'left-6' : 'left-1'"
/>
</button>
</label>
</div>
</div>
</div>
<div ref="contractListScrollWrapRef" class="mt-4 flex-1 min-h-0">
<!-- 合同卡片列表滚动容器:由 ScrollArea 承载滚动行为与滚动条样式。 -->
<ScrollArea :class="['ht-contract-scroll-area h-full', isDraggingContracts && 'is-dragging']">
<draggable
v-if="!isSearchingContracts && filteredContracts.length > 0"
:key="`contracts-${isListLayout ? 'list' : 'grid'}`"
v-model="contracts"
item-key="id"
:disabled="isSelectingContracts"
handle=".contract-drag-handle"
ghost-class="ht-sortable-ghost"
chosen-class="ht-sortable-chosen"
drag-class="ht-sortable-drag"
:class="[
'grid grid-cols-1 pb-4 pr-4 pt-3',
isListLayout ? 'gap-2' : 'gap-4',
!isListLayout && 'md:grid-cols-2 lg:grid-cols-3'
]"
animation="200"
@start="startContractAutoScroll"
@end="handleDragEnd"
>
<template #item="{ element, index }">
<Card
:class="[
'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
isSelectingContracts
? 'ht-contract-card--selecting'
: cardMotionState === 'enter' && !isDraggingContracts
? 'ht-contract-card--enter'
: 'ht-contract-card--ready',
isContractSelected(element.id) && 'ht-contract-card--selected',
isListLayout && 'gap-0 py-0'
]"
:style="getContractCardStyle(index)"
@click="handleCardClick(element)"
>
<label
v-if="isSelectingContracts"
class="absolute left-2 top-2 z-10 inline-flex cursor-pointer items-center rounded bg-background/90 p-0.5 shadow-sm"
@click.stop
>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer"
:checked="isContractSelected(element.id)"
@change.stop="toggleContractSelection(element.id)"
/>
</label>
<CardHeader
:class="[
'flex flex-row items-center justify-between gap-0 space-y-0',
isListLayout ? 'px-3 py-2' : 'pb-4'
]"
>
<CardTitle
:class="[
'text-sm font-medium',
isListLayout && 'mr-1.5 flex min-w-0 flex-1 items-center gap-1.5'
]"
>
<span :class="isListLayout ? 'min-w-0 truncate' : ''">{{ element.name }}</span>
<template v-if="isListLayout">
<span class="min-w-0 shrink text-[11px] leading-none font-normal text-muted-foreground truncate">
ID: {{ element.id }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
预算:{{ formatBudgetAmount(contractBudgetById[element.id]) }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
创建时间:{{ formatDateTime(element.createdAt) }}
</span>
</template>
</CardTitle>
<div
v-if="!isSelectingContracts"
:class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']"
>
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
:class="[
'contract-drag-handle inline-flex cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-muted active:cursor-grabbing',
isListLayout ? 'h-6 w-6' : 'h-7 w-7'
]"
@click.stop
>
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</button>
</TooltipTrigger>
<TooltipContent side="top">拖动排序</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="isListLayout ? 'h-6 w-6' : 'h-7 w-7'"
@click.stop="openEditModal(element)"
>
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">编辑</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="[isListLayout ? 'h-6 w-6' : 'h-7 w-7', 'text-destructive']"
@click.stop="requestDeleteContract(element.id)"
>
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">删除</TooltipContent>
</TooltipRoot>
</div>
</CardHeader>
<div
v-if="!isListLayout"
:class="[
'px-6 text-xs text-muted-foreground',
'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>
</Card>
</template>
</draggable>
<div
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>
<div
v-else
:key="`contracts-search-${isListLayout ? 'list' : 'grid'}`"
:class="[
'grid grid-cols-1 pb-4 pr-4 pt-3',
isListLayout ? 'gap-2' : 'gap-4',
!isListLayout && 'md:grid-cols-2 lg:grid-cols-3'
]"
>
<Card
v-for="(element, index) in filteredContracts"
:key="element.id"
:class="[
'group relative cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
isSelectingContracts
? 'ht-contract-card--selecting'
: cardMotionState === 'enter' && !isDraggingContracts
? 'ht-contract-card--enter'
: 'ht-contract-card--ready',
isContractSelected(element.id) && 'ht-contract-card--selected',
isListLayout && 'gap-0 py-0'
]"
:style="getContractCardStyle(index)"
@click="handleCardClick(element)"
>
<label
v-if="isSelectingContracts"
class="absolute left-2 top-2 z-10 inline-flex cursor-pointer items-center rounded bg-background/90 p-0.5 shadow-sm"
@click.stop
>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer"
:checked="isContractSelected(element.id)"
@change.stop="toggleContractSelection(element.id)"
/>
</label>
<CardHeader
:class="[
'flex flex-row items-center justify-between gap-0 space-y-0',
isListLayout ? 'px-3 py-2' : 'pb-2'
]"
>
<CardTitle
:class="[
'text-sm font-medium',
isListLayout && 'mr-1.5 flex min-w-0 flex-1 items-center gap-1.5'
]"
>
<span :class="isListLayout ? 'min-w-0 truncate' : ''">{{ element.name }}</span>
<template v-if="isListLayout">
<span class="min-w-0 shrink text-[11px] leading-none font-normal text-muted-foreground truncate">
ID: {{ element.id }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
预算:{{ formatBudgetAmount(contractBudgetById[element.id]) }}
</span>
<span class="shrink-0 text-[11px] leading-none font-normal text-muted-foreground">
创建时间:{{ formatDateTime(element.createdAt) }}
</span>
</template>
</CardTitle>
<div
v-if="!isSelectingContracts"
:class="['flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100', isListLayout ? 'gap-0.5' : 'gap-1']"
>
<TooltipRoot>
<TooltipTrigger as-child>
<span
:class="[
'inline-flex items-center justify-center rounded-md text-muted-foreground',
isListLayout ? 'h-6 w-6' : 'h-7 w-7'
]"
>
<GripVertical :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</span>
</TooltipTrigger>
<TooltipContent side="top">拖动排序(搜索时关闭)</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="isListLayout ? 'h-6 w-6' : 'h-7 w-7'"
@click.stop="openEditModal(element)"
>
<Edit3 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">编辑</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
:class="[isListLayout ? 'h-6 w-6' : 'h-7 w-7', 'text-destructive']"
@click.stop="requestDeleteContract(element.id)"
>
<Trash2 :class="isListLayout ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">删除</TooltipContent>
</TooltipRoot>
</div>
</CardHeader>
<div
v-if="!isListLayout"
:class="[
'px-6 text-xs text-muted-foreground',
'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>
</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"
>
未找到匹配的合同段
</div>
</div>
</ScrollArea>
</div>
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
aria-label="回到顶部"
: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'
]"
@click="scrollContractsToTop()"
>
<ArrowUp class="h-5 w-5" />
</button>
</TooltipTrigger>
<TooltipContent side="left">回到顶部</TooltipContent>
</TooltipRoot>
<div
v-if="showCreateModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
>
<div
class="w-full max-w-md rounded-lg border bg-background shadow-xl"
:style="{ transform: `translate(${modalOffset.x}px, ${modalOffset.y}px)` }"
>
<div
class="flex items-center justify-between border-b px-5 py-4 cursor-move select-none"
@mousedown.prevent="startDrag"
>
<h4 class="text-base font-semibold">
{{ editingContractId ? '编辑合同段' : '新增合同段' }}
</h4>
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeCreateModal">
<X class="h-4 w-4" />
</Button>
</div>
<div class="space-y-2 px-5 py-4">
<label class="block text-sm font-medium text-foreground">合同段名称</label>
<input
v-model="contractNameInput"
type="text"
placeholder="请输入合同段名称"
class="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
@keydown.enter="createContract"
/>
</div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
<Button variant="outline" @click="closeCreateModal">取消</Button>
<Button :disabled="!contractNameInput.trim()" @click="createContract">
{{ editingContractId ? '保存' : '确定' }}
</Button>
</div>
</div>
</div>
</div>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<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>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
即将删除{{ pendingDeleteContractName }}及其关联咨询服务和计价数据是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline" @click="pendingDeleteContractId = null">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="confirmDeleteContract">确认删除</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<ToastRoot
v-model:open="toastOpen"
:duration="1800"
class="group pointer-events-auto flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 text-foreground shadow-lg"
>
<div class="grid gap-1">
<ToastTitle class="text-sm font-semibold text-foreground">{{ toastTitle }}</ToastTitle>
<ToastDescription class="text-xs text-muted-foreground">{{ toastText }}</ToastDescription>
</div>
<ToastAction
alt-text="知道了"
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"
>
知道了
</ToastAction>
</ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />
</TooltipProvider>
</ToastProvider>
</template>
<style scoped src="@/features/ht/ht.css"></style>