2026-02-28 16:34:36 +08:00

846 lines
28 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, Plus, Trash2, X } from 'lucide-vue-next'
import {
ToastAction,
ToastDescription,
ToastProvider,
ToastRoot,
ToastTitle,
ToastViewport
} from 'reka-ui'
interface ContractItem {
id: string
name: string
order: number
createdAt: string
}
const STORAGE_KEY = 'ht-card-v1'
const tabStore = useTabStore()
const contracts = ref<ContractItem[]>([])
const contractSearchKeyword = ref('')
const isListLayout = ref(false)
const showCreateModal = ref(false)
const contractNameInput = ref('')
const editingContractId = ref<string | null>(null)
const toastOpen = ref(false)
const toastTitle = ref('操作成功')
const toastText = ref('')
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 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 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 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 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 = () => {
editingContractId.value = null
contractNameInput.value = ''
modalOffset.value = { x: 0, y: 0 }
showCreateModal.value = true
}
const openEditModal = (item: ContractItem) => {
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)
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) => {
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)
}
onMounted(async () => {
await loadContracts()
triggerCardEnterAnimation()
await nextTick()
bindContractListScroll()
})
onActivated(() => {
triggerCardEnterAnimation()
void nextTick(() => {
bindContractListScroll()
})
})
onBeforeUnmount(() => {
stopDrag()
stopContractAutoScroll()
unbindContractListScroll()
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>
<Button @click="openCreateModal">
<Plus class="mr-2 h-4 w-4" />
添加合同段
</Button>
</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>
<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"
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 cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
cardMotionState === 'enter' && !isDraggingContracts ? 'ht-contract-card--enter' : 'ht-contract-card--ready',
isListLayout && 'gap-0 py-0'
]"
:style="cardMotionState === 'enter' ? getCardEnterStyle(index) : undefined"
@click="handleCardClick(element)"
>
<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 :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="deleteContract(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 cursor-pointer snap-start snap-always transition-colors hover:border-primary ht-contract-card',
cardMotionState === 'enter' && !isDraggingContracts ? 'ht-contract-card--enter' : 'ht-contract-card--ready',
isListLayout && 'gap-0 py-0'
]"
:style="cardMotionState === 'enter' ? getCardEnterStyle(index) : undefined"
@click="handleCardClick(element)"
>
<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 :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="deleteContract(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>
<button
type="button"
title="回到顶部"
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>
<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>
<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;
}
.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);
}
@keyframes ht-card-slide-in {
from {
opacity: 0;
transform: translate3d(44px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.ht-contract-card--enter {
animation: none;
opacity: 1;
transform: none;
}
}
</style>