初始化
This commit is contained in:
@@ -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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 491 B |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
235
src/components/CinematicVideoPlayer.vue
Normal file
235
src/components/CinematicVideoPlayer.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
2185
src/style.css
2185
src/style.css
File diff suppressed because it is too large
Load Diff
@@ -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
266
src/views/video/index.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user