重构
This commit is contained in:
1 parent
33913c29d2
commit
42fd6e48c4
19 files changed
+1163
-420
No files matched your search
+271
-50
@@ -4,7 +4,8 @@ import type { ComponentPublicInstance } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ChevronDown, CircleHelp, RotateCcw, X } from 'lucide-vue-next'
|
||||
import localforage from 'localforage'
|
||||
import {
|
||||
@@ -53,6 +54,9 @@ interface ScaleRowLike {
|
||||
interface XmInfoStorageLike extends XmInfoLike {
|
||||
detailRows?: ScaleRowLike[]
|
||||
}
|
||||
interface XmScaleStorageLike {
|
||||
detailRows?: ScaleRowLike[]
|
||||
}
|
||||
|
||||
interface ContractCardItem {
|
||||
id: string
|
||||
@@ -215,6 +219,8 @@ interface ExportReportPayload {
|
||||
}
|
||||
|
||||
const USER_GUIDE_COMPLETED_KEY = 'jgjs-user-guide-completed-v1'
|
||||
const PROJECT_INFO_DB_KEY = 'xm-base-info-v1'
|
||||
const LEGACY_PROJECT_DB_KEY = 'xm-info-v3'
|
||||
const userGuideSteps: UserGuideStep[] = [
|
||||
{
|
||||
title: '欢迎使用',
|
||||
@@ -309,10 +315,21 @@ const tabContextRef = ref<HTMLElement | null>(null)
|
||||
const dataMenuOpen = ref(false)
|
||||
const dataMenuRef = ref<HTMLElement | null>(null)
|
||||
const importFileRef = ref<HTMLInputElement | null>(null)
|
||||
const importConfirmOpen = ref(false)
|
||||
const pendingImportPayload = ref<DataPackage | null>(null)
|
||||
const pendingImportFileName = ref('')
|
||||
const userGuideOpen = ref(false)
|
||||
const userGuideStepIndex = ref(0)
|
||||
const tabItemElMap = new Map<string, HTMLElement>()
|
||||
const tabTitleElMap = new Map<string, HTMLElement>()
|
||||
const tabPanelElMap = new Map<string, HTMLElement>()
|
||||
const tabScrollAreaRef = ref<HTMLElement | null>(null)
|
||||
const showTabScrollLeft = ref(false)
|
||||
const showTabScrollRight = ref(false)
|
||||
const isTabStripHover = ref(false)
|
||||
const tabTitleOverflowMap = ref<Record<string, boolean>>({})
|
||||
let tabStripViewportEl: HTMLElement | null = null
|
||||
let tabTitleOverflowRafId: number | null = null
|
||||
|
||||
const tabsModel = computed({
|
||||
get: () => tabStore.tabs,
|
||||
@@ -489,6 +506,36 @@ const setTabItemRef = (id: string, el: Element | ComponentPublicInstance | null)
|
||||
return
|
||||
}
|
||||
tabItemElMap.delete(id)
|
||||
tabTitleElMap.delete(id)
|
||||
delete tabTitleOverflowMap.value[id]
|
||||
scheduleUpdateTabTitleOverflow()
|
||||
}
|
||||
|
||||
const setTabTitleRef = (id: string, el: Element | ComponentPublicInstance | null) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
tabTitleElMap.set(id, el)
|
||||
scheduleUpdateTabTitleOverflow()
|
||||
return
|
||||
}
|
||||
tabTitleElMap.delete(id)
|
||||
delete tabTitleOverflowMap.value[id]
|
||||
scheduleUpdateTabTitleOverflow()
|
||||
}
|
||||
|
||||
const updateTabTitleOverflow = () => {
|
||||
const nextMap: Record<string, boolean> = {}
|
||||
for (const [id, el] of tabTitleElMap.entries()) {
|
||||
nextMap[id] = el.scrollWidth > el.clientWidth + 1
|
||||
}
|
||||
tabTitleOverflowMap.value = nextMap
|
||||
}
|
||||
|
||||
const scheduleUpdateTabTitleOverflow = () => {
|
||||
if (tabTitleOverflowRafId != null) return
|
||||
tabTitleOverflowRafId = requestAnimationFrame(() => {
|
||||
tabTitleOverflowRafId = null
|
||||
updateTabTitleOverflow()
|
||||
})
|
||||
}
|
||||
|
||||
const setTabPanelRef = (id: string, el: Element | ComponentPublicInstance | null) => {
|
||||
@@ -499,6 +546,58 @@ const setTabPanelRef = (id: string, el: Element | ComponentPublicInstance | null
|
||||
tabPanelElMap.delete(id)
|
||||
}
|
||||
|
||||
const setTabScrollAreaRef = (el: Element | ComponentPublicInstance | null) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
tabScrollAreaRef.value = el
|
||||
return
|
||||
}
|
||||
const rootEl =
|
||||
el && typeof el === 'object' && '$el' in el ? (el as ComponentPublicInstance).$el : null
|
||||
tabScrollAreaRef.value = rootEl instanceof HTMLElement ? rootEl : null
|
||||
}
|
||||
|
||||
const getTabStripViewport = () =>
|
||||
tabScrollAreaRef.value?.querySelector<HTMLElement>('[data-slot="scroll-area-viewport"]') || null
|
||||
|
||||
const updateTabScrollButtons = () => {
|
||||
const viewport = getTabStripViewport()
|
||||
if (!viewport) {
|
||||
showTabScrollLeft.value = false
|
||||
showTabScrollRight.value = false
|
||||
return
|
||||
}
|
||||
const maxLeft = Math.max(0, viewport.scrollWidth - viewport.clientWidth)
|
||||
showTabScrollLeft.value = viewport.scrollLeft > 1
|
||||
showTabScrollRight.value = viewport.scrollLeft < maxLeft - 1
|
||||
}
|
||||
|
||||
const handleTabStripScroll = () => {
|
||||
updateTabScrollButtons()
|
||||
}
|
||||
|
||||
const bindTabStripScroll = () => {
|
||||
const viewport = getTabStripViewport()
|
||||
if (tabStripViewportEl === viewport) {
|
||||
updateTabScrollButtons()
|
||||
return
|
||||
}
|
||||
if (tabStripViewportEl) {
|
||||
tabStripViewportEl.removeEventListener('scroll', handleTabStripScroll)
|
||||
}
|
||||
tabStripViewportEl = viewport
|
||||
if (tabStripViewportEl) {
|
||||
tabStripViewportEl.addEventListener('scroll', handleTabStripScroll, { passive: true })
|
||||
}
|
||||
updateTabScrollButtons()
|
||||
}
|
||||
|
||||
const scrollTabStripBy = (delta: number) => {
|
||||
const viewport = getTabStripViewport()
|
||||
if (!viewport) return
|
||||
viewport.scrollBy({ left: delta, behavior: 'smooth' })
|
||||
requestAnimationFrame(updateTabScrollButtons)
|
||||
}
|
||||
|
||||
const ensureActiveTabVisible = () => {
|
||||
const activeId = tabStore.activeTabId
|
||||
const el = tabItemElMap.get(activeId)
|
||||
@@ -619,7 +718,9 @@ const formatExportTimestamp = (date: Date): string => {
|
||||
}
|
||||
|
||||
const getExportProjectName = (entries: DataEntry[]): string => {
|
||||
const target = entries.find(item => item.key === 'xm-info-v3')
|
||||
const target =
|
||||
entries.find(item => item.key === PROJECT_INFO_DB_KEY) ||
|
||||
entries.find(item => item.key === LEGACY_PROJECT_DB_KEY)
|
||||
const data = (target?.value || {}) as XmInfoLike
|
||||
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
|
||||
}
|
||||
@@ -853,14 +954,16 @@ const buildServiceFee = (
|
||||
}
|
||||
|
||||
const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
const [xmInfoRaw, contractCardsRaw] = await Promise.all([
|
||||
localforage.getItem<XmInfoStorageLike>('xm-info-v3'),
|
||||
const [projectInfoRaw, projectScaleRaw, contractCardsRaw] = await Promise.all([
|
||||
localforage.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
|
||||
localforage.getItem<XmInfoStorageLike>(LEGACY_PROJECT_DB_KEY),
|
||||
localforage.getItem<ContractCardItem[]>('ht-card-v1')
|
||||
])
|
||||
|
||||
const xmInfo = xmInfoRaw || {}
|
||||
const projectScale = buildScaleRows(xmInfo.detailRows)
|
||||
const projectName = isNonEmptyString(xmInfo.projectName) ? xmInfo.projectName.trim() : '造价项目'
|
||||
const projectInfo = projectInfoRaw || {}
|
||||
const projectScaleSource = projectScaleRaw || {}
|
||||
const projectScale = buildScaleRows(projectScaleSource.detailRows)
|
||||
const projectName = isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目'
|
||||
|
||||
const contractCards = (Array.isArray(contractCardsRaw) ? contractCardsRaw : [])
|
||||
.filter(item => item && typeof item.id === 'string')
|
||||
@@ -1000,7 +1103,27 @@ const importData = async (event: Event) => {
|
||||
}
|
||||
const buffer = await file.arrayBuffer()
|
||||
const payload = await decodeZwArchive<DataPackage>(buffer)
|
||||
pendingImportPayload.value = payload
|
||||
pendingImportFileName.value = file.name
|
||||
importConfirmOpen.value = true
|
||||
} catch (error) {
|
||||
console.error('import failed:', error)
|
||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||
} finally {
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const cancelImportConfirm = () => {
|
||||
importConfirmOpen.value = false
|
||||
pendingImportPayload.value = null
|
||||
pendingImportFileName.value = ''
|
||||
}
|
||||
|
||||
const confirmImportOverride = async () => {
|
||||
const payload = pendingImportPayload.value
|
||||
if (!payload) return
|
||||
try {
|
||||
writeWebStorage(localStorage, normalizeEntries(payload.localStorage))
|
||||
writeWebStorage(sessionStorage, normalizeEntries(payload.sessionStorage))
|
||||
await writeForage(localforage, normalizeEntries(payload.localforageDefault))
|
||||
@@ -1009,10 +1132,10 @@ const importData = async (event: Event) => {
|
||||
dataMenuOpen.value = false
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('import failed:', error)
|
||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||
console.error('import apply failed:', error)
|
||||
window.alert('导入失败:写入本地数据时发生错误。')
|
||||
} finally {
|
||||
input.value = ''
|
||||
cancelImportConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1032,8 +1155,12 @@ const handleReset = async () => {
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||
void nextTick(() => {
|
||||
bindTabStripScroll()
|
||||
ensureActiveTabVisible()
|
||||
updateTabScrollButtons()
|
||||
scheduleUpdateTabTitleOverflow()
|
||||
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
|
||||
})
|
||||
void (async () => {
|
||||
@@ -1046,6 +1173,15 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('mousedown', handleGlobalMouseDown)
|
||||
window.removeEventListener('keydown', handleGlobalKeyDown)
|
||||
window.removeEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||
if (tabStripViewportEl) {
|
||||
tabStripViewportEl.removeEventListener('scroll', handleTabStripScroll)
|
||||
tabStripViewportEl = null
|
||||
}
|
||||
if (tabTitleOverflowRafId != null) {
|
||||
cancelAnimationFrame(tabTitleOverflowRafId)
|
||||
tabTitleOverflowRafId = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -1053,7 +1189,10 @@ watch(
|
||||
(nextId, prevId) => {
|
||||
saveTabInnerScrollTop(prevId)
|
||||
void nextTick(() => {
|
||||
bindTabStripScroll()
|
||||
ensureActiveTabVisible()
|
||||
updateTabScrollButtons()
|
||||
scheduleUpdateTabTitleOverflow()
|
||||
scheduleRestoreTabInnerScrollTop(nextId)
|
||||
})
|
||||
}
|
||||
@@ -1073,54 +1212,100 @@ watch(
|
||||
} catch (error) {
|
||||
console.error('cleanup tab scroll cache failed:', error)
|
||||
}
|
||||
void nextTick(() => {
|
||||
bindTabStripScroll()
|
||||
updateTabScrollButtons()
|
||||
scheduleUpdateTabTitleOverflow()
|
||||
})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="flex flex-col w-full h-screen bg-background overflow-hidden">
|
||||
<div class="flex items-start gap-2 border-b bg-muted/30 px-2 pt-2 flex-none">
|
||||
<ScrollArea type="auto" class="min-w-0 flex-1 whitespace-nowrap pb-2">
|
||||
<draggable
|
||||
v-model="tabsModel"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="flex w-max gap-1"
|
||||
:animation="180"
|
||||
:move="canMoveTab"
|
||||
<div
|
||||
class="mb-2 flex min-w-0 flex-1 items-center gap-1"
|
||||
@mouseenter="isTabStripHover = true"
|
||||
@mouseleave="isTabStripHover = false"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'h-9 w-8 shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
|
||||
isTabStripHover && showTabScrollLeft ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
]"
|
||||
@click="scrollTabStripBy(-260)"
|
||||
>
|
||||
<template #item="{ element: tab }">
|
||||
<div
|
||||
:ref="el => setTabItemRef(tab.id, el)"
|
||||
@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' : ''
|
||||
]"
|
||||
>
|
||||
<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)"
|
||||
<
|
||||
</button>
|
||||
<ScrollArea :ref="setTabScrollAreaRef" type="auto" class="tab-strip-scroll-area min-w-0 flex-1 whitespace-nowrap">
|
||||
<draggable
|
||||
v-model="tabsModel"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="flex w-max gap-1"
|
||||
:animation="180"
|
||||
:move="canMoveTab"
|
||||
>
|
||||
<template #item="{ element: tab }">
|
||||
<div
|
||||
:ref="el => setTabItemRef(tab.id, el)"
|
||||
@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>
|
||||
</template>
|
||||
</draggable>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<span
|
||||
:ref="el => setTabTitleRef(tab.id, el)"
|
||||
class="truncate mr-2"
|
||||
>
|
||||
{{ tab.title }}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent v-if="tabTitleOverflowMap[tab.id]" side="bottom">{{ tab.title }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
|
||||
<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>
|
||||
</ScrollArea>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'h-9 w-8 shrink-0 cursor-pointer rounded border bg-background text-sm text-muted-foreground transition hover:bg-muted',
|
||||
isTabStripHover && showTabScrollRight ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
]"
|
||||
@click="scrollTabStripBy(260)"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="dataMenuRef" class="relative mb-2 shrink-0">
|
||||
<Button variant="outline" size="sm" class="cursor-pointer" @click="dataMenuOpen = !dataMenuOpen">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 min-h-9 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="dataMenuOpen = !dataMenuOpen"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 mr-1" />
|
||||
导入/导出
|
||||
</Button>
|
||||
@@ -1156,14 +1341,19 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" class="mb-2 shrink-0 cursor-pointer" @click="openUserGuide(0)">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mb-2 h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none cursor-pointer"
|
||||
@click="openUserGuide(0)"
|
||||
>
|
||||
<CircleHelp class="h-4 w-4 mr-1" />
|
||||
使用引导
|
||||
</Button>
|
||||
|
||||
<AlertDialogRoot>
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" size="sm" class="mb-2 shrink-0">
|
||||
<Button variant="destructive" size="sm" class="mb-2 h-9 min-h-9 shrink-0 px-3 py-0 text-sm leading-none">
|
||||
<RotateCcw class="h-4 w-4 mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
@@ -1186,6 +1376,26 @@ watch(
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
|
||||
<AlertDialogRoot :open="importConfirmOpen" @update:open="importConfirmOpen = $event">
|
||||
<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">
|
||||
将使用“{{ pendingImportFileName || '所选文件' }}”覆盖当前本地全部数据,是否继续?
|
||||
</AlertDialogDescription>
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
<AlertDialogCancel as-child>
|
||||
<Button variant="outline" @click="cancelImportConfirm">取消</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction as-child>
|
||||
<Button variant="destructive" @click="confirmImportOverride">确认覆盖</Button>
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto relative">
|
||||
@@ -1194,7 +1404,7 @@ watch(
|
||||
:key="tab.id"
|
||||
:ref="el => setTabPanelRef(tab.id, el)"
|
||||
v-show="tabStore.activeTabId === tab.id"
|
||||
class="h-full w-full p-6 animate-in fade-in duration-300"
|
||||
class="h-full w-full p-4 animate-in fade-in duration-300"
|
||||
>
|
||||
<component :is="componentMap[tab.componentName]" v-bind="tab.props || {}" />
|
||||
</div>
|
||||
@@ -1281,4 +1491,15 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]) {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tab-strip-scroll-area :deep([data-slot="scroll-area-viewport"]::-webkit-scrollbar) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user