1334 lines
44 KiB
Vue
1334 lines
44 KiB
Vue
<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>
|