初始化

This commit is contained in:
李琦
2026-07-24 15:12:29 +08:00
parent 6d1e1f08dc
commit 1473a11300
11 changed files with 3591 additions and 46 deletions

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title> <title>Vite + Vue</title>
</head> </head>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Maximize2, Pause, Play, RotateCcw, Volume2, VolumeX } from 'lucide-vue-next'
interface CinematicVideoPlayerProps {
src: string
poster?: string
title?: string
accent?: string
}
const props = withDefaults(defineProps<CinematicVideoPlayerProps>(), {
poster: '',
title: '产品演示视频',
accent: '#14b8a6'
})
const videoRef = ref<HTMLVideoElement | null>(null)
const frameRef = ref<HTMLElement | null>(null)
const isPlaying = ref(false)
const isMuted = ref(false)
const isLoading = ref(true)
const hasError = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const progressPreview = ref(0)
const isScrubbing = ref(false)
const reduceMotion = ref(false)
let motionQuery: MediaQueryList | null = null
const progress = computed(() => {
if (!duration.value) return 0
return (currentTime.value / duration.value) * 100
})
const progressStyle = computed(() => ({
'--video-progress': `${isScrubbing.value ? progressPreview.value : progress.value}%`,
'--video-accent': props.accent
}))
function formatTime(value: number) {
if (!Number.isFinite(value)) return '00:00'
const minutes = Math.floor(value / 60)
const seconds = Math.floor(value % 60)
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
function syncState() {
const video = videoRef.value
if (!video) return
isPlaying.value = !video.paused
isMuted.value = video.muted
currentTime.value = video.currentTime
duration.value = video.duration || 0
}
async function togglePlay() {
const video = videoRef.value
if (!video || hasError.value) return
try {
if (video.paused) {
await video.play()
} else {
video.pause()
}
syncState()
} catch {
hasError.value = true
}
}
function replay() {
const video = videoRef.value
if (!video || hasError.value) return
video.currentTime = 0
void video.play()
syncState()
}
function toggleMute() {
const video = videoRef.value
if (!video) return
video.muted = !video.muted
syncState()
}
function handleProgressInput(event: Event) {
const input = event.target as HTMLInputElement
const nextProgress = Number(input.value)
progressPreview.value = nextProgress
isScrubbing.value = true
if (duration.value && videoRef.value) {
videoRef.value.currentTime = (nextProgress / 100) * duration.value
currentTime.value = videoRef.value.currentTime
}
}
function finishScrubbing() {
isScrubbing.value = false
syncState()
}
async function enterFullscreen() {
const target = frameRef.value
if (!target || !document.fullscreenEnabled) return
if (document.fullscreenElement) {
await document.exitFullscreen()
return
}
await target.requestFullscreen()
}
function readMotionPreference() {
reduceMotion.value = Boolean(motionQuery?.matches)
}
watch(
() => props.src,
() => {
hasError.value = false
isLoading.value = true
isPlaying.value = false
currentTime.value = 0
duration.value = 0
nextTick(() => videoRef.value?.load())
}
)
onMounted(() => {
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
readMotionPreference()
motionQuery.addEventListener('change', readMotionPreference)
})
onBeforeUnmount(() => {
motionQuery?.removeEventListener('change', readMotionPreference)
})
</script>
<template>
<section
ref="frameRef"
class="cinematic-player"
:class="{ 'cinematic-player-reduced': reduceMotion, 'cinematic-player-error': hasError }"
:style="progressStyle"
:aria-label="title"
>
<video
ref="videoRef"
class="cinematic-player-video"
:src="src"
:poster="poster"
playsinline
preload="metadata"
@click="togglePlay"
@loadedmetadata="syncState"
@loadeddata="isLoading = false"
@canplay="isLoading = false"
@waiting="isLoading = true"
@playing="isLoading = false; syncState()"
@pause="syncState"
@timeupdate="syncState"
@ended="syncState"
@error="hasError = true; isLoading = false"
/>
<div class="cinematic-player-vignette" aria-hidden="true"></div>
<div v-if="isLoading && !hasError" class="cinematic-player-state" aria-live="polite">
<span></span>
正在准备视频
</div>
<div v-if="hasError" class="cinematic-player-state cinematic-player-state-error" aria-live="polite">
视频暂时无法加载请稍后重试
</div>
<button
v-if="!hasError && !isPlaying"
type="button"
class="cinematic-player-main"
:aria-label="isPlaying ? '暂停视频' : '播放视频'"
@click="togglePlay"
>
<Pause v-if="isPlaying" :size="30" fill="currentColor" aria-hidden="true" />
<Play v-else :size="30" fill="currentColor" aria-hidden="true" />
</button>
<div class="cinematic-player-controls" :class="{ 'cinematic-player-controls-visible': !isPlaying }">
<button type="button" :aria-label="isPlaying ? '暂停视频' : '播放视频'" @click="togglePlay">
<Pause v-if="isPlaying" :size="18" fill="currentColor" aria-hidden="true" />
<Play v-else :size="18" fill="currentColor" aria-hidden="true" />
</button>
<button type="button" aria-label="重新播放" @click="replay">
<RotateCcw :size="17" aria-hidden="true" />
</button>
<span class="cinematic-player-time">{{ formatTime(currentTime) }}</span>
<input
class="cinematic-player-progress"
type="range"
min="0"
max="100"
step="0.1"
:value="progress"
aria-label="视频播放进度"
@input="handleProgressInput"
@change="finishScrubbing"
@pointerup="finishScrubbing"
@keyup.enter="finishScrubbing"
>
<span class="cinematic-player-time">{{ formatTime(duration) }}</span>
<button type="button" :aria-label="isMuted ? '打开声音' : '静音'" @click="toggleMute">
<VolumeX v-if="isMuted" :size="18" aria-hidden="true" />
<Volume2 v-else :size="18" aria-hidden="true" />
</button>
<button type="button" aria-label="全屏播放" @click="enterFullscreen">
<Maximize2 :size="18" aria-hidden="true" />
</button>
</div>
</section>
</template>

View File

@@ -1,10 +1,35 @@
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ChevronDown, Menu, Monitor, Moon, Search, Stethoscope, Sun, User, X } from 'lucide-vue-next' import {
import { siteConfig } from '@/site.config' ArrowRight,
ChevronDown,
Clock,
FileText,
History,
Menu,
Monitor,
Moon,
Search,
Stethoscope,
Sun,
Trash2,
User,
X
} from 'lucide-vue-next'
import {
caseStudies,
faqs,
homeCaseCards,
homeServiceItems,
pricingPlans,
productFeatures,
siteConfig,
videoShowcase
} from '@/site.config'
const route = useRoute() const route = useRoute()
const router = useRouter()
const themeMode = ref('system') const themeMode = ref('system')
const resolvedTheme = ref('light') const resolvedTheme = ref('light')
const isThemeOpen = ref(false) const isThemeOpen = ref(false)
@@ -13,6 +38,13 @@ const themePopoverStyle = ref({})
const isMobileOpen = ref(false) const isMobileOpen = ref(false)
const openDesktopMenu = ref(null) const openDesktopMenu = ref(null)
const openMobileGroups = ref(['home']) const openMobileGroups = ref(['home'])
const isPastFirstScreen = ref(false)
const isSearchOpen = ref(false)
const searchQuery = ref('')
const searchInputRef = ref(null)
const searchHistory = ref([])
const SEARCH_HISTORY_KEY = 'xiaokang-search-history'
const themeOptions = [ const themeOptions = [
{ value: 'system', label: '跟随系统', icon: Monitor }, { value: 'system', label: '跟随系统', icon: Monitor },
@@ -22,18 +54,209 @@ const themeOptions = [
const menuGroups = [ const menuGroups = [
{ key: 'home', label: '首页', to: '/', links: siteConfig.navigation.secondary.home }, { key: 'home', label: '首页', to: '/', links: siteConfig.navigation.secondary.home },
{ key: 'product', label: '产品', to: '/product', links: siteConfig.navigation.secondary.product } { key: 'product', label: '产品', to: '/product', links: siteConfig.navigation.secondary.product },
{ key: 'video', label: '视频', to: '/video/user', activePath: '/video', links: siteConfig.navigation.secondary.video }
] ]
const activeTheme = computed(() => themeOptions.find((item) => item.value === themeMode.value) || themeOptions[0]) const activeTheme = computed(() => themeOptions.find((item) => item.value === themeMode.value) || themeOptions[0])
const isHome = computed(() => route.path === '/') const isHome = computed(() => route.path === '/')
const isProduct = computed(() => route.path.startsWith('/product')) const isProduct = computed(() => route.path.startsWith('/product'))
const isVideo = computed(() => route.path.startsWith('/video'))
const isNavSolid = computed(() => isPastFirstScreen.value)
const navTone = computed(() => { const navTone = computed(() => {
if (isHome.value) return 'dark' if (isHome.value && !isNavSolid.value) return 'dark'
return resolvedTheme.value === 'dark' ? 'dark' : 'light' return resolvedTheme.value === 'dark' ? 'dark' : 'light'
}) })
const routeSearchItems = computed(() => [
{
id: 'route-home',
title: '首页',
description: siteConfig.brand.tagline,
to: '/',
category: '页面',
keywords: [siteConfig.brand.name, siteConfig.brand.description, 'home']
},
{
id: 'route-product',
title: '产品',
description: '功能说明、演示视频、定价计划、FAQ 与联系方式。',
to: '/product',
category: '页面',
keywords: ['产品介绍', '功能', '价格', 'FAQ']
},
{
id: 'route-video',
title: '视频',
description: '用户端、医生端、小程序店长管理端视频库。',
to: '/video/user',
category: '页面',
keywords: ['视频库', '演示视频', '用户端', '医生端', '店长端']
}
])
const navigationSearchItems = computed(() => {
const groups = [
{ category: '首页标题', links: siteConfig.navigation.secondary.home },
{ category: '产品标题', links: siteConfig.navigation.secondary.product },
{ category: '视频分区', links: siteConfig.navigation.secondary.video }
]
return groups.flatMap((group) =>
group.links.map((link) => ({
id: `nav-${link.to}`,
title: link.label,
description: `前往${group.category.replace('标题', '')}中的「${link.label}」。`,
to: link.to,
category: group.category,
keywords: [link.label, link.to]
}))
)
})
const contentSearchItems = computed(() => {
const homeItems = [
...homeServiceItems.map((item) => ({
id: `home-service-${item.number}`,
title: item.name,
description: item.description,
to: '/#intro',
category: '首页标题',
keywords: [item.number, item.name, item.description]
})),
...homeCaseCards.map((item) => ({
id: `home-case-${item.number}`,
title: item.name,
description: item.summary,
to: '/#cases',
category: '客户案例',
keywords: [item.category, item.name, item.summary]
}))
]
const productItems = [
...productFeatures.map((item) => ({
id: `product-feature-${item.title}`,
title: item.title,
description: item.body,
to: '/product#features',
category: '产品功能',
keywords: [item.title, item.body]
})),
...caseStudies.map((item) => ({
id: `product-case-${item.client}`,
title: item.client,
description: item.result,
to: '/product',
category: '客户案例',
keywords: [item.client, item.result, item.quote]
})),
...pricingPlans.map((item) => ({
id: `pricing-${item.name}`,
title: `${item.name}方案`,
description: item.description,
to: '/product#pricing',
category: '定价计划',
keywords: [item.name, item.price, item.unit, item.description, ...item.features]
})),
...faqs.map((item) => ({
id: `faq-${item.q}`,
title: item.q,
description: item.a,
to: '/product#faq',
category: 'FAQ',
keywords: [item.q, item.a]
}))
]
const videoItems = Object.entries(videoShowcase).flatMap(([audience, config]) => [
{
id: `video-audience-${audience}`,
title: config.label,
description: config.subtitle,
to: `/video/${audience}`,
category: '视频分区',
keywords: [config.badge, config.title, config.subtitle, ...config.scenes]
},
...config.videos.map((video) => ({
id: `video-${audience}-${video.id}`,
title: video.title,
description: video.description,
to: `/video/${audience}?video=${video.id}`,
category: config.label,
keywords: [
config.label,
config.title,
video.title,
video.description,
video.tag,
video.duration,
...video.highlights,
...video.chapters.flatMap((chapter) => [chapter.title, chapter.body, chapter.time])
]
}))
])
return [...homeItems, ...productItems, ...videoItems]
})
const searchItems = computed(() => {
const seen = new Set()
return [...routeSearchItems.value, ...navigationSearchItems.value, ...contentSearchItems.value]
.filter((item) => {
const key = `${item.to}-${item.title}`
if (seen.has(key)) return false
seen.add(key)
return true
})
})
const normalizedSearchQuery = computed(() => searchQuery.value.trim().toLowerCase())
const searchResults = computed(() => {
const query = normalizedSearchQuery.value
if (!query) return searchItems.value.slice(0, 8)
return searchItems.value
.map((item) => {
const haystack = [
item.title,
item.description,
item.category,
...(item.keywords || [])
].join(' ').toLowerCase()
let score = 0
if (item.title.toLowerCase().includes(query)) score += 6
if (item.category.toLowerCase().includes(query)) score += 3
if (haystack.includes(query)) score += 2
return { ...item, score }
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || a.title.length - b.title.length)
.slice(0, 10)
})
const visibleHistory = computed(() => {
const available = new Map(searchItems.value.map((item) => [item.to, item]))
return searchHistory.value
.map((item) => available.get(item.to) || item)
.slice(0, 6)
})
let mediaQuery let mediaQuery
let scrollRaf = 0
function updateHeaderScrollState() {
scrollRaf = 0
const firstScreenBottom = Math.max(0, window.innerHeight - 1)
isPastFirstScreen.value = window.scrollY > firstScreenBottom
}
function requestHeaderScrollState() {
if (scrollRaf) return
scrollRaf = window.requestAnimationFrame(updateHeaderScrollState)
}
function syncThemePopoverPosition() { function syncThemePopoverPosition() {
if (!themeButtonRef.value) return if (!themeButtonRef.value) return
@@ -89,10 +312,74 @@ function toggleMobileGroup(key) {
: [...openMobileGroups.value, key] : [...openMobileGroups.value, key]
} }
function openSearch() {
isSearchOpen.value = true
isThemeOpen.value = false
openDesktopMenu.value = null
nextTick(() => searchInputRef.value?.focus())
}
function closeSearch() {
isSearchOpen.value = false
searchQuery.value = ''
}
function loadSearchHistory() {
try {
const saved = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '[]')
searchHistory.value = Array.isArray(saved) ? saved.slice(0, 8) : []
} catch {
searchHistory.value = []
}
}
function saveSearchHistory(item) {
const record = {
id: item.id,
title: item.title,
description: item.description,
to: item.to,
category: item.category
}
const nextHistory = [
record,
...searchHistory.value.filter((historyItem) => historyItem.to !== item.to)
].slice(0, 8)
searchHistory.value = nextHistory
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(nextHistory))
}
function clearSearchHistory() {
searchHistory.value = []
localStorage.removeItem(SEARCH_HISTORY_KEY)
}
function goSearchResult(item) {
saveSearchHistory(item)
closeSearch()
router.push(item.to)
}
function handleSearchKeydown(event) {
const isShortcut = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k'
if (isShortcut) {
event.preventDefault()
openSearch()
return
}
if (event.key === 'Escape' && isSearchOpen.value) {
closeSearch()
}
}
watch(() => route.fullPath, () => { watch(() => route.fullPath, () => {
isMobileOpen.value = false isMobileOpen.value = false
isThemeOpen.value = false isThemeOpen.value = false
isSearchOpen.value = false
openDesktopMenu.value = null openDesktopMenu.value = null
nextTick(updateHeaderScrollState)
}) })
watch(isThemeOpen, (isOpen) => { watch(isThemeOpen, (isOpen) => {
@@ -111,12 +398,21 @@ onMounted(() => {
themeMode.value = localStorage.getItem('xiaokang-theme-mode') || 'system' themeMode.value = localStorage.getItem('xiaokang-theme-mode') || 'system'
applyTheme() applyTheme()
mediaQuery.addEventListener('change', applyTheme) mediaQuery.addEventListener('change', applyTheme)
loadSearchHistory()
window.addEventListener('keydown', handleSearchKeydown)
updateHeaderScrollState()
window.addEventListener('scroll', requestHeaderScrollState, { passive: true })
window.addEventListener('resize', requestHeaderScrollState)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
mediaQuery?.removeEventListener('change', applyTheme) mediaQuery?.removeEventListener('change', applyTheme)
window.removeEventListener('resize', syncThemePopoverPosition) window.removeEventListener('resize', syncThemePopoverPosition)
window.removeEventListener('scroll', syncThemePopoverPosition, true) window.removeEventListener('scroll', syncThemePopoverPosition, true)
window.removeEventListener('scroll', requestHeaderScrollState)
window.removeEventListener('resize', requestHeaderScrollState)
window.removeEventListener('keydown', handleSearchKeydown)
if (scrollRaf) window.cancelAnimationFrame(scrollRaf)
}) })
</script> </script>
@@ -126,9 +422,10 @@ onBeforeUnmount(() => {
:class="[ :class="[
`site-header-tone-${navTone}`, `site-header-tone-${navTone}`,
{ {
'site-header-home': isHome, 'site-header-home': isHome && !isNavSolid,
'site-header-solid': !isHome, 'site-header-solid': isNavSolid,
'site-header-product': isProduct 'site-header-product': isProduct,
'site-header-video': isVideo
} }
]" ]"
> >
@@ -137,16 +434,17 @@ onBeforeUnmount(() => {
:class="[ :class="[
`site-nav-tone-${navTone}`, `site-nav-tone-${navTone}`,
{ {
'site-nav-overlay': isHome, 'site-nav-solid': isNavSolid,
'site-nav-solid': !isHome, 'site-nav-overlay': !isNavSolid,
'site-nav-product': isProduct 'site-nav-product': isProduct,
'site-nav-video': isVideo
} }
]" ]"
style="animation-delay: 0ms" style="animation-delay: 0ms"
> >
<RouterLink to="/" class="site-logo" aria-label="萧康云医首页"> <RouterLink to="/" class="site-logo" aria-label="萧康云医首页">
<span class="site-logo-mark"> <span class="site-logo-mark">
<Stethoscope :size="20" stroke-width="2.4" aria-hidden="true" /> <img src="/logo.png" alt="">
</span> </span>
<span>{{ siteConfig.brand.name }}</span> <span>{{ siteConfig.brand.name }}</span>
</RouterLink> </RouterLink>
@@ -163,7 +461,7 @@ onBeforeUnmount(() => {
<RouterLink <RouterLink
:to="group.to" :to="group.to"
class="primary-nav-link" class="primary-nav-link"
:class="{ 'primary-nav-link-active': isActive(group.to) }" :class="{ 'primary-nav-link-active': isActive(group.activePath || group.to) }"
> >
{{ group.label }} {{ group.label }}
<ChevronDown :size="15" aria-hidden="true" /> <ChevronDown :size="15" aria-hidden="true" />
@@ -185,7 +483,13 @@ onBeforeUnmount(() => {
</nav> </nav>
<div class="site-actions"> <div class="site-actions">
<button type="button" class="action-pill liquid-glass nav-action animate-blur-fade-up" style="animation-delay: 300ms"> <button
type="button"
class="action-pill liquid-glass site-search-trigger animate-blur-fade-up"
style="animation-delay: 300ms"
aria-label="打开全站搜索"
@click="openSearch"
>
<Search :size="18" aria-hidden="true" /> <Search :size="18" aria-hidden="true" />
<span>搜索</span> <span>搜索</span>
</button> </button>
@@ -266,4 +570,91 @@ onBeforeUnmount(() => {
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
<Teleport to="body">
<Transition name="search-panel">
<div v-if="isSearchOpen" class="site-search-overlay" @click.self="closeSearch">
<section
class="site-search-panel"
:class="`site-search-panel-tone-${navTone}`"
role="dialog"
aria-modal="true"
aria-label="全站搜索"
>
<div class="site-search-input-shell">
<Search :size="20" aria-hidden="true" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="search"
placeholder="搜索页面、标题、视频、FAQ..."
aria-label="搜索页面标题视频FAQ"
>
<button type="button" class="site-search-close" aria-label="关闭搜索" @click="closeSearch">
<X :size="18" aria-hidden="true" />
</button>
</div>
<div class="site-search-meta">
<span v-if="normalizedSearchQuery">找到 {{ searchResults.length }} 个相关结果</span>
<span v-else>可搜索全部路由页面标题和视频条目</span>
<span class="site-search-shortcut">Ctrl K</span>
</div>
<div v-if="!normalizedSearchQuery && visibleHistory.length" class="site-search-history">
<div class="site-search-section-title">
<span><History :size="15" aria-hidden="true" /> 最近搜索</span>
<button type="button" @click="clearSearchHistory">
<Trash2 :size="14" aria-hidden="true" />
清空
</button>
</div>
<div class="site-search-results">
<button
v-for="item in visibleHistory"
:key="`history-${item.to}`"
type="button"
class="site-search-result site-search-result-history"
@click="goSearchResult(item)"
>
<span class="site-search-result-icon"><Clock :size="17" aria-hidden="true" /></span>
<span class="site-search-result-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.category }} · {{ item.description }}</small>
</span>
<ArrowRight :size="16" aria-hidden="true" />
</button>
</div>
</div>
<div class="site-search-results">
<div class="site-search-section-title">
<span><FileText :size="15" aria-hidden="true" /> {{ normalizedSearchQuery ? '搜索结果' : '推荐入口' }}</span>
</div>
<button
v-for="item in searchResults"
:key="item.id"
type="button"
class="site-search-result"
@click="goSearchResult(item)"
>
<span class="site-search-result-icon">{{ item.category.slice(0, 1) }}</span>
<span class="site-search-result-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.category }} · {{ item.description }}</small>
</span>
<ArrowRight :size="16" aria-hidden="true" />
</button>
<div v-if="normalizedSearchQuery && !searchResults.length" class="site-search-empty">
<Search :size="22" aria-hidden="true" />
<strong>没有找到匹配内容</strong>
<span>可以试试医生端FAQ随访店长这些关键词</span>
</div>
</div>
</section>
</div>
</Transition>
</Teleport>
</template> </template>

View File

@@ -6,7 +6,11 @@ import ThemeBackdrop from '@/components/ThemeBackdrop.vue'
import { siteConfig } from '@/site.config' import { siteConfig } from '@/site.config'
const route = useRoute() const route = useRoute()
const backdropClass = computed(() => route.path.startsWith('/product') ? 'product-route-backdrop' : 'home-route-backdrop') const backdropClass = computed(() => {
if (route.path.startsWith('/video')) return 'video-route-backdrop'
if (route.path.startsWith('/product')) return 'product-route-backdrop'
return 'home-route-backdrop'
})
</script> </script>
<template> <template>
@@ -25,6 +29,7 @@ const backdropClass = computed(() => route.path.startsWith('/product') ? 'produc
<nav aria-label="底部导航"> <nav aria-label="底部导航">
<RouterLink to="/">首页</RouterLink> <RouterLink to="/">首页</RouterLink>
<RouterLink to="/product">产品</RouterLink> <RouterLink to="/product">产品</RouterLink>
<RouterLink to="/video/user">视频</RouterLink>
<RouterLink to="/#business">业务介绍</RouterLink> <RouterLink to="/#business">业务介绍</RouterLink>
<RouterLink to="/product#pricing">定价计划</RouterLink> <RouterLink to="/product#pricing">定价计划</RouterLink>
<RouterLink to="/product#contact">联系咨询</RouterLink> <RouterLink to="/product#contact">联系咨询</RouterLink>

View File

@@ -9,6 +9,8 @@ const routes = [
children: [ children: [
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') }, { path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
{ path: 'product', name: 'Product', component: () => import('@/views/product/index.vue') }, { path: 'product', name: 'Product', component: () => import('@/views/product/index.vue') },
{ path: 'video', redirect: '/video/user' },
{ path: 'video/:audience(user|doctor|manager)', name: 'VideoShowcase', component: () => import('@/views/video/index.vue') },
], ],
}, },
] ]

View File

@@ -7,7 +7,8 @@ export const siteConfig = {
navigation: { navigation: {
primary: [ primary: [
{ label: '首页', to: '/' }, { label: '首页', to: '/' },
{ label: '产品', to: '/product' } { label: '产品', to: '/product' },
{ label: '视频', to: '/video/user' }
], ],
secondary: { secondary: {
home: [ home: [
@@ -22,6 +23,11 @@ export const siteConfig = {
{ label: '定价计划', to: '/product#pricing' }, { label: '定价计划', to: '/product#pricing' },
{ label: 'FAQ', to: '/product#faq' }, { label: 'FAQ', to: '/product#faq' },
{ label: '联系方式', to: '/product#contact' } { label: '联系方式', to: '/product#contact' }
],
video: [
{ label: '用户端', to: '/video/user' },
{ label: '医生端', to: '/video/doctor' },
{ label: '小程序店长管理端', to: '/video/manager' }
] ]
} }
}, },
@@ -39,6 +45,192 @@ export const siteConfig = {
} }
} }
export const videoShowcase = {
user: {
label: '用户端',
badge: 'Patient App',
title: '把就医、复诊和健康管理放进一个温柔的移动体验。',
subtitle: '面向患者和家属,展示预约复诊、报告查看、用药提醒、随访沟通与健康档案的完整路径。',
cta: '预约用户端演示',
accent: '#14b8a6',
metrics: [
{ value: '3 步', label: '完成复诊预约' },
{ value: '24h', label: '随访消息触达' },
{ value: '1 个档案', label: '串联全周期记录' }
],
scenes: ['互联网医院入口', '慢病随访服务', '复诊预约与报告查询'],
videos: [
{
id: 'patient-home',
title: '用户端总览',
description: '从健康首页进入,快速看懂预约、报告、提醒和随访任务如何集中管理。',
duration: '01:12',
tag: '入门必看',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:08', title: '首页健康卡片', body: '把待办、报告和医生提醒集中呈现,用户打开就知道下一步。' },
{ time: '00:28', title: '复诊与随访', body: '支持线上复诊、随访问卷、用药提醒和医患消息闭环。' },
{ time: '00:52', title: '家庭成员管理', body: '家属可协助老人和儿童管理档案、预约与健康任务。' }
],
highlights: ['报告与处方清晰可追踪', '消息提醒不打扰但不错过', '适配移动端单手操作']
},
{
id: 'patient-followup',
title: '随访与复诊流程',
description: '展示患者收到提醒、填写问卷、提交复诊申请到收到医生建议的完整闭环。',
duration: '01:36',
tag: '服务流程',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:06', title: '提醒触达', body: '用清楚的任务卡片提示患者按时完成随访和复诊动作。' },
{ time: '00:31', title: '问卷填写', body: '把症状、用药、指标和反馈拆成移动端易填写的步骤。' },
{ time: '01:05', title: '医生建议回传', body: '医生处理后,患者端能看到复诊建议、宣教内容和下一次提醒。' }
],
highlights: ['随访任务清单化', '复诊状态实时可见', '医患沟通自然衔接']
},
{
id: 'patient-family',
title: '家庭成员代管',
description: '面向老人、儿童和家庭照护场景,展示家属如何代管健康档案与复诊任务。',
duration: '01:08',
tag: '家庭场景',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:05', title: '成员切换', body: '同一账号下快速切换家庭成员,避免档案和任务混淆。' },
{ time: '00:26', title: '健康档案', body: '重要报告、处方、随访记录按成员沉淀,后续查询更省心。' },
{ time: '00:49', title: '照护提醒', body: '把用药、复诊和随访提醒同步给家属,减少遗漏。' }
],
highlights: ['多成员管理清晰', '适合家庭照护', '降低老人操作压力']
}
]
},
doctor: {
label: '医生端',
badge: 'Doctor Console',
title: '让医生快速看清患者状态,并把关键任务稳稳接住。',
subtitle: '面向医生团队,展示患者画像、风险提醒、复诊处理、随访审核和协同任务流。',
cta: '预约医生端演示',
accent: '#22d3ee',
metrics: [
{ value: '1 屏', label: '查看患者概况' },
{ value: 'AI', label: '辅助风险分层' },
{ value: '多角色', label: '协同处理任务' }
],
scenes: ['专科医生工作台', '慢病管理中心', '区域会诊协同'],
videos: [
{
id: 'doctor-workbench',
title: '医生工作台总览',
description: '展示医生如何在一个视图里查看患者状态、风险标签、待处理任务和沟通记录。',
duration: '01:20',
tag: '工作台',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:10', title: '患者画像', body: '聚合就诊、检查、随访和风险标签,减少医生来回切换。' },
{ time: '00:34', title: '任务分诊', body: '把高风险、待复诊、待审核事项按优先级推送给责任医生。' },
{ time: '01:04', title: '医患沟通', body: '复诊建议、处方流转和健康宣教在同一工作台完成。' }
],
highlights: ['患者状态清楚呈现', '高风险任务优先处理', '适合医生和护理团队协同']
},
{
id: 'doctor-risk',
title: '风险预警处理',
description: '聚焦 AI 风险分层、高危患者提醒和医生处理路径,适合慢病中心演示。',
duration: '01:28',
tag: '风险管理',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:07', title: '风险列表', body: '按风险等级、异常指标和随访反馈筛选需要优先处理的患者。' },
{ time: '00:39', title: '详情研判', body: '把历史指标、用药、报告和沟通记录放到同一判断上下文。' },
{ time: '01:02', title: '处理闭环', body: '医生可下发建议、发起复诊或转给护理团队继续跟进。' }
],
highlights: ['风险分层可解释', '高危患者不遗漏', '处理动作可追踪']
},
{
id: 'doctor-collaboration',
title: '医护协同任务流',
description: '展示医生、护士、运营人员如何围绕同一个患者任务分工协同。',
duration: '01:18',
tag: '协同流程',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:09', title: '任务分派', body: '责任医生发起任务后,可按角色分派给护理或运营团队。' },
{ time: '00:33', title: '进度同步', body: '每个处理节点都有状态记录,团队成员看到同一份进度。' },
{ time: '00:58', title: '结果归档', body: '复诊、随访和沟通结果沉淀到患者档案中,方便复盘。' }
],
highlights: ['多角色分工清晰', '任务状态实时同步', '结果自动沉淀']
}
]
},
manager: {
label: '小程序店长管理端',
badge: 'Mini Program Manager',
title: '让店长在小程序里看懂运营、服务和团队执行。',
subtitle: '面向门店店长和运营负责人,展示门店指标、服务进度、人员任务、客户触达与异常提醒。',
cta: '预约店长端演示',
accent: '#059669',
metrics: [
{ value: '实时', label: '查看门店经营状态' },
{ value: '清单化', label: '跟进服务任务' },
{ value: '移动端', label: '随时处理异常' }
],
scenes: ['连锁诊所门店', '康复服务中心', '健康管理机构'],
videos: [
{
id: 'manager-dashboard',
title: '门店经营总览',
description: '把今日服务、预约、复诊、随访和转化指标放到店长能快速扫读的移动看板。',
duration: '01:16',
tag: '经营看板',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:12', title: '门店经营总览', body: '把今日服务量、预约、复诊、随访和转化指标放到一张看板。' },
{ time: '00:38', title: '团队任务管理', body: '按成员查看待处理事项,帮助店长及时调度服务资源。' },
{ time: '01:02', title: '客户触达跟进', body: '沉淀客户服务记录,异常提醒和回访动作可直接闭环。' }
],
highlights: ['移动端看板适合巡店', '服务进度一眼可见', '异常提醒可快速处理']
},
{
id: 'manager-team',
title: '团队任务排班',
description: '展示店长如何查看成员任务、调度服务资源,并及时处理未完成事项。',
duration: '01:22',
tag: '团队管理',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:08', title: '成员视图', body: '按成员查看今日待办、超时任务和服务完成情况。' },
{ time: '00:36', title: '任务调度', body: '店长可以快速调整负责人,让门店服务节奏更稳定。' },
{ time: '01:01', title: '执行复盘', body: '服务结果和异常原因保留记录,方便日后复盘团队执行。' }
],
highlights: ['成员状态清晰', '调度动作更快', '适合移动巡店']
},
{
id: 'manager-customer',
title: '客户触达与回访',
description: '面向客户运营,展示门店如何查看触达任务、回访状态和异常提醒。',
duration: '01:14',
tag: '客户运营',
src: siteConfig.hero.video.src,
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:06', title: '触达清单', body: '把待回访、待确认、待复诊的客户集中到清单里。' },
{ time: '00:29', title: '回访记录', body: '客户沟通结果实时记录,避免门店多人跟进时信息丢失。' },
{ time: '00:56', title: '异常提醒', body: '异常反馈会进入店长视图,便于快速介入处理。' }
],
highlights: ['客户任务集中呈现', '回访结果有记录', '异常反馈更快闭环']
}
]
}
}
export const metrics = [ export const metrics = [
{ value: '1200+', label: '接入医疗机构' }, { value: '1200+', label: '接入医疗机构' },
{ value: '38%', label: '随访效率提升' }, { value: '38%', label: '随访效率提升' },
@@ -182,26 +374,104 @@ export const homeCaseCards = [
export const pricingPlans = [ export const pricingPlans = [
{ {
name: '标准版', name: '免费版',
price: '¥6,800', step: '01',
unit: '机构 / 月', tagline: '基础开诊',
description: '适合单体医院、门诊部或成长型诊所。', visualTone: 'starter',
features: ['患者档案云管理', '在线复诊与随访', '基础运营报表', '标准接口支持'] price: '¥3,888',
unit: '机构 / 年',
audience: '适合刚开始数字化运营的诊所或门诊。',
description: '覆盖医生、处方、挂号、对账和推广员等基础经营闭环。',
highlights: ['3 个医生账号开通', '处方挂号经营闭环', '推广员与对账可用'],
features: ['3 个医生账号', '分账功能', '开具处方功能', '挂号功能', '对账功能', '推广员模块'],
ctaLabel: '咨询开通',
ctaIntent: '我想了解免费版开通'
}, },
{ {
name: '专业版', name: '高级版',
price: '¥18,000', step: '02',
unit: '医疗集团 / 月', tagline: '推荐成长型机构',
description: '适合多院区协同、连锁机构与医共体。', visualTone: 'growth',
features: ['区域协同工作台', 'AI 风险预警', '高级权限治理', '专属实施顾问'], price: '¥5,888',
highlighted: true unit: '机构 / 年',
audience: '适合需要持续回访、客户沉淀和转诊协同的门店。',
description: '在免费版基础上强化客户管理和转诊流转。',
highlights: ['包含免费版全部能力', '客户快捷回访', '转诊协同流程'],
features: ['客户管理', '快捷回访', '转诊功能'],
includedFrom: '包含免费版所有功能',
isRecommended: true,
highlighted: true,
ctaLabel: '预约高级版方案',
ctaIntent: '我想了解高级版方案'
}, },
{ {
name: '旗舰版', name: '定制版',
price: '定制', step: '03',
unit: '私有化 / 混合云', tagline: '互联网诊疗',
description: '适合大型医疗集团、政府平台与高合规场景。', visualTone: 'clinical',
features: ['私有化部署', '数据中台集成', '定制模型与流程', 'SLA 与驻场支持'] price: '¥13,888',
unit: '机构 / 年',
audience: '适合计划开展在线诊疗、品牌化运营的医疗机构。',
description: '在高级版基础上增加线上诊疗合规协助和店铺装修。',
highlights: ['包含高级版全部能力', '在线诊疗开通', '备案协助与店装'],
features: ['在线诊疗功能', '协助备案', '店铺装修'],
includedFrom: '包含高级版所有功能',
ctaLabel: '咨询定制方案',
ctaIntent: '我想了解定制版方案'
},
{
name: '私有化部署',
step: '04',
tagline: '企业私有化',
visualTone: 'enterprise',
price: '面议',
unit: '客户服务器 / 定制项目',
audience: '适合对服务器归属、合规备案和流程定制要求更高的客户。',
description: '支持备案到客户自有服务器,并按项目做定制化开发。',
highlights: ['包含定制版全部能力', '客户自有服务器备案', '按项目定制开发'],
features: ['备案到客户自有服务器', '私有化部署支持', '定制化开发'],
includedFrom: '包含定制版所有功能',
isEnterprise: true,
ctaLabel: '联系部署顾问',
ctaIntent: '我想了解私有化部署'
}
]
export const pricingFeatureGroups = [
{
name: '基础经营',
rows: [
{ label: '医生账号', values: { 免费版: '3 个', 高级版: '3 个', 定制版: '3 个', 私有化部署: '3 个起' } },
{ label: '分账功能', values: { 免费版: true, 高级版: true, 定制版: true, 私有化部署: true } },
{ label: '开具处方功能', values: { 免费版: true, 高级版: true, 定制版: true, 私有化部署: true } },
{ label: '挂号功能', values: { 免费版: true, 高级版: true, 定制版: true, 私有化部署: true } },
{ label: '对账功能', values: { 免费版: true, 高级版: true, 定制版: true, 私有化部署: true } },
{ label: '推广员模块', values: { 免费版: true, 高级版: true, 定制版: true, 私有化部署: true } }
]
},
{
name: '客户运营',
rows: [
{ label: '客户管理', values: { 免费版: false, 高级版: true, 定制版: true, 私有化部署: true } },
{ label: '快捷回访', values: { 免费版: false, 高级版: true, 定制版: true, 私有化部署: true } },
{ label: '转诊功能', values: { 免费版: false, 高级版: true, 定制版: true, 私有化部署: true } }
]
},
{
name: '互联网诊疗',
rows: [
{ label: '在线诊疗功能', values: { 免费版: false, 高级版: false, 定制版: true, 私有化部署: true } },
{ label: '协助备案', values: { 免费版: false, 高级版: false, 定制版: true, 私有化部署: true } },
{ label: '店铺装修', values: { 免费版: false, 高级版: false, 定制版: true, 私有化部署: true } }
]
},
{
name: '部署与定制',
rows: [
{ label: '备案到客户自有服务器', values: { 免费版: false, 高级版: false, 定制版: false, 私有化部署: true } },
{ label: '私有化部署支持', values: { 免费版: false, 高级版: false, 定制版: false, 私有化部署: true } },
{ label: '定制化开发', values: { 免费版: false, 高级版: false, 定制版: false, 私有化部署: true } }
]
} }
] ]

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,57 @@
<script setup> <script setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import { ArrowRight, BadgeCheck, Check, CircleDollarSign, ClipboardCheck, Headphones, MessageCircle, Play, ShieldCheck, Star, Video } from 'lucide-vue-next' 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 { caseStudies, faqs, pricingFeatureGroups, pricingPlans, productFeatures, siteConfig } from '@/site.config'
import AnimatedContent from '@/components/vue-bits/AnimatedContent.vue' import AnimatedContent from '@/components/vue-bits/AnimatedContent.vue'
import DecryptedText from '@/components/vue-bits/DecryptedText.vue' import DecryptedText from '@/components/vue-bits/DecryptedText.vue'
import Magnet from '@/components/vue-bits/Magnet.vue' import Magnet from '@/components/vue-bits/Magnet.vue'
const openFaq = ref(0) const openFaq = ref(0)
const demoVideoPlaying = ref(false)
const selectedPricingPlanName = ref(
pricingPlans.find((plan) => plan.isRecommended)?.name ?? pricingPlans[0]?.name ?? ''
)
const selectedComparisonPlanName = ref(selectedPricingPlanName.value)
const selectedPricingPlan = computed(() => (
pricingPlans.find((plan) => plan.name === selectedPricingPlanName.value) ?? pricingPlans[0]
))
function selectPricingPlan(plan) {
selectedPricingPlanName.value = plan.name
selectedComparisonPlanName.value = plan.name
}
function selectComparisonPlan(plan) {
selectedComparisonPlanName.value = plan.name
selectedPricingPlanName.value = plan.name
}
function featureValueLabel(value) {
if (value === true) {
return '包含'
}
if (!value) {
return '—'
}
return value
}
function isFeatureAvailable(value) {
return value === true || typeof value === 'string'
}
function featureOriginTone(row, planName) {
if (!isFeatureAvailable(row.values[planName])) {
return 'off'
}
const originPlan = pricingPlans.find((plan) => isFeatureAvailable(row.values[plan.name]))
return originPlan?.visualTone ?? 'starter'
}
const users = [ const users = [
'医疗集团信息化负责人', '医疗集团信息化负责人',
@@ -149,10 +194,13 @@ const reviews = [
class="demo-video" class="demo-video"
controls controls
:poster="siteConfig.hero.video.poster" :poster="siteConfig.hero.video.poster"
@play="demoVideoPlaying = true"
@pause="demoVideoPlaying = false"
@ended="demoVideoPlaying = false"
> >
<source :src="siteConfig.hero.video.src" type="video/mp4"> <source :src="siteConfig.hero.video.src" type="video/mp4">
</video> </video>
<div class="demo-play" aria-hidden="true"> <div v-if="!demoVideoPlaying" class="demo-play" aria-hidden="true">
<Play :size="28" fill="currentColor" /> <Play :size="28" fill="currentColor" />
</div> </div>
</div> </div>
@@ -202,18 +250,34 @@ const reviews = [
</section> </section>
<section id="pricing" class="section-band"> <section id="pricing" class="section-band">
<div class="mx-auto max-w-7xl px-5 md:px-8"> <div class="pricing-section-inner px-5 md:px-8">
<AnimatedContent :distance="48" :duration="0.9" class-name="section-heading"> <AnimatedContent :distance="48" :duration="0.9" class-name="section-heading">
<span>定价计划</span> <span>定价计划</span>
<h2>按机构规模和部署要求选择服务方案</h2> <h2>按机构规模和部署要求选择服务方案</h2>
</AnimatedContent> </AnimatedContent>
<div class="mt-10 grid gap-5 lg:grid-cols-3"> <div class="pricing-grid">
<AnimatedContent v-for="(plan, index) in pricingPlans" :key="plan.name" :delay="index * 0.08" :distance="52" class-name="pricing-reveal"> <AnimatedContent v-for="(plan, index) in pricingPlans" :key="plan.name" :delay="index * 0.08" :distance="52" class-name="pricing-reveal">
<article <article
class="pricing-card" class="pricing-card"
:class="{ 'pricing-featured': plan.highlighted }" :class="{
'pricing-featured': plan.highlighted,
'pricing-selected': selectedPricingPlanName === plan.name,
'pricing-enterprise': plan.isEnterprise,
[`pricing-tone-${plan.visualTone}`]: plan.visualTone
}"
tabindex="0"
:aria-label="`${plan.name}${plan.price}${plan.audience}`"
@click="selectPricingPlan(plan)"
@focus="selectPricingPlan(plan)"
> >
<div class="pricing-card-rail" aria-hidden="true">
<span>{{ plan.step }}</span>
</div>
<div class="pricing-tagline-row">
<span class="pricing-tagline">{{ plan.tagline }}</span>
<span v-if="plan.isRecommended" class="pricing-recommended">推荐</span>
</div>
<div class="pricing-head"> <div class="pricing-head">
<span>{{ plan.name }}</span> <span>{{ plan.name }}</span>
<CircleDollarSign :size="24" aria-hidden="true" /> <CircleDollarSign :size="24" aria-hidden="true" />
@@ -222,17 +286,143 @@ const reviews = [
<strong>{{ plan.price }}</strong> <strong>{{ plan.price }}</strong>
<small>{{ plan.unit }}</small> <small>{{ plan.unit }}</small>
</div> </div>
<p class="pricing-audience">{{ plan.audience }}</p>
<p>{{ plan.description }}</p> <p>{{ plan.description }}</p>
<ul> <div v-if="plan.includedFrom" class="pricing-included">
<li v-for="feature in plan.features" :key="feature"> {{ plan.includedFrom }}
</div>
<ul class="pricing-highlights">
<li v-for="highlight in plan.highlights" :key="highlight">
<Check :size="17" aria-hidden="true" /> <Check :size="17" aria-hidden="true" />
{{ feature }} {{ highlight }}
</li> </li>
</ul> </ul>
<a href="#contact" class="pricing-button">{{ plan.highlighted ? '预约方案顾问' : '开始咨询' }}</a> <a
href="#contact"
class="pricing-button"
:aria-label="`${plan.ctaLabel}${plan.name}`"
@click="selectPricingPlan(plan)"
>
{{ plan.ctaLabel }}
</a>
</article> </article>
</AnimatedContent> </AnimatedContent>
</div> </div>
<AnimatedContent :distance="40" :duration="0.8" class-name="pricing-comparison-shell">
<section class="pricing-comparison" aria-labelledby="pricing-comparison-title">
<div class="comparison-head">
<div>
<span>功能对比</span>
<h3 id="pricing-comparison-title">所有版本包含项一屏看清</h3>
</div>
<a href="#contact" class="comparison-head-action">
咨询{{ selectedPricingPlan.name }}
<ArrowRight :size="17" aria-hidden="true" />
</a>
</div>
<div class="comparison-desktop" tabindex="0" aria-label="套餐功能对比表">
<table class="comparison-table">
<thead>
<tr>
<th scope="col">功能项</th>
<th
v-for="plan in pricingPlans"
:key="plan.name"
scope="col"
:class="{ 'comparison-selected-col': selectedComparisonPlanName === plan.name }"
>
<button
type="button"
class="comparison-plan-head"
:class="`comparison-tone-${plan.visualTone}`"
@click="selectComparisonPlan(plan)"
>
<span>{{ plan.name }}</span>
<strong>{{ plan.price }}</strong>
<small>{{ plan.tagline }}</small>
</button>
</th>
</tr>
</thead>
<tbody v-for="group in pricingFeatureGroups" :key="group.name">
<tr class="comparison-group-row">
<th scope="rowgroup" :colspan="pricingPlans.length + 1">{{ group.name }}</th>
</tr>
<tr v-for="row in group.rows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td
v-for="plan in pricingPlans"
:key="plan.name"
:class="{
'comparison-cell-on': isFeatureAvailable(row.values[plan.name]),
'comparison-cell-off': !isFeatureAvailable(row.values[plan.name]),
'comparison-selected-col': selectedComparisonPlanName === plan.name,
[`comparison-tone-${featureOriginTone(row, plan.name)}`]: isFeatureAvailable(row.values[plan.name])
}"
>
<span class="comparison-value">
<Check v-if="isFeatureAvailable(row.values[plan.name])" :size="15" aria-hidden="true" />
{{ featureValueLabel(row.values[plan.name]) }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="comparison-mobile">
<div class="comparison-tabs" role="tablist" aria-label="选择套餐查看功能">
<button
v-for="plan in pricingPlans"
:key="plan.name"
type="button"
role="tab"
:aria-selected="selectedComparisonPlanName === plan.name"
:class="{
'comparison-tab-active': selectedComparisonPlanName === plan.name,
[`comparison-tone-${plan.visualTone}`]: plan.visualTone
}"
@click="selectComparisonPlan(plan)"
>
<span>{{ plan.name }}</span>
<small>{{ plan.price }}</small>
</button>
</div>
<div v-for="group in pricingFeatureGroups" :key="group.name" class="mobile-comparison-group">
<h4>{{ group.name }}</h4>
<div v-for="row in group.rows" :key="row.label" class="mobile-comparison-row">
<span>{{ row.label }}</span>
<strong
:class="{
'comparison-cell-off': !isFeatureAvailable(row.values[selectedComparisonPlanName]),
[`comparison-tone-${featureOriginTone(row, selectedComparisonPlanName)}`]: isFeatureAvailable(row.values[selectedComparisonPlanName])
}"
>
<Check v-if="isFeatureAvailable(row.values[selectedComparisonPlanName])" :size="15" aria-hidden="true" />
{{ featureValueLabel(row.values[selectedComparisonPlanName]) }}
</strong>
</div>
</div>
</div>
</section>
</AnimatedContent>
<AnimatedContent :distance="34" :duration="0.72" class-name="pricing-detail-shell">
<div class="pricing-detail" aria-live="polite">
<div>
<span>已选择</span>
<h3>{{ selectedPricingPlan.name }} · {{ selectedPricingPlan.tagline }}</h3>
<p>{{ selectedPricingPlan.audience }}</p>
</div>
<a href="#contact" class="pricing-detail-action">
{{ selectedPricingPlan.ctaLabel }}
<ArrowRight :size="18" aria-hidden="true" />
</a>
</div>
</AnimatedContent>
</div> </div>
</section> </section>
@@ -298,6 +488,11 @@ const reviews = [
<AnimatedContent :distance="56" direction="horizontal" :reverse="true" :duration="0.95" class-name="contact-form-shell"> <AnimatedContent :distance="56" direction="horizontal" :reverse="true" :duration="0.95" class-name="contact-form-shell">
<form class="contact-form" aria-label="预约演示表单"> <form class="contact-form" aria-label="预约演示表单">
<div class="selected-plan-note">
<span>已选择套餐</span>
<strong>{{ selectedPricingPlan.name }} · {{ selectedPricingPlan.price }}</strong>
<small>{{ selectedPricingPlan.ctaIntent }}</small>
</div>
<label> <label>
姓名 姓名
<input type="text" placeholder="请输入姓名"> <input type="text" placeholder="请输入姓名">
@@ -312,7 +507,7 @@ const reviews = [
</label> </label>
<label> <label>
关注场景 关注场景
<textarea rows="4" placeholder="例如医共体协同、慢病随访、互联网医院建设"></textarea> <textarea rows="4" :placeholder="`${selectedPricingPlan.ctaIntent},也想了解例如医共体协同、慢病随访、互联网医院建设。`"></textarea>
</label> </label>
<button type="button" class="btn-primary justify-center"> <button type="button" class="btn-primary justify-center">
提交预约 提交预约

266
src/views/video/index.vue Normal file
View File

@@ -0,0 +1,266 @@
<script setup>
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { ArrowRight, BadgeCheck, CheckCircle2, Clock3, Layers3, ListVideo, Play, Smartphone, Sparkles, Stethoscope, Store } from 'lucide-vue-next'
import CinematicVideoPlayer from '@/components/CinematicVideoPlayer.vue'
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 SplitText from '@/components/vue-bits/SplitText.vue'
import { videoShowcase } from '@/site.config'
const route = useRoute()
const videoTabs = [
{ key: 'user', label: '用户端', to: '/video/user', icon: Smartphone },
{ key: 'doctor', label: '医生端', to: '/video/doctor', icon: Stethoscope },
{ key: 'manager', label: '小程序店长管理端', to: '/video/manager', icon: Store }
]
const currentKey = computed(() => {
const key = route.params.audience
return Object.prototype.hasOwnProperty.call(videoShowcase, key) ? key : 'user'
})
const currentVideo = computed(() => videoShowcase[currentKey.value])
const videoQueue = computed(() => currentVideo.value.videos || [])
const queryVideoId = computed(() => {
const value = route.query.video
return Array.isArray(value) ? value[0] : value
})
const selectedVideo = computed(() => (
videoQueue.value.find((video) => video.id === queryVideoId.value) || videoQueue.value[0]
))
function videoTo(id) {
return {
name: 'VideoShowcase',
params: { audience: currentKey.value },
query: { video: id }
}
}
</script>
<template>
<main class="video-page" :style="{ '--video-accent': currentVideo.accent }">
<section class="video-hero section-pad">
<div class="video-hero-shell mx-auto grid max-w-7xl gap-10 px-5 pt-24 md:px-8 xl:grid-cols-[0.88fr_1.12fr]">
<AnimatedContent :key="`${currentKey}-copy`" :distance="54" :duration="0.9" class-name="video-hero-copy">
<div class="video-eyebrow">
<BadgeCheck :size="16" aria-hidden="true" />
视频中心 · {{ currentVideo.badge }}
</div>
<SplitText
:key="`${currentKey}-title`"
:text="currentVideo.title"
tag="h1"
class-name="video-hero-title"
split-type="lines"
:delay="64"
:duration="0.95"
ease="power4.out"
:from="{ opacity: 0, y: 42, filter: 'blur(14px)' }"
:to="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
/>
<p class="video-hero-subtitle">
<DecryptedText
:key="`${currentKey}-subtitle`"
:text="currentVideo.subtitle"
animate-on="view"
reveal-direction="center"
:speed="30"
:max-iterations="10"
encrypted-class-name="decrypted-ghost"
class-name="decrypted-final"
/>
</p>
<nav class="video-role-tabs" aria-label="视频角色导航">
<RouterLink
v-for="tab in videoTabs"
:key="tab.key"
:to="tab.to"
class="video-role-tab"
:class="{ 'video-role-tab-active': tab.key === currentKey }"
>
<component :is="tab.icon" :size="18" aria-hidden="true" />
<span>{{ tab.label }}</span>
</RouterLink>
</nav>
<div class="video-hero-actions">
<Magnet :padding="80" :magnet-strength="5.2">
<RouterLink to="/product#contact" class="video-primary-action">
{{ currentVideo.cta }}
<ArrowRight :size="18" aria-hidden="true" />
</RouterLink>
</Magnet>
<a href="#chapters" class="video-secondary-action">
查看章节
</a>
</div>
</AnimatedContent>
<AnimatedContent :key="`${currentKey}-player`" :distance="68" direction="horizontal" :reverse="true" :duration="0.95" class-name="video-player-shell">
<div class="video-player-layout">
<div class="video-player-stage">
<CinematicVideoPlayer
:key="selectedVideo.id"
:src="selectedVideo.src"
:poster="selectedVideo.poster"
:title="selectedVideo.title"
:accent="currentVideo.accent"
/>
<div class="video-now-playing">
<span>{{ selectedVideo.tag }}</span>
<div>
<h2>{{ selectedVideo.title }}</h2>
<p>{{ selectedVideo.description }}</p>
</div>
<small>{{ selectedVideo.duration }}</small>
</div>
</div>
<aside class="video-playlist" aria-label="视频播放列表">
<div class="video-playlist-head">
<ListVideo :size="20" aria-hidden="true" />
<div>
<strong>{{ currentVideo.label }}视频库</strong>
<span>{{ videoQueue.length }} 个视频</span>
</div>
</div>
<RouterLink
v-for="(video, index) in videoQueue"
:key="video.id"
:to="videoTo(video.id)"
class="video-playlist-card"
:class="{ 'video-playlist-card-active': video.id === selectedVideo.id }"
>
<img :src="video.poster" :alt="`${video.title}封面`" loading="lazy">
<div>
<span>{{ String(index + 1).padStart(2, '0') }} · {{ video.tag }}</span>
<strong>{{ video.title }}</strong>
<p>{{ video.description }}</p>
</div>
<small>{{ video.duration }}</small>
</RouterLink>
</aside>
</div>
</AnimatedContent>
</div>
</section>
<section class="video-metrics-section">
<div class="mx-auto grid max-w-7xl gap-4 px-5 md:grid-cols-3 md:px-8">
<AnimatedContent
v-for="(metric, index) in currentVideo.metrics"
:key="`${currentKey}-${metric.label}`"
:delay="index * 0.07"
:distance="38"
class-name="video-metric-reveal"
>
<article class="video-metric-card">
<strong>{{ metric.value }}</strong>
<span>{{ metric.label }}</span>
</article>
</AnimatedContent>
</div>
</section>
<section id="chapters" class="video-section video-section-muted">
<div class="mx-auto grid max-w-7xl gap-10 px-5 md:px-8 lg:grid-cols-[0.82fr_1.18fr]">
<AnimatedContent :distance="44" :duration="0.85" class-name="video-section-heading">
<span>Chapter Flow</span>
<h2>按观看节奏拆解关键场景</h2>
<p>适合销售演示客户培训和内部对齐先让用户看懂价值再进入细节</p>
</AnimatedContent>
<div class="video-chapter-list">
<AnimatedContent
v-for="(chapter, index) in selectedVideo.chapters"
:key="`${currentKey}-${selectedVideo.id}-${chapter.title}`"
:delay="index * 0.08"
:distance="46"
direction="horizontal"
:reverse="index % 2 === 1"
class-name="video-chapter-reveal"
>
<article class="video-chapter-card">
<span>{{ chapter.time }}</span>
<div>
<h3>{{ chapter.title }}</h3>
<p>{{ chapter.body }}</p>
</div>
<Play :size="19" fill="currentColor" aria-hidden="true" />
</article>
</AnimatedContent>
</div>
</div>
</section>
<section class="video-section">
<div class="mx-auto max-w-7xl px-5 md:px-8">
<AnimatedContent :distance="42" :duration="0.85" class-name="video-section-heading centered">
<span>Experience Details</span>
<h2>{{ currentVideo.label }}体验亮点</h2>
</AnimatedContent>
<div class="video-highlight-grid">
<AnimatedContent
v-for="(highlight, index) in selectedVideo.highlights"
:key="`${currentKey}-${selectedVideo.id}-${highlight}`"
:delay="index * 0.08"
:distance="42"
class-name="video-highlight-reveal"
>
<article class="video-highlight-card">
<CheckCircle2 :size="24" aria-hidden="true" />
<h3>{{ highlight }}</h3>
<p>围绕真实 C 端观看和演示场景保持内容清楚交互轻盈重点突出</p>
</article>
</AnimatedContent>
</div>
</div>
</section>
<section class="video-section video-final-section">
<div class="mx-auto grid max-w-7xl gap-8 px-5 md:px-8 lg:grid-cols-[1fr_0.9fr]">
<AnimatedContent :distance="48" :duration="0.9" class-name="video-final-copy">
<div class="video-eyebrow">
<Sparkles :size="16" aria-hidden="true" />
Demo Ready
</div>
<h2>需要替换真实视频素材时只改配置即可</h2>
<p>三端页面已经按视频库建模后续可以为每条视频单独配置地址封面章节标签时长和讲解重点</p>
<Magnet :padding="84" :magnet-strength="5">
<RouterLink to="/product#contact" class="video-primary-action">
联系顾问
<ArrowRight :size="18" aria-hidden="true" />
</RouterLink>
</Magnet>
</AnimatedContent>
<AnimatedContent :distance="48" direction="horizontal" :reverse="true" :duration="0.9" class-name="video-scene-panel">
<div class="video-scene-head">
<Layers3 :size="24" aria-hidden="true" />
<span>适用场景</span>
</div>
<div class="video-scene-list">
<span v-for="scene in currentVideo.scenes" :key="scene">{{ scene }}</span>
</div>
<div class="video-timeline" aria-hidden="true">
<span v-for="chapter in selectedVideo.chapters" :key="chapter.time"></span>
</div>
<div class="video-duration-note">
<Clock3 :size="18" aria-hidden="true" />
建议演示时长 90 秒以内
</div>
</AnimatedContent>
</div>
</section>
</main>
</template>