This commit is contained in:
zwgczx committed 2026-02-25 14:26:40 +08:00
1 parent f121aa233e
commit 5734cfa534
12 files changed
+1584 -134

No files matched your search

+360 -35
View File
@@ -1,62 +1,387 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, markRaw, onBeforeUnmount, onMounted, ref } from 'vue'
import draggable from 'vuedraggable'
import { useTabStore } from '@/pinia/tab'
import { defineAsyncComponent, markRaw } from 'vue'
import { Button } from '@/components/ui/button'
import { ScrollArea ,ScrollBar} from '@/components/ui/scroll-area'
import { X } from 'lucide-vue-next'
// 组件映射表:所有的动态组件需要在这里注册
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { ChevronDown, RotateCcw, X } from 'lucide-vue-next'
import localforage from 'localforage'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from 'reka-ui'
interface DataEntry {
key: string
value: any
}
interface DataPackage {
version: number
exportedAt: string
localStorage: DataEntry[]
sessionStorage: DataEntry[]
localforageDefault: DataEntry[]
localforageFormState: DataEntry[]
}
const componentMap: Record<string, any> = {
XmView: markRaw(defineAsyncComponent(() => import('@/components/views/Xm.vue'))),
ContractDetailView: markRaw(defineAsyncComponent(() => import('@/components/views/ContractDetailView.vue'))),
}
const tabStore = useTabStore()
// useTabStore
const formStore = localforage.createInstance({
name: 'jgjs-pricing-db',
storeName: 'form_state'
})
const tabContextOpen = ref(false)
const tabContextX = ref(0)
const tabContextY = ref(0)
const contextTabId = ref<string>('XmView')
const tabContextRef = ref<HTMLElement | null>(null)
const dataMenuOpen = ref(false)
const dataMenuRef = ref<HTMLElement | null>(null)
const importFileRef = ref<HTMLInputElement | null>(null)
const tabsModel = computed({
get: () => tabStore.tabs,
set: (value) => {
tabStore.tabs = value
}
})
const contextTabIndex = computed(() => tabStore.tabs.findIndex(t => t.id === contextTabId.value))
const hasClosableTabs = computed(() => tabStore.tabs.some(t => t.id !== 'XmView'))
const canCloseLeft = computed(() => {
if (contextTabIndex.value <= 0) return false
return tabStore.tabs.slice(0, contextTabIndex.value).some(t => t.id !== 'XmView')
})
const canCloseRight = computed(() => {
if (contextTabIndex.value < 0) return false
return tabStore.tabs.slice(contextTabIndex.value + 1).some(t => t.id !== 'XmView')
})
const canCloseOther = computed(() =>
tabStore.tabs.some(t => t.id !== 'XmView' && t.id !== contextTabId.value)
)
const closeMenus = () => {
tabContextOpen.value = false
dataMenuOpen.value = false
}
const openTabContextMenu = (event: MouseEvent, tabId: string) => {
contextTabId.value = tabId
tabContextX.value = event.clientX
tabContextY.value = event.clientY
tabContextOpen.value = true
}
const handleGlobalMouseDown = (event: MouseEvent) => {
const target = event.target as Node
if (tabContextOpen.value && tabContextRef.value && !tabContextRef.value.contains(target)) {
tabContextOpen.value = false
}
if (dataMenuOpen.value && dataMenuRef.value && !dataMenuRef.value.contains(target)) {
dataMenuOpen.value = false
}
}
const runTabMenuAction = (action: 'all' | 'left' | 'right' | 'other') => {
if (action === 'all') tabStore.closeAllTabs()
if (action === 'left') tabStore.closeLeftTabs(contextTabId.value)
if (action === 'right') tabStore.closeRightTabs(contextTabId.value)
if (action === 'other') tabStore.closeOtherTabs(contextTabId.value)
tabContextOpen.value = false
}
const canMoveTab = (event: any) => {
const draggedId = event?.draggedContext?.element?.id
const targetIndex = event?.relatedContext?.index
if (draggedId === 'XmView') return false
if (typeof targetIndex === 'number' && targetIndex === 0) return false
return true
}
const readWebStorage = (storageObj: Storage): DataEntry[] => {
const entries: DataEntry[] = []
for (let i = 0; i < storageObj.length; i++) {
const key = storageObj.key(i)
if (!key) continue
const raw = storageObj.getItem(key)
let value: any = raw
if (raw != null) {
try {
value = JSON.parse(raw)
} catch {
value = raw
}
}
entries.push({ key, value })
}
return entries
}
const writeWebStorage = (storageObj: Storage, entries: DataEntry[]) => {
storageObj.clear()
for (const entry of entries || []) {
const value = typeof entry.value === 'string' ? entry.value : JSON.stringify(entry.value)
storageObj.setItem(entry.key, value)
}
}
const readForage = async (store: typeof localforage): Promise<DataEntry[]> => {
const keys = await store.keys()
const entries: DataEntry[] = []
for (const key of keys) {
const value = await store.getItem(key)
entries.push({ key, value })
}
return entries
}
const writeForage = async (store: typeof localforage, entries: DataEntry[]) => {
await store.clear()
for (const entry of entries || []) {
await store.setItem(entry.key, entry.value)
}
}
const exportData = async () => {
try {
const payload: DataPackage = {
version: 1,
exportedAt: new Date().toISOString(),
localStorage: readWebStorage(localStorage),
sessionStorage: readWebStorage(sessionStorage),
localforageDefault: await readForage(localforage),
localforageFormState: await readForage(formStore as any)
}
const content = JSON.stringify(payload, null, 2)
const blob = new Blob([content], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `jgjs-data-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('export failed:', error)
window.alert('导出失败,请重试。')
} finally {
dataMenuOpen.value = false
}
}
const triggerImport = () => {
importFileRef.value?.click()
}
const importData = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
try {
const text = await file.text()
const payload = JSON.parse(text) as DataPackage
writeWebStorage(localStorage, payload.localStorage || [])
writeWebStorage(sessionStorage, payload.sessionStorage || [])
await writeForage(localforage, payload.localforageDefault || [])
await writeForage(formStore as any, payload.localforageFormState || [])
tabStore.resetTabs()
dataMenuOpen.value = false
window.location.reload()
} catch (error) {
console.error('import failed:', error)
window.alert('导入失败,文件格式不正确。')
} finally {
input.value = ''
}
}
const handleReset = async () => {
try {
localStorage.clear()
sessionStorage.clear()
await localforage.clear()
} catch (error) {
console.error('reset failed:', error)
} finally {
tabStore.resetTabs()
window.location.reload()
}
}
onMounted(() => {
window.addEventListener('mousedown', handleGlobalMouseDown)
})
onBeforeUnmount(() => {
window.removeEventListener('mousedown', handleGlobalMouseDown)
})
</script>
<template>
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
<div class="flex items-center border-b bg-muted/30 px-2 pt-2 flex-none">
<ScrollArea class="flex-1 whitespace-nowrap">
<div class="flex gap-1">
<div
v-for="(tab, index) in tabStore.tabs"
:key="tab.id"
@click="tabStore.activeTabId = tab.id"
:class="[
'group relative flex items-center h-9 px-4 min-w-[120px] max-w-[200px] cursor-pointer rounded-t-md border-x border-t transition-all text-sm',
tabStore.activeTabId === tab.id
? 'bg-background border-border font-medium'
: 'border-transparent hover:bg-muted text-muted-foreground'
]"
>
<span class="truncate mr-2">{{ tab.title }}</span>
<Button
v-if="index !== 0"
variant="ghost"
size="icon"
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
@click="tabStore.removeTab(tab.id)"
<draggable
v-model="tabsModel"
item-key="id"
tag="div"
class="flex gap-1"
:animation="180"
:move="canMoveTab"
>
<template #item="{ element: tab }">
<div
@click="tabStore.activeTabId = tab.id"
@contextmenu.prevent="openTabContextMenu($event, tab.id)"
:class="[
'group relative flex items-center h-9 px-4 min-w-[120px] max-w-[220px] cursor-pointer rounded-t-md border-x border-t transition-all text-sm',
tabStore.activeTabId === tab.id
? 'bg-background border-border font-medium'
: 'border-transparent hover:bg-muted text-muted-foreground',
tab.id !== 'XmView' ? 'cursor-move' : ''
]"
>
<X class="h-3 w-3" />
</Button>
</div>
</div>
<span class="truncate mr-2">{{ tab.title }}</span>
<Button
v-if="tab.id !== 'XmView'"
variant="ghost"
size="icon"
class="h-4 w-4 ml-auto opacity-0 group-hover:opacity-100 hover:bg-destructive hover:text-destructive-foreground transition-opacity"
@click.stop="tabStore.removeTab(tab.id)"
>
<X class="h-3 w-3" />
</Button>
</div>
</template>
</draggable>
<ScrollBar orientation="horizontal" class="invisible" />
</ScrollArea>
<div ref="dataMenuRef" class="relative ml-2 mb-2">
<Button variant="outline" size="sm" @click="dataMenuOpen = !dataMenuOpen">
<ChevronDown class="h-4 w-4 mr-1" />
导入/导出
</Button>
<div
v-if="dataMenuOpen"
class="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-md border bg-background p-1 shadow-md"
>
<button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="exportData"
>
导出数据
</button>
<button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
@click="triggerImport"
>
导入数据
</button>
</div>
<input
ref="importFileRef"
type="file"
accept="application/json,.json"
class="hidden"
@change="importData"
/>
</div>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button variant="destructive" size="sm" class="ml-2 mb-2">
<RotateCcw class="h-4 w-4 mr-1" />
重置
</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 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">
将清空本地缓存(IndexDB / LocalStorage / SessionStorage)并恢复默认页面,确认继续吗?
</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="handleReset">确认重置</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
<div class="flex-1 overflow-auto relative">
<div
v-for="tab in tabStore.tabs"
<div
v-for="tab in tabStore.tabs"
:key="tab.id"
v-show="tabStore.activeTabId === tab.id"
class="h-full w-full p-6 animate-in fade-in duration-300"
>
<component :is="componentMap[tab.componentName]" />
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
</div>
</div>
<div
v-if="tabContextOpen"
ref="tabContextRef"
class="fixed z-[70] min-w-[150px] rounded-md border bg-background p-1 shadow-lg"
:style="{ left: `${tabContextX}px`, top: `${tabContextY}px` }"
>
<button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!hasClosableTabs"
@click="runTabMenuAction('all')"
>
删除所有
</button>
<button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseLeft"
@click="runTabMenuAction('left')"
>
删除左侧
</button>
<button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseRight"
@click="runTabMenuAction('right')"
>
删除右侧
</button>
<button
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!canCloseOther"
@click="runTabMenuAction('other')"
>
删除其他
</button>
</div>
</div>
</template>
</template>
+1 -1
View File
@@ -57,7 +57,7 @@ const activeComponent = computed(() => {
<template>
<div class="flex h-full w-full bg-background">
<div class="w-1/5 border-r p-6 flex flex-col gap-8 relative">
<div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</div>
<!-- <div class="font-bold text-lg mb-4 text-primary">{{ props.title }}</div> -->
<div class="flex flex-col gap-10 relative">
<div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>