JGJS2026/tmp_typeline_numbered.txt
2026-03-02 16:55:27 +08:00

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>