352 lines
12 KiB
Plaintext
352 lines
12 KiB
Plaintext
1 <script setup lang="ts">
|
|
2 import { computed, onBeforeUnmount, ref, watch, type Component ,onMounted} from 'vue'
|
|
3 import { ScrollArea } from '@/components/ui/scroll-area'
|
|
4 import { Button } from '@/components/ui/button'
|
|
5 import {
|
|
6 DialogClose,
|
|
7 DialogContent,
|
|
8 DialogOverlay,
|
|
9 DialogPortal,
|
|
10 DialogRoot,
|
|
11 DialogTitle,
|
|
12 DialogTrigger,DialogDescription
|
|
13 } from 'reka-ui'
|
|
14 import { Icon } from '@iconify/vue'
|
|
15 import { useWindowSize } from '@vueuse/core'
|
|
16 import { animate, AnimatePresence, Motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion-v'
|
|
17 interface TypeLineCategory {
|
|
18 key: string
|
|
19 label: string
|
|
20 component: Component
|
|
21 }
|
|
22
|
|
23 const props = withDefaults(
|
|
24 defineProps<{
|
|
25 scene?: string
|
|
26 title?: string
|
|
27 subtitle?: string
|
|
28 copyText?: string
|
|
29 categories: TypeLineCategory[]
|
|
30 storageKey?: string
|
|
31 defaultCategory?: string
|
|
32 }>(),
|
|
33 {
|
|
34 scene: 'default',
|
|
35 title: '配置',
|
|
36 subtitle: '',
|
|
37 copyText: '',
|
|
38 storageKey: '',
|
|
39 defaultCategory: ''
|
|
40 }
|
|
41 )
|
|
42
|
|
43 const cacheKey = computed(() => props.storageKey || `type-line-active-cat-${props.scene}`)
|
|
44
|
|
45 const resolveInitialCategory = () => {
|
|
46 const defaultKey = props.defaultCategory || props.categories[0]?.key || ''
|
|
47 const savedKey = sessionStorage.getItem(cacheKey.value)
|
|
48 const validSavedKey = props.categories.some(item => item.key === savedKey)
|
|
49 return validSavedKey ? (savedKey as string) : defaultKey
|
|
50 }
|
|
51
|
|
52 const activeCategory = ref(resolveInitialCategory())
|
|
53
|
|
54 watch(
|
|
55 () => [props.categories, props.defaultCategory, cacheKey.value],
|
|
56 () => {
|
|
57 activeCategory.value = resolveInitialCategory()
|
|
58 },
|
|
59 { deep: true }
|
|
60 )
|
|
61
|
|
62 const switchCategory = (cat: string) => {
|
|
63 activeCategory.value = cat
|
|
64 sessionStorage.setItem(cacheKey.value, cat)
|
|
65 }
|
|
66
|
|
67 const activeComponent = computed(() => {
|
|
68 const selected = props.categories.find(item => item.key === activeCategory.value)
|
|
69 return selected?.component || props.categories[0]?.component || null
|
|
70 })
|
|
71
|
|
72 const copyBtnText = ref('复制')
|
|
73 const sheetOpen = ref(false)
|
|
74 let copyBtnTimer: ReturnType<typeof setTimeout> | null = null
|
|
75
|
|
76 const handleCopySubtitle = async () => {
|
|
77 const text = (props.copyText || '').trim()
|
|
78 if (!text) return
|
|
79
|
|
80 try {
|
|
81 await navigator.clipboard.writeText(text)
|
|
82 copyBtnText.value = '已复制'
|
|
83 } catch (error) {
|
|
84 console.error('copy failed:', error)
|
|
85 copyBtnText.value = '复制失败'
|
|
86 }
|
|
87
|
|
88 if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
|
89 copyBtnTimer = setTimeout(() => {
|
|
90 copyBtnText.value = '复制'
|
|
91 }, 1200)
|
|
92 }
|
|
93
|
|
94 onBeforeUnmount(() => {
|
|
95 if (copyBtnTimer) clearTimeout(copyBtnTimer)
|
|
96 if (!root) return
|
|
97 root.style.scale = ''
|
|
98 root.style.translate = ''
|
|
99 root.style.borderRadius = ''
|
|
100 })
|
|
101
|
|
102 //
|
|
103
|
|
104
|
|
105 const inertiaTransition = {
|
|
106 type: 'inertia' as const,
|
|
107 bounceStiffness: 300,
|
|
108 bounceDamping: 40,
|
|
109 timeConstant: 300,
|
|
110 }
|
|
111
|
|
112 const staticTransition = {
|
|
113 duration: 0.5,
|
|
114 ease: [0.32, 0.72, 0, 1] as const,
|
|
115 }
|
|
116
|
|
117 const SHEET_TOP_RATIO = 0.1
|
|
118 const SHEET_RADIUS = 12
|
|
119 const OFFICIAL_SITE_URL = '/'
|
|
120
|
|
121 let root: HTMLElement | null = null
|
|
122
|
|
123 onMounted(() => {
|
|
124 root = document.body.firstElementChild as HTMLElement | null
|
|
125 })
|
|
126
|
|
127 const { height, width } = useWindowSize()
|
|
128
|
|
129 const sheetTop = computed(() => Math.round(height.value * SHEET_TOP_RATIO))
|
|
130 const h = computed(() => Math.max(0, height.value - sheetTop.value))
|
|
131 const y = useMotionValue(h.value)
|
|
132
|
|
133 watch(
|
|
134 () => h.value,
|
|
135 (nextHeight) => {
|
|
136 if (!sheetOpen.value) y.jump(nextHeight)
|
|
137 }
|
|
138 )
|
|
139
|
|
140 watch(
|
|
141 () => sheetOpen.value,
|
|
142 (isOpen) => {
|
|
143 if (!isOpen) {
|
|
144 y.jump(h.value)
|
|
145 return
|
|
146 }
|
|
147 y.jump(h.value)
|
|
148 animate(y, 0, staticTransition)
|
|
149 }
|
|
150 )
|
|
151
|
|
152 // Scale the body down and adjust the border radius when the sheet is open.
|
|
153 const bodyScale = useTransform(
|
|
154 y,
|
|
155 [0, h.value],
|
|
156 [(width.value - sheetTop.value) / width.value, 1],
|
|
157 )
|
|
158 const bodyTranslate = useTransform(y, [0, h.value], [sheetTop.value - SHEET_RADIUS, 0])
|
|
159 const bodyBorderRadius = useTransform(y, [0, h.value], [SHEET_RADIUS, 0])
|
|
160
|
|
161 useMotionValueEvent(bodyScale, 'change', (v) => {
|
|
162 if (!root) return
|
|
163 root.style.scale = `${v}`
|
|
164 })
|
|
165 useMotionValueEvent(
|
|
166 bodyTranslate,
|
|
167 'change',
|
|
168 (v) => {
|
|
169 if (!root) return
|
|
170 root.style.translate = `0 ${v}px`
|
|
171 },
|
|
172 )
|
|
173 useMotionValueEvent(
|
|
174 bodyBorderRadius,
|
|
175 'change',
|
|
176 (v) => {
|
|
177 if (!root) return
|
|
178 root.style.borderRadius = `${v}px`
|
|
179 },
|
|
180 )
|
|
181 </script>
|
|
182
|
|
183 <template>
|
|
184 <div class="flex h-full w-full bg-background">
|
|
185 <div class="w-12/100 border-r p-6 flex flex-col gap-8 relative">
|
|
186 <div v-if="props.title || props.subtitle" class="space-y-1">
|
|
187 <div v-if="props.title" class="font-bold text-base leading-6 text-primary break-words">
|
|
188 {{ props.title }}
|
|
189 </div>
|
|
190 <div
|
|
191 v-if="props.subtitle"
|
|
192 class="flex flex-wrap items-center gap-2 text-xs leading-5 text-muted-foreground"
|
|
193 >
|
|
194 <span class="break-all">{{ props.subtitle }}</span>
|
|
195 <Button
|
|
196 v-if="props.copyText"
|
|
197 type="button"
|
|
198 variant="outline"
|
|
199 size="sm"
|
|
200 class="h-6 rounded-md px-2 text-[11px]"
|
|
201 @click.stop="handleCopySubtitle"
|
|
202 >
|
|
203 {{ copyBtnText }}
|
|
204 </Button>
|
|
205 </div>
|
|
206 </div>
|
|
207
|
|
208 <div class="flex flex-col gap-10 relative">
|
|
209 <div class="absolute left-[11px] top-2 bottom-2 w-[2px] bg-muted"></div>
|
|
210
|
|
211 <div
|
|
212 v-for="item in props.categories"
|
|
213 :key="item.key"
|
|
214 class="relative flex items-center gap-4 cursor-pointer group"
|
|
215 @click="switchCategory(item.key)"
|
|
216 >
|
|
217 <div
|
|
218 :class="[
|
|
219 'z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all',
|
|
220 activeCategory === item.key
|
|
221 ? 'bg-primary border-primary scale-110'
|
|
222 : 'bg-background border-muted-foreground'
|
|
223 ]"
|
|
224 >
|
|
225 <div v-if="activeCategory === item.key" class="w-2 h-2 bg-background rounded-full"></div>
|
|
226 </div>
|
|
227 <span
|
|
228 :class="[
|
|
229 'text-sm transition-colors',
|
|
230 activeCategory === item.key
|
|
231 ? 'font-bold text-primary'
|
|
232 : 'text-muted-foreground group-hover:text-foreground'
|
|
233 ]"
|
|
234 >
|
|
235 {{ item.label }}
|
|
236 </span>
|
|
237 </div>
|
|
238 </div>
|
|
239
|
|
240 <DialogRoot v-model:open="sheetOpen">
|
|
241 <DialogTrigger as-child>
|
|
242 <button
|
|
243 type="button"
|
|
244 class="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 hover:bg-muted/55 hover:text-foreground transition-colors"
|
|
245 >
|
|
246 <img src="/favicon.ico" alt="众为咨询" class="h-5 w-5 shrink-0 rounded-sm" />
|
|
247 <span>本网站由众为工程咨询有限公司提供免费技术支持</span>
|
|
248 </button>
|
|
249 </DialogTrigger>
|
|
250 <DialogPortal>
|
|
251 <AnimatePresence
|
|
252 multiple
|
|
253 as="div"
|
|
254 >
|
|
255 <DialogOverlay as-child>
|
|
256 <Motion
|
|
257 class="fixed inset-0 z-10 bg-black/45 backdrop-blur-[2px]"
|
|
258 :initial="{ opacity: 0 }"
|
|
259 :animate="{ opacity: 1 }"
|
|
260 :exit="{ opacity: 0 }"
|
|
261 :transition="staticTransition"
|
|
262 />
|
|
263 </DialogOverlay>
|
|
264
|
|
265 <DialogContent as-child>
|
|
266 <Motion
|
|
267 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"
|
|
268 :style="{
|
|
269 y,
|
|
270 top: `${sheetTop}px`,
|
|
271 }"
|
|
272 drag="y"
|
|
273 :drag-constraints="{ top: 0 }"
|
|
274 @drag-end="(e, { offset, velocity }) => {
|
|
275 if (offset.y > h * 0.35 || velocity.y > 10) {
|
|
276 sheetOpen = false;
|
|
277 }
|
|
278 else {
|
|
279 animate(y, 0, { ...inertiaTransition, min: 0, max: 0 });
|
|
280 }
|
|
281 }"
|
|
282 >
|
|
283 <div class="mx-auto mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/35" />
|
|
284 <div class="mx-auto flex h-full w-full max-w-2xl flex-col px-4 pb-5 pt-3">
|
|
285 <div class="mb-3 flex items-center justify-between gap-3">
|
|
286 <DialogTitle class="m-0">
|
|
287 <div class="flex items-center gap-3">
|
|
288 <img src="/favicon.ico" alt="众为咨询" class="h-7 w-7 shrink-0 rounded-sm" />
|
|
289 <span class="text-2xl font-semibold leading-none">关于我们</span>
|
|
290 </div>
|
|
291 </DialogTitle>
|
|
292 <div class="flex items-center gap-2">
|
|
293 <a
|
|
294 :href="OFFICIAL_SITE_URL"
|
|
295 target="_blank"
|
|
296 rel="noopener noreferrer"
|
|
297 class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
|
298 aria-label="跳转到官网首页"
|
|
299 title="官网首页"
|
|
300 >
|
|
301 <Icon icon="lucide:arrow-up-right" class="h-4 w-4" />
|
|
302 </a>
|
|
303 <DialogClose class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-muted-foreground/30 text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground">
|
|
304 <Icon icon="lucide:x" class="h-4 w-4" />
|
|
305 </DialogClose>
|
|
306 </div>
|
|
307 </div>
|
|
308
|
|
309 <DialogDescription class="mb-4 text-base text-muted-foreground">
|
|
310 <p class="font-medium text-foreground">众为工程咨询有限公司</p>
|
|
311 </DialogDescription>
|
|
312
|
|
313 <div class="space-y-4 overflow-y-auto pr-1 text-[15px] leading-7">
|
|
314 <p>
|
|
315 众为工程咨询有限公司长期专注于工程咨询与数字化服务,致力于为客户提供高效、可靠、可持续的解决方案。
|
|
316 </p>
|
|
317 <p>
|
|
318 我们围绕咨询管理、数据治理、系统建设与运维支持,持续提升项目交付质量,帮助客户降低沟通成本、提升协同效率。
|
|
319 </p>
|
|
320 <p>
|
|
321 本网站由众为工程咨询有限公司提供免费技术支持,如需商务合作或技术咨询,请与我们联系。
|
|
322 </p>
|
|
323 </div>
|
|
324 </div>
|
|
325 </Motion>
|
|
326 </DialogContent>
|
|
327 </AnimatePresence>
|
|
328 </DialogPortal>
|
|
329 </DialogRoot>
|
|
330
|
|
331 </div>
|
|
332
|
|
333 <div class="w-88/100 min-h-0 h-full flex flex-col">
|
|
334 <ScrollArea class="h-full w-full min-h-0 rightMain">
|
|
335 <div class="p-3 h-full min-h-0 flex flex-col">
|
|
336 <keep-alive>
|
|
337 <component :is="activeComponent" />
|
|
338 </keep-alive>
|
|
339 </div>
|
|
340 </ScrollArea>
|
|
341 </div>
|
|
342 </div>
|
|
343 </template>
|
|
344 <style scoped>
|
|
345 /* 核心修改:添加 :deep() 穿透 scoped 作用域 */
|
|
346 :deep(.rightMain > div > div) {
|
|
347 height: 100%;
|
|
348 min-height: 0;
|
|
349 box-sizing: border-box;
|
|
350 }
|
|
351 </style>
|