初始化

This commit is contained in:
李琦
2026-07-23 16:15:11 +08:00
parent aa37fc4119
commit 6d1e1f08dc
43 changed files with 8541 additions and 102 deletions

View File

@@ -0,0 +1,165 @@
# Vue-Bits Skill
基于 [vue-bits.dev](https://vue-bits.dev) 极简创意设计哲学的 Vue 3 组件开发技能。专为消除 AI 生成前端代码的模板化、同质化问题而设计。
## 设计哲学
- **少即是多** — 移除一切不必要的装饰元素
- **沉浸式交互** — 组件响应鼠标、滚动,让页面"活"起来
- **精致的克制** — 动画服务于内容,是微妙的视觉增强,而非喧宾夺主
- **暗色优先** — 大多数组件内置 `dark` prop无缝配合 Tailwind `dark:`
- **高性能** — 基于 `requestAnimationFrame`GPU 加速,零依赖或极简依赖
## 组件分类
### 动画 (Animations)
| 组件 | 描述 |
|------|------|
| Splash Cursor | 鼠标驱动的流体色彩扩散 (WebGL 流体模拟) |
| Text Scramble | 终端风格字符随机化渐变为目标文本 |
| Profile Card | 3D 卡片沿椭圆轨道旋转,带物理惯性 |
| Infinite Menu | 圆形按钮点击后弹性分裂为多个子项 |
| Circular Gallery | 元素排列于圆形轨道,拖拽旋转,支持自动滚动 |
| AnimatedContent | 内容区域的进入/离开过渡动画 |
### 文本动画 (Text Animations)
| 组件 | 描述 |
|------|------|
| Curved Loop | 文字沿 SVG `<textPath>` 曲线路径无限滚动 |
| SplitText | 逐字/逐词飞入动画 (GSAP 风格,可配置交错延迟) |
| Text Pressure | 压力/变形文本效果 |
| Neon | 霓虹灯管发光文本,可配置颜色和闪烁 |
| Typewriter | 逐字符打字效果 |
| Kaleidoscope | 万花筒式文本变换 |
| Flow Light | 流光扫过式跑马灯文本效果 |
### 背景 (Backgrounds)
| 组件 | 描述 |
|------|------|
| Pixel Blast | 深层粒子宇宙,像素簇呼吸/聚合,鼠标响应 |
| Grid Distortion | 网格扭曲/变形,涟漪式图案扰动 |
| Hyperspeed | 赛博朋克隧道穿越,滚轮透视拉伸 |
| Silk | 丝绸般流畅的动态背景 |
| Sparkles | 星光粒子闪烁背景,可配置密度和颜色 |
| Neon Grid | 霓虹灯管质感网格背景 |
| Ripple | 涟漪扩散背景 |
| FaultyTerminal | 老式终端 CRT 闪烁/故障效果背景 |
### UI 组件
| 组件 | 描述 |
|------|------|
| GradientButton | 渐变按钮hover 缩放,可配置颜色断点 |
| Infinite Menu | 无限滚动菜单 |
| Circular Gallery | 圆形图片画廊 |
| Profile Card | 3D 个人资料卡片 |
## 触发方式
当你向 Claude Code 描述以下任意需求时,此技能将**自动激活**
- 开发任何 Vue 前端组件
- 提到关键词动画、动效、文本动画、背景、组件、UI
- 优化现有前端界面或组件
- 任何与前端视觉呈现或交互效果相关的请求
## 工作流程
技能激活后遵循严格的流程:
1. **停止所有代码生成** — 不会立即输出代码
2. **设计偏好询问** — 通过标准问卷了解你的需求:
- 视觉风格偏好(极简现代、赛博朋克、复古怀旧、玻璃拟态等)
- 动画效果类型(无限循环、悬停触发、滚动触发等)
- 交互功能需求(可拖拽、响应式、深色模式等)
- 配色方案和字体要求
3. **确认后开发** — 仅在你明确回复偏好后才开始编写组件
## 质量标准
### 设计准则
- 严格遵循 vue-bits.dev "少即是多" 的设计哲学
- 动画稳定在 60fps避免生硬过渡和过度动效
- 配色满足 WCAG 无障碍对比度标准
- 组件高度可定制Props 接口清晰
- 默认支持暗色模式
### 代码规范
- Vue 3 Composition API + `<script setup>` 语法
- 完整 TypeScript 类型定义
- CSS 自定义属性用于主题定制
- 使用 `requestAnimationFrame` 替代 `setInterval`
- GPU 加速属性优先:`transform``opacity`
- 支持 `prefers-reduced-motion` 媒体查询
### 通用 Props 模式
```typescript
interface VueBitsComponentProps {
// 视觉
colorFrom?: string // 渐变起始色
colorTo?: string // 渐变结束色
dark?: boolean // 暗色模式开关
// 动画
duration?: number // 动画时长 (秒)
delay?: number // 交错延迟 (秒)
ease?: string // 缓动函数
threshold?: number // IntersectionObserver 阈值 (0-1)
// 行为
paused?: boolean // 暂停动画
repeat?: number | 'indefinite'
// 内容
text?: string // 文本内容
className?: string // 额外 CSS 类
}
```
### CSS 自定义属性约定
```css
.component-root {
--vb-color-from: #6366f1;
--vb-color-to: #a855f7;
--vb-duration: 1.5s;
--vb-delay: 0s;
--vb-ease: cubic-bezier(0.16, 1, 0.3, 1);
--vb-font-size: 1rem;
--vb-gap: 0.5rem;
}
```
## 性能优化检查清单
- `requestAnimationFrame` 而非 `setInterval`
- 仅动画化 GPU 属性:`transform``opacity``filter`
- 谨慎使用 `will-change`,动画结束后移除
- 使用 `contain: layout style paint` 隔离重绘范围
- `IntersectionObserver` 暂停屏幕外动画
- 响应 `prefers-reduced-motion` 用户偏好
- 简单路径动画优先使用 SVG可访问性更好
- 使用 `requestAnimationFrame` 作为自然的 resize/mousemove 节流
## 禁止行为
- **绝不**在询问用户偏好前生成组件代码
- **绝不**生成模板化、千篇一律的 AI 风格代码
- **绝不**使用过于复杂或晦涩的代码结构
- **绝不**添加用户未明确要求的功能
- **绝不**输出不完整或无法运行的代码
- **绝不**使用 `<marquee>` 等已废弃的 HTML 元素
- **绝不**硬编码颜色 — 始终使用 CSS 自定义属性或 Props
- **绝不**跳过 TypeScript 类型定义
- **绝不**动画化会触发布局的属性 (`width``height``margin` 等)
## 文件结构
```
vue-bits/
├── SKILL.md # 技能定义与完整规则
├── README.md # 本文件
└── reference/
└── implementation-patterns.md # 核心实现模式参考
```
## 许可
本技能遵循 [vue-bits.dev](https://vue-bits.dev) 的设计哲学,用于在 Claude Code 中构建高质量 Vue 3 组件。

View File

@@ -0,0 +1,170 @@
---
name: vue-bits
description: Build high-quality Vue 3 components following vue-bits.dev minimalist creative design philosophy. Triggers on any Vue/frontend component, animation, or UI request. Uses Vue3 Composition API + TypeScript + Tailwind CSS. Never generates template/AI-generic code — always asks about design preferences first.
---
# Vue-Bits Design Skill
## Role
You are a senior frontend component engineer who has deeply mastered the vue-bits.dev design philosophy. You have top-tier UI aesthetics and animation design capabilities, focused on creating non-AI-flavored, high-texture, production-ready Vue components.
**Core mission:** Completely eliminate the templated, homogenized problem of AI-generated frontend code. All components must strictly follow vue-bits.dev's minimalist, creative, interaction-friendly design philosophy.
## Knowledge Base
You have deep mastery of the full vue-bits.dev component design system across four major categories:
### Animations
- **Splash Cursor** — Mouse-driven fluid color diffusion (WebGL fluid simulation, ~128 SIM resolution, configurable dye dissipation)
- **Text Scramble** — Terminal-style character randomization that resolves to target text (duration/speed controllable, custom scramble character sets)
- **Profile Card** — 3D card rotating on elliptical orbit with physical inertia on enter/exit
- **Infinite Menu** — Circular button that elastically splits into multiple sub-items on click
- **Circular Gallery** — Items arranged on circular orbit, drag-to-rotate, auto-scroll support
- **AnimatedContent** — Enter/leave transition animations for content regions
### Text Animations
- **Curved Loop** — Text follows SVG `<textPath>` along a curved `<path>`, infinitely scrolling via `startOffset` animation (0% → 100%)
- **SplitText** — Per-character/word fly-in animation (GSAP-style, stagger delay, configurable from/to transforms)
- **Text Pressure** — Pressure/distortion/deformation text effects
- **Neon** — Neon tube glow text with configurable colors and flicker
- **Typewriter** — Character-by-character typing effect
- **Kaleidoscope** — Kaleidoscope-style text transformations
- **Flow Light** — Light sweep marquee text effect
### Backgrounds
- **Pixel Blast** — Deep particle universe, pixel clusters breathing/aggregating, mouse-responsive
- **Grid Distortion** — Grid warp/deformation, ripple-like logo/pattern distortion
- **Hyperspeed** — Cyberpunk tunnel traversal, perspective stretch on scroll
- **Silk** — Silk-like smooth flowing dynamic background
- **Sparkles** — Star particle sparkle background, configurable density and color
- **Neon Grid** — Neon tube textured grid background
- **Ripple** — Ripple diffusion background
- **FaultyTerminal** — Old terminal CRT flicker/glitch effect background
### Components (UI)
- **GradientButton** — Gradient button with hover scale, configurable color stops
- **Infinite Menu** — Infinite scrolling menu
- **Circular Gallery** — Circular image gallery
- **Profile Card** — 3D profile card
### Design Language
- Sci-fi/futuristic — particles, neon, tunnel traversal effects
- Immersive interaction — components respond to mouse, making pages feel "alive"
- Refined restraint — animations serve as subtle visual enhancement, not overwhelming
- Dark-first — most components have dark prop, seamless with Tailwind `dark:` classes
- Lightweight & performant — `requestAnimationFrame`-based, GPU-accelerated, minimal dependencies
## Mandatory Trigger Rules
**IMMEDIATELY trigger this skill when the user mentions ANY of the following — do NOT generate code directly:**
1. Requests to develop any Vue frontend component
2. Mentions keywords: "动画" (animation), "动效" (motion), "文本动画" (text animation), "背景" (background), "组件" (component), "UI"
3. Requests to optimize existing frontend interfaces or components
4. Any request related to frontend visual presentation or interaction effects
### Mandatory Actions After Triggering
1. **HALT all code generation immediately**
2. **Ask the user about design preferences using the standard script below** (must be used verbatim)
3. **ONLY begin component development after receiving explicit user feedback on style and effects**
## Standard Questioning Script (Must Use Verbatim)
Copy and send this exact message to the user:
---
我将为你打造符合 Vue-Bits 极简创意风格的高质量组件。为了避免 AI 模板化效果,给你最贴合需求的设计,请告诉我:
1. **你喜欢哪种视觉风格?**(例如:极简现代、赛博朋克、复古怀旧、玻璃拟态、新拟态、手绘风等)
2. **你想要实现什么具体的动画效果?**(例如:无限循环、悬停触发、滚动触发、渐变、位移、旋转、缩放等)
3. **这个组件需要具备哪些交互功能?**(例如:可拖拽、点击事件、响应式、深色模式适配等)
4. **有没有特定的配色方案或字体要求?**
---
## Component Development Quality Standards
Only apply these after receiving user feedback.
### Design Principles
1. Strictly follow vue-bits.dev's "less is more" design philosophy — remove all unnecessary decorative elements
2. Animations must be fluid and natural, stable at 60fps, avoid harsh transitions and excessive animation
3. Color harmony must meet WCAG accessibility contrast standards
4. Components must be highly customizable with clear Props interfaces
5. Prefer subtle, refined animations over flashy ones — the animation serves the content, not the other way around
6. Dark mode support by default, using Tailwind `dark:` variants or CSS custom properties
### Code Standards
1. Use Vue 3 Composition API + `<script setup>` syntax
2. Clear component structure: template, script, style separated
3. Provide complete TypeScript type definitions
4. All configurable items exposed through Props with defaults referencing vue-bits.dev conventions
5. Styles use CSS custom properties for theme customization
6. Comments concise and clear, only annotate key logic (no verbose docstrings)
7. Use `requestAnimationFrame` for animation loops, avoid `setInterval`
8. Leverage GPU-accelerated properties: `transform`, `opacity` — avoid animating `width`, `height`, `top`, `left`
9. Use SVG for curved/irregular animation paths where appropriate
10. Support `prefers-reduced-motion` media query for accessibility
### Output Requirements
1. Provide complete Single File Component (SFC) code
2. List all available Props with types, default values, and descriptions
3. Provide component usage examples (at least 2 variants)
4. Explain key implementation principles and performance optimization points
5. Provide methods for custom styling (CSS variable overrides, slot usage, etc.)
6. Include Tailwind CSS classes where possible, fall back to scoped CSS when needed
### Common Props Pattern (vue-bits style)
```typescript
interface VueBitsComponentProps {
// Visual
colorFrom?: string // Gradient start color
colorTo?: string // Gradient end color
dark?: boolean // Dark mode toggle
// Animation
duration?: number // Animation duration in seconds
delay?: number // Stagger delay in seconds
ease?: string // Easing function
threshold?: number // IntersectionObserver threshold (0-1)
// Behavior
paused?: boolean // Pause animation
repeat?: number | 'indefinite'
// Content
text?: string // Text content
className?: string // Additional CSS classes
}
```
## Forbidden Behaviors
1. **NEVER** generate component code without first asking user preferences
2. **NEVER** generate templated, cookie-cutter AI-style code
3. **NEVER** use overly complex or obscure code structures
4. **NEVER** add features the user did not explicitly request
5. **NEVER** output incomplete or non-runnable code
6. **NEVER** use `<marquee>` or other deprecated HTML elements
7. **NEVER** hardcode colors — always use CSS custom properties or props
8. **NEVER** skip TypeScript type definitions
9. **NEVER** animate layout-triggering properties (`width`, `height`, `margin`, etc.)
## Skill Priority
This skill has **HIGHER PRIORITY** than all other frontend development related skills. When trigger conditions are met, all rules of this skill **MUST** be enforced.
## Anti-AI-Flavor Checklist
Before outputting any component code, verify:
- [ ] Does this component have a unique visual identity, or could it be from any generic UI library?
- [ ] Are the animation curves custom-tuned or just `ease-in-out` defaults?
- [ ] Is there at least one surprising/delightful detail that shows craft?
- [ ] Would this component feel at home on vue-bits.dev among the showcase?
- [ ] Are the default props values thoughtfully chosen, not just `''` or `0`?
- [ ] Does the component handle edge cases (empty state, long text, reduced motion)?

View File

@@ -0,0 +1,147 @@
# Vue-Bits Component Implementation Patterns
## Curved Loop Text Animation (SVG textPath)
The core technique: text follows an SVG `<textPath>` along a `<path>`, animated by changing `startOffset`.
```vue
<template>
<svg viewBox="0 0 1200 300" overflow="visible" class="w-full">
<defs>
<path id="curve-path" d="M100,150 Q600,-100 1100,150" />
</defs>
<text :fill="color" :font-size="fontSize" font-weight="600">
<textPath href="#curve-path" startOffset="0%">
<animate
attributeName="startOffset"
from="0%" :to="direction === 'right' ? '100%' : '-100%'"
:dur="`${speed}s`"
repeatCount="indefinite"
/>
{{ repeatedText }}
</textPath>
</text>
</svg>
</template>
```
Key optimization: SVG `<animate>` runs on the compositor thread, no JS overhead.
### Alternative: JS-driven for dynamic control
```typescript
import { ref, onMounted, onUnmounted } from 'vue'
const offset = ref(0)
let rafId: number
function animate() {
offset.value = (offset.value + speed.value / 60) % 100
rafId = requestAnimationFrame(animate)
}
onMounted(() => { rafId = requestAnimationFrame(animate) })
onUnmounted(() => cancelAnimationFrame(rafId))
```
## Text Scramble Pattern
Character-by-character randomization using `setInterval` + progressive reveal.
```typescript
const CHARS = '!<>-_\\/[]{}—=+*^?#________'
const displayText = ref('')
let frame = 0
let queue: { from: string; to: string; start: number; end: number }[] = []
function update() {
let output = ''
let complete = 0
for (let i = 0; i < queue.length; i++) {
let { from, to, start, end } = queue[i]
if (frame >= end) { output += to; complete++; continue }
if (frame >= start) {
// Random chars during scramble phase
const progress = (frame - start) / (end - start)
let scrambled = ''
for (let j = 0; j < to.length; j++) {
if (Math.random() < progress) scrambled += to[j]
else scrambled += CHARS[Math.floor(Math.random() * CHARS.length)]
}
output += scrambled
} else {
output += from
}
}
displayText.value = output
if (complete === queue.length) return // done
frame++
requestAnimationFrame(() => setTimeout(update, speed.value * 1000))
}
```
## Hyperspeed Background Pattern
Canvas-based tunnel effect using perspective projection of particles.
```typescript
// Core: project 3D points to 2D with perspective divide
function project(x: number, y: number, z: number): [number, number] {
const scale = focalLength / (focalLength + z)
return [x * scale + centerX, y * scale + centerY]
}
// Stars move toward viewer (z decreases), reset when behind camera
function updateStars() {
for (const star of stars) {
star.z -= speed
if (star.z < -focalLength) {
star.z = maxDepth
star.x = (Math.random() - 0.5) * spread
star.y = (Math.random() - 0.5) * spread
}
}
}
```
Optimization: Pre-allocate typed arrays, use `OffscreenCanvas` when available, never read back pixel data.
## Splash Cursor Pattern
WebGL fluid simulation (simplified — full implementation uses double FBO ping-pong).
Key parameters:
- `SIM_RESOLUTION` (128): simulation grid size
- `DYE_RESOLUTION` (1440): dye texture resolution
- `DENSITY_DISSIPATION` (3.5): how fast color fades
- `SPLAT_RADIUS` (0.2): mouse click splat radius
- `SPLAT_FORCE` (6000): mouse click splat intensity
The simulation runs a Navier-Stokes solver on GPU via fragment shaders each frame.
## Performance Optimization Checklist
1. **RAF over setInterval**`requestAnimationFrame` syncs with display refresh
2. **GPU properties only** — animate `transform`, `opacity`, `filter`; never `width`/`height`/`margin`
3. **`will-change` sparingly** — only on elements about to animate, remove when done
4. **`contain` property** — `contain: layout style paint` isolates repaint scope
5. **IntersectionObserver** — pause off-screen animations (`threshold` prop)
6. **`prefers-reduced-motion`** — disable animations when user prefers reduced motion
7. **Debounce resize/mousemove** — use `requestAnimationFrame` as natural throttle
8. **SVG over Canvas** — for simple path animations (curved text, line draw), SVG is more accessible
## CSS Custom Properties Convention
```css
.component-root {
--vb-color-from: #6366f1;
--vb-color-to: #a855f7;
--vb-duration: 1.5s;
--vb-delay: 0s;
--vb-ease: cubic-bezier(0.16, 1, 0.3, 1);
--vb-font-size: 1rem;
--vb-gap: 0.5rem;
}
```
Users override these to customize without touching component internals.

2
.gitignore vendored
View File

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.pnpm-store/
dist
dist-ssr
*.local
@@ -25,3 +26,4 @@ dist-ssr
/.cursor/
/.opencode/
/.vscode/
/.codex/skills/ui-ux-pro-max/

10
jsrepo.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'jsrepo';
export default defineConfig({
// configure where stuff comes from here
registries: ['https://vue-bits.dev/r'],
// configure where stuff goes here
paths: {
component: 'src/components/vue-bits',
},
});

View File

@@ -6,11 +6,10 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"preview:static": "node scripts/preview-dist.cjs"
},
"dependencies": {
"tailwindcss": "^4.1.4",
"vue": "^3.5.13",
"@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1",
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -18,13 +17,20 @@
"@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1",
"axios": "^1.6.2",
"gsap": "3.13.0",
"lenis": "1.3.13",
"lucide-vue-next": "^0.507.0",
"ogl": "1.0.11",
"pinia": "^2.1.7",
"swiper": "^11.0.5",
"tailwindcss": "^4.1.4",
"three": "^0.178.0",
"vue": "^3.5.13",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.2",
"jsrepo": "^3.8.1",
"sass": "^1.71.0",
"vite": "^6.3.1"
}

437
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@ant-design/icons-vue':
specifier: ^7.0.1
version: 7.0.1(vue@3.5.13)
'@fortawesome/fontawesome-free':
specifier: ^6.7.2
version: 6.7.2
'@tailwindcss/vite':
specifier: ^4.1.4
version: 4.1.4(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))
@@ -23,15 +26,21 @@ importers:
ant-design-vue:
specifier: ^4.1.1
version: 4.2.6(vue@3.5.13)
aos:
specifier: ^2.3.4
version: 2.3.4
axios:
specifier: ^1.6.2
version: 1.9.0
gsap:
specifier: 3.13.0
version: 3.13.0
lenis:
specifier: 1.3.13
version: 1.3.13(vue@3.5.13)
lucide-vue-next:
specifier: ^0.507.0
version: 0.507.0(vue@3.5.13)
ogl:
specifier: 1.0.11
version: 1.0.11
pinia:
specifier: ^2.1.7
version: 2.3.1(vue@3.5.13)
@@ -41,6 +50,9 @@ importers:
tailwindcss:
specifier: ^4.1.4
version: 4.1.4
three:
specifier: ^0.178.0
version: 0.178.0
vue:
specifier: ^3.5.13
version: 3.5.13
@@ -51,6 +63,9 @@ importers:
'@vitejs/plugin-vue':
specifier: ^5.2.2
version: 5.2.3(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))(vue@3.5.13)
jsrepo:
specifier: ^3.8.1
version: 3.8.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.13)
sass:
specifier: ^1.71.0
version: 1.88.0
@@ -107,6 +122,15 @@ packages:
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
engines: {node: '>=10'}
'@emnapi/core@1.11.2':
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
'@emnapi/runtime@1.11.2':
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@emotion/hash@0.9.2':
resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==}
@@ -263,9 +287,149 @@ packages:
cpu: [x64]
os: [win32]
'@fortawesome/fontawesome-free@6.7.2':
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
engines: {node: '>=6'}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@napi-rs/wasm-runtime@1.1.6':
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@oxc-parser/binding-android-arm-eabi@0.114.0':
resolution: {integrity: sha512-O64coa+5VuqhwQV5t7PcgiY1ubGy35QZSMi+GrXewCRbi7MMzQcuyfv2xg1FV7Kj7SR47+gg/eC9xrOph2Pbcg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxc-parser/binding-android-arm64@0.114.0':
resolution: {integrity: sha512-tfINDNQTAynm7gHWhkCuAnAICf12wD2r3fd2vrMrW7Q9xQcR5pmJN+GUvFjRTeKILdMIPpji+d05PKQtczUxTA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxc-parser/binding-darwin-arm64@0.114.0':
resolution: {integrity: sha512-oQpuBInKrb77rgAStzuRkulUOhoqPxTYaQ1vc3CCE2WeVvJyysQF8/f7jtoJUWpEHGKvIq/UELkHyHoETAso6A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.114.0':
resolution: {integrity: sha512-nRpuvxB9MTtgChXZ7CdpoVHAfzdo/2+C35UseYbG9LSj2UV9U4Bu7IrHhhDpBZsHT2OaVIMim1joyBNQV0TJqg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-freebsd-x64@0.114.0':
resolution: {integrity: sha512-zNHFhMwqvwYuXWSyGHMKwobVH4cpcAQSRdUocwT9tbvgsNId00OkQ9sTuXFEthMK6xvAO1prlSiIabkcpLQ/9A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxc-parser/binding-linux-arm-gnueabihf@0.114.0':
resolution: {integrity: sha512-Ew5Fj5iy1YV8pFvdo3oSaOkpgLOySxacZ14XdnY+0eajmsf5VdIGATCISkkacgQWoijlgKTVd8RlsV+wb3v6UQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm-musleabihf@0.114.0':
resolution: {integrity: sha512-p3w2kXEHKdAcRKn5T9dVDO39grxFtRLuSu5STVZYEOQ2Z9N/kjsXEsXOumj4kQXCnvF44OUdiURca4NUOm1hmQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm64-gnu@0.114.0':
resolution: {integrity: sha512-qSo2Nnn6XcuSzPOQvidc6HSJMZv09wvtKKi3r8NK4m1zFSCEsZu52I1UMK7qlTVMb0DlPwGzT2QpHdFyad+etQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-musl@0.114.0':
resolution: {integrity: sha512-MiLmga+J7LBwZQDJih7+30PiKYZ0AFpcwz+/H3VllRk3sJQgp2H9pi2UqSwPp4AuW5Mx0Ww5qjI2r++Fvlm8Bg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-ppc64-gnu@0.114.0':
resolution: {integrity: sha512-6zvF/xXydC7P+Gd6DcSipnt/AmSEKtWZhfrKxKwZjA9jkhFG8VoMZsRPe5T6KPuYw3Ieg0afNUnQy5ck6M9X+Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.114.0':
resolution: {integrity: sha512-X/IWI+AOP4mQYNjNBxg5vVGlLPPf7VbP7KLnSi1hwj35zL58x82Vi38l++VPj+V5OEaAzUcXRk6gPgpbMkCzcw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-musl@0.114.0':
resolution: {integrity: sha512-HX9Femqubg2VQaDB3MMrGz5hfMlEpnV+MyOuH3ZG8WS41S1oSIFn3/zS1PwnIpd4/a0lssi2995W64qNdFB3fQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-s390x-gnu@0.114.0':
resolution: {integrity: sha512-K/YYc/BFOD5fKk/vELxoUW0smdC9JpJY/KvnZohi5YMHS0zc45Z/yC3B8GQAWfVFqRFRC4RMy/zX0SgzRHcAOw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.114.0':
resolution: {integrity: sha512-uVcvtE8JXtRhrYc/I/ZR8slH4sMxzfeADJFZcS2T5BPpXABKe8RBZbpLhGvbLafmlSGcJ6vsAMKF7spXBe1N3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-musl@0.114.0':
resolution: {integrity: sha512-lDlzcKpqMMIsErm8Frfz9a7a3qfcphzRwV9oxb27GRMmwmbaVZGBaWRmXXw3HqS2JR5ezF5WQaOqpYtsaxsReA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-openharmony-arm64@0.114.0':
resolution: {integrity: sha512-8mTgZNPfrWYM3nx0VsDvfFuquNZ3/oZ2Wrvy3xV+plBTncXE7oQJFroKF3AGe4c/OPzoFjpnwhwdTRjG+4J3Cg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxc-parser/binding-wasm32-wasi@0.114.0':
resolution: {integrity: sha512-GQ5rpymHe7MSZgAGtmxE+v8IzUuM/oJHsiBOurzA6t4ilzVkjUqCLXKckEnVf9r1q9XuTvHmGN5nQnn7uxHRlw==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-parser/binding-win32-arm64-msvc@0.114.0':
resolution: {integrity: sha512-bxPbl48dbczdXam3TrYzl1kwA2lmx1B58icoTeyWb/N2uk1W8MBR7EhWOd0mDuXJ26zLoPtOx8S5DN5AKFMfrw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-ia32-msvc@0.114.0':
resolution: {integrity: sha512-9JGKhZTwqu5lJwlJfxgoOx3MorcLEv/xynJ1DcUdYYL67OvR1FJElcAXTybePfD1LpIpCxmw0q4tm9exuCiMzg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.114.0':
resolution: {integrity: sha512-ATX7ZOUS/FP2vy9ey0FALKFPJDnN3uoEKSCTxH10vTNoREL1D5SIrxfTJcChTWqc4yhed//BrmBCgv+3EvU3VQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxc-project/types@0.114.0':
resolution: {integrity: sha512-//nBfbzHQHvJs8oFIjv6coZ6uxQ4alLfiPe6D5vit6c4pmxATHHlVwgB1k+Hv4yoAMyncdxgRBF5K4BYWUCzvA==}
'@parcel/watcher-android-arm64@2.5.1':
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
engines: {node: '>= 10.0.0'}
@@ -354,6 +518,9 @@ packages:
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@quansync/fs@1.0.0':
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
'@rollup/rollup-android-arm-eabi@4.40.0':
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
cpu: [arm]
@@ -562,6 +729,9 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@@ -632,9 +802,6 @@ packages:
peerDependencies:
vue: '>=3.2.0'
aos@2.3.4:
resolution: {integrity: sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==}
array-tree-filter@2.1.0:
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
@@ -659,13 +826,14 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
classlist-polyfill@1.2.0:
resolution: {integrity: sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
compute-scroll-into-view@1.0.20:
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
@@ -678,6 +846,9 @@ packages:
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -781,6 +952,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
gsap@3.13.0:
resolution: {integrity: sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -819,9 +993,39 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jsrepo@3.8.1:
resolution: {integrity: sha512-ruBJYmmXMN923dMWnVruWmFyHBcD0TzQ+upx6nssiSzRheqTakE/nciEhIpLlO3JcrPsVSXKaXpdt4ExXkVEYA==}
hasBin: true
peerDependencies:
svelte: ^5.0.0
vue: ^3.0.0
peerDependenciesMeta:
svelte:
optional: true
vue:
optional: true
lenis@1.3.13:
resolution: {integrity: sha512-9FTVlAm2PA82JHnFKWgzJkuxlG7orJneVHqUDhZvVPAQDduEVxlVtXRTNgdhERVR5MUX1iDrRaklBvHbVhxQpg==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
vue: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
react:
optional: true
vue:
optional: true
lightningcss-darwin-arm64@1.29.2:
resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==}
engines: {node: '>= 12.0.0'}
@@ -893,12 +1097,6 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -908,6 +1106,7 @@ packages:
lucide-vue-next@0.507.0:
resolution: {integrity: sha512-n0AZRmez4xq5vu5ZOfnrhC5wKBpu5tpwDHA6Ue2+sKyJv3USAPxqIerXLbdYbkHRW1tVZ3U9GE1MACsu6X/Z7Q==}
deprecated: Package deprecated. Please use @lucide/vue instead.
peerDependencies:
vue: '>=3.0.1'
@@ -941,9 +1140,16 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
ogl@1.0.11:
resolution: {integrity: sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==}
omit.js@2.0.2:
resolution: {integrity: sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg==}
oxc-parser@0.114.0:
resolution: {integrity: sha512-V+tATPyadb6MP51BpK2OMnB27Ln/pDwG7Uk5cAi5jPHRyZwdb72zacuPI6umafcV3VJjh9Jc9WWp0rwEM0AjSQ==}
engines: {node: ^20.19.0 || >=22.12.0}
packrup@0.1.2:
resolution: {integrity: sha512-ZcKU7zrr5GlonoS9cxxrb5HVswGnyj6jQvwFBa6p5VFw7G71VAHcUKL5wyZSU/ECtPM/9gacWxy2KFQKt1gMNA==}
@@ -974,6 +1180,9 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -1015,6 +1224,9 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
three@0.178.0:
resolution: {integrity: sha512-ybFIB0+x8mz0wnZgSGy2MO/WCO6xZhQSZnmfytSPyNpM0sBafGRVhdaj+erYh5U+RhQOAg/eXqw5uVDiM2BjhQ==}
throttle-debounce@5.0.2:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
@@ -1027,6 +1239,15 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
unconfig-core@7.5.0:
resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
unconfig@7.5.0:
resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==}
unhead@1.11.20:
resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==}
@@ -1150,6 +1371,22 @@ snapshots:
'@ctrl/tinycolor@3.6.1': {}
'@emnapi/core@1.11.2':
dependencies:
'@emnapi/wasi-threads': 1.2.2
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.2':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.2':
dependencies:
tslib: 2.8.1
optional: true
'@emotion/hash@0.9.2': {}
'@emotion/unitless@0.8.1': {}
@@ -1229,8 +1466,84 @@ snapshots:
'@esbuild/win32-x64@0.25.3':
optional: true
'@fortawesome/fontawesome-free@6.7.2': {}
'@jridgewell/sourcemap-codec@1.5.0': {}
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
dependencies:
'@emnapi/core': 1.11.2
'@emnapi/runtime': 1.11.2
'@tybys/wasm-util': 0.10.3
optional: true
'@oxc-parser/binding-android-arm-eabi@0.114.0':
optional: true
'@oxc-parser/binding-android-arm64@0.114.0':
optional: true
'@oxc-parser/binding-darwin-arm64@0.114.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.114.0':
optional: true
'@oxc-parser/binding-freebsd-x64@0.114.0':
optional: true
'@oxc-parser/binding-linux-arm-gnueabihf@0.114.0':
optional: true
'@oxc-parser/binding-linux-arm-musleabihf@0.114.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.114.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.114.0':
optional: true
'@oxc-parser/binding-linux-ppc64-gnu@0.114.0':
optional: true
'@oxc-parser/binding-linux-riscv64-gnu@0.114.0':
optional: true
'@oxc-parser/binding-linux-riscv64-musl@0.114.0':
optional: true
'@oxc-parser/binding-linux-s390x-gnu@0.114.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.114.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.114.0':
optional: true
'@oxc-parser/binding-openharmony-arm64@0.114.0':
optional: true
'@oxc-parser/binding-wasm32-wasi@0.114.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.114.0':
optional: true
'@oxc-parser/binding-win32-ia32-msvc@0.114.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.114.0':
optional: true
'@oxc-project/types@0.114.0': {}
'@parcel/watcher-android-arm64@2.5.1':
optional: true
@@ -1292,6 +1605,10 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.1
optional: true
'@quansync/fs@1.0.0':
dependencies:
quansync: 1.0.0
'@rollup/rollup-android-arm-eabi@4.40.0':
optional: true
@@ -1422,6 +1739,11 @@ snapshots:
tailwindcss: 4.1.4
vite: 6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0)
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
optional: true
'@types/estree@1.0.7': {}
'@unhead/dom@1.11.20':
@@ -1547,12 +1869,6 @@ snapshots:
vue-types: 3.0.2(vue@3.5.13)
warning: 4.0.3
aos@2.3.4:
dependencies:
classlist-polyfill: 1.2.0
lodash.debounce: 4.0.8
lodash.throttle: 4.1.1
array-tree-filter@2.1.0: {}
async-validator@4.2.5: {}
@@ -1581,12 +1897,12 @@ snapshots:
dependencies:
readdirp: 4.1.2
classlist-polyfill@1.2.0: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@14.0.3: {}
compute-scroll-into-view@1.0.20: {}
core-js@3.42.0: {}
@@ -1595,6 +1911,8 @@ snapshots:
dayjs@1.11.13: {}
defu@6.1.7: {}
delayed-stream@1.0.0: {}
detect-libc@1.0.3:
@@ -1709,6 +2027,8 @@ snapshots:
graceful-fs@4.2.11: {}
gsap@3.13.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
@@ -1738,8 +2058,25 @@ snapshots:
jiti@2.4.2: {}
jiti@2.7.0: {}
js-tokens@4.0.0: {}
jsrepo@3.8.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(vue@3.5.13):
dependencies:
commander: 14.0.3
oxc-parser: 0.114.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
unconfig: 7.5.0
optionalDependencies:
vue: 3.5.13
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
lenis@1.3.13(vue@3.5.13):
optionalDependencies:
vue: 3.5.13
lightningcss-darwin-arm64@1.29.2:
optional: true
@@ -1787,10 +2124,6 @@ snapshots:
lodash-es@4.17.21: {}
lodash.debounce@4.0.8: {}
lodash.throttle@4.1.1: {}
lodash@4.17.21: {}
loose-envify@1.4.0:
@@ -1826,8 +2159,38 @@ snapshots:
node-addon-api@7.1.1:
optional: true
ogl@1.0.11: {}
omit.js@2.0.2: {}
oxc-parser@0.114.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2):
dependencies:
'@oxc-project/types': 0.114.0
optionalDependencies:
'@oxc-parser/binding-android-arm-eabi': 0.114.0
'@oxc-parser/binding-android-arm64': 0.114.0
'@oxc-parser/binding-darwin-arm64': 0.114.0
'@oxc-parser/binding-darwin-x64': 0.114.0
'@oxc-parser/binding-freebsd-x64': 0.114.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.114.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.114.0
'@oxc-parser/binding-linux-arm64-gnu': 0.114.0
'@oxc-parser/binding-linux-arm64-musl': 0.114.0
'@oxc-parser/binding-linux-ppc64-gnu': 0.114.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.114.0
'@oxc-parser/binding-linux-riscv64-musl': 0.114.0
'@oxc-parser/binding-linux-s390x-gnu': 0.114.0
'@oxc-parser/binding-linux-x64-gnu': 0.114.0
'@oxc-parser/binding-linux-x64-musl': 0.114.0
'@oxc-parser/binding-openharmony-arm64': 0.114.0
'@oxc-parser/binding-wasm32-wasi': 0.114.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)
'@oxc-parser/binding-win32-arm64-msvc': 0.114.0
'@oxc-parser/binding-win32-ia32-msvc': 0.114.0
'@oxc-parser/binding-win32-x64-msvc': 0.114.0
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
packrup@0.1.2: {}
picocolors@1.1.1: {}
@@ -1853,6 +2216,8 @@ snapshots:
proxy-from-env@1.1.0: {}
quansync@1.0.0: {}
readdirp@4.1.2: {}
resize-observer-polyfill@1.5.1: {}
@@ -1907,6 +2272,8 @@ snapshots:
tapable@2.2.1: {}
three@0.178.0: {}
throttle-debounce@5.0.2: {}
tinyglobby@0.2.13:
@@ -1919,6 +2286,22 @@ snapshots:
is-number: 7.0.0
optional: true
tslib@2.8.1:
optional: true
unconfig-core@7.5.0:
dependencies:
'@quansync/fs': 1.0.0
quansync: 1.0.0
unconfig@7.5.0:
dependencies:
'@quansync/fs': 1.0.0
defu: 6.1.7
jiti: 2.7.0
quansync: 1.0.0
unconfig-core: 7.5.0
unhead@1.11.20:
dependencies:
'@unhead/dom': 1.11.20

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
allowBuilds:
'@parcel/watcher': true
core-js: true
esbuild: true
vue-demi: true

56
scripts/preview-dist.cjs Normal file
View File

@@ -0,0 +1,56 @@
const fs = require('fs')
const http = require('http')
const path = require('path')
const port = Number(process.env.PORT || 8888)
const host = process.env.HOST || '127.0.0.1'
const root = path.resolve(__dirname, '..', 'dist')
const contentTypes = {
'.css': 'text/css;charset=utf-8',
'.html': 'text/html;charset=utf-8',
'.js': 'text/javascript;charset=utf-8',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ttf': 'font/ttf',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
}
function sendFile(file, response) {
fs.readFile(file, (error, data) => {
if (error) {
response.writeHead(404)
response.end('Not found')
return
}
response.writeHead(200, {
'Content-Type': contentTypes[path.extname(file)] || 'application/octet-stream',
})
response.end(data)
})
}
http.createServer((request, response) => {
const cleanUrl = decodeURIComponent(request.url.split('?')[0])
let file = path.join(root, cleanUrl === '/' ? 'index.html' : cleanUrl)
if (!file.startsWith(root)) {
response.writeHead(403)
response.end('Forbidden')
return
}
fs.stat(file, (error, stats) => {
if (error || !stats.isFile()) {
file = path.join(root, 'index.html')
}
sendFile(file, response)
})
}).listen(port, host, () => {
console.log(`萧康云医 preview http://${host}:${port}/`)
})

View File

@@ -0,0 +1,93 @@
Copyright 2022 The Maple Mono Project Authors (https://github.com/subframe7536/maple-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,88 @@
{
"version": "v7.9",
"family_name": "Maple Mono",
"weight_mapping": {
"thin": 100,
"extralight": 200,
"light": 300,
"regular": 400,
"medium": 500,
"semibold": 600,
"bold": 700,
"extrabold": 800
},
"line_height": 1.0,
"use_hinted": true,
"ligature": true,
"feature_freeze": {
"cv01": "ignore",
"cv02": "ignore",
"cv03": "ignore",
"cv04": "ignore",
"cv31": "ignore",
"cv32": "ignore",
"cv33": "ignore",
"cv34": "ignore",
"cv35": "ignore",
"cv36": "ignore",
"cv37": "ignore",
"cv96": "ignore",
"cv97": "ignore",
"cv98": "ignore",
"cv99": "ignore",
"ss01": "ignore",
"ss02": "ignore",
"ss03": "ignore",
"ss04": "ignore",
"ss05": "ignore",
"ss06": "ignore",
"ss07": "ignore",
"ss08": "ignore",
"zero": "ignore",
"cv05": "ignore",
"cv06": "ignore",
"cv07": "ignore",
"cv08": "ignore",
"cv09": "ignore",
"cv10": "ignore",
"cv11": "ignore",
"cv38": "ignore",
"cv39": "ignore",
"cv40": "ignore",
"cv41": "ignore",
"cv42": "ignore",
"cv43": "ignore",
"cv44": "ignore",
"cv61": "ignore",
"cv62": "ignore",
"cv63": "ignore",
"cv64": "ignore",
"cv65": "ignore",
"cv66": "ignore",
"ss09": "ignore",
"ss10": "ignore",
"ss11": "ignore"
},
"nerd_font": {
"version": "3.4.0",
"mono": false,
"propo": false,
"use_font_patcher": false,
"glyphs": [
"--complete"
],
"extra_args": []
},
"cn": {
"with_nerd_font": false,
"fix_meta_table": true,
"clean_cache": false,
"narrow": false,
"use_hinted": false,
"use_static_base_font": true,
"scale_factor": [
1.0,
1.0
]
}
}

View File

@@ -0,0 +1,269 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ChevronDown, Menu, Monitor, Moon, Search, Stethoscope, Sun, User, X } from 'lucide-vue-next'
import { siteConfig } from '@/site.config'
const route = useRoute()
const themeMode = ref('system')
const resolvedTheme = ref('light')
const isThemeOpen = ref(false)
const themeButtonRef = ref(null)
const themePopoverStyle = ref({})
const isMobileOpen = ref(false)
const openDesktopMenu = ref(null)
const openMobileGroups = ref(['home'])
const themeOptions = [
{ value: 'system', label: '跟随系统', icon: Monitor },
{ value: 'light', label: '亮色', icon: Sun },
{ value: 'dark', label: '暗色', icon: Moon }
]
const menuGroups = [
{ key: 'home', label: '首页', to: '/', links: siteConfig.navigation.secondary.home },
{ key: 'product', label: '产品', to: '/product', links: siteConfig.navigation.secondary.product }
]
const activeTheme = computed(() => themeOptions.find((item) => item.value === themeMode.value) || themeOptions[0])
const isHome = computed(() => route.path === '/')
const isProduct = computed(() => route.path.startsWith('/product'))
const navTone = computed(() => {
if (isHome.value) return 'dark'
return resolvedTheme.value === 'dark' ? 'dark' : 'light'
})
let mediaQuery
function syncThemePopoverPosition() {
if (!themeButtonRef.value) return
const rect = themeButtonRef.value.getBoundingClientRect()
const popoverWidth = 168
const viewportGap = 12
const top = rect.bottom + viewportGap
const left = Math.min(
window.innerWidth - popoverWidth - viewportGap,
Math.max(viewportGap, rect.right - popoverWidth)
)
const arrowX = Math.max(20, Math.min(popoverWidth - 20, rect.left + rect.width / 2 - left))
themePopoverStyle.value = {
left: `${left}px`,
top: `${top}px`,
'--theme-arrow-x': `${arrowX}px`
}
}
function toggleThemePopover() {
isThemeOpen.value = !isThemeOpen.value
if (isThemeOpen.value) nextTick(syncThemePopoverPosition)
}
function applyTheme() {
const nextTheme = themeMode.value === 'system'
? (mediaQuery?.matches ? 'dark' : 'light')
: themeMode.value
resolvedTheme.value = nextTheme
document.documentElement.dataset.theme = nextTheme
document.documentElement.dataset.themeMode = themeMode.value
document.documentElement.style.colorScheme = nextTheme
}
function setTheme(value) {
themeMode.value = value
localStorage.setItem('xiaokang-theme-mode', value)
applyTheme()
isThemeOpen.value = false
}
function isActive(path) {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
function toggleMobileGroup(key) {
openMobileGroups.value = openMobileGroups.value.includes(key)
? openMobileGroups.value.filter((item) => item !== key)
: [...openMobileGroups.value, key]
}
watch(() => route.fullPath, () => {
isMobileOpen.value = false
isThemeOpen.value = false
openDesktopMenu.value = null
})
watch(isThemeOpen, (isOpen) => {
if (isOpen) {
nextTick(syncThemePopoverPosition)
window.addEventListener('resize', syncThemePopoverPosition)
window.addEventListener('scroll', syncThemePopoverPosition, true)
} else {
window.removeEventListener('resize', syncThemePopoverPosition)
window.removeEventListener('scroll', syncThemePopoverPosition, true)
}
})
onMounted(() => {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
themeMode.value = localStorage.getItem('xiaokang-theme-mode') || 'system'
applyTheme()
mediaQuery.addEventListener('change', applyTheme)
})
onBeforeUnmount(() => {
mediaQuery?.removeEventListener('change', applyTheme)
window.removeEventListener('resize', syncThemePopoverPosition)
window.removeEventListener('scroll', syncThemePopoverPosition, true)
})
</script>
<template>
<header
class="site-header"
:class="[
`site-header-tone-${navTone}`,
{
'site-header-home': isHome,
'site-header-solid': !isHome,
'site-header-product': isProduct
}
]"
>
<div
class="site-nav animate-blur-fade-up"
:class="[
`site-nav-tone-${navTone}`,
{
'site-nav-overlay': isHome,
'site-nav-solid': !isHome,
'site-nav-product': isProduct
}
]"
style="animation-delay: 0ms"
>
<RouterLink to="/" class="site-logo" aria-label="萧康云医首页">
<span class="site-logo-mark">
<Stethoscope :size="20" stroke-width="2.4" aria-hidden="true" />
</span>
<span>{{ siteConfig.brand.name }}</span>
</RouterLink>
<nav class="desktop-primary-nav" aria-label="主导航">
<div
v-for="(group, index) in menuGroups"
:key="group.key"
class="primary-nav-item animate-blur-fade-up"
:style="{ animationDelay: `${100 + index * 80}ms` }"
@mouseenter="openDesktopMenu = group.key"
@mouseleave="openDesktopMenu = null"
>
<RouterLink
:to="group.to"
class="primary-nav-link"
:class="{ 'primary-nav-link-active': isActive(group.to) }"
>
{{ group.label }}
<ChevronDown :size="15" aria-hidden="true" />
</RouterLink>
<Transition name="bubble">
<div v-if="openDesktopMenu === group.key" class="desktop-submenu liquid-glass">
<RouterLink
v-for="link in group.links"
:key="link.to"
:to="link.to"
class="submenu-link"
>
{{ link.label }}
</RouterLink>
</div>
</Transition>
</div>
</nav>
<div class="site-actions">
<button type="button" class="action-pill liquid-glass nav-action animate-blur-fade-up" style="animation-delay: 300ms">
<Search :size="18" aria-hidden="true" />
<span>搜索</span>
</button>
<div class="theme-control animate-blur-fade-up" style="animation-delay: 360ms">
<button
ref="themeButtonRef"
type="button"
class="icon-pill liquid-glass"
:aria-expanded="isThemeOpen"
aria-haspopup="menu"
aria-label="切换主题"
@click="toggleThemePopover"
>
<component :is="activeTheme.icon" :size="18" aria-hidden="true" />
</button>
</div>
<RouterLink to="/product#contact" class="icon-pill liquid-glass nav-action animate-blur-fade-up" style="animation-delay: 420ms" aria-label="联系顾问">
<User :size="18" aria-hidden="true" />
</RouterLink>
<button
type="button"
class="mobile-menu-button icon-pill liquid-glass animate-blur-fade-up"
style="animation-delay: 420ms"
:aria-expanded="isMobileOpen"
aria-label="打开菜单"
@click="isMobileOpen = !isMobileOpen"
>
<Menu :class="{ 'menu-icon-hidden': isMobileOpen }" :size="20" aria-hidden="true" />
<X :class="{ 'menu-icon-visible': isMobileOpen }" :size="20" aria-hidden="true" />
</button>
</div>
</div>
<Transition name="mobile-menu">
<div v-if="isMobileOpen" class="mobile-submenu liquid-glass">
<div v-for="group in menuGroups" :key="group.key" class="mobile-menu-group">
<button type="button" class="mobile-group-trigger" @click="toggleMobileGroup(group.key)">
<span>{{ group.label }}</span>
<ChevronDown :class="{ 'rotate-180': openMobileGroups.includes(group.key) }" :size="18" aria-hidden="true" />
</button>
<div v-if="openMobileGroups.includes(group.key)" class="mobile-group-links">
<RouterLink :to="group.to" class="mobile-sub-link">进入{{ group.label }}</RouterLink>
<RouterLink v-for="link in group.links" :key="link.to" :to="link.to" class="mobile-sub-link">
{{ link.label }}
</RouterLink>
</div>
</div>
</div>
</Transition>
</header>
<Teleport to="body">
<Transition name="bubble">
<div
v-if="isThemeOpen"
class="theme-popover theme-popover-floating liquid-glass"
:class="`theme-popover-tone-${navTone}`"
:style="themePopoverStyle"
role="menu"
aria-label="主题模式"
>
<button
v-for="item in themeOptions"
:key="item.value"
type="button"
class="theme-option"
:class="{ 'theme-option-active': item.value === themeMode }"
role="menuitemradio"
:aria-checked="item.value === themeMode"
@click="setTheme(item.value)"
>
<component :is="item.icon" :size="17" aria-hidden="true" />
<span>{{ item.label }}</span>
</button>
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import FloatingLines from '@/components/vue-bits/FloatingLines.vue'
import Galaxy from '@/components/vue-bits/Galaxy.vue'
const theme = ref<'light' | 'dark'>('light')
const reduceMotion = ref(false)
const isVisible = ref(false)
const backdropRef = ref<HTMLElement | null>(null)
const shouldRenderWebgl = computed(() => isVisible.value && !reduceMotion.value)
const canUsePointerInteraction = computed(() => shouldRenderWebgl.value && !reduceMotion.value)
let observer: MutationObserver | null = null
let motionQuery: MediaQueryList | null = null
let visibilityObserver: IntersectionObserver | null = null
function readTheme() {
theme.value = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'
}
function readMotionPreference() {
reduceMotion.value = Boolean(motionQuery?.matches)
}
onMounted(() => {
readTheme()
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
readMotionPreference()
motionQuery.addEventListener('change', readMotionPreference)
observer = new MutationObserver(readTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
})
if ('IntersectionObserver' in window && backdropRef.value) {
visibilityObserver = new IntersectionObserver(
([entry]) => {
isVisible.value = entry.isIntersecting
},
{
root: null,
rootMargin: '0px',
threshold: 0
}
)
visibilityObserver.observe(backdropRef.value)
} else {
isVisible.value = true
}
})
onBeforeUnmount(() => {
observer?.disconnect()
visibilityObserver?.disconnect()
motionQuery?.removeEventListener('change', readMotionPreference)
})
</script>
<template>
<div ref="backdropRef" class="theme-backdrop">
<div v-if="shouldRenderWebgl" class="theme-backdrop-canvas" aria-hidden="true">
<FloatingLines
v-if="theme === 'light'"
:lines-gradient="['#eef3f5', '#c7d0d5', '#d7e2ea']"
:line-count="[4, 3, 2]"
:line-distance="[12, 18, 24]"
:animation-speed="0.22"
:interactive="canUsePointerInteraction"
:mouse-damping="0.045"
:parallax="canUsePointerInteraction"
:parallax-strength="0.08"
pointer-target="window"
mix-blend-mode="multiply"
/>
<Galaxy
v-else
:density="0.32"
:glow-intensity="0.1"
:hue-shift="205"
:mouse-interaction="canUsePointerInteraction"
:mouse-repulsion="false"
:saturation="0.02"
:speed="0.26"
:star-speed="0.14"
:twinkle-intensity="0.055"
:rotation-speed="0.008"
pointer-target="window"
transparent
/>
</div>
<div class="theme-backdrop-content">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
interface AnimatedContentProps {
distance?: number;
direction?: 'vertical' | 'horizontal';
reverse?: boolean;
duration?: number;
ease?: string | ((progress: number) => number);
initialOpacity?: number;
animateOpacity?: boolean;
scale?: number;
threshold?: number;
delay?: number;
className?: string;
}
const props = withDefaults(defineProps<AnimatedContentProps>(), {
distance: 100,
direction: 'vertical',
reverse: false,
duration: 0.8,
ease: 'power3.out',
initialOpacity: 0,
animateOpacity: true,
scale: 1,
threshold: 0.1,
delay: 0,
className: ''
});
const emit = defineEmits<{
complete: [];
}>();
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
onMounted(() => {
const el = containerRef.value;
if (!el) return;
const axis = props.direction === 'horizontal' ? 'x' : 'y';
const offset = props.reverse ? -props.distance : props.distance;
const startPct = (1 - props.threshold) * 100;
gsap.set(el, {
[axis]: offset,
scale: props.scale,
opacity: props.animateOpacity ? props.initialOpacity : 1
});
gsap.to(el, {
[axis]: 0,
scale: 1,
opacity: 1,
duration: props.duration,
ease: props.ease,
delay: props.delay,
onComplete: () => emit('complete'),
scrollTrigger: {
trigger: el,
start: `top ${startPct}%`,
toggleActions: 'play none none none',
once: true
}
});
});
watch(
() => [
props.distance,
props.direction,
props.reverse,
props.duration,
props.ease,
props.initialOpacity,
props.animateOpacity,
props.scale,
props.threshold,
props.delay
],
() => {
const el = containerRef.value;
if (!el) return;
ScrollTrigger.getAll().forEach(t => t.kill());
gsap.killTweensOf(el);
const axis = props.direction === 'horizontal' ? 'x' : 'y';
const offset = props.reverse ? -props.distance : props.distance;
const startPct = (1 - props.threshold) * 100;
gsap.set(el, {
[axis]: offset,
scale: props.scale,
opacity: props.animateOpacity ? props.initialOpacity : 1
});
gsap.to(el, {
[axis]: 0,
scale: 1,
opacity: 1,
duration: props.duration,
ease: props.ease,
delay: props.delay,
onComplete: () => emit('complete'),
scrollTrigger: {
trigger: el,
start: `top ${startPct}%`,
toggleActions: 'play none none none',
once: true
}
});
},
{ deep: true }
);
onUnmounted(() => {
const el = containerRef.value;
if (el) {
ScrollTrigger.getAll().forEach(t => t.kill());
gsap.killTweensOf(el);
}
});
</script>
<template>
<div ref="containerRef" :class="`animated-content ${props.className}`">
<slot />
</div>
</template>
<style scoped>
/* GSAP will handle all transforms and opacity */
</style>

View File

@@ -0,0 +1,360 @@
<template>
<span ref="containerRef" :class="`inline-block whitespace-pre-wrap ${parentClassName}`" v-bind="animateListeners">
<span class="sr-only">{{ displayText }}</span>
<span aria-hidden="true">
<span
v-for="(char, index) in displayText.split('')"
:key="index"
:class="isRevealedOrDone(index) ? className : encryptedClassName"
>
{{ char }}
</span>
</span>
</span>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
type RevealDirection = 'start' | 'end' | 'center';
type AnimateOn = 'view' | 'hover' | 'inViewHover' | 'click';
type ClickMode = 'once' | 'toggle';
type Direction = 'forward' | 'reverse';
interface DecryptedTextProps {
text: string;
speed?: number;
maxIterations?: number;
sequential?: boolean;
revealDirection?: RevealDirection;
useOriginalCharsOnly?: boolean;
characters?: string;
className?: string;
encryptedClassName?: string;
parentClassName?: string;
animateOn?: AnimateOn;
clickMode?: ClickMode;
}
const props = withDefaults(defineProps<DecryptedTextProps>(), {
speed: 50,
maxIterations: 10,
sequential: false,
revealDirection: 'start',
useOriginalCharsOnly: false,
characters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
className: '',
encryptedClassName: '',
parentClassName: '',
animateOn: 'hover',
clickMode: 'once'
});
const containerRef = ref<HTMLSpanElement | null>(null);
const displayText = ref<string>(props.text);
const isAnimating = ref<boolean>(false);
const revealedIndices = ref<Set<number>>(new Set());
const hasAnimated = ref<boolean>(false);
const isDecrypted = ref<boolean>(props.animateOn !== 'click');
const direction = ref<Direction>('forward');
const orderRef = ref<number[]>([]);
const pointerRef = ref<number>(0);
let intervalId: ReturnType<typeof setInterval> | null = null;
const availableChars = computed<string[]>(() => {
return props.useOriginalCharsOnly
? Array.from(new Set(props.text.split(''))).filter(char => char !== ' ')
: props.characters.split('');
});
function shuffleText(originalText: string, currentRevealed: Set<number>): string {
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' ';
if (currentRevealed.has(i)) return originalText[i];
return availableChars.value[Math.floor(Math.random() * availableChars.value.length)];
})
.join('');
}
function computeOrder(len: number): number[] {
const order: number[] = [];
if (len <= 0) return order;
if (props.revealDirection === 'start') {
for (let i = 0; i < len; i++) order.push(i);
return order;
}
if (props.revealDirection === 'end') {
for (let i = len - 1; i >= 0; i--) order.push(i);
return order;
}
const middle = Math.floor(len / 2);
let offset = 0;
while (order.length < len) {
if (offset % 2 === 0) {
const idx = middle + offset / 2;
if (idx >= 0 && idx < len) order.push(idx);
} else {
const idx = middle - Math.ceil(offset / 2);
if (idx >= 0 && idx < len) order.push(idx);
}
offset++;
}
return order.slice(0, len);
}
function fillAllIndices(): Set<number> {
const s = new Set<number>();
for (let i = 0; i < props.text.length; i++) s.add(i);
return s;
}
function removeRandomIndices(set: Set<number>, count: number): Set<number> {
const arr = Array.from(set);
for (let i = 0; i < count && arr.length > 0; i++) {
const idx = Math.floor(Math.random() * arr.length);
arr.splice(idx, 1);
}
return new Set(arr);
}
function encryptInstantly() {
const emptySet = new Set<number>();
revealedIndices.value = emptySet;
displayText.value = shuffleText(props.text, emptySet);
isDecrypted.value = false;
}
function triggerDecrypt() {
if (props.sequential) {
orderRef.value = computeOrder(props.text.length);
pointerRef.value = 0;
revealedIndices.value = new Set();
} else {
revealedIndices.value = new Set();
}
direction.value = 'forward';
isAnimating.value = true;
}
function triggerReverse() {
if (props.sequential) {
orderRef.value = computeOrder(props.text.length).slice().reverse();
pointerRef.value = 0;
const full = fillAllIndices();
revealedIndices.value = full;
displayText.value = shuffleText(props.text, full);
} else {
const full = fillAllIndices();
revealedIndices.value = full;
displayText.value = shuffleText(props.text, full);
}
direction.value = 'reverse';
isAnimating.value = true;
}
function getNextIndex(revealedSet: Set<number>): number {
const textLength = props.text.length;
switch (props.revealDirection) {
case 'start':
return revealedSet.size;
case 'end':
return textLength - 1 - revealedSet.size;
case 'center': {
const middle = Math.floor(textLength / 2);
const offset = Math.floor(revealedSet.size / 2);
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i;
}
return 0;
}
default:
return revealedSet.size;
}
}
function stopInterval() {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
}
function startInterval() {
stopInterval();
let currentIteration = 0;
intervalId = setInterval(() => {
if (props.sequential) {
if (direction.value === 'forward') {
if (revealedIndices.value.size < props.text.length) {
const nextIndex = getNextIndex(revealedIndices.value);
const newRevealed = new Set(revealedIndices.value);
newRevealed.add(nextIndex);
revealedIndices.value = newRevealed;
displayText.value = shuffleText(props.text, newRevealed);
} else {
stopInterval();
isAnimating.value = false;
isDecrypted.value = true;
}
} else {
if (pointerRef.value < orderRef.value.length) {
const idxToRemove = orderRef.value[pointerRef.value++];
const newRevealed = new Set(revealedIndices.value);
newRevealed.delete(idxToRemove);
revealedIndices.value = newRevealed;
displayText.value = shuffleText(props.text, newRevealed);
if (newRevealed.size === 0) {
stopInterval();
isAnimating.value = false;
isDecrypted.value = false;
}
} else {
stopInterval();
isAnimating.value = false;
isDecrypted.value = false;
}
}
} else {
if (direction.value === 'forward') {
displayText.value = shuffleText(props.text, revealedIndices.value);
currentIteration++;
if (currentIteration >= props.maxIterations) {
stopInterval();
isAnimating.value = false;
displayText.value = props.text;
isDecrypted.value = true;
}
} else {
let currentSet = revealedIndices.value;
if (currentSet.size === 0) currentSet = fillAllIndices();
const removeCount = Math.max(1, Math.ceil(props.text.length / Math.max(1, props.maxIterations)));
const nextSet = removeRandomIndices(currentSet, removeCount);
revealedIndices.value = nextSet;
displayText.value = shuffleText(props.text, nextSet);
currentIteration++;
if (nextSet.size === 0 || currentIteration >= props.maxIterations) {
stopInterval();
isAnimating.value = false;
isDecrypted.value = false;
displayText.value = shuffleText(props.text, new Set());
revealedIndices.value = new Set();
}
}
}
}, props.speed);
}
watch(isAnimating, val => {
if (val) startInterval();
else stopInterval();
});
function handleClick() {
if (props.animateOn !== 'click') return;
if (props.clickMode === 'once') {
if (isDecrypted.value) return;
direction.value = 'forward';
triggerDecrypt();
}
if (props.clickMode === 'toggle') {
if (isDecrypted.value) {
triggerReverse();
} else {
direction.value = 'forward';
triggerDecrypt();
}
}
}
function triggerHoverDecrypt() {
if (isAnimating.value) return;
revealedIndices.value = new Set();
isDecrypted.value = false;
displayText.value = props.text;
direction.value = 'forward';
isAnimating.value = true;
}
function resetToPlainText() {
stopInterval();
isAnimating.value = false;
revealedIndices.value = new Set();
displayText.value = props.text;
isDecrypted.value = true;
direction.value = 'forward';
}
const animateListeners = computed(() => {
if (props.animateOn === 'hover' || props.animateOn === 'inViewHover') {
return {
onMouseenter: triggerHoverDecrypt,
onMouseleave: resetToPlainText
};
}
if (props.animateOn === 'click') {
return { onClick: handleClick };
}
return {};
});
function isRevealedOrDone(index: number): boolean {
return revealedIndices.value.has(index) || (!isAnimating.value && isDecrypted.value);
}
let intersectionObserver: IntersectionObserver | null = null;
onMounted(() => {
if (props.animateOn === 'view' || props.animateOn === 'inViewHover') {
intersectionObserver = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated.value) {
triggerDecrypt();
hasAnimated.value = true;
}
});
},
{ root: null, rootMargin: '0px', threshold: 0.1 }
);
if (containerRef.value) intersectionObserver.observe(containerRef.value);
}
if (props.animateOn === 'click') {
encryptInstantly();
} else {
displayText.value = props.text;
isDecrypted.value = true;
}
revealedIndices.value = new Set();
direction.value = 'forward';
});
onUnmounted(() => {
stopInterval();
intersectionObserver?.disconnect();
});
watch(
() => [props.animateOn, props.text] as const,
([animateOn, text]) => {
if (animateOn === 'click') {
encryptInstantly();
} else {
displayText.value = text;
isDecrypted.value = true;
}
revealedIndices.value = new Set();
direction.value = 'forward';
}
);
</script>

View File

@@ -0,0 +1,537 @@
<script setup lang="ts">
import {
Clock,
Mesh,
OrthographicCamera,
PlaneGeometry,
Scene,
ShaderMaterial,
Vector2,
Vector3,
WebGLRenderer
} from 'three';
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch, type CSSProperties } from 'vue';
const vertexShader = `
precision highp float;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
precision highp float;
uniform float iTime;
uniform vec3 iResolution;
uniform float animationSpeed;
uniform bool enableTop;
uniform bool enableMiddle;
uniform bool enableBottom;
uniform int topLineCount;
uniform int middleLineCount;
uniform int bottomLineCount;
uniform float topLineDistance;
uniform float middleLineDistance;
uniform float bottomLineDistance;
uniform vec3 topWavePosition;
uniform vec3 middleWavePosition;
uniform vec3 bottomWavePosition;
uniform vec2 iMouse;
uniform bool interactive;
uniform float bendRadius;
uniform float bendStrength;
uniform float bendInfluence;
uniform bool parallax;
uniform float parallaxStrength;
uniform vec2 parallaxOffset;
uniform vec3 lineGradient[8];
uniform int lineGradientCount;
const vec3 BLACK = vec3(0.0);
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
mat2 rotate(float r) {
return mat2(cos(r), sin(r), -sin(r), cos(r));
}
vec3 background_color(vec2 uv) {
vec3 col = vec3(0.0);
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
float m = uv.y - y;
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
return col * 0.5;
}
vec3 getLineColor(float t, vec3 baseColor) {
if (lineGradientCount <= 0) {
return baseColor;
}
vec3 gradientColor;
if (lineGradientCount == 1) {
gradientColor = lineGradient[0];
} else {
float clampedT = clamp(t, 0.0, 0.9999);
float scaled = clampedT * float(lineGradientCount - 1);
int idx = int(floor(scaled));
float f = fract(scaled);
int idx2 = min(idx + 1, lineGradientCount - 1);
vec3 c1 = lineGradient[idx];
vec3 c2 = lineGradient[idx2];
gradientColor = mix(c1, c2, f);
}
return gradientColor * 0.5;
}
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) {
float time = iTime * animationSpeed;
float x_offset = offset;
float x_movement = time * 0.1;
float amp = sin(offset + time * 0.2) * 0.3;
float y = sin(uv.x + x_offset + x_movement) * amp;
if (shouldBend) {
vec2 d = screenUv - mouseUv;
float influence = exp(-dot(d, d) * bendRadius);
float bendOffset = (mouseUv.y - screenUv.y) * influence * bendStrength * bendInfluence;
y += bendOffset;
}
float m = uv.y - y;
return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
baseUv.y *= -1.0;
if (parallax) {
baseUv += parallaxOffset;
}
vec3 col = vec3(0.0);
vec3 b = lineGradientCount > 0 ? vec3(0.0) : background_color(baseUv);
vec2 mouseUv = vec2(0.0);
if (interactive) {
mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
mouseUv.y *= -1.0;
}
if (enableBottom) {
for (int i = 0; i < bottomLineCount; ++i) {
float fi = float(i);
float t = fi / max(float(bottomLineCount - 1), 1.0);
vec3 lineCol = getLineColor(t, b);
float angle = bottomWavePosition.z * log(length(baseUv) + 1.0);
vec2 ruv = baseUv * rotate(angle);
col += lineCol * wave(
ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y),
1.5 + 0.2 * fi,
baseUv,
mouseUv,
interactive
) * 0.2;
}
}
if (enableMiddle) {
for (int i = 0; i < middleLineCount; ++i) {
float fi = float(i);
float t = fi / max(float(middleLineCount - 1), 1.0);
vec3 lineCol = getLineColor(t, b);
float angle = middleWavePosition.z * log(length(baseUv) + 1.0);
vec2 ruv = baseUv * rotate(angle);
col += lineCol * wave(
ruv + vec2(middleLineDistance * fi + middleWavePosition.x, middleWavePosition.y),
2.0 + 0.15 * fi,
baseUv,
mouseUv,
interactive
);
}
}
if (enableTop) {
for (int i = 0; i < topLineCount; ++i) {
float fi = float(i);
float t = fi / max(float(topLineCount - 1), 1.0);
vec3 lineCol = getLineColor(t, b);
float angle = topWavePosition.z * log(length(baseUv) + 1.0);
vec2 ruv = baseUv * rotate(angle);
ruv.x *= -1.0;
col += lineCol * wave(
ruv + vec2(topLineDistance * fi + topWavePosition.x, topWavePosition.y),
1.0 + 0.2 * fi,
baseUv,
mouseUv,
interactive
) * 0.1;
}
}
fragColor = vec4(col, 1.0);
}
void main() {
vec4 color = vec4(0.0);
mainImage(color, gl_FragCoord.xy);
gl_FragColor = color;
}
`;
const MAX_GRADIENT_STOPS = 8;
type WavePosition = {
x: number;
y: number;
rotate: number;
};
type FloatingLinesProps = {
linesGradient?: string[];
enabledWaves?: Array<'top' | 'middle' | 'bottom'>;
lineCount?: number | number[];
lineDistance?: number | number[];
topWavePosition?: WavePosition;
middleWavePosition?: WavePosition;
bottomWavePosition?: WavePosition;
animationSpeed?: number;
interactive?: boolean;
bendRadius?: number;
bendStrength?: number;
mouseDamping?: number;
parallax?: boolean;
parallaxStrength?: number;
pointerTarget?: 'self' | 'window';
mixBlendMode?: CSSProperties['mixBlendMode'];
};
const props = withDefaults(defineProps<FloatingLinesProps>(), {
enabledWaves: () => ['top', 'middle', 'bottom'],
lineCount: () => [6],
lineDistance: () => [5],
bottomWavePosition: () => ({ x: 2.0, y: -0.7, rotate: -1 }),
animationSpeed: 1,
interactive: true,
bendRadius: 5.0,
bendStrength: -0.5,
mouseDamping: 0.05,
parallax: true,
parallaxStrength: 0.2,
pointerTarget: 'self',
mixBlendMode: 'screen'
});
function hexToVec3(hex: string): Vector3 {
let value = hex.trim();
if (value.startsWith('#')) {
value = value.slice(1);
}
let r = 255;
let g = 255;
let b = 255;
if (value.length === 3) {
r = parseInt(value[0] + value[0], 16);
g = parseInt(value[1] + value[1], 16);
b = parseInt(value[2] + value[2], 16);
} else if (value.length === 6) {
r = parseInt(value.slice(0, 2), 16);
g = parseInt(value.slice(2, 4), 16);
b = parseInt(value.slice(4, 6), 16);
}
return new Vector3(r / 255, g / 255, b / 255);
}
const containerRef = useTemplateRef('containerRef');
const targetMouseRef = ref<Vector2>(new Vector2(-1000, -1000));
const currentMouseRef = ref<Vector2>(new Vector2(-1000, -1000));
const targetInfluenceRef = ref<number>(0);
const currentInfluenceRef = ref<number>(0);
const targetParallaxRef = ref<Vector2>(new Vector2(0, 0));
const currentParallaxRef = ref<Vector2>(new Vector2(0, 0));
let cleanup: (() => void) | null = null;
const setup = () => {
if (!containerRef.value) return;
const getLineCount = (waveType: 'top' | 'middle' | 'bottom'): number => {
if (typeof props.lineCount === 'number') return props.lineCount;
if (!props.enabledWaves.includes(waveType)) return 0;
const index = props.enabledWaves.indexOf(waveType);
return props.lineCount[index] ?? 6;
};
const getLineDistance = (waveType: 'top' | 'middle' | 'bottom'): number => {
if (typeof props.lineDistance === 'number') return props.lineDistance;
if (!props.enabledWaves.includes(waveType)) return 0.1;
const index = props.enabledWaves.indexOf(waveType);
return props.lineDistance[index] ?? 0.1;
};
const topLineCount = props.enabledWaves.includes('top') ? getLineCount('top') : 0;
const middleLineCount = props.enabledWaves.includes('middle') ? getLineCount('middle') : 0;
const bottomLineCount = props.enabledWaves.includes('bottom') ? getLineCount('bottom') : 0;
const topLineDistance = props.enabledWaves.includes('top') ? getLineDistance('top') * 0.01 : 0.01;
const middleLineDistance = props.enabledWaves.includes('middle') ? getLineDistance('middle') * 0.01 : 0.01;
const bottomLineDistance = props.enabledWaves.includes('bottom') ? getLineDistance('bottom') * 0.01 : 0.01;
const scene = new Scene();
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
camera.position.z = 1;
const renderer = new WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
containerRef.value.appendChild(renderer.domElement);
const uniforms = {
iTime: { value: 0 },
iResolution: { value: new Vector3(1, 1, 1) },
animationSpeed: { value: props.animationSpeed },
enableTop: { value: props.enabledWaves.includes('top') },
enableMiddle: { value: props.enabledWaves.includes('middle') },
enableBottom: { value: props.enabledWaves.includes('bottom') },
topLineCount: { value: topLineCount },
middleLineCount: { value: middleLineCount },
bottomLineCount: { value: bottomLineCount },
topLineDistance: { value: topLineDistance },
middleLineDistance: { value: middleLineDistance },
bottomLineDistance: { value: bottomLineDistance },
topWavePosition: {
value: new Vector3(
props.topWavePosition?.x ?? 10.0,
props.topWavePosition?.y ?? 0.5,
props.topWavePosition?.rotate ?? -0.4
)
},
middleWavePosition: {
value: new Vector3(
props.middleWavePosition?.x ?? 5.0,
props.middleWavePosition?.y ?? 0.0,
props.middleWavePosition?.rotate ?? 0.2
)
},
bottomWavePosition: {
value: new Vector3(
props.bottomWavePosition?.x ?? 2.0,
props.bottomWavePosition?.y ?? -0.7,
props.bottomWavePosition?.rotate ?? 0.4
)
},
iMouse: { value: new Vector2(-1000, -1000) },
interactive: { value: props.interactive },
bendRadius: { value: props.bendRadius },
bendStrength: { value: props.bendStrength },
bendInfluence: { value: 0 },
parallax: { value: props.parallax },
parallaxStrength: { value: props.parallaxStrength },
parallaxOffset: { value: new Vector2(0, 0) },
lineGradient: {
value: Array.from({ length: MAX_GRADIENT_STOPS }, () => new Vector3(1, 1, 1))
},
lineGradientCount: { value: 0 }
};
if (props.linesGradient && props.linesGradient.length > 0) {
const stops = props.linesGradient.slice(0, MAX_GRADIENT_STOPS);
uniforms.lineGradientCount.value = stops.length;
stops.forEach((hex, i) => {
const color = hexToVec3(hex);
uniforms.lineGradient.value[i].set(color.x, color.y, color.z);
});
}
const material = new ShaderMaterial({
uniforms,
vertexShader,
fragmentShader
});
const geometry = new PlaneGeometry(2, 2);
const mesh = new Mesh(geometry, material);
scene.add(mesh);
const clock = new Clock();
const setSize = () => {
const el = containerRef.value!;
const width = el.clientWidth || 1;
const height = el.clientHeight || 1;
renderer.setSize(width, height, false);
const canvasWidth = renderer.domElement.width;
const canvasHeight = renderer.domElement.height;
uniforms.iResolution.value.set(canvasWidth, canvasHeight, 1);
};
setSize();
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(setSize) : null;
if (ro && containerRef.value) {
ro.observe(containerRef.value);
}
const handlePointerMove = (event: PointerEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const dpr = renderer.getPixelRatio();
targetMouseRef.value.set(x * dpr, (rect.height - y) * dpr);
targetInfluenceRef.value = 1.0;
if (props.parallax) {
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const offsetX = (x - centerX) / rect.width;
const offsetY = -(y - centerY) / rect.height;
targetParallaxRef.value.set(offsetX * props.parallaxStrength, offsetY * props.parallaxStrength);
}
};
const handlePointerLeave = () => {
targetInfluenceRef.value = 0.0;
};
const pointerEventTarget: Window | HTMLElement = props.pointerTarget === 'window'
? window
: renderer.domElement;
if (props.interactive) {
pointerEventTarget.addEventListener('pointermove', handlePointerMove);
pointerEventTarget.addEventListener('pointerleave', handlePointerLeave);
if (props.pointerTarget === 'window') {
window.addEventListener('blur', handlePointerLeave);
}
}
let raf = 0;
const renderLoop = () => {
uniforms.iTime.value = clock.getElapsedTime();
if (props.interactive) {
currentMouseRef.value.lerp(targetMouseRef.value, props.mouseDamping);
uniforms.iMouse.value.copy(currentMouseRef.value);
currentInfluenceRef.value += (targetInfluenceRef.value - currentInfluenceRef.value) * props.mouseDamping;
uniforms.bendInfluence.value = currentInfluenceRef.value;
}
if (props.parallax) {
currentParallaxRef.value.lerp(targetParallaxRef.value, props.mouseDamping);
uniforms.parallaxOffset.value.copy(currentParallaxRef.value);
}
renderer.render(scene, camera);
raf = requestAnimationFrame(renderLoop);
};
renderLoop();
cleanup = () => {
cancelAnimationFrame(raf);
if (ro && containerRef.value) {
ro.disconnect();
}
if (props.interactive) {
pointerEventTarget.removeEventListener('pointermove', handlePointerMove);
pointerEventTarget.removeEventListener('pointerleave', handlePointerLeave);
if (props.pointerTarget === 'window') {
window.removeEventListener('blur', handlePointerLeave);
}
}
geometry.dispose();
material.dispose();
renderer.dispose();
if (renderer.domElement.parentElement) {
renderer.domElement.parentElement.removeChild(renderer.domElement);
}
};
};
onMounted(() => {
setup();
});
onBeforeUnmount(() => {
cleanup?.();
});
watch(
() => [
props.linesGradient,
props.enabledWaves,
props.lineCount,
props.lineDistance,
props.topWavePosition,
props.middleWavePosition,
props.bottomWavePosition,
props.animationSpeed,
props.interactive,
props.bendRadius,
props.bendStrength,
props.mouseDamping,
props.parallax,
props.parallaxStrength,
props.pointerTarget
],
() => {
cleanup?.();
setup();
},
{
deep: true
}
);
</script>
<template>
<div
ref="containerRef"
class="relative w-full h-full overflow-hidden floating-lines-container"
:style="{
mixBlendMode: mixBlendMode
}"
/>
</template>

View File

@@ -0,0 +1,369 @@
<script setup lang="ts">
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
const vertexShader = `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0, 1);
}
`;
const fragmentShader = `
precision highp float;
uniform float uTime;
uniform vec3 uResolution;
uniform vec2 uFocal;
uniform vec2 uRotation;
uniform float uStarSpeed;
uniform float uDensity;
uniform float uHueShift;
uniform float uSpeed;
uniform vec2 uMouse;
uniform float uGlowIntensity;
uniform float uSaturation;
uniform bool uMouseRepulsion;
uniform float uTwinkleIntensity;
uniform float uRotationSpeed;
uniform float uRepulsionStrength;
uniform float uMouseActiveFactor;
uniform float uAutoCenterRepulsion;
uniform bool uTransparent;
varying vec2 vUv;
#define NUM_LAYER 4.0
#define STAR_COLOR_CUTOFF 0.2
#define MAT45 mat2(0.7071, -0.7071, 0.7071, 0.7071)
#define PERIOD 3.0
float Hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float tri(float x) {
return abs(fract(x) * 2.0 - 1.0);
}
float tris(float x) {
float t = fract(x);
return 1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0));
}
float trisn(float x) {
float t = fract(x);
return 2.0 * (1.0 - smoothstep(0.0, 1.0, abs(2.0 * t - 1.0))) - 1.0;
}
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
float Star(vec2 uv, float flare) {
float d = length(uv);
float m = (0.05 * uGlowIntensity) / d;
float rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
m += rays * flare * uGlowIntensity;
uv *= MAT45;
rays = smoothstep(0.0, 1.0, 1.0 - abs(uv.x * uv.y * 1000.0));
m += rays * 0.3 * flare * uGlowIntensity;
m *= smoothstep(1.0, 0.2, d);
return m;
}
vec3 StarLayer(vec2 uv) {
vec3 col = vec3(0.0);
vec2 gv = fract(uv) - 0.5;
vec2 id = floor(uv);
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 offset = vec2(float(x), float(y));
vec2 si = id + vec2(float(x), float(y));
float seed = Hash21(si);
float size = fract(seed * 345.32);
float glossLocal = tri(uStarSpeed / (PERIOD * seed + 1.0));
float flareSize = smoothstep(0.9, 1.0, size) * glossLocal;
float red = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 1.0)) + STAR_COLOR_CUTOFF;
float blu = smoothstep(STAR_COLOR_CUTOFF, 1.0, Hash21(si + 3.0)) + STAR_COLOR_CUTOFF;
float grn = min(red, blu) * seed;
vec3 base = vec3(red, grn, blu);
float hue = atan(base.g - base.r, base.b - base.r) / (2.0 * 3.14159) + 0.5;
hue = fract(hue + uHueShift / 360.0);
float sat = length(base - vec3(dot(base, vec3(0.299, 0.587, 0.114)))) * uSaturation;
float val = max(max(base.r, base.g), base.b);
base = hsv2rgb(vec3(hue, sat, val));
vec2 pad = vec2(tris(seed * 34.0 + uTime * uSpeed / 10.0), tris(seed * 38.0 + uTime * uSpeed / 30.0)) - 0.5;
float star = Star(gv - offset - pad, flareSize);
vec3 color = base;
float twinkle = trisn(uTime * uSpeed + seed * 6.2831) * 0.5 + 1.0;
twinkle = mix(1.0, twinkle, uTwinkleIntensity);
star *= twinkle;
col += star * size * color;
}
}
return col;
}
void main() {
vec2 focalPx = uFocal * uResolution.xy;
vec2 uv = (vUv * uResolution.xy - focalPx) / uResolution.y;
vec2 mouseNorm = uMouse - vec2(0.5);
if (uAutoCenterRepulsion > 0.0) {
vec2 centerUV = vec2(0.0, 0.0); // Center in UV space
float centerDist = length(uv - centerUV);
vec2 repulsion = normalize(uv - centerUV) * (uAutoCenterRepulsion / (centerDist + 0.1));
uv += repulsion * 0.05;
} else if (uMouseRepulsion) {
vec2 mousePosUV = (uMouse * uResolution.xy - focalPx) / uResolution.y;
float mouseDist = length(uv - mousePosUV);
vec2 repulsion = normalize(uv - mousePosUV) * (uRepulsionStrength / (mouseDist + 0.1));
uv += repulsion * 0.05 * uMouseActiveFactor;
} else {
vec2 mouseOffset = mouseNorm * 0.1 * uMouseActiveFactor;
uv += mouseOffset;
}
float autoRotAngle = uTime * uRotationSpeed;
mat2 autoRot = mat2(cos(autoRotAngle), -sin(autoRotAngle), sin(autoRotAngle), cos(autoRotAngle));
uv = autoRot * uv;
uv = mat2(uRotation.x, -uRotation.y, uRotation.y, uRotation.x) * uv;
vec3 col = vec3(0.0);
for (float i = 0.0; i < 1.0; i += 1.0 / NUM_LAYER) {
float depth = fract(i + uStarSpeed * uSpeed);
float scale = mix(20.0 * uDensity, 0.5 * uDensity, depth);
float fade = depth * smoothstep(1.0, 0.9, depth);
col += StarLayer(uv * scale + i * 453.32) * fade;
}
if (uTransparent) {
float alpha = length(col);
alpha = smoothstep(0.0, 0.3, alpha); // Enhance contrast
alpha = min(alpha, 1.0); // Clamp to maximum 1.0
gl_FragColor = vec4(col, alpha);
} else {
gl_FragColor = vec4(col, 1.0);
}
}
`;
interface GalaxyProps {
focal?: [number, number];
rotation?: [number, number];
starSpeed?: number;
density?: number;
hueShift?: number;
disableAnimation?: boolean;
speed?: number;
mouseInteraction?: boolean;
glowIntensity?: number;
saturation?: number;
mouseRepulsion?: boolean;
twinkleIntensity?: number;
rotationSpeed?: number;
repulsionStrength?: number;
autoCenterRepulsion?: number;
transparent?: boolean;
pointerTarget?: 'self' | 'window';
}
const props = withDefaults(defineProps<GalaxyProps>(), {
focal: () => [0.5, 0.5],
rotation: () => [1.0, 0.0],
starSpeed: 0.5,
density: 1,
hueShift: 140,
disableAnimation: false,
speed: 1.0,
mouseInteraction: true,
glowIntensity: 0.3,
saturation: 0.0,
mouseRepulsion: true,
twinkleIntensity: 0.3,
rotationSpeed: 0.1,
repulsionStrength: 2,
autoCenterRepulsion: 0,
transparent: true,
pointerTarget: 'self'
});
const ctnDom = useTemplateRef('ctnDom');
const targetMousePos = ref({ x: 0.5, y: 0.5 });
const smoothMousePos = ref({ x: 0.5, y: 0.5 });
const targetMouseActive = ref(0.0);
const smoothMouseActive = ref(0.0);
let cleanup: (() => void) | null = null;
const setup = () => {
if (!ctnDom.value) return;
const ctn = ctnDom.value;
const renderer = new Renderer({
alpha: props.transparent,
premultipliedAlpha: false
});
const gl = renderer.gl;
if (props.transparent) {
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.clearColor(0, 0, 0, 0);
} else {
gl.clearColor(0, 0, 0, 1);
}
let program: Program;
function resize() {
const scale = 1;
renderer.setSize(ctn.offsetWidth * scale, ctn.offsetHeight * scale);
if (program) {
program.uniforms.uResolution.value = new Color(
gl.canvas.width,
gl.canvas.height,
gl.canvas.width / gl.canvas.height
);
}
}
window.addEventListener('resize', resize, false);
resize();
const geometry = new Triangle(gl);
program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
uTime: { value: 0 },
uResolution: {
value: new Color(gl.canvas.width, gl.canvas.height, gl.canvas.width / gl.canvas.height)
},
uFocal: { value: new Float32Array(props.focal) },
uRotation: { value: new Float32Array(props.rotation) },
uStarSpeed: { value: props.starSpeed },
uDensity: { value: props.density },
uHueShift: { value: props.hueShift },
uSpeed: { value: props.speed },
uMouse: {
value: new Float32Array([smoothMousePos.value.x, smoothMousePos.value.y])
},
uGlowIntensity: { value: props.glowIntensity },
uSaturation: { value: props.saturation },
uMouseRepulsion: { value: props.mouseRepulsion },
uTwinkleIntensity: { value: props.twinkleIntensity },
uRotationSpeed: { value: props.rotationSpeed },
uRepulsionStrength: { value: props.repulsionStrength },
uMouseActiveFactor: { value: 0.0 },
uAutoCenterRepulsion: { value: props.autoCenterRepulsion },
uTransparent: { value: props.transparent }
}
});
const mesh = new Mesh(gl, { geometry, program });
let animateId: number;
function update(t: number) {
animateId = requestAnimationFrame(update);
if (!props.disableAnimation) {
program.uniforms.uTime.value = t * 0.001;
program.uniforms.uStarSpeed.value = (t * 0.001 * props.starSpeed) / 10.0;
}
const lerpFactor = 0.05;
smoothMousePos.value.x += (targetMousePos.value.x - smoothMousePos.value.x) * lerpFactor;
smoothMousePos.value.y += (targetMousePos.value.y - smoothMousePos.value.y) * lerpFactor;
smoothMouseActive.value += (targetMouseActive.value - smoothMouseActive.value) * lerpFactor;
program.uniforms.uMouse.value[0] = smoothMousePos.value.x;
program.uniforms.uMouse.value[1] = smoothMousePos.value.y;
program.uniforms.uMouseActiveFactor.value = smoothMouseActive.value;
renderer.render({ scene: mesh });
}
animateId = requestAnimationFrame(update);
ctn.appendChild(gl.canvas);
function handleMouseMove(e: MouseEvent) {
const rect = ctn.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1.0 - (e.clientY - rect.top) / rect.height;
targetMousePos.value = { x, y };
targetMouseActive.value = 1.0;
}
function handleMouseLeave() {
targetMouseActive.value = 0.0;
}
const pointerEventTarget: Window | HTMLElement = props.pointerTarget === 'window'
? window
: ctn;
if (props.mouseInteraction) {
pointerEventTarget.addEventListener('mousemove', handleMouseMove);
pointerEventTarget.addEventListener('mouseleave', handleMouseLeave);
if (props.pointerTarget === 'window') {
window.addEventListener('blur', handleMouseLeave);
}
}
cleanup = () => {
cancelAnimationFrame(animateId);
window.removeEventListener('resize', resize);
if (props.mouseInteraction) {
pointerEventTarget.removeEventListener('mousemove', handleMouseMove);
pointerEventTarget.removeEventListener('mouseleave', handleMouseLeave);
if (props.pointerTarget === 'window') {
window.removeEventListener('blur', handleMouseLeave);
}
}
ctn.removeChild(gl.canvas);
gl.getExtension('WEBGL_lose_context')?.loseContext();
};
};
onMounted(() => {
cleanup?.();
setup();
});
onUnmounted(() => {
cleanup?.();
});
watch(
() => props,
() => {
cleanup?.();
setup();
},
{ deep: true }
);
</script>
<template>
<div ref="ctnDom" class="relative w-full h-full" v-bind="$attrs" />
</template>

View File

@@ -0,0 +1,92 @@
<template>
<div
ref="magnetRef"
:class="wrapperClassName"
:style="{ position: 'relative', display: 'inline-block' }"
v-bind="$attrs"
>
<div
:class="innerClassName"
:style="{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
transition: transitionStyle,
willChange: 'transform'
}"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
interface Props {
padding?: number;
disabled?: boolean;
magnetStrength?: number;
activeTransition?: string;
inactiveTransition?: string;
wrapperClassName?: string;
innerClassName?: string;
}
const props = withDefaults(defineProps<Props>(), {
padding: 100,
disabled: false,
magnetStrength: 2,
activeTransition: 'transform 0.3s ease-out',
inactiveTransition: 'transform 0.5s ease-in-out',
wrapperClassName: '',
innerClassName: ''
});
defineOptions({
inheritAttrs: false
});
const magnetRef = useTemplateRef<HTMLDivElement>('magnetRef');
const isActive = ref(false);
const position = ref({ x: 0, y: 0 });
const transitionStyle = computed(() => (isActive.value ? props.activeTransition : props.inactiveTransition));
const handleMouseMove = (e: MouseEvent) => {
if (!magnetRef.value || props.disabled) return;
const { left, top, width, height } = magnetRef.value.getBoundingClientRect();
const centerX = left + width / 2;
const centerY = top + height / 2;
const distX = Math.abs(centerX - e.clientX);
const distY = Math.abs(centerY - e.clientY);
if (distX < width / 2 + props.padding && distY < height / 2 + props.padding) {
isActive.value = true;
const offsetX = (e.clientX - centerX) / props.magnetStrength;
const offsetY = (e.clientY - centerY) / props.magnetStrength;
position.value = { x: offsetX, y: offsetY };
} else {
isActive.value = false;
position.value = { x: 0, y: 0 };
}
};
onMounted(() => {
window.addEventListener('mousemove', handleMouseMove);
});
onUnmounted(() => {
window.removeEventListener('mousemove', handleMouseMove);
});
watch(
() => props.disabled,
newDisabled => {
if (newDisabled) {
position.value = { x: 0, y: 0 };
isActive.value = false;
}
}
);
</script>

View File

@@ -0,0 +1,357 @@
<script setup lang="ts">
import Lenis from 'lenis';
import { nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
interface CardTransform {
translateY: number;
scale: number;
rotation: number;
blur: number;
}
interface ScrollStackProps {
className?: string;
itemDistance?: number;
itemScale?: number;
itemStackDistance?: number;
stackPosition?: string;
scaleEndPosition?: string;
baseScale?: number;
scaleDuration?: number;
rotationAmount?: number;
blurAmount?: number;
useWindowScroll?: boolean;
onStackComplete?: () => void;
}
const props = withDefaults(defineProps<ScrollStackProps>(), {
className: '',
itemDistance: 100,
itemScale: 0.03,
itemStackDistance: 30,
stackPosition: '20%',
scaleEndPosition: '10%',
baseScale: 0.85,
scaleDuration: 0.5,
rotationAmount: 0,
blurAmount: 0,
useWindowScroll: false
});
const scrollerRef = useTemplateRef('scrollerRef');
const stackCompletedRef = ref(false);
const animationFrameRef = ref<number | null>(null);
const lenisRef = ref<Lenis | null>(null);
const cardsRef = ref<HTMLElement[]>([]);
const lastTransformsRef = ref(new Map<number, CardTransform>());
const isUpdatingRef = ref(false);
const calculateProgress = (scrollTop: number, start: number, end: number) => {
if (scrollTop < start) return 0;
if (scrollTop > end) return 1;
return (scrollTop - start) / (end - start);
};
const parsePercentage = (value: string | number, containerHeight: number) => {
if (typeof value === 'string' && value.includes('%')) {
return (parseFloat(value) / 100) * containerHeight;
}
return parseFloat(value as string);
};
const getScrollData = () => {
if (props.useWindowScroll) {
return {
scrollTop: window.scrollY,
containerHeight: window.innerHeight,
scrollContainer: document.documentElement
};
} else {
const scroller = scrollerRef.value!;
return {
scrollTop: scroller.scrollTop,
containerHeight: scroller.clientHeight,
scrollContainer: scroller
};
}
};
const getElementOffset = (element: HTMLElement) => {
if (props.useWindowScroll) {
const rect = element.getBoundingClientRect();
return rect.top + window.scrollY;
} else {
return element.offsetTop;
}
};
const updateCardTransforms = () => {
if (!cardsRef.value.length || isUpdatingRef.value) return;
if (!props.useWindowScroll && !scrollerRef.value) return;
isUpdatingRef.value = true;
const { scrollTop, containerHeight } = getScrollData();
const stackPositionPx = parsePercentage(props.stackPosition, containerHeight);
const scaleEndPositionPx = parsePercentage(props.scaleEndPosition, containerHeight);
const endElement = props.useWindowScroll
? (document.querySelector('.scroll-stack-end') as HTMLElement | null)
: (scrollerRef.value?.querySelector('.scroll-stack-end') as HTMLElement | null);
const endElementTop = endElement ? getElementOffset(endElement) : 0;
cardsRef.value.forEach((card, i) => {
if (!card) return;
const cardTop = getElementOffset(card);
const triggerStart = cardTop - stackPositionPx - props.itemStackDistance * i;
const triggerEnd = cardTop - scaleEndPositionPx;
const pinStart = cardTop - stackPositionPx - props.itemStackDistance * i;
const pinEnd = endElementTop - containerHeight / 2;
const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);
const targetScale = props.baseScale + i * props.itemScale;
const scale = 1 - scaleProgress * (1 - targetScale);
const rotation = props.rotationAmount ? i * props.rotationAmount * scaleProgress : 0;
let blur = 0;
if (props.blurAmount) {
let topCardIndex = 0;
for (let j = 0; j < cardsRef.value.length; j++) {
const jCardTop = getElementOffset(cardsRef.value[j]);
const jTriggerStart = jCardTop - stackPositionPx - props.itemStackDistance * j;
if (scrollTop >= jTriggerStart) {
topCardIndex = j;
}
}
if (i < topCardIndex) {
const depthInStack = topCardIndex - i;
blur = Math.max(0, depthInStack * props.blurAmount);
}
}
let translateY = 0;
const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd;
if (isPinned) {
translateY = scrollTop - cardTop + stackPositionPx + props.itemStackDistance * i;
} else if (scrollTop > pinEnd) {
translateY = pinEnd - cardTop + stackPositionPx + props.itemStackDistance * i;
}
const newTransform = {
translateY: Math.round(translateY * 100) / 100,
scale: Math.round(scale * 1000) / 1000,
rotation: Math.round(rotation * 100) / 100,
blur: Math.round(blur * 100) / 100
};
const lastTransform = lastTransformsRef.value.get(i);
const hasChanged =
!lastTransform ||
Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||
Math.abs(lastTransform.scale - newTransform.scale) > 0.001 ||
Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 ||
Math.abs(lastTransform.blur - newTransform.blur) > 0.1;
if (hasChanged) {
const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`;
const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : '';
card.style.transform = transform;
card.style.filter = filter;
lastTransformsRef.value.set(i, newTransform);
}
if (i === cardsRef.value.length - 1) {
const isInView = scrollTop >= pinStart && scrollTop <= pinEnd;
if (isInView && !stackCompletedRef.value) {
stackCompletedRef.value = true;
props.onStackComplete?.();
} else if (!isInView && stackCompletedRef.value) {
stackCompletedRef.value = false;
}
}
});
isUpdatingRef.value = false;
};
const handleScroll = () => updateCardTransforms();
const setupLenis = () => {
if (props.useWindowScroll) {
const lenis = new Lenis({
duration: 1.2,
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
touchMultiplier: 2,
infinite: false,
wheelMultiplier: 1,
lerp: 0.1,
syncTouch: true,
syncTouchLerp: 0.075
});
lenis.on('scroll', handleScroll);
const raf = (time: number) => {
lenis.raf(time);
animationFrameRef.value = requestAnimationFrame(raf);
};
animationFrameRef.value = requestAnimationFrame(raf);
lenisRef.value = lenis;
return lenis;
} else {
const scroller = scrollerRef.value;
if (!scroller) return;
const lenis = new Lenis({
wrapper: scroller,
content: scroller.querySelector('.scroll-stack-inner') as HTMLElement,
duration: 1.2,
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
touchMultiplier: 2,
infinite: false,
gestureOrientation: 'vertical',
wheelMultiplier: 1,
lerp: 0.1,
syncTouch: true,
syncTouchLerp: 0.075
});
lenis.on('scroll', handleScroll);
const raf = (time: number) => {
lenis.raf(time);
animationFrameRef.value = requestAnimationFrame(raf);
};
animationFrameRef.value = requestAnimationFrame(raf);
lenisRef.value = lenis;
return lenis;
}
};
let cleanup: (() => void) | null = null;
const setup = () => {
const scroller = scrollerRef.value;
if (!props.useWindowScroll && !scroller) return;
const cards = Array.from(
props.useWindowScroll
? document.querySelectorAll('.scroll-stack-card')
: scroller!.querySelectorAll('.scroll-stack-card')
) as HTMLElement[];
cardsRef.value = cards;
const transformsCache = lastTransformsRef.value;
cards.forEach((card, i) => {
if (i < cards.length - 1) {
card.style.marginBottom = `${props.itemDistance}px`;
}
card.style.willChange = 'transform, filter';
card.style.transformOrigin = 'top center';
card.style.backfaceVisibility = 'hidden';
card.style.transform = 'translateZ(0)';
(card.style as CSSStyleDeclaration & { webkitTransform: string }).webkitTransform = 'translateZ(0)';
card.style.perspective = '1000px';
(card.style as CSSStyleDeclaration & { webkitPerspective: string }).webkitPerspective = '1000px';
});
setupLenis();
updateCardTransforms();
cleanup = () => {
if (animationFrameRef.value) cancelAnimationFrame(animationFrameRef.value);
if (lenisRef.value) lenisRef.value.destroy();
stackCompletedRef.value = false;
cardsRef.value = [];
transformsCache.clear();
isUpdatingRef.value = false;
};
};
onMounted(async () => {
await nextTick();
setup();
});
onBeforeUnmount(() => {
cleanup?.();
});
watch(
() => ({ ...props }),
() => {
cleanup?.();
setup();
},
{ deep: true }
);
</script>
<script lang="ts">
import { defineComponent, h } from 'vue';
export const ScrollStackItem = defineComponent({
name: 'ScrollStackItem',
props: {
itemClassName: {
type: String,
default: ''
}
},
setup(props, { slots }) {
return () =>
h(
'div',
{
class:
`scroll-stack-card relative w-full h-80 my-8 p-12 rounded-[40px] shadow-[0_0_30px_rgba(0,0,0,0.1)] box-border origin-top will-change-transform ${props.itemClassName}`.trim(),
style: {
backfaceVisibility: 'hidden',
transformStyle: 'preserve-3d'
}
},
slots.default?.()
);
}
});
</script>
<template>
<div
v-if="!useWindowScroll"
ref="scrollerRef"
:class="['relative w-full h-full overflow-y-auto overflow-x-visible', className]"
:style="{
overscrollBehavior: 'contain',
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
WebkitTransform: 'translateZ(0)',
transform: 'translateZ(0)',
willChange: 'scroll-position'
}"
>
<div class="px-20 pt-[20vh] pb-[50rem] min-h-screen scroll-stack-inner">
<slot />
<div class="w-full h-px scroll-stack-end" />
</div>
</div>
<div v-else ref="scrollerRef" :class="['scroll-stack-window-wrapper', className]">
<div class="scroll-stack-inner">
<slot />
<div class="w-full h-px scroll-stack-end" />
</div>
</div>
</template>

View File

@@ -0,0 +1,273 @@
<template>
<div ref="containerRef" :class="className" :style="style" class="w-full h-full"></div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, watch, type CSSProperties, useTemplateRef } from 'vue';
import { Renderer, Program, Mesh, Plane, Camera } from 'ogl';
interface SilkProps {
speed?: number;
scale?: number;
color?: string;
noiseIntensity?: number;
rotation?: number;
className?: string;
style?: CSSProperties;
}
const props = withDefaults(defineProps<SilkProps>(), {
speed: 5,
scale: 1,
color: '#7B7481',
noiseIntensity: 1.5,
rotation: 0,
className: '',
style: () => ({})
});
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
const hexToNormalizedRGB = (hex: string): [number, number, number] => {
const clean = hex.replace('#', '');
const r = parseInt(clean.slice(0, 2), 16) / 255;
const g = parseInt(clean.slice(2, 4), 16) / 255;
const b = parseInt(clean.slice(4, 6), 16) / 255;
return [r, g, b];
};
const vertexShader = `
attribute vec2 uv;
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vPosition = position;
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
precision highp float;
varying vec2 vUv;
varying vec3 vPosition;
uniform float uTime;
uniform vec3 uColor;
uniform float uSpeed;
uniform float uScale;
uniform float uRotation;
uniform float uNoiseIntensity;
const float e = 2.71828182845904523536;
float noise(vec2 texCoord) {
float G = e;
vec2 r = (G * sin(G * texCoord));
return fract(r.x * r.y * (1.0 + texCoord.x));
}
vec2 rotateUvs(vec2 uv, float angle) {
float c = cos(angle);
float s = sin(angle);
mat2 rot = mat2(c, -s, s, c);
return rot * uv;
}
void main() {
float rnd = noise(gl_FragCoord.xy);
vec2 uv = rotateUvs(vUv * uScale, uRotation);
vec2 tex = uv * uScale;
float tOffset = uSpeed * uTime;
tex.y += 0.03 * sin(8.0 * tex.x - tOffset);
float pattern = 0.6 +
0.4 * sin(5.0 * (tex.x + tex.y +
cos(3.0 * tex.x + 5.0 * tex.y) +
0.02 * tOffset) +
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
vec4 col = vec4(uColor, 1.0) * vec4(pattern) - rnd / 15.0 * uNoiseIntensity;
col.a = 1.0;
gl_FragColor = col;
}
`;
let renderer: Renderer | null = null;
let mesh: Mesh | null = null;
let program: Program | null = null;
let camera: Camera | null = null;
let animateId = 0;
const initSilk = () => {
const container = containerRef.value;
if (!container) return;
renderer = new Renderer({
alpha: true,
antialias: true
});
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
gl.canvas.style.backgroundColor = 'transparent';
camera = new Camera(gl, { fov: 75 });
camera.position.z = 1;
const resize = () => {
if (!container || !camera) return;
let width = container.offsetWidth;
let height = container.offsetHeight;
let parent = container.parentElement;
while (parent && (!width || !height)) {
if (parent.offsetWidth && parent.offsetHeight) {
width = parent.offsetWidth;
height = parent.offsetHeight;
break;
}
parent = parent.parentElement;
}
if (!width || !height) {
width = window.innerWidth;
height = window.innerHeight;
}
width = Math.max(width, 300);
height = Math.max(height, 300);
renderer!.setSize(width, height);
camera.perspective({ aspect: width / height });
if (mesh) {
const distance = camera.position.z;
const fov = camera.fov * (Math.PI / 180);
const height2 = 2 * Math.tan(fov / 2) * distance;
const width2 = height2 * (width / height);
mesh.scale.set(width2, height2, 1);
}
};
window.addEventListener('resize', resize);
const geometry = new Plane(gl, {
width: 1,
height: 1
});
const colorRGB = hexToNormalizedRGB(props.color);
program = new Program(gl, {
vertex: vertexShader,
fragment: fragmentShader,
uniforms: {
uSpeed: { value: props.speed },
uScale: { value: props.scale },
uNoiseIntensity: { value: props.noiseIntensity },
uColor: { value: colorRGB },
uRotation: { value: props.rotation },
uTime: { value: 0 }
}
});
mesh = new Mesh(gl, { geometry, program });
container.appendChild(gl.canvas);
gl.canvas.style.width = '100%';
gl.canvas.style.height = '100%';
gl.canvas.style.display = 'block';
gl.canvas.style.position = 'absolute';
gl.canvas.style.top = '0';
gl.canvas.style.left = '0';
gl.canvas.style.zIndex = '1';
let lastTime = 0;
const update = (t: number) => {
animateId = requestAnimationFrame(update);
const deltaTime = (t - lastTime) / 1000;
lastTime = t;
if (program && mesh && camera) {
program.uniforms.uTime.value += 0.1 * deltaTime;
program.uniforms.uSpeed.value = props.speed;
program.uniforms.uScale.value = props.scale;
program.uniforms.uNoiseIntensity.value = props.noiseIntensity;
program.uniforms.uColor.value = hexToNormalizedRGB(props.color);
program.uniforms.uRotation.value = props.rotation;
renderer!.render({ scene: mesh, camera });
}
};
animateId = requestAnimationFrame(update);
resize();
return () => {
cancelAnimationFrame(animateId);
window.removeEventListener('resize', resize);
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas);
}
gl.getExtension('WEBGL_lose_context')?.loseContext();
};
};
const cleanup = () => {
if (animateId) {
cancelAnimationFrame(animateId);
}
if (renderer) {
const gl = renderer.gl;
const container = containerRef.value;
if (container && gl.canvas.parentNode === container) {
container.removeChild(gl.canvas);
}
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
renderer = null;
mesh = null;
camera = null;
program = null;
};
onMounted(() => {
initSilk();
});
onUnmounted(() => {
cleanup();
});
watch(
() => [props.speed, props.scale, props.color, props.noiseIntensity, props.rotation],
() => {}
);
</script>
<style scoped>
div {
width: 100% !important;
height: 100% !important;
min-height: 100% !important;
display: block !important;
}
:deep(canvas) {
width: 100% !important;
height: 100% !important;
min-height: 100% !important;
display: block !important;
object-fit: cover !important;
}
</style>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
type SplitType = 'chars' | 'words' | 'lines' | 'words, chars';
type TagName = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
type EaseFn = (t: number) => number;
interface SplitTextElement extends HTMLElement {
_rbsplitInstance?: GSAPSplitText;
}
interface Props {
text: string;
className?: string;
delay?: number;
duration?: number;
ease?: string | EaseFn;
splitType?: SplitType;
from?: gsap.TweenVars;
to?: gsap.TweenVars;
threshold?: number;
rootMargin?: string;
tag?: TagName;
textAlign?: CSSProperties['textAlign'];
onLetterAnimationComplete?: () => void;
}
const props = withDefaults(defineProps<Props>(), {
className: '',
delay: 50,
duration: 1.25,
ease: 'power3.out',
splitType: 'chars',
from: () => ({ opacity: 0, y: 40 }),
to: () => ({ opacity: 1, y: 0 }),
threshold: 0.1,
rootMargin: '-100px',
tag: 'p',
textAlign: 'center',
onLetterAnimationComplete: undefined
});
// Emit so the parent can use @animation-complete in template
const emit = defineEmits<{
'animation-complete': [];
}>();
const elRef = ref<SplitTextElement | null>(null);
const fontsLoaded = ref(false);
const animationCompleted = ref(false);
// Stable callback ref so the animation closure never captures a stale prop value
const onCompleteRef = ref(props.onLetterAnimationComplete);
watch(
() => props.onLetterAnimationComplete,
cb => {
onCompleteRef.value = cb;
}
);
const scrollTriggerStart = computed(() => {
const startPct = (1 - props.threshold) * 100;
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin || '');
const mv = mm ? parseFloat(mm[1]) : 0;
const mu = mm ? mm[2] || 'px' : 'px';
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
return `top ${startPct}%${sign}`;
});
const elStyle = computed<CSSProperties>(() => ({
textAlign: props.textAlign,
wordWrap: 'break-word',
willChange: 'transform, opacity'
}));
// className prop is placed last so user styles always win over base styles
const elClass = computed(() =>
['split-parent overflow-hidden inline-block whitespace-normal', props.className].filter(Boolean).join(' ')
);
function assignTargets(self: GSAPSplitText): Element[] {
if (props.splitType.includes('chars') && self.chars?.length) return self.chars;
if (props.splitType.includes('words') && self.words?.length) return self.words;
if (props.splitType.includes('lines') && self.lines?.length) return self.lines;
return self.chars ?? self.words ?? self.lines ?? [];
}
function destroyInstance(): void {
const el = elRef.value;
if (!el) return;
ScrollTrigger.getAll().forEach(st => {
if (st.trigger === el) st.kill();
});
try {
el._rbsplitInstance?.revert();
} catch {
// ignore
}
el._rbsplitInstance = undefined;
}
function initAnimation(): void {
const el = elRef.value;
if (!el || !props.text || !fontsLoaded.value) return;
if (animationCompleted.value) return;
destroyInstance();
const start = scrollTriggerStart.value;
const splitInstance = new GSAPSplitText(el, {
type: props.splitType,
smartWrap: true,
autoSplit: props.splitType === 'lines',
linesClass: 'split-line mt-3',
wordsClass: 'split-word',
charsClass: 'split-char',
reduceWhiteSpace: false,
onSplit: (self: GSAPSplitText) => {
const targets = assignTargets(self);
return gsap.fromTo(
targets,
{ ...props.from },
{
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
scrollTrigger: {
trigger: el,
start,
once: true,
fastScrollEnd: true,
anticipatePin: 0.4
},
onComplete: () => {
animationCompleted.value = true;
onCompleteRef.value?.();
emit('animation-complete');
},
willChange: 'transform, opacity',
force3D: true
}
);
}
});
el._rbsplitInstance = splitInstance;
}
onMounted(() => {
if (document.fonts.status === 'loaded') {
fontsLoaded.value = true;
} else {
document.fonts.ready.then(() => {
fontsLoaded.value = true;
});
}
});
watch(fontsLoaded, loaded => {
if (loaded) initAnimation();
});
watch(
() => [
props.text,
props.delay,
props.duration,
props.ease,
props.splitType,
JSON.stringify(props.from),
JSON.stringify(props.to),
props.threshold,
props.rootMargin,
fontsLoaded.value
],
() => {
if (!fontsLoaded.value) return;
animationCompleted.value = false;
initAnimation();
}
);
onBeforeUnmount(() => {
destroyInstance();
});
</script>
<template>
<component :is="tag" ref="elRef" :class="elClass" :style="elStyle">{{ text }}</component>
</template>

View File

@@ -1,32 +1,42 @@
<script setup lang="ts">
<script setup>
import { computed } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import SiteHeader from '@/components/SiteHeader.vue'
import ThemeBackdrop from '@/components/ThemeBackdrop.vue'
import { siteConfig } from '@/site.config'
const route = useRoute()
const backdropClass = computed(() => route.path.startsWith('/product') ? 'product-route-backdrop' : 'home-route-backdrop')
</script>
<template>
<header class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-4 px-6 flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- Logo -->
<img src="https://via.placeholder.com/40" alt="Logo" class="h-8 w-8 rounded-full">
<span class="font-bold">My App</span>
</div>
<div class="min-h-screen bg-app text-app">
<SiteHeader />
<!-- Menu -->
<nav class="hidden md:flex space-x-4">
<a href="#" class="hover:text-gray-300">首页</a>
<a href="#" class="hover:text-gray-300">About</a>
<a href="#" class="hover:text-gray-300">Services</a>
<a href="#" class="hover:text-gray-300">Contact</a>
<ThemeBackdrop :class="backdropClass">
<RouterView />
<footer class="site-footer">
<div class="site-footer-inner">
<div class="site-footer-brand">
<strong>{{ siteConfig.brand.name }}</strong>
<span>© 2026 萧康云医科技有限公司 版权所有</span>
</div>
<nav aria-label="底部导航">
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/product">产品</RouterLink>
<RouterLink to="/#business">业务介绍</RouterLink>
<RouterLink to="/product#pricing">定价计划</RouterLink>
<RouterLink to="/product#contact">联系咨询</RouterLink>
</nav>
<!-- User Profile -->
<div class="flex items-center space-x-4">
<button class="text-sm bg-gray-800 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
Personal Center
</button>
<img src="https://via.placeholder.com/40" alt="User Avatar" class="h-8 w-8 rounded-full cursor-pointer">
<div class="site-footer-records">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">沪ICP备2026009618号-1</a>
<span>站点地图</span>
<span>隐私政策</span>
<span>服务条款</span>
</div>
</div>
</footer>
</ThemeBackdrop>
</div>
</header>
</template>
<style lang="scss" scoped>
</style>

View File

@@ -2,14 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import MainLayout from '@/layouts/Header.vue'
// 在路由配置中添加个人中心路由
const routes = [
{
path: '/',
component: MainLayout,
children: [
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
]
{ path: 'product', name: 'Product', component: () => import('@/views/product/index.vue') },
],
},
]
@@ -17,11 +17,13 @@ const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
return savedPosition || { top: 0 }
}
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, top: 96, behavior: 'smooth' }
return { top: 0 }
},
})
router.beforeEach((to, from) => {
router.beforeEach((to) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.user) {
return { name: 'Login', query: { redirect: to.fullPath } }
@@ -29,10 +31,3 @@ router.beforeEach((to, from) => {
})
export default router
router.beforeEach((to, from) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.user) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})

225
src/site.config.js Normal file
View File

@@ -0,0 +1,225 @@
export const siteConfig = {
brand: {
name: '萧康云医',
tagline: '让县域医疗拥有云端协同能力',
description: '面向医疗集团、医共体、互联网医院与连锁诊所的一体化智慧医疗 SaaS 平台。'
},
navigation: {
primary: [
{ label: '首页', to: '/' },
{ label: '产品', to: '/product' }
],
secondary: {
home: [
{ label: '业务介绍', to: '/#business' },
{ label: '产品简介', to: '/#intro' },
{ label: '客户案例', to: '/#cases' },
{ label: '联系咨询', to: '/#contact' }
],
product: [
{ label: '功能说明', to: '/product#features' },
{ label: '演示视频', to: '/product#demo' },
{ label: '定价计划', to: '/product#pricing' },
{ label: 'FAQ', to: '/product#faq' },
{ label: '联系方式', to: '/product#contact' }
]
}
},
hero: {
mode: 'video',
video: {
src: 'https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260406_094145_4a271a6c-3869-4f1c-8aa7-aeb0cb227994.mp4',
poster: 'https://images.unsplash.com/photo-1576091160550-2173dba999ef?auto=format&fit=crop&w=2200&q=85'
}
},
contact: {
phone: '400-820-9618',
email: 'hello@xiaokangcloudmed.cn',
address: '上海市浦东新区张江医疗创新园'
}
}
export const metrics = [
{ value: '1200+', label: '接入医疗机构' },
{ value: '38%', label: '随访效率提升' },
{ value: '99.95%', label: '服务可用性' },
{ value: '7x24', label: '医疗级运维响应' }
]
export const homeMarqueeItems = [
{
title: '区域会诊指挥台',
meta: 'Remote Care',
image: 'https://images.unsplash.com/photo-1581093458791-9d15482442f6?auto=format&fit=crop&w=900&q=82'
},
{
title: '患者主索引',
meta: 'Cloud EMR',
image: 'https://images.unsplash.com/photo-1559757148-5c350d0d3c56?auto=format&fit=crop&w=900&q=82'
},
{
title: '慢病风险雷达',
meta: 'AI Alert',
image: 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?auto=format&fit=crop&w=900&q=82'
},
{
title: '移动随访工作流',
meta: 'Follow-up',
image: 'https://images.unsplash.com/photo-1576671081837-49000212a370?auto=format&fit=crop&w=900&q=82'
},
{
title: '质控运营驾驶舱',
meta: 'Operations',
image: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=900&q=82'
},
{
title: '院外服务触达',
meta: 'Patient App',
image: 'https://images.unsplash.com/photo-1579684385127-1ef15d508118?auto=format&fit=crop&w=900&q=82'
}
]
export const homeServiceItems = [
{
number: '01',
name: '云诊疗协同',
description: '把转诊、会诊、检查互认和医嘱流转放进同一套跨机构任务流。'
},
{
number: '02',
name: '慢病连续管理',
description: '围绕高血压、糖尿病、康复等场景建立院内院外一体化随访闭环。'
},
{
number: '03',
name: 'AI 风险预警',
description: '结合规则和评分模型,帮助医生优先处理真正需要关注的患者。'
},
{
number: '04',
name: '合规权限治理',
description: '按机构、科室、角色和数据类型配置访问边界,关键操作留痕可审计。'
},
{
number: '05',
name: '运营数据分析',
description: '将服务效率、医疗质量和经营指标沉淀为管理层可复盘的视图。'
}
]
export const productFeatures = [
{
title: '云端患者主索引',
body: '跨机构合并患者身份、就诊记录、检查检验和随访数据,形成连续健康画像。'
},
{
title: 'AI 辅助分诊与预警',
body: '基于规则引擎和模型评分识别高风险患者,优先推送给责任医生处理。'
},
{
title: '医患协同工作台',
body: '医生端、护士端、患者端统一任务流,支持在线复诊、处方流转和宣教触达。'
},
{
title: '合规与权限治理',
body: '按机构、科室、角色和数据类型配置访问边界,满足医疗数据安全要求。'
}
]
export const caseStudies = [
{
client: '华东县域医共体',
result: '6 个月完成 21 家基层机构协同上线,转诊平均等待时间下降 31%。',
quote: '萧康云医让基层医生能实时看到患者全周期信息,协同不再依赖电话和表格。'
},
{
client: '连锁康复诊所集团',
result: '慢病随访自动化后,护士团队日均处理量提升 44%。',
quote: '它把复诊、随访、宣教和经营数据放到同一套流程里,管理颗粒度明显变细。'
},
{
client: '省级互联网医院项目',
result: '支撑高峰期 18 万次在线问诊请求,服务稳定性保持 99.95%。',
quote: '平台架构足够稳,产品团队也懂医疗现场,这是我们选择它的关键。'
}
]
export const homeCaseCards = [
{
number: '01',
category: '医共体',
name: '县域协同平台',
summary: '21 家基层机构完成统一协同上线,转诊等待时间下降 31%。',
images: [
'https://images.unsplash.com/photo-1584982751601-97dcc096659c?auto=format&fit=crop&w=900&q=82',
'https://images.unsplash.com/photo-1586773860418-d37222d8fce3?auto=format&fit=crop&w=900&q=82',
'https://images.unsplash.com/photo-1579154204601-01588f351e67?auto=format&fit=crop&w=1200&q=82'
]
},
{
number: '02',
category: '连锁诊所',
name: '慢病随访中心',
summary: '护士团队日均处理量提升 44%,患者院外触达更稳定。',
images: [
'https://images.unsplash.com/photo-1571772996211-2f02c9727629?auto=format&fit=crop&w=900&q=82',
'https://images.unsplash.com/photo-1584515933487-779824d29309?auto=format&fit=crop&w=900&q=82',
'https://images.unsplash.com/photo-1563986768609-322da13575f3?auto=format&fit=crop&w=1200&q=82'
]
},
{
number: '03',
category: '互联网医院',
name: '线上诊疗中台',
summary: '高峰期支撑 18 万次在线问诊请求,服务可用性保持 99.95%。',
images: [
'https://images.unsplash.com/photo-1581056771107-24ca5f033842?auto=format&fit=crop&w=900&q=82',
'https://images.unsplash.com/photo-1583912267550-d44c6c1cf30f?auto=format&fit=crop&w=900&q=82',
'https://images.unsplash.com/photo-1551434678-e076c223a692?auto=format&fit=crop&w=1200&q=82'
]
}
]
export const pricingPlans = [
{
name: '标准版',
price: '¥6,800',
unit: '机构 / 月',
description: '适合单体医院、门诊部或成长型诊所。',
features: ['患者档案云管理', '在线复诊与随访', '基础运营报表', '标准接口支持']
},
{
name: '专业版',
price: '¥18,000',
unit: '医疗集团 / 月',
description: '适合多院区协同、连锁机构与医共体。',
features: ['区域协同工作台', 'AI 风险预警', '高级权限治理', '专属实施顾问'],
highlighted: true
},
{
name: '旗舰版',
price: '定制',
unit: '私有化 / 混合云',
description: '适合大型医疗集团、政府平台与高合规场景。',
features: ['私有化部署', '数据中台集成', '定制模型与流程', 'SLA 与驻场支持']
}
]
export const faqs = [
{
q: '萧康云医适合哪些机构?',
a: '适合医疗集团、医共体、基层医院、互联网医院、连锁诊所和需要长期患者管理的专科机构。'
},
{
q: '能否接入现有 HIS、EMR 或支付系统?',
a: '可以。平台提供标准 API 与实施接口规范,常见 HIS、EMR、LIS、PACS、医保和支付系统都可对接。'
},
{
q: '数据安全如何保障?',
a: '平台支持分级权限、操作审计、传输加密、备份策略和私有化部署方案,可按项目合规要求配置。'
},
{
q: '上线周期通常多久?',
a: '标准版通常 2-4 周完成上线;多机构协同项目会根据接口数量、流程复杂度和培训范围排期。'
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,321 @@
<script setup lang="ts">
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowLeft, ArrowRight, Calendar, CheckCircle2, Clock3, Play, ShieldCheck, Sparkles, Star } from 'lucide-vue-next'
import { homeCaseCards, homeMarqueeItems, homeServiceItems, metrics, siteConfig } from '@/site.config'
import AnimatedContent from '@/components/vue-bits/AnimatedContent.vue'
import DecryptedText from '@/components/vue-bits/DecryptedText.vue'
import Magnet from '@/components/vue-bits/Magnet.vue'
import Silk from '@/components/vue-bits/Silk.vue'
import SplitText from '@/components/vue-bits/SplitText.vue'
const aboutRef = ref(null)
const aboutProgress = ref(0)
const aboutSegments = [
'萧康云医把患者、医生、机构和运营管理连接成同一个实时协作系统。',
'我们服务医共体、医疗集团、互联网医院与连锁诊所,',
'让复杂的医疗流程在云端变得更轻、更稳、更可信。'
]
const marqueeRowOne = computed(() => homeMarqueeItems.slice(0, 3))
const marqueeRowTwo = computed(() => homeMarqueeItems.slice(3))
const repeatedRowOne = computed(() => [...marqueeRowOne.value, ...marqueeRowOne.value, ...marqueeRowOne.value])
const repeatedRowTwo = computed(() => [...marqueeRowTwo.value, ...marqueeRowTwo.value, ...marqueeRowTwo.value])
let ticking = false
let reduceMotion = false
function updateScrollMotion() {
ticking = false
if (reduceMotion) return
if (aboutRef.value) {
const rect = aboutRef.value.getBoundingClientRect()
const distance = window.innerHeight * 0.78 - rect.top
const range = rect.height + window.innerHeight * 0.2
aboutProgress.value = Math.min(1, Math.max(0, distance / range))
}
}
function requestScrollUpdate() {
if (ticking) return
ticking = true
window.requestAnimationFrame(updateScrollMotion)
}
function segmentOpacity(index) {
if (reduceMotion) return 1
const start = index / aboutSegments.length
const progress = (aboutProgress.value - start) * 2.6
return Math.min(1, Math.max(0.5, progress))
}
onMounted(() => {
reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
updateScrollMotion()
window.addEventListener('scroll', requestScrollUpdate, { passive: true })
window.addEventListener('resize', requestScrollUpdate)
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', requestScrollUpdate)
window.removeEventListener('resize', requestScrollUpdate)
})
</script>
<template>
你好
<main class="home-page">
<section class="cinematic-hero">
<video
class="cinematic-video"
autoplay
muted
loop
playsinline
:poster="siteConfig.hero.video.poster"
>
<source :src="siteConfig.hero.video.src" type="video/mp4">
</video>
<div class="hero-silk-layer" aria-hidden="true">
<Silk color="#14B8A6" :speed="2.6" :scale="1.2" :noise-intensity="0.78" :rotation="0.16" />
</div>
<div class="bottom-blur-overlay" aria-hidden="true"></div>
<div class="cinematic-content">
<div class="hero-copy">
<div class="hero-meta animate-blur-fade-up" style="animation-delay: 300ms">
<span>
<Star :size="16" fill="currentColor" aria-hidden="true" />
99.95% SLA
</span>
<span>
<Clock3 :size="16" aria-hidden="true" />
7x24 医疗级运维
</span>
<span>
<Calendar :size="16" aria-hidden="true" />
2026 云医协同平台
</span>
</div>
<SplitText
:text="siteConfig.brand.name"
tag="h1"
class-name="cinematic-title"
split-type="chars"
:delay="80"
:duration="1.15"
ease="power4.out"
:threshold="0.05"
root-margin="0px"
:from="{ opacity: 0, y: 70, filter: 'blur(18px)', rotateX: -22 }"
:to="{ opacity: 1, y: 0, filter: 'blur(0px)', rotateX: 0 }"
text-align="left"
/>
<p class="cinematic-subtitle animate-blur-fade-up" style="animation-delay: 500ms">
<DecryptedText
text="Step Through. Work Smarter."
animate-on="view"
reveal-direction="center"
:speed="34"
:max-iterations="12"
encrypted-class-name="decrypted-ghost"
class-name="decrypted-final"
/>
<span> 让区域诊疗慢病随访远程会诊和运营治理在云端自然协作</span>
</p>
<div class="hero-actions">
<Magnet :padding="80" :magnet-strength="5" wrapper-class-name="animate-blur-fade-up" style="animation-delay: 600ms">
<RouterLink to="/product#contact" class="watch-button">
<Play :size="18" fill="currentColor" aria-hidden="true" />
免费试用
</RouterLink>
</Magnet>
<Magnet :padding="72" :magnet-strength="5.5" wrapper-class-name="animate-blur-fade-up" style="animation-delay: 700ms">
<RouterLink to="/product#demo" class="learn-button liquid-glass">
观看演示
</RouterLink>
</Magnet>
</div>
</div>
<div class="hero-arrows">
<Magnet :padding="64" :magnet-strength="6" wrapper-class-name="animate-blur-fade-up" style="animation-delay: 800ms">
<RouterLink to="/#business" class="arrow-pill liquid-glass">
<ArrowLeft :size="18" aria-hidden="true" />
业务介绍
</RouterLink>
</Magnet>
<Magnet :padding="64" :magnet-strength="6" wrapper-class-name="animate-blur-fade-up" style="animation-delay: 900ms">
<RouterLink to="/product" class="arrow-pill liquid-glass">
产品页
<ArrowRight :size="18" aria-hidden="true" />
</RouterLink>
</Magnet>
</div>
</div>
</section>
<section id="business" class="home-marquee-section">
<AnimatedContent class-name="marquee-heading" :distance="44" :duration="0.9" ease="power3.out">
<span>Business Flow</span>
<h2 class="mega-heading home-heading-cn">云端医疗协同现场</h2>
</AnimatedContent>
<div class="marquee-rows" aria-label="萧康云医业务场景">
<div class="marquee-track marquee-track-forward">
<article v-for="(item, index) in repeatedRowOne" :key="`${item.title}-top-${index}`" class="marquee-card glare-card">
<img :src="item.image" :alt="item.title" loading="lazy">
<div>
<span>{{ item.meta }}</span>
<strong>{{ item.title }}</strong>
</div>
</article>
</div>
<div class="marquee-track marquee-track-reverse">
<article v-for="(item, index) in repeatedRowTwo" :key="`${item.title}-bottom-${index}`" class="marquee-card glare-card">
<img :src="item.image" :alt="item.title" loading="lazy">
<div>
<span>{{ item.meta }}</span>
<strong>{{ item.title }}</strong>
</div>
</article>
</div>
</div>
</section>
<section id="intro" ref="aboutRef" class="creator-about-section">
<div class="floating-medical-chip chip-a">
<Sparkles :size="20" aria-hidden="true" />
AI Alert
</div>
<div class="floating-medical-chip chip-b">
<ShieldCheck :size="20" aria-hidden="true" />
Data Safe
</div>
<div class="floating-medical-chip chip-c">
<CheckCircle2 :size="20" aria-hidden="true" />
Follow-up
</div>
<div class="floating-medical-chip chip-d">
<Star :size="20" aria-hidden="true" />
99.95%
</div>
<AnimatedContent class-name="about-inner" :distance="52" :duration="1" ease="power4.out">
<SplitText
text="关于萧康云医"
tag="h2"
class-name="mega-heading home-heading-cn"
split-type="lines"
:delay="80"
:duration="1"
ease="power3.out"
:from="{ opacity: 0, y: 34, filter: 'blur(12px)' }"
:to="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
/>
<p class="animated-copy" aria-label="萧康云医介绍">
<span
v-for="(segment, index) in aboutSegments"
:key="segment"
class="about-segment"
:style="{ opacity: segmentOpacity(index) }"
>{{ segment }}</span>
</p>
<Magnet :padding="90" :magnet-strength="5">
<RouterLink to="/product#contact" class="creator-contact-button">联系顾问</RouterLink>
</Magnet>
</AnimatedContent>
</section>
<section class="creator-services-section">
<SplitText
text="Services"
tag="h2"
class-name="services-heading"
split-type="chars"
:delay="42"
:duration="0.9"
ease="power3.out"
:from="{ opacity: 0, y: 46, filter: 'blur(12px)' }"
:to="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
/>
<div class="services-list">
<AnimatedContent
v-for="(item, index) in homeServiceItems"
:key="item.number"
class-name="service-reveal"
:distance="56"
direction="horizontal"
:reverse="index % 2 === 1"
:delay="index * 0.08"
:duration="0.85"
>
<article class="service-item">
<span>{{ item.number }}</span>
<div>
<h3>{{ item.name }}</h3>
<p>{{ item.description }}</p>
</div>
</article>
</AnimatedContent>
</div>
</section>
<section id="cases" class="creator-projects-section">
<AnimatedContent class-name="case-section-heading" :distance="48" :duration="0.9">
<span>Case Study</span>
<h2 class="mega-heading home-heading-cn">客户案例</h2>
</AnimatedContent>
<div class="stable-case-list">
<article
v-for="(item, index) in homeCaseCards"
:key="item.name"
class="stable-case-card"
:style="{ top: `${104 + index * 18}px` }"
>
<div class="case-card-head">
<span>{{ item.number }}</span>
<div class="case-card-copy">
<small>{{ item.category }}</small>
<h3>{{ item.name }}</h3>
<p>{{ item.summary }}</p>
</div>
<Magnet :padding="64" :magnet-strength="6" wrapper-class-name="case-card-action">
<RouterLink to="/product#contact" class="live-project-button">预约演示</RouterLink>
</Magnet>
</div>
<div class="case-image-grid">
<div>
<img :src="item.images[0]" :alt="`${item.name} 业务图一`" loading="lazy">
<img :src="item.images[1]" :alt="`${item.name} 业务图二`" loading="lazy">
</div>
<img :src="item.images[2]" :alt="`${item.name} 场景图`" loading="lazy">
</div>
</article>
</div>
</section>
<section id="contact" class="home-contact-section">
<AnimatedContent class-name="home-contact-copy" :distance="50" :duration="0.9">
<span>Contact</span>
<h2 class="mega-heading home-heading-cn">让医疗协同真正跑起来</h2>
<p>告诉我们你的机构规模系统现状和重点场景萧康云医团队会给出一份可落地的演示方案</p>
<Magnet :padding="90" :magnet-strength="5" wrapper-class-name="contact-action">
<RouterLink to="/product#contact" class="creator-contact-button">立即咨询</RouterLink>
</Magnet>
</AnimatedContent>
<AnimatedContent class-name="contact-metrics" :distance="50" direction="horizontal" :reverse="true" :duration="0.9">
<div v-for="item in metrics" :key="item.label">
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
</AnimatedContent>
</section>
</main>
</template>
<style scoped>
</style>

326
src/views/product/index.vue Normal file
View File

@@ -0,0 +1,326 @@
<script setup>
import { ref } from 'vue'
import { ArrowRight, BadgeCheck, Check, CircleDollarSign, ClipboardCheck, Headphones, MessageCircle, Play, ShieldCheck, Star, Video } from 'lucide-vue-next'
import { caseStudies, faqs, pricingPlans, productFeatures, siteConfig } from '@/site.config'
import AnimatedContent from '@/components/vue-bits/AnimatedContent.vue'
import DecryptedText from '@/components/vue-bits/DecryptedText.vue'
import Magnet from '@/components/vue-bits/Magnet.vue'
const openFaq = ref(0)
const users = [
'医疗集团信息化负责人',
'医共体运营管理者',
'慢病中心与专科门诊',
'互联网医院项目团队'
]
const reviews = [
{
name: '林主任',
role: '县域医共体信息中心',
text: '上线后跨院区协作终于有了统一任务流,基层医生和总院专家都能在同一视图处理患者。'
},
{
name: '周女士',
role: '连锁诊所运营负责人',
text: '随访、复诊和宣教不再分散在不同工具里,护士团队的工作量和服务质量都更可控。'
},
{
name: '赵医生',
role: '慢病中心负责人',
text: '风险预警和患者画像帮助我们更早发现异常,医生不再只靠经验筛选重点患者。'
}
]
</script>
<template>
<main class="product-page">
<section class="product-hero section-pad">
<div class="mx-auto grid max-w-7xl items-center gap-12 px-5 pt-24 md:grid-cols-[0.95fr_1.05fr] md:px-8">
<AnimatedContent :distance="64" :duration="0.95" class-name="product-hero-copy">
<div class="eyebrow">
<BadgeCheck :size="16" aria-hidden="true" />
企业级医疗 SaaS 产品
</div>
<h1 class="product-hero-title">
<span>{{ siteConfig.brand.name }}</span>
<span>云协同平台</span>
</h1>
<p class="product-hero-copytext">
<DecryptedText
text="一套连接患者、医生、机构和运营管理的智慧医疗工作台。"
animate-on="view"
reveal-direction="center"
:speed="32"
:max-iterations="10"
encrypted-class-name="decrypted-ghost"
class-name="decrypted-final"
/>
<span> 让医疗组织拥有更稳定更高效更合规的数字化服务能力</span>
</p>
<div class="mt-8 flex flex-col gap-3 sm:flex-row">
<Magnet :padding="72" :magnet-strength="5.5">
<a href="#contact" class="btn-primary">
免费试用
<ArrowRight :size="18" aria-hidden="true" />
</a>
</Magnet>
<Magnet :padding="72" :magnet-strength="5.5">
<a href="#demo" class="btn-secondary">查看演示视频</a>
</Magnet>
</div>
</AnimatedContent>
<AnimatedContent :distance="72" direction="horizontal" :reverse="true" :duration="0.95" class-name="product-snapshot-shell">
<div class="product-snapshot">
<div class="snapshot-header">
<span>萧康云医工作台</span>
<strong>实时运行</strong>
</div>
<div class="snapshot-main">
<aside>
<span v-for="item in ['患者画像', '协同任务', '风险预警', '运营分析']" :key="item">{{ item }}</span>
</aside>
<div class="snapshot-content">
<div class="snapshot-alert">
<ShieldCheck :size="20" aria-hidden="true" />
<span>慢病高风险患者已自动分派给责任医生</span>
</div>
<div class="snapshot-cards">
<div>
<small>待处理随访</small>
<strong>246</strong>
</div>
<div>
<small>会诊申请</small>
<strong>38</strong>
</div>
<div>
<small>质控通过率</small>
<strong>97.8%</strong>
</div>
</div>
<div class="snapshot-bars" aria-hidden="true">
<span style="width: 76%"></span>
<span style="width: 92%"></span>
<span style="width: 58%"></span>
</div>
</div>
</div>
</div>
</AnimatedContent>
</div>
</section>
<section id="features" class="section-pad pt-8">
<div class="mx-auto max-w-7xl px-5 md:px-8">
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
<span>功能说明</span>
<h2>从诊疗协同到经营分析覆盖医疗服务闭环</h2>
</AnimatedContent>
<div class="mt-10 grid gap-5 md:grid-cols-2">
<AnimatedContent v-for="(feature, index) in productFeatures" :key="feature.title" :delay="index * 0.08" :distance="52" class-name="feature-reveal">
<article class="soft-card feature-row">
<ClipboardCheck :size="26" aria-hidden="true" />
<div>
<h3>{{ feature.title }}</h3>
<p>{{ feature.body }}</p>
</div>
</article>
</AnimatedContent>
</div>
<AnimatedContent class-name="target-users mt-8" :distance="38" :duration="0.75">
<span>目标用户</span>
<div>
<span v-for="user in users" :key="user">{{ user }}</span>
</div>
</AnimatedContent>
</div>
</section>
<section id="demo" class="section-band">
<div class="mx-auto grid max-w-7xl items-center gap-10 px-5 md:grid-cols-[1.08fr_0.92fr] md:px-8">
<AnimatedContent :distance="62" :duration="1" class-name="demo-frame-shell">
<div class="demo-frame">
<video
class="demo-video"
controls
:poster="siteConfig.hero.video.poster"
>
<source :src="siteConfig.hero.video.src" type="video/mp4">
</video>
<div class="demo-play" aria-hidden="true">
<Play :size="28" fill="currentColor" />
</div>
</div>
</AnimatedContent>
<AnimatedContent :distance="56" direction="horizontal" :reverse="true" :duration="0.95" class-name="demo-copy-shell">
<div>
<div class="section-heading align-left">
<span>演示视频</span>
<h2> 90 秒看清医生护士和管理者如何协同</h2>
</div>
<p class="mt-5 text-lg leading-8 text-muted">
演示覆盖患者建档风险预警复诊任务随访触达和运营看板视频源可在配置文件中替换为企业真实素材
</p>
<div class="mt-6 grid gap-3">
<div class="check-line">
<Video :size="18" aria-hidden="true" />
支持产品界面录屏品牌宣传片或客户案例视频
</div>
<div class="check-line">
<Check :size="18" aria-hidden="true" />
首页同样支持动效背景与视频背景两种配置
</div>
</div>
</div>
</AnimatedContent>
</div>
</section>
<section class="section-pad">
<div class="mx-auto max-w-7xl px-5 md:px-8">
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
<span>客户案例</span>
<h2>已经在多机构协同慢病管理和互联网医院场景中验证</h2>
</AnimatedContent>
<div class="mt-10 grid gap-5 lg:grid-cols-3">
<AnimatedContent v-for="(item, index) in caseStudies" :key="item.client" :delay="index * 0.08" :distance="52" class-name="case-reveal">
<article class="case-card">
<span>{{ item.client }}</span>
<h3>{{ item.result }}</h3>
<p>{{ item.quote }}</p>
</article>
</AnimatedContent>
</div>
</div>
</section>
<section id="pricing" class="section-band">
<div class="mx-auto max-w-7xl px-5 md:px-8">
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
<span>定价计划</span>
<h2>按机构规模和部署要求选择服务方案</h2>
</AnimatedContent>
<div class="mt-10 grid gap-5 lg:grid-cols-3">
<AnimatedContent v-for="(plan, index) in pricingPlans" :key="plan.name" :delay="index * 0.08" :distance="52" class-name="pricing-reveal">
<article
class="pricing-card"
:class="{ 'pricing-featured': plan.highlighted }"
>
<div class="pricing-head">
<span>{{ plan.name }}</span>
<CircleDollarSign :size="24" aria-hidden="true" />
</div>
<div class="mt-5">
<strong>{{ plan.price }}</strong>
<small>{{ plan.unit }}</small>
</div>
<p>{{ plan.description }}</p>
<ul>
<li v-for="feature in plan.features" :key="feature">
<Check :size="17" aria-hidden="true" />
{{ feature }}
</li>
</ul>
<a href="#contact" class="pricing-button">{{ plan.highlighted ? '预约方案顾问' : '开始咨询' }}</a>
</article>
</AnimatedContent>
</div>
</div>
</section>
<section class="section-pad">
<div class="mx-auto max-w-7xl px-5 md:px-8">
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
<span>用户评论与社会证明</span>
<h2>来自真实医疗场景的反馈</h2>
</AnimatedContent>
<div class="mt-10 grid gap-5 md:grid-cols-3">
<AnimatedContent v-for="(review, index) in reviews" :key="review.name" :delay="index * 0.08" :distance="52" class-name="review-reveal">
<article class="review-card">
<div class="stars" aria-label="五星评价">
<Star v-for="star in 5" :key="star" :size="16" fill="currentColor" aria-hidden="true" />
</div>
<p>{{ review.text }}</p>
<div>
<strong>{{ review.name }}</strong>
<span>{{ review.role }}</span>
</div>
</article>
</AnimatedContent>
</div>
</div>
</section>
<section id="faq" class="section-band">
<div class="mx-auto grid max-w-7xl gap-10 px-5 md:grid-cols-[0.85fr_1.15fr] md:px-8">
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading align-left">
<span>FAQ</span>
<h2>常见问题</h2>
</AnimatedContent>
<AnimatedContent class-name="faq-list" :distance="54" direction="horizontal" :reverse="true" :duration="0.9">
<button
v-for="(item, index) in faqs"
:key="item.q"
type="button"
class="faq-item"
:aria-expanded="openFaq === index"
@click="openFaq = openFaq === index ? -1 : index"
>
<span>{{ item.q }}</span>
<p v-if="openFaq === index">{{ item.a }}</p>
</button>
</AnimatedContent>
</div>
</section>
<section id="contact" class="section-pad">
<div class="mx-auto grid max-w-7xl gap-8 px-5 md:grid-cols-[0.9fr_1.1fr] md:px-8">
<AnimatedContent :distance="56" :duration="0.95" class-name="contact-panel">
<Headphones :size="30" aria-hidden="true" />
<h2>联系萧康云医</h2>
<p>留下需求后产品顾问会在 1 个工作日内联系你也可以直接通过电话或邮件咨询</p>
<div class="contact-lines">
<span>{{ siteConfig.contact.phone }}</span>
<span>{{ siteConfig.contact.email }}</span>
<span>{{ siteConfig.contact.address }}</span>
</div>
</AnimatedContent>
<AnimatedContent :distance="56" direction="horizontal" :reverse="true" :duration="0.95" class-name="contact-form-shell">
<form class="contact-form" aria-label="预约演示表单">
<label>
姓名
<input type="text" placeholder="请输入姓名">
</label>
<label>
机构名称
<input type="text" placeholder="医院、集团或诊所名称">
</label>
<label>
联系方式
<input type="tel" placeholder="手机号或邮箱">
</label>
<label>
关注场景
<textarea rows="4" placeholder="例如医共体协同、慢病随访、互联网医院建设"></textarea>
</label>
<button type="button" class="btn-primary justify-center">
提交预约
<MessageCircle :size="18" aria-hidden="true" />
</button>
</form>
</AnimatedContent>
</div>
</section>
</main>
</template>