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>
