diff --git a/src/components/views/Ht.vue b/src/components/views/Ht.vue index e3e6b40..b355b9e 100644 --- a/src/components/views/Ht.vue +++ b/src/components/views/Ht.vue @@ -7,7 +7,8 @@ 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 { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next' +import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive' import { ToastAction, ToastDescription, @@ -24,7 +25,24 @@ interface ContractItem { 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() @@ -34,6 +52,11 @@ const tabStore = useTabStore() const contracts = ref([]) const contractSearchKeyword = ref('') const isListLayout = ref(false) +const contractDataMenuOpen = ref(false) +const contractDataMenuRef = ref(null) +const contractImportFileRef = ref(null) +const isExportSelecting = ref(false) +const selectedExportContractIds = ref([]) const showCreateModal = ref(false) const contractNameInput = ref('') @@ -67,6 +90,7 @@ 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 => { @@ -101,6 +125,47 @@ const notify = (text: string) => { }) } +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( '[data-slot="scroll-area-viewport"]' @@ -179,6 +244,20 @@ 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) @@ -188,6 +267,194 @@ const saveContracts = async () => { } } +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 + 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 | 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) => { + 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(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() + 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() @@ -239,6 +506,7 @@ const loadContracts = async () => { } const openCreateModal = () => { + closeContractDataMenu() editingContractId.value = null contractNameInput.value = '' modalOffset.value = { x: 0, y: 0 } @@ -246,6 +514,7 @@ const openCreateModal = () => { } const openEditModal = (item: ContractItem) => { + closeContractDataMenu() editingContractId.value = item.id contractNameInput.value = item.name modalOffset.value = { x: 0, y: 0 } @@ -307,6 +576,7 @@ const deleteContract = async (id: string) => { await cleanupContractRelatedData(id) contracts.value = contracts.value.filter(item => item.id !== id) + selectedExportContractIds.value = selectedExportContractIds.value.filter(item => item !== id) await saveContracts() notify('删除成功') } @@ -390,6 +660,10 @@ const stopContractAutoScroll = () => { } const handleCardClick = (item: ContractItem) => { + if (isExportSelecting.value) { + toggleExportContractSelection(item.id) + return + } tabStore.openTab({ id: `contract-${item.id}`, title: `合同段${item.name}`, @@ -419,11 +693,20 @@ const startDrag = (event: MouseEvent) => { 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(() => { @@ -437,6 +720,7 @@ onBeforeUnmount(() => { stopDrag() stopContractAutoScroll() unbindContractListScroll() + window.removeEventListener('mousedown', handleGlobalMouseDown) if (cardEnterTimer) clearTimeout(cardEnterTimer) void saveContracts() }) @@ -449,10 +733,61 @@ onBeforeUnmount(() => {

合同段列表

- +
+ + +
@@ -477,6 +812,9 @@ onBeforeUnmount(() => {
搜索中({{ filteredContracts.length }} / {{ contracts.length }}),已关闭拖拽排序
+
+ 导出选择模式:勾选合同段后点击“导出已选” +