2026-03-02 16:55:27 +08:00

1334 lines
44 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 } from 'vue'
import draggable from 'vuedraggable'
import localforage from 'localforage'
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 { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
ToastAction,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport
} from 'reka-ui'
interface ContractItem {
id: string
name: string
order: number
createdAt: string
}
interface DataEntry {
key: string
value: any
}
interface ContractSegmentPackage {
version: number
exportedAt: string
contracts: ContractItem[]
localforageEntries: DataEntry[]
}
const STORAGE_KEY = 'ht-card-v1'
const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
const CONTRACT_SEGMENT_VERSION = 1
const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
const SERVICE_KEY_PREFIX = 'zxFW-'
const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-']
const tabStore = useTabStore()
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 isExportSelecting = ref(false)
const selectedExportContractIds = 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 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')
let contractAutoScrollRaf = 0
let dragPointerClientY: number | null = null
let cardEnterTimer: ReturnType<typeof setTimeout> | null = null
let contractListScrollBoundEl: HTMLElement | 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 selectedExportCount = computed(() => selectedExportContractIds.value.length)
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 normalizeOrder = (list: ContractItem[]): ContractItem[] =>
list.map((item, index) => ({
...item,
order: index,
createdAt: item.createdAt || new Date().toISOString()
}))
const formatDateTime = (value: string) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const notify = (text: string) => {
toastTitle.value = '操作成功'
toastText.value = text
toastOpen.value = false
requestAnimationFrame(() => {
toastOpen.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
if (!open) pendingDeleteContractId.value = null
}
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 formatExportTimestamp = (date: Date): string => {
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
return `${yyyy}${mm}${dd}-${hh}${mi}`
}
const isContractSelectedForExport = (contractId: string) =>
selectedExportContractIds.value.includes(contractId)
const toggleExportContractSelection = (contractId: string) => {
if (!isExportSelecting.value) return
if (isContractSelectedForExport(contractId)) {
selectedExportContractIds.value = selectedExportContractIds.value.filter(id => id !== contractId)
return
}
selectedExportContractIds.value = [...selectedExportContractIds.value, contractId]
}
const exitContractExportMode = () => {
isExportSelecting.value = false
selectedExportContractIds.value = []
}
const enterContractExportMode = () => {
closeContractDataMenu()
isExportSelecting.value = true
selectedExportContractIds.value = []
}
const triggerContractImport = () => {
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 (isExportSelecting.value) {
return getCardSelectStyle(index)
}
if (cardMotionState.value === 'enter') {
return getCardEnterStyle(index)
}
return undefined
}
const saveContracts = async () => {
try {
contracts.value = normalizeOrder(contracts.value)
await localforage.setItem(STORAGE_KEY, JSON.parse(JSON.stringify(contracts.value)))
} catch (error) {
console.error('save contracts failed:', error)
}
}
const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object')
.map((item, index) => {
const row = item as Partial<ContractItem>
const name = typeof row.name === 'string' ? row.name.trim() : ''
const createdAt = typeof row.createdAt === 'string' ? row.createdAt : new Date().toISOString()
const id = typeof row.id === 'string' ? row.id : `import-contract-${index}`
return {
id,
name: name || `导入合同段-${index + 1}`,
order: index,
createdAt
}
})
}
const normalizeDataEntries = (value: unknown): DataEntry[] => {
if (!Array.isArray(value)) return []
return value
.filter(item => item && typeof item === 'object' && typeof (item as DataEntry).key === 'string')
.map(item => ({
key: String((item as DataEntry).key),
value: (item as DataEntry).value
}))
}
const isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => {
const payload = value as Partial<ContractSegmentPackage> | null
return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts))
}
const isContractRelatedForageKey = (key: string, contractId: string) => {
if (key === `${CONTRACT_KEY_PREFIX}${contractId}`) return true
if (key === `${SERVICE_KEY_PREFIX}${contractId}`) return true
if (PRICING_KEY_PREFIXES.some(prefix => key.startsWith(`${prefix}${contractId}-`))) return true
return false
}
const readContractRelatedForageEntries = async (contractIds: string[]) => {
const keys = await localforage.keys()
const idSet = new Set(contractIds)
const targetKeys = keys.filter(key => {
for (const id of idSet) {
if (isContractRelatedForageKey(key, id)) return true
}
return false
})
return Promise.all(
targetKeys.map(async key => ({
key,
value: await localforage.getItem(key)
}))
)
}
const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
for (const prefix of PRICING_KEY_PREFIXES) {
if (key.startsWith(`${prefix}${fromId}-`)) {
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
}
}
return key
}
const generateContractId = (usedIds: Set<string>) => {
let nextId = ''
while (!nextId || usedIds.has(nextId)) {
nextId = `ct-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
}
usedIds.add(nextId)
return nextId
}
const exportSelectedContracts = async () => {
if (selectedExportContractIds.value.length === 0) {
window.alert('请先勾选至少一个合同段。')
return
}
try {
const selectedSet = new Set(selectedExportContractIds.value)
const selectedContracts = contracts.value
.filter(item => selectedSet.has(item.id))
.map((item, index) => ({
...item,
order: index
}))
const localforageEntries = await readContractRelatedForageEntries(
selectedContracts.map(item => item.id)
)
const now = new Date()
const payload: ContractSegmentPackage = {
version: CONTRACT_SEGMENT_VERSION,
exportedAt: now.toISOString(),
contracts: selectedContracts,
localforageEntries
}
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} 个合同段)`)
exitContractExportMode()
} catch (error) {
console.error('export selected contracts failed:', error)
window.alert('导出失败,请重试。')
}
}
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 importedContracts = normalizeContractsFromPayload(payload.contracts)
if (importedContracts.length === 0) {
throw new Error('EMPTY_CONTRACTS')
}
const importedEntries = normalizeDataEntries(payload.localforageEntries)
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 = importedEntries.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 => localforage.setItem(entry.key, entry.value)))
contracts.value = [...contracts.value, ...nextContracts]
await saveContracts()
notify(`导入成功(${nextContracts.length} 个合同段)`)
await nextTick()
scrollContractsToBottom()
} catch (error) {
console.error('import contract segments failed:', error)
window.alert('导入失败:文件无效、已损坏或不是合同段导出文件。')
} finally {
input.value = ''
}
}
const removeForageKeysByContractId = async (store: typeof localforage, 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) => {
await Promise.all([
removeForageKeysByContractId(localforage, contractId),
])
}
const loadContracts = async () => {
try {
const saved = await localforage.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 = () => {
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
}
contracts.value = [
...contracts.value,
{
id: `ct-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
name,
order: contracts.value.length,
createdAt: new Date().toISOString()
}
]
await saveContracts()
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)
selectedExportContractIds.value = selectedExportContractIds.value.filter(item => item !== id)
await saveContracts()
notify('删除成功')
}
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 (isExportSelecting.value) {
toggleExportContractSelection(item.id)
return
}
tabStore.openTab({
id: `contract-${item.id}`,
title: `合同段${item.name}`,
componentName: 'ContractDetailView',
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 loadContracts()
triggerCardEnterAnimation()
await nextTick()
bindContractListScroll()
window.addEventListener('mousedown', handleGlobalMouseDown)
})
onActivated(() => {
triggerCardEnterAnimation()
void nextTick(() => {
bindContractListScroll()
})
})
onBeforeUnmount(() => {
stopDrag()
stopContractAutoScroll()
unbindContractListScroll()
window.removeEventListener('mousedown', handleGlobalMouseDown)
if (cardEnterTimer) clearTimeout(cardEnterTimer)
void saveContracts()
})
</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">
<h3 class="text-lg font-bold">合同段列表</h3>
<div class="flex items-center gap-2">
<template v-if="isExportSelecting">
<div class="text-xs text-muted-foreground">已选 {{ selectedExportCount }} 个</div>
<Button
variant="outline"
:disabled="selectedExportCount === 0"
@click="exportSelectedContracts"
>
导出已选
</Button>
<Button variant="ghost" @click="exitContractExportMode">
取消
</Button>
</template>
<template v-else>
<Button @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 cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="enterContractExportMode"
>
导出合同段
</button>
<button
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@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="isExportSelecting" 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 :class="['ht-contract-scroll-area h-full', isDraggingContracts && 'is-dragging']">
<draggable
v-if="!isSearchingContracts"
:key="`contracts-${isListLayout ? 'list' : 'grid'}`"
v-model="contracts"
item-key="id"
:disabled="isExportSelecting"
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',
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',
isExportSelecting
? 'ht-contract-card--selecting'
: cardMotionState === 'enter' && !isDraggingContracts
? 'ht-contract-card--enter'
: 'ht-contract-card--ready',
isContractSelectedForExport(element.id) && 'ht-contract-card--selected',
isListLayout && 'gap-0 py-0'
]"
:style="getContractCardStyle(index)"
@click="handleCardClick(element)"
>
<label
v-if="isExportSelecting"
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="isContractSelectedForExport(element.id)"
@change.stop="toggleExportContractSelection(element.id)"
/>
</label>
<CardHeader
:class="[
'flex flex-row items-center justify-between gap-0 space-y-0',
isListLayout ? 'px-3 py-2' : 'pb-6'
]"
>
<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">
创建时间:{{ formatDateTime(element.createdAt) }}
</span>
</template>
</CardTitle>
<div
v-if="!isExportSelecting"
: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 '
]"
>
<div class="break-all">ID{{ element.id }}</div>
<div>创建时间:{{ formatDateTime(element.createdAt) }}</div>
</div>
</Card>
</template>
</draggable>
<div
v-else
:key="`contracts-search-${isListLayout ? 'list' : 'grid'}`"
:class="[
'grid grid-cols-1 pb-4 pr-4',
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',
isExportSelecting
? 'ht-contract-card--selecting'
: cardMotionState === 'enter' && !isDraggingContracts
? 'ht-contract-card--enter'
: 'ht-contract-card--ready',
isContractSelectedForExport(element.id) && 'ht-contract-card--selected',
isListLayout && 'gap-0 py-0'
]"
:style="getContractCardStyle(index)"
@click="handleCardClick(element)"
>
<label
v-if="isExportSelecting"
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="isContractSelectedForExport(element.id)"
@change.stop="toggleExportContractSelection(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">
创建时间:{{ formatDateTime(element.createdAt) }}
</span>
</template>
</CardTitle>
<div
v-if="!isExportSelecting"
: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>创建时间:{{ 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">取消</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-slate-800/90 bg-slate-900 px-4 py-3 text-white shadow-xl"
>
<div class="grid gap-1">
<ToastTitle class="text-sm font-semibold text-white">{{ toastTitle }}</ToastTitle>
<ToastDescription class="text-xs text-slate-100">{{ toastText }}</ToastDescription>
</div>
<ToastAction
alt-text="知道了"
class="ml-auto inline-flex h-7 items-center rounded-md border border-white/30 bg-white/10 px-2 text-xs text-white hover:bg-white/20"
@click="toastOpen = false"
>
知道了
</ToastAction>
</ToastRoot>
<ToastViewport class="fixed bottom-5 right-5 z-[80] flex w-[380px] max-w-[92vw] flex-col gap-2 outline-none" />
</TooltipProvider>
</ToastProvider>
</template>
<style scoped>
.ht-contract-scroll-area :deep([data-slot='scroll-area-viewport']) {
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
}
.ht-contract-scroll-area.is-dragging :deep([data-slot='scroll-area-viewport']) {
scroll-snap-type: none;
}
.ht-contract-scroll-area :deep(.ht-sortable-ghost) {
opacity: 0.35;
}
.ht-contract-scroll-area :deep(.ht-sortable-chosen),
.ht-contract-scroll-area :deep(.ht-sortable-drag) {
will-change: transform, opacity;
transform: translateZ(0);
backface-visibility: hidden;
}
.ht-contract-card {
will-change: transform, opacity;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
contain: paint;
}
.ht-contract-card--ready {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.ht-contract-card--enter {
animation: ht-card-slide-in 560ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
animation-delay: var(--ht-card-enter-delay, 0ms);
}
.ht-contract-card--selecting {
transform-origin: 50% 100%;
animation: ht-card-select-wave 2200ms linear infinite both;
animation-delay: var(--ht-card-select-delay, 0ms);
}
.ht-contract-card--selecting:hover {
animation-play-state: paused;
}
.ht-contract-card--selecting.ht-contract-card--selected {
animation: none;
transform: translate3d(0, 0, 0) rotate(0deg);
}
.ht-contract-card--selected {
border-color: hsl(var(--primary));
box-shadow: 0 0 0 1px hsl(var(--primary) / 0.22);
}
@keyframes ht-card-slide-in {
from {
opacity: 0;
transform: translate3d(44px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes ht-card-select-wave {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
11% {
transform: translate3d(-0.4px, 0, 0) rotate(-0.7deg);
}
22% {
transform: translate3d(-0.9px, 0, 0) rotate(-1.6deg);
}
34% {
transform: translate3d(-1.2px, 0, 0) rotate(-2.3deg);
}
48% {
transform: translate3d(-0.2px, 0, 0) rotate(-0.4deg);
}
62% {
transform: translate3d(0.8px, 0, 0) rotate(1.5deg);
}
76% {
transform: translate3d(1.25px, 0, 0) rotate(2.35deg);
}
88% {
transform: translate3d(0.35px, 0, 0) rotate(0.65deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ht-contract-card--enter,
.ht-contract-card--selecting {
animation: none;
opacity: 1;
transform: none;
}
}
</style>