fix more
This commit is contained in:
1 parent
f121aa233e
commit
5734cfa534
12 files changed
+1584
-134
No files matched your search
+360
-35
@@ -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>
|
||||
Reference in new issue
Block a user