重构
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>
|
||||
+87
-96
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch, type Component ,onMounted} from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch, type Component, onMounted } from 'vue'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger,DialogDescription
|
||||
DialogTrigger, DialogDescription
|
||||
} from 'reka-ui'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
|
||||
@@ -58,6 +59,8 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
|
||||
|
||||
const switchCategory = (cat: string) => {
|
||||
activeCategory.value = cat
|
||||
sessionStorage.setItem(cacheKey.value, cat)
|
||||
@@ -70,7 +73,18 @@ const activeComponent = computed(() => {
|
||||
|
||||
const copyBtnText = ref('复制')
|
||||
const sheetOpen = ref(false)
|
||||
const titleRef = ref<HTMLElement | null>(null)
|
||||
const isTitleOverflow = ref(false)
|
||||
const subtitleRef = ref<HTMLElement | null>(null)
|
||||
const isSubtitleOverflow = ref(false)
|
||||
let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let titleOverflowRafId: number | null = null
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handleCopySubtitle = async () => {
|
||||
const text = (props.copyText || '').trim()
|
||||
@@ -92,6 +106,10 @@ const handleCopySubtitle = async () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
||||
if (titleOverflowRafId != null) {
|
||||
cancelAnimationFrame(titleOverflowRafId)
|
||||
titleOverflowRafId = null
|
||||
}
|
||||
if (!root) return
|
||||
root.style.scale = ''
|
||||
root.style.translate = ''
|
||||
@@ -180,25 +198,29 @@ useMotionValueEvent(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider>
|
||||
<div class="flex h-full w-full bg-background">
|
||||
<div class="w-12/100 border-r p-6 flex flex-col gap-8 relative">
|
||||
<div class="w-12/100 border-r p-2 flex flex-col gap-8 relative">
|
||||
<div v-if="props.title || props.subtitle" class="space-y-1">
|
||||
<div v-if="props.title" class="font-bold text-base leading-6 text-primary break-words">
|
||||
{{ props.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="props.subtitle"
|
||||
class="flex flex-wrap items-center gap-2 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
<span class="break-all">{{ props.subtitle }}</span>
|
||||
<Button
|
||||
v-if="props.copyText"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 rounded-md px-2 text-[11px]"
|
||||
@click.stop="handleCopySubtitle"
|
||||
>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<div v-if="props.title" ref="titleRef" class="title-ellipsis-2 max-w-full font-bold text-base leading-6 text-primary">
|
||||
{{ props.title }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" :avoid-collisions="false">{{ props.title }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
<div v-if="props.subtitle" class="flex min-w-0 items-center gap-2 text-xs leading-5 text-muted-foreground">
|
||||
<div class="min-w-0 flex-1">
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<span ref="subtitleRef" class="block max-w-full truncate">{{ props.subtitle }}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" :avoid-collisions="false">{{ props.subtitle }}</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
<Button v-if="props.copyText" type="button" variant="outline" size="sm"
|
||||
class="h-6 rounded-md px-2 text-[11px]" @click.stop="handleCopySubtitle">
|
||||
{{ copyBtnText }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -207,30 +229,22 @@ useMotionValueEvent(
|
||||
<div class="flex flex-col gap-10 relative">
|
||||
<div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>
|
||||
|
||||
<div
|
||||
v-for="item in props.categories"
|
||||
:key="item.key"
|
||||
class="relative flex items-center gap-4 cursor-pointer group"
|
||||
@click="switchCategory(item.key)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
|
||||
activeCategory === item.key
|
||||
? 'bg-primary border-primary scale-110'
|
||||
: 'bg-background border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
<div v-for="item in props.categories" :key="item.key"
|
||||
class="relative flex items-center gap-4 cursor-pointer group" @click="switchCategory(item.key)">
|
||||
<div :class="[
|
||||
'z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
|
||||
activeCategory === item.key
|
||||
? 'bg-primary border-primary scale-110'
|
||||
: 'bg-background border-muted-foreground'
|
||||
]">
|
||||
<div v-if="activeCategory === item.key" class="w-2 h-2 bg-background rounded-full"></div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'text-sm transition-colors',
|
||||
activeCategory === item.key
|
||||
? 'font-bold text-primary'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
]"
|
||||
>
|
||||
<span :class="[
|
||||
'text-sm transition-colors',
|
||||
activeCategory === item.key
|
||||
? 'font-bold text-primary'
|
||||
: 'text-muted-foreground group-hover:text-foreground'
|
||||
]">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -238,27 +252,17 @@ useMotionValueEvent(
|
||||
|
||||
<DialogRoot v-model:open="sheetOpen">
|
||||
<DialogTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-4 right-4 bottom-4 flex cursor-pointer flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[12px] leading-5 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
|
||||
>
|
||||
<button type="button"
|
||||
class="cursor-pointer absolute left-4 right-4 bottom-4 flex flex-col items-center gap-1.5 rounded-lg border bg-muted/35 px-3 py-2 text-center text-[12px] leading-5 text-foreground/85 shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground">
|
||||
<img src="/favicon.ico" alt="众为咨询" class="h-5 w-5 shrink-0 rounded-sm" />
|
||||
<span>本网站由众为工程咨询有限公司提供免费技术支持</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogPortal>
|
||||
<AnimatePresence
|
||||
multiple
|
||||
as="div"
|
||||
>
|
||||
<AnimatePresence multiple as="div">
|
||||
<DialogOverlay as-child>
|
||||
<Motion
|
||||
class="fixed inset-0 z-10 bg-black/45 backdrop-blur-[2px]"
|
||||
:initial="{ opacity: 0 }"
|
||||
:animate="{ opacity: 1 }"
|
||||
:exit="{ opacity: 0 }"
|
||||
:transition="staticTransition"
|
||||
/>
|
||||
<Motion class="fixed inset-0 z-10 bg-black/45 backdrop-blur-[2px]" :initial="{ opacity: 0 }"
|
||||
:animate="{ opacity: 1 }" :exit="{ opacity: 0 }" :transition="staticTransition" />
|
||||
</DialogOverlay>
|
||||
|
||||
<DialogContent as-child>
|
||||
@@ -267,32 +271,24 @@ useMotionValueEvent(
|
||||
:style="{
|
||||
y,
|
||||
top: `${sheetTop}px`,
|
||||
}"
|
||||
drag="y"
|
||||
:drag-constraints="{ top: 0 }"
|
||||
@drag-end="(e, { offset, velocity }) => {
|
||||
}" drag="y" :drag-constraints="{ top: 0 }" @drag-end="(e, { offset, velocity }) => {
|
||||
if (offset.y > h * 0.35 || velocity.y > 10) {
|
||||
sheetOpen = false;
|
||||
}
|
||||
else {
|
||||
animate(y, 0, { ...inertiaTransition, min: 0, max: 0 });
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="mx-auto mt-2 h-1.5 w-12 cursor-grab rounded-full bg-muted-foreground/35 active:cursor-grabbing" />
|
||||
}">
|
||||
<div
|
||||
class="mx-auto mt-2 h-1.5 w-12 cursor-grab rounded-full bg-muted-foreground/35 active:cursor-grabbing" />
|
||||
<div class="mx-auto flex h-full w-full max-w-2xl flex-col px-4 pb-5 pt-3">
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-end">
|
||||
<DialogClose class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
|
||||
<DialogClose
|
||||
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 6L6 18M6 6l12 12"
|
||||
/>
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</DialogClose>
|
||||
</div>
|
||||
@@ -306,31 +302,16 @@ useMotionValueEvent(
|
||||
|
||||
<DialogDescription class="mb-4 text-base text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
:href="OFFICIAL_SITE_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline"
|
||||
>
|
||||
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex cursor-pointer items-center font-medium text-foreground transition-colors hover:text-primary hover:underline">
|
||||
众为工程咨询有限公司
|
||||
</a>
|
||||
<a
|
||||
:href="OFFICIAL_SITE_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<a :href="OFFICIAL_SITE_URL" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||
aria-label="跳转到官网首页"
|
||||
title="官网首页"
|
||||
>
|
||||
aria-label="跳转到官网首页" title="官网首页">
|
||||
<svg viewBox="0 0 24 24" class="h-4 w-4" aria-hidden="true">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h10v10M7 17L17 7"
|
||||
/>
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2" d="M7 7h10v10M7 17L17 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -338,7 +319,8 @@ useMotionValueEvent(
|
||||
|
||||
<div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
|
||||
<p>
|
||||
众为工程咨询有限公司 2009 年成立,专注工程造价与工程成本管控全过程咨询,是广东省政府审计入库优选单位。公司服务覆盖多领域、全类型客户,累计服务投资额超万亿元,深度参与港珠澳大桥、澳门大学横琴校区等国家级重点工程,参编三十余项国家及省市行业标准。
|
||||
众为工程咨询有限公司 2009
|
||||
年成立,专注工程造价与工程成本管控全过程咨询,是广东省政府审计入库优选单位。公司服务覆盖多领域、全类型客户,累计服务投资额超万亿元,深度参与港珠澳大桥、澳门大学横琴校区等国家级重点工程,参编三十余项国家及省市行业标准。
|
||||
</p>
|
||||
<p>
|
||||
公司立足大湾区,布局全球,设有澳门公司、斯里兰卡分公司,具备跨境与海外项目服务能力,以十五年专业沉淀、万亿级项目经验,为客户提供精准、可靠的工程咨询服务。
|
||||
@@ -349,8 +331,8 @@ useMotionValueEvent(
|
||||
</DialogContent>
|
||||
</AnimatePresence>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
</DialogRoot>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-88/100 min-h-0 h-full flex flex-col">
|
||||
@@ -363,6 +345,7 @@ useMotionValueEvent(
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 核心修改:添加 :deep() 穿透 scoped 作用域 */
|
||||
@@ -371,4 +354,12 @@ useMotionValueEvent(
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.title-ellipsis-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user