374 lines
11 KiB
Vue
374 lines
11 KiB
Vue
<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>
|