This commit is contained in:
zwgczx committed 2026-03-04 17:43:06 +08:00
1 parent 33913c29d2
commit 42fd6e48c4
19 files changed
+1163 -420

No files matched your search

+271 -50
View File
@@ -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)"
&lt;
</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)"
>
&gt;
</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>