1567 lines
56 KiB
Vue
1567 lines
56 KiB
Vue
<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>
|
||
|