2026-02-26 15:55:36 +08:00

374 lines
11 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 { nextTick, 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 { useTabStore } from '@/pinia/tab'
import { Edit3, 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 formStore = localforage.createInstance({
name: 'jgjs-pricing-db',
storeName: 'form_state'
})
const tabStore = useTabStore()
const contracts = ref<ContractItem[]>([])
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 buildDefaultContracts = (): ContractItem[] => [
]
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 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),
removeForageKeysByContractId(formStore as any, 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()
}
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 }) => {
if (
event.oldIndex == null ||
event.newIndex == null ||
event.oldIndex === event.newIndex
) {
return
}
await saveContracts()
notify('排序完成')
}
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()
})
onBeforeUnmount(() => {
stopDrag()
void saveContracts()
})
</script>
<template>
<ToastProvider>
<div>
<div class="mb-6 flex items-center justify-between">
<h3 class="text-lg font-bold">合同段列表</h3>
<Button @click="openCreateModal">
<Plus class="mr-2 h-4 w-4" />
添加合同段
</Button>
</div>
<draggable
v-model="contracts"
item-key="id"
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
animation="200"
@end="handleDragEnd"
>
<template #item="{ element }">
<Card
class="group cursor-pointer transition-colors hover:border-primary"
@click="handleCardClick(element)"
>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">
<span class="ml-2">{{ element.name }}</span>
</CardTitle>
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click.stop="openEditModal(element)"
>
<Edit3 class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click.stop="deleteContract(element.id)"
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</CardHeader>
<div class="px-6 pb-4 text-xs text-muted-foreground">
创建时间{{ formatDateTime(element.createdAt) }}
</div>
</Card>
</template>
</draggable>
<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" />
</ToastProvider>
</template>