371 lines
13 KiB
Vue
371 lines
13 KiB
Vue
<script setup lang="ts">
|
|
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,
|
|
DialogOverlay,
|
|
DialogPortal,
|
|
DialogRoot,
|
|
DialogTitle,
|
|
DialogTrigger, DialogDescription
|
|
} from 'reka-ui'
|
|
import { useWindowSize } from '@vueuse/core'
|
|
import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
|
|
interface TypeLineCategory {
|
|
key: string
|
|
label: string
|
|
component: Component
|
|
}
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
scene?: string
|
|
title?: string
|
|
subtitle?: string
|
|
metaText?: string
|
|
copyText?: string
|
|
categories: TypeLineCategory[]
|
|
storageKey?: string
|
|
defaultCategory?: string
|
|
}>(),
|
|
{
|
|
scene: 'default',
|
|
title: '配置',
|
|
subtitle: '',
|
|
metaText: '',
|
|
copyText: '',
|
|
storageKey: '',
|
|
defaultCategory: ''
|
|
}
|
|
)
|
|
|
|
const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
|
|
|
|
const resolveInitialCategory = () => {
|
|
const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
|
|
const savedKey = sessionStorage.getItem(cacheKey.value)
|
|
const validSavedKey = props.categories.some(item => item.key === savedKey)
|
|
return validSavedKey ? (savedKey as string) : defaultKey
|
|
}
|
|
|
|
const activeCategory = ref(resolveInitialCategory())
|
|
|
|
watch(
|
|
() => [props.categories, props.defaultCategory, cacheKey.value],
|
|
() => {
|
|
activeCategory.value = resolveInitialCategory()
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
|
|
|
|
const switchCategory = (cat: string) => {
|
|
activeCategory.value = cat
|
|
sessionStorage.setItem(cacheKey.value, cat)
|
|
}
|
|
|
|
const activeComponent = computed(() => {
|
|
const selected = props.categories.find(item => item.key === activeCategory.value)
|
|
return selected?.component || props.categories[0]?.component || null
|
|
})
|
|
|
|
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()
|
|
if (!text) return
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
copyBtnText.value = '已复制'
|
|
} catch (error) {
|
|
console.error('copy failed:', error)
|
|
copyBtnText.value = '复制失败'
|
|
}
|
|
|
|
if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
|
copyBtnTimer = setTimeout(() => {
|
|
copyBtnText.value = '复制'
|
|
}, 1200)
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
|
if (titleOverflowRafId != null) {
|
|
cancelAnimationFrame(titleOverflowRafId)
|
|
titleOverflowRafId = null
|
|
}
|
|
if (!root) return
|
|
root.style.scale = ''
|
|
root.style.translate = ''
|
|
root.style.borderRadius = ''
|
|
})
|
|
|
|
//
|
|
|
|
|
|
const inertiaTransition = {
|
|
type: 'inertia' as const,
|
|
bounceStiffness: 300,
|
|
bounceDamping: 40,
|
|
timeConstant: 300,
|
|
}
|
|
|
|
const staticTransition = {
|
|
duration: 0.5,
|
|
ease: [0.32, 0.72, 0, 1] as const,
|
|
}
|
|
|
|
const SHEET_TOP_RATIO = 0.1
|
|
const SHEET_RADIUS = 12
|
|
const OFFICIAL_SITE_URL = 'http://www.zwgczx.com.cn/'
|
|
|
|
let root: HTMLElement | null = null
|
|
|
|
onMounted(() => {
|
|
root = document.body.firstElementChild as HTMLElement | null
|
|
})
|
|
|
|
const { height, width } = useWindowSize()
|
|
|
|
const sheetTop = computed(() => Math.round(height.value * SHEET_TOP_RATIO))
|
|
const h = computed(() => Math.max(0, height.value - sheetTop.value))
|
|
const y = useMotionValue(h.value)
|
|
|
|
watch(
|
|
() => h.value,
|
|
(nextHeight) => {
|
|
if (!sheetOpen.value) y.jump(nextHeight)
|
|
}
|
|
)
|
|
|
|
watch(
|
|
() => sheetOpen.value,
|
|
(isOpen) => {
|
|
if (!isOpen) {
|
|
y.jump(h.value)
|
|
return
|
|
}
|
|
y.jump(h.value)
|
|
animate(y, 0, staticTransition)
|
|
}
|
|
)
|
|
|
|
// Scale the body down and adjust the border radius when the sheet is open.
|
|
const bodyScale = useTransform(
|
|
y,
|
|
[0, h.value],
|
|
[(width.value - sheetTop.value) / width.value, 1],
|
|
)
|
|
const bodyTranslate = useTransform(y, [0, h.value], [sheetTop.value - SHEET_RADIUS, 0])
|
|
const bodyBorderRadius = useTransform(y, [0, h.value], [SHEET_RADIUS, 0])
|
|
|
|
useMotionValueEvent(bodyScale, 'change', (v) => {
|
|
if (!root) return
|
|
root.style.scale = `${v}`
|
|
})
|
|
useMotionValueEvent(
|
|
bodyTranslate,
|
|
'change',
|
|
(v) => {
|
|
if (!root) return
|
|
root.style.translate = `0 ${v}px`
|
|
},
|
|
)
|
|
useMotionValueEvent(
|
|
bodyBorderRadius,
|
|
'change',
|
|
(v) => {
|
|
if (!root) return
|
|
root.style.borderRadius = `${v}px`
|
|
},
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<TooltipProvider>
|
|
<div class="flex h-full w-full bg-background">
|
|
<div class="w-12/100 border-r p-2 flex flex-col gap-8 relative">
|
|
<div v-if="props.title || props.subtitle || props.metaText" class="space-y-1">
|
|
<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>
|
|
<div v-if="props.metaText" class="text-xs leading-5 text-muted-foreground">
|
|
{{ props.metaText }}
|
|
</div>
|
|
</div>
|
|
|
|
<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-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'
|
|
]">
|
|
{{ item.label }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogRoot v-model:open="sheetOpen">
|
|
<DialogTrigger as-child>
|
|
<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">
|
|
<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" />
|
|
</DialogOverlay>
|
|
|
|
<DialogContent as-child>
|
|
<Motion
|
|
class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-border/60 bg-card/95 shadow-2xl backdrop-blur-xl will-change-transform"
|
|
:style="{
|
|
y,
|
|
top: `${sheetTop}px`,
|
|
}" 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 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">
|
|
<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" />
|
|
</svg>
|
|
</DialogClose>
|
|
</div>
|
|
<DialogTitle class="mt-2">
|
|
<div class="flex items-center gap-3">
|
|
<img src="/favicon.ico" alt="众为咨询" class="h-7 w-7 shrink-0 rounded-sm" />
|
|
<span class="text-2xl font-semibold leading-none">关于我们</span>
|
|
</div>
|
|
</DialogTitle>
|
|
</div>
|
|
|
|
<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>
|
|
<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="官网首页">
|
|
<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" />
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
</DialogDescription>
|
|
|
|
<div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
|
|
<p>
|
|
众为工程咨询有限公司 2009
|
|
年成立,专注工程造价与工程成本管控全过程咨询,是广东省政府审计入库优选单位。公司服务覆盖多领域、全类型客户,累计服务投资额超万亿元,深度参与港珠澳大桥、澳门大学横琴校区等国家级重点工程,参编三十余项国家及省市行业标准。
|
|
</p>
|
|
<p>
|
|
公司立足大湾区,布局全球,设有澳门公司、斯里兰卡分公司,具备跨境与海外项目服务能力,以十五年专业沉淀、万亿级项目经验,为客户提供精准、可靠的工程咨询服务。
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Motion>
|
|
</DialogContent>
|
|
</AnimatePresence>
|
|
</DialogPortal>
|
|
</DialogRoot>
|
|
|
|
</div>
|
|
|
|
<div class="w-88/100 min-h-0 h-full flex flex-col">
|
|
<ScrollArea class="h-full w-full min-h-0 rightMain">
|
|
<div class="p-3 h-full min-h-0 flex flex-col">
|
|
<keep-alive>
|
|
<component :is="activeComponent" />
|
|
</keep-alive>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
</TooltipProvider>
|
|
</template>
|
|
<style scoped>
|
|
/* 核心修改:添加 :deep() 穿透 scoped 作用域 */
|
|
:deep(.rightMain > div > div) {
|
|
height: 100%;
|
|
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>
|