模拟操作

This commit is contained in:
李琦
2026-08-12 17:17:36 +08:00
parent 634be8991c
commit 36f557afa5
30 changed files with 14555 additions and 220 deletions

View File

@@ -12,7 +12,8 @@ interface CinematicVideoPlayerProps {
const props = withDefaults(defineProps<CinematicVideoPlayerProps>(), {
poster: '',
title: '产品演示视频',
accent: '#14b8a6'
// 默认强调色与全站品牌青蓝一致(--brand 亮色值)
accent: '#0891b2'
})
const videoRef = ref<HTMLVideoElement | null>(null)

View File

@@ -55,6 +55,7 @@ const themeOptions = [
const menuGroups = [
{ key: 'home', label: '首页', to: '/', links: siteConfig.navigation.secondary.home },
{ key: 'product', label: '产品', to: '/product', links: siteConfig.navigation.secondary.product },
{ key: 'demo', label: '流程模拟', to: '/demo/full-journey', activePath: '/demo', links: siteConfig.navigation.secondary.demo },
{ key: 'video', label: '视频', to: '/video/user', activePath: '/video', links: siteConfig.navigation.secondary.video },
{ key: 'about', label: '关于我们', to: '/about', links: siteConfig.navigation.secondary.about }
]
@@ -87,6 +88,14 @@ const routeSearchItems = computed(() => [
category: '页面',
keywords: ['产品介绍', '功能', '价格', 'FAQ']
},
{
id: 'route-demo',
title: '流程模拟',
description: '跨端全流程演示(患者扫码到支付完成),以及线下挂号、在线复诊、药店购药、订单支付、医生端接诊开方与 PC 端接诊开方、代付的可交互演示。',
to: '/demo/full-journey',
category: '页面',
keywords: ['流程模拟', '演示', '全流程', '全景', '跨端', '挂号', '复诊', '购药', '支付', '医生端', '开方', 'PC端', '线下接诊', '在线复诊开方', '发送处方', '就诊人', '认领', '代付']
},
{
id: 'route-video',
title: '视频',
@@ -109,6 +118,7 @@ const navigationSearchItems = computed(() => {
const groups = [
{ category: '首页标题', links: siteConfig.navigation.secondary.home },
{ category: '产品标题', links: siteConfig.navigation.secondary.product },
{ category: '流程模拟', links: siteConfig.navigation.secondary.demo },
{ category: '视频分区', links: siteConfig.navigation.secondary.video },
{ category: '关于我们', links: siteConfig.navigation.secondary.about }
]

View File

@@ -0,0 +1,119 @@
<template>
<span ref="rootRef" class="count-up" :aria-label="value">{{ displayText }}</span>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
/**
* 数字滚动组件(CountUp)
*
* 为什么这样写:
* - 官网多个指标(1200+ / 38% / 99.95% / 7x24)都是「数字 + 后缀」的混合字符串,
* 因此组件直接接收原始字符串,内部解析出「前缀 + 数字 + 后缀」,滚动数字部分、原样保留其余部分,
* 调用方无需拆字段,配置文件也不用改结构。
* - 使用 IntersectionObserver 进入视口才启动,避免首屏外的指标提前跑完;只触发一次。
* - 使用 requestAnimationFrame + easeOutCubic 缓动,结尾减速更有「落定」感。
* - 尊重 prefers-reduced-motion:直接显示最终值,不做动画。
*/
interface Props {
/** 原始展示值,如 "1200+"、"99.95%"、"7x24",会自动解析数字部分 */
value: string;
/** 动画时长(毫秒) */
duration?: number;
/** 进入视口多少比例后启动 */
threshold?: number;
/** 起始值(默认从 0 滚动) */
from?: number;
}
const props = withDefaults(defineProps<Props>(), {
duration: 1600,
threshold: 0.4,
from: 0
});
const rootRef = ref<HTMLElement | null>(null);
const started = ref(false);
const current = ref(props.from);
let observer: IntersectionObserver | null = null;
let rafId = 0;
/** 解析 "前缀 + 数字 + 后缀",例如 "99.95%" → { prefix: '', num: 99.95, decimals: 2, suffix: '%' } */
const parsed = computed(() => {
const match = String(props.value).match(/^([^0-9]*)([0-9]+(?:\.[0-9]+)?)([\s\S]*)$/);
if (!match) {
return { prefix: String(props.value), num: null as number | null, decimals: 0, suffix: '' };
}
const decimals = match[2].includes('.') ? match[2].split('.')[1].length : 0;
return { prefix: match[1], num: Number(match[2]), decimals, suffix: match[3] };
});
const displayText = computed(() => {
const { prefix, num, decimals, suffix } = parsed.value;
if (num === null) return prefix;
const shown = started.value ? current.value : props.from;
return `${prefix}${shown.toFixed(decimals)}${suffix}`;
});
/** 启动滚动动画:easeOutCubic 缓动到目标值 */
function startAnimation() {
if (started.value || parsed.value.num === null) {
started.value = true;
return;
}
started.value = true;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const target = parsed.value.num;
if (reduceMotion) {
current.value = target;
return;
}
const startTime = performance.now();
const from = props.from;
const tick = (now: number) => {
const progress = Math.min(1, (now - startTime) / props.duration);
const eased = 1 - Math.pow(1 - progress, 3);
current.value = from + (target - from) * eased;
if (progress < 1) {
rafId = requestAnimationFrame(tick);
} else {
current.value = target;
}
};
rafId = requestAnimationFrame(tick);
}
onMounted(() => {
if (!rootRef.value || !('IntersectionObserver' in window)) {
startAnimation();
return;
}
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
startAnimation();
observer?.disconnect();
observer = null;
}
},
{ threshold: props.threshold }
);
observer.observe(rootRef.value);
});
onBeforeUnmount(() => {
observer?.disconnect();
if (rafId) cancelAnimationFrame(rafId);
});
</script>
<style scoped>
.count-up {
/* 等宽数字避免滚动过程中抖动 */
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div
ref="frameRef"
class="tilt-frame"
:style="{ perspective: `${perspective}px` }"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
>
<div ref="bodyRef" class="tilt-body" :class="{ 'tilt-body-active': isHovering }">
<slot />
<div v-if="showGlare" class="tilt-glare" aria-hidden="true" :style="glareStyle"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
/**
* 3D 倾斜卡片(TiltCard)
*
* 为什么这样写:
* - 官网「驾驶舱/手机模拟器」需要跟随鼠标的 3D 倾斜效果(UI 规范中的「3D 倾斜驾驶舱」)。
* - 只用 transform(rotateX/rotateY)+ opacity,不引入第三方动画库,保证流畅并符合项目动效约束。
* - 用 rAF 循环做 lerp 平滑(阻尼跟随),而不是直接 set,避免鼠标快速移动时的生硬跳动。
* - 触摸设备与 prefers-reduced-motion 用户自动禁用,保证可用性。
*/
interface Props {
/** 最大倾斜角度(度) */
maxTilt?: number;
/** 透视距离,越小透视越强 */
perspective?: number;
/** 阻尼系数 0-1,越大跟随越快 */
damping?: number;
/** 悬停时的整体放大比例 */
hoverScale?: number;
/** 是否显示跟随光泽 */
showGlare?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
maxTilt: 9,
perspective: 1100,
damping: 0.12,
hoverScale: 1.012,
showGlare: true
});
const frameRef = ref<HTMLElement | null>(null);
const bodyRef = ref<HTMLElement | null>(null);
const isHovering = ref(false);
const glarePos = ref({ x: 50, y: 38 });
/** 目标角度与当前角度分离,rAF 里做插值平滑 */
const target = { rx: 0, ry: 0, scale: 1 };
const state = { rx: 0, ry: 0, scale: 1 };
let rafId = 0;
let disabled = false;
const glareStyle = computed(() => ({
opacity: isHovering.value ? 0.55 : 0,
background: `radial-gradient(420px circle at ${glarePos.value.x}% ${glarePos.value.y}%, rgba(255, 255, 255, 0.26), transparent 62%)`
}));
/** rAF 循环:向目标角度做 lerp,静止后停帧节能 */
function loop() {
state.rx += (target.rx - state.rx) * props.damping;
state.ry += (target.ry - state.ry) * props.damping;
state.scale += (target.scale - state.scale) * props.damping;
if (bodyRef.value) {
bodyRef.value.style.transform = `rotateX(${state.rx.toFixed(3)}deg) rotateY(${state.ry.toFixed(3)}deg) scale(${state.scale.toFixed(4)})`;
}
const settled =
Math.abs(target.rx - state.rx) < 0.01 &&
Math.abs(target.ry - state.ry) < 0.01 &&
Math.abs(target.scale - state.scale) < 0.001;
rafId = settled ? 0 : requestAnimationFrame(loop);
}
function kickLoop() {
if (!rafId) rafId = requestAnimationFrame(loop);
}
function handlePointerMove(event: PointerEvent) {
if (disabled || event.pointerType === 'touch' || !frameRef.value) return;
const rect = frameRef.value.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width;
const py = (event.clientY - rect.top) / rect.height;
isHovering.value = true;
target.ry = (px - 0.5) * 2 * props.maxTilt;
target.rx = -(py - 0.5) * 2 * props.maxTilt;
target.scale = props.hoverScale;
glarePos.value = { x: Math.round(px * 100), y: Math.round(py * 100) };
kickLoop();
}
function handlePointerLeave() {
isHovering.value = false;
target.rx = 0;
target.ry = 0;
target.scale = 1;
kickLoop();
}
onMounted(() => {
disabled = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
});
onBeforeUnmount(() => {
if (rafId) cancelAnimationFrame(rafId);
});
</script>
<style scoped>
.tilt-frame {
position: relative;
}
.tilt-body {
position: relative;
transform-style: preserve-3d;
will-change: transform;
}
.tilt-glare {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
transition: opacity 0.45s ease;
mix-blend-mode: soft-light;
}
</style>

View File

@@ -30,6 +30,7 @@ const backdropClass = computed(() => {
<nav aria-label="底部导航">
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/product">产品</RouterLink>
<RouterLink to="/demo/full-journey">流程模拟</RouterLink>
<RouterLink to="/video/user">视频</RouterLink>
<RouterLink to="/about">关于我们</RouterLink>
<RouterLink to="/#business">业务介绍</RouterLink>

View File

@@ -9,6 +9,9 @@ const routes = [
children: [
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
{ path: 'product', name: 'Product', component: () => import('@/views/product/index.vue') },
// 流程模拟:小程序真实流程的可交互演示(患者端 5 个流程 + 医生端接诊开方)
{ path: 'demo', redirect: '/demo/full-journey' },
{ path: 'demo/:flow(full-journey|offline-register|online-followup|pharmacy-buy|pharmacy-followup|order-pay|doctor-reception|pc-prescription|pc-reception)', name: 'FlowDemo', component: () => import('@/views/demo/index.vue') },
{ path: 'video', redirect: '/video/user' },
{ path: 'video/:audience(user|doctor|manager)', name: 'VideoShowcase', component: () => import('@/views/video/index.vue') },
{ path: 'about', name: 'About', component: () => import('@/views/about/index.vue') },

View File

@@ -8,6 +8,7 @@ export const siteConfig = {
primary: [
{ label: '首页', to: '/' },
{ label: '产品', to: '/product' },
{ label: '流程模拟', to: '/demo/full-journey' },
{ label: '视频', to: '/video/user' },
{ label: '关于我们', to: '/about' }
],
@@ -25,6 +26,18 @@ export const siteConfig = {
{ label: 'FAQ', to: '/product#faq' },
{ label: '联系方式', to: '/product#contact' }
],
// 流程模拟子导航:跨端全景 + 患者端五个业务流程 + 医生端 / PC 端接诊开方
demo: [
{ label: '全流程演示', to: '/demo/full-journey' },
{ label: '线下挂号', to: '/demo/offline-register' },
{ label: '在线复诊', to: '/demo/online-followup' },
{ label: '药店购药', to: '/demo/pharmacy-buy' },
{ label: '药店复诊', to: '/demo/pharmacy-followup' },
{ label: '订单支付', to: '/demo/order-pay' },
{ label: '医生端接诊开方', to: '/demo/doctor-reception' },
{ label: 'PC端在线复诊开方', to: '/demo/pc-prescription' },
{ label: 'PC端线下接诊与代付', to: '/demo/pc-reception' }
],
video: [
{ label: '用户端', to: '/video/user' },
{ label: '医生端', to: '/video/doctor' },
@@ -81,11 +94,12 @@ export const siteConfig = {
export const videoShowcase = {
user: {
label: '用户端',
badge: 'Patient App',
badge: '患者端演示',
title: '把就医、复诊和健康管理放进一个温柔的移动体验。',
subtitle: '面向患者和家属,展示预约复诊、报告查看、用药提醒、随访沟通与健康档案的完整路径。',
cta: '预约用户端演示',
accent: '#14b8a6',
// 视频页强调色统一走品牌青蓝(与全站 --brand 一致),不再每端一个色
accent: '#0891b2',
metrics: [
{ value: '3 步', label: '完成复诊预约' },
{ value: '24h', label: '随访消息触达' },
@@ -93,60 +107,60 @@ export const videoShowcase = {
],
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-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: '随访与复诊流程',
title: '患者复诊流程',
description: '展示患者收到提醒、填写问卷、提交复诊申请到收到医生建议的完整闭环。',
duration: '01:36',
duration: '02:20',
tag: '服务流程',
src: siteConfig.hero.video.src,
src: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/blog/%E6%82%A3%E8%80%85%E7%AB%AF%E5%9C%A8%E7%BA%BF%E5%A4%8D%E8%AF%8A.mov',
poster: siteConfig.hero.video.poster,
chapters: [
{ time: '00:06', title: '提醒触达', body: '用清楚的任务卡片提示患者按时完成随访和复诊动作。' },
{ time: '00:31', title: '问卷填写', body: '把症状、用药、指标和反馈拆成移动端易填写的步骤。' },
{ time: '01:05', title: '医生建议回传', body: '医生处理后,患者端能看到复诊建议、宣教内容和下一次提醒。' }
// { time: '00:06', title: '提醒触达', body: '用清楚的任务卡片提示患者按时完成随访和复诊动作。' },
// { time: '00:31', title: '问卷填写', body: '把症状、用药、指标和反馈拆成移动端易填写的步骤。' },
// { time: '01:05', title: '医生建议回传', body: '医生处理后,患者端能看到复诊建议、宣教内容和下一次提醒。' }
],
highlights: ['随访任务清单化', '复诊状态实时可见', '医患沟通自然衔接']
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: ['多成员管理清晰', '适合家庭照护', '降低老人操作压力']
}
// {
// 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',
badge: '医生端演示',
title: '让医生快速看清患者状态,并把关键任务稳稳接住。',
subtitle: '面向医生团队,展示患者画像、风险提醒、复诊处理、随访审核和协同任务流。',
cta: '预约医生端演示',
accent: '#22d3ee',
accent: '#0891b2',
metrics: [
{ value: '1 屏', label: '查看患者概况' },
{ value: 'AI', label: '辅助风险分层' },
@@ -203,11 +217,11 @@ export const videoShowcase = {
},
manager: {
label: '小程序店长管理端',
badge: 'Mini Program Manager',
badge: '店长端演示',
title: '让店长在小程序里看懂运营、服务和团队执行。',
subtitle: '面向门店店长和运营负责人,展示门店指标、服务进度、人员任务、客户触达与异常提醒。',
cta: '预约店长端演示',
accent: '#059669',
accent: '#0891b2',
metrics: [
{ value: '实时', label: '查看门店经营状态' },
{ value: '清单化', label: '跟进服务任务' },

View File

@@ -2431,6 +2431,12 @@ textarea {
right: 1rem;
top: 76px;
z-index: 40;
/* 菜单组展开后可能高于视口(流程模拟组有 7 个子链接),限高并让内部滚动,避免底部选项超出屏幕够不到 */
max-height: calc(100vh - 92px);
max-height: calc(100dvh - 92px);
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.mobile-menu-group {
@@ -2490,6 +2496,31 @@ textarea {
transform: translateY(-1rem);
}
/* 窄机型顶栏防溢出:搜索按钮收成纯图标,控件间距收紧,logo 文字保留但不再撑最小宽 */
@media (max-width: 480px) {
.site-header {
padding: 0.75rem;
}
.site-actions {
gap: 0.4rem;
}
.site-search-trigger {
height: 2.5rem;
padding: 0;
width: 2.5rem;
}
.site-search-trigger span {
display: none;
}
.site-logo span {
font-size: 0.95rem;
}
}
.home-page {
background: transparent;
color: #d7e2ea;
@@ -2612,20 +2643,21 @@ textarea {
padding: 0.72rem 1.35rem;
}
/* 首页主 CTA:统一品牌青蓝渐变,与全站 --brand 同色系(替换原先的薄荷绿渐变) */
.watch-button {
align-items: center;
background: linear-gradient(135deg, #ffffff 0%, #c8fff2 48%, #ffffff 100%);
color: #041012;
background: linear-gradient(135deg, #0e7490 0%, #0891b2 46%, #22d3ee 100%);
color: #ffffff;
display: inline-flex;
font-weight: 700;
gap: 0.5rem;
text-decoration: none;
transition: background 180ms ease-out, transform 180ms ease-out;
box-shadow: 0 20px 48px rgba(20, 184, 166, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.72);
transition: filter 180ms ease-out, transform 180ms ease-out;
box-shadow: 0 18px 42px rgba(8, 145, 178, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.28);
}
.watch-button:hover {
background: #e5e7eb;
filter: brightness(1.1);
transform: translateY(-1px);
}
@@ -2841,13 +2873,18 @@ textarea {
text-transform: uppercase;
}
/* 联系顾问/立即咨询:原为作品集模板遗留的紫红橙渐变,统一为品牌青蓝渐变 */
.creator-contact-button {
background: linear-gradient(123deg, #18011f 7%, #b600a8 37%, #7621b0 72%, #be4c00 100%);
box-shadow: 0 4px 4px rgba(181, 1, 167, 0.25), 4px 4px 12px #7721b1 inset;
background: linear-gradient(135deg, #0e7490 0%, #0891b2 48%, #22d3ee 100%);
box-shadow: 0 14px 34px rgba(8, 145, 178, 0.36), inset 0 1px 0 rgba(255, 255, 255, 0.24);
color: white;
outline: 2px solid white;
outline-offset: -3px;
padding: 0.9rem 2.5rem;
transition: filter 180ms ease-out, transform 180ms ease-out;
}
.creator-contact-button:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.creator-services-section {
@@ -3006,15 +3043,18 @@ textarea {
max-width: 44rem;
}
/* 案例卡「预约演示」描边按钮:描边与文字向品牌青靠拢,与主 CTA 同色系 */
.live-project-button {
border: 2px solid #d7e2ea;
color: #d7e2ea;
border: 2px solid rgba(34, 211, 238, 0.45);
color: #a5f3fc;
padding: 0.85rem 1.5rem;
white-space: nowrap;
transition: background 180ms ease-out, border-color 180ms ease-out;
}
.live-project-button:hover {
background: rgba(215, 226, 234, 0.1);
background: rgba(34, 211, 238, 0.12);
border-color: rgba(34, 211, 238, 0.7);
}
.case-image-grid {
@@ -5260,9 +5300,10 @@ textarea {
}
/* Video showcase: cinematic but accessible C-end demo experience */
/* 背景光斑统一品牌青蓝(--brand),不再混入 teal 第二色系 */
.video-route-backdrop {
background:
radial-gradient(circle at 20% 0%, color-mix(in srgb, #14b8a6 14%, transparent), transparent 34rem),
radial-gradient(circle at 20% 0%, color-mix(in srgb, #0891b2 14%, transparent), transparent 34rem),
radial-gradient(circle at 86% 18%, color-mix(in srgb, #22d3ee 16%, transparent), transparent 30rem),
var(--app-bg);
}
@@ -5286,7 +5327,8 @@ textarea {
}
.video-page {
--video-accent: #14b8a6;
/* 强调色直接引用全站品牌变量:亮色 #0891b2、暗色自动切到 #22d3ee,保证与全站主题一致 */
--video-accent: var(--brand);
--video-ink: #102a32;
--video-muted: #4d6570;
--video-panel: rgba(255, 255, 255, 0.84);
@@ -5435,11 +5477,12 @@ textarea {
transition: background 200ms ease-out, border-color 200ms ease-out, color 200ms ease-out, transform 200ms ease-out;
}
/* 主 CTA 与首页 watch-button 同款品牌青蓝渐变,全站按钮语言统一 */
.video-primary-action {
background: linear-gradient(135deg, #ffffff 0%, color-mix(in srgb, var(--video-accent) 30%, #ffffff) 55%, #ffffff 100%);
border: 1px solid color-mix(in srgb, var(--video-accent) 36%, rgba(255, 255, 255, 0.72));
box-shadow: 0 18px 42px color-mix(in srgb, var(--video-accent) 20%, rgba(10, 66, 74, 0.12));
color: #061316;
background: linear-gradient(135deg, #0e7490 0%, #0891b2 46%, #22d3ee 100%);
border: 1px solid color-mix(in srgb, var(--video-accent) 40%, transparent);
box-shadow: 0 18px 42px color-mix(in srgb, var(--video-accent) 26%, rgba(10, 66, 74, 0.12));
color: #ffffff;
}
.video-primary-action:hover {
@@ -5658,7 +5701,7 @@ textarea {
.cinematic-player-video {
cursor: pointer;
height: 100%;
object-fit: cover;
object-fit: contain;
width: 100%;
}

View File

@@ -0,0 +1,182 @@
<template>
<div class="browser-frame">
<!-- 标签栏:红黄绿窗控 + 页签 -->
<div class="browser-tabs">
<span class="browser-lights" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="browser-tab">
<span class="browser-favicon" aria-hidden="true"></span>
{{ tabTitle }}
<svg class="browser-tab-close" width="8" height="8" viewBox="0 0 8 8" aria-hidden="true"><path d="M1 1l6 6M7 1L1 7" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</span>
<span class="browser-tab-add" aria-hidden="true">+</span>
</div>
<!-- 地址栏 -->
<div class="browser-addr">
<span class="browser-nav-btns" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M15 5l-7 7 7 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" opacity="0.4"><path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M20 11A8 8 0 1 0 20 13" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M20 5v6h-6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="browser-url">
<svg width="10" height="12" viewBox="0 0 12 14" fill="none" aria-hidden="true"><rect x="1" y="6" width="10" height="7" rx="1.6" fill="currentColor" opacity="0.75"/><path d="M3.5 6V4.5a2.5 2.5 0 0 1 5 0V6" stroke="currentColor" stroke-width="1.6"/></svg>
{{ url }}
</span>
</div>
<!-- 页面内容:逻辑尺寸 1024 x 640 -->
<div class="browser-viewport">
<slot />
<!-- 覆盖层:居中弹窗、消息提示等 -->
<div class="browser-overlay">
<slot name="overlay" />
</div>
</div>
</div>
</template>
<script setup>
/**
* 浏览器窗口壳(BrowserFrame)
*
* 为什么这样写:
* - PC 管理端的流程模拟需要一个「桌面浏览器」语境,与手机壳(PhoneFrame)形成明确的端别区分。
* - 视口逻辑尺寸固定 1024x640,复刻 Vben Admin 布局时按此宽度排版,外层由 demo 壳统一等比缩放。
* - overlay 插槽承载居中弹窗(VbenModal 形态)与消息提示,盖在页面内容之上。
*/
defineProps({
/** 浏览器页签标题 */
tabTitle: { type: String, default: '萧康云医管理后台' },
/** 地址栏 URL */
url: { type: String, default: 'admin.xkyy.cn/doctor/doctor-reception' }
})
</script>
<style scoped>
.browser-frame {
width: 1024px;
border-radius: 12px;
overflow: hidden;
background: #dee1e6;
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.08),
0 30px 70px rgba(10, 30, 46, 0.32),
0 8px 20px rgba(10, 30, 46, 0.2);
}
/* 标签栏 */
.browser-tabs {
display: flex;
align-items: flex-end;
gap: 8px;
height: 38px;
padding: 0 12px;
background: #dee1e6;
}
.browser-lights {
display: inline-flex;
gap: 7px;
margin: 0 6px 9px 2px;
}
.browser-lights i {
width: 11px;
height: 11px;
border-radius: 50%;
}
.browser-lights i:nth-child(1) { background: #ff5f57; }
.browser-lights i:nth-child(2) { background: #febc2e; }
.browser-lights i:nth-child(3) { background: #28c840; }
.browser-tab {
display: inline-flex;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 12px;
border-radius: 9px 9px 0 0;
background: #f7f8fa;
font-size: 12px;
color: #3c4043;
max-width: 240px;
white-space: nowrap;
}
.browser-favicon {
width: 13px;
height: 13px;
border-radius: 4px;
background: linear-gradient(135deg, #298dff, #4175ee);
flex-shrink: 0;
}
.browser-tab-close {
color: #9aa0a6;
margin-left: 4px;
flex-shrink: 0;
}
.browser-tab-add {
margin-bottom: 7px;
font-size: 15px;
color: #5f6368;
line-height: 1;
}
/* 地址栏 */
.browser-addr {
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 12px;
background: #f7f8fa;
border-bottom: 1px solid #e4e7eb;
}
.browser-nav-btns {
display: inline-flex;
align-items: center;
gap: 10px;
color: #5f6368;
}
.browser-url {
flex: 1;
display: inline-flex;
align-items: center;
gap: 7px;
height: 26px;
padding: 0 13px;
border-radius: 13px;
background: #eef1f4;
font-size: 12px;
color: #3c4043;
letter-spacing: 0.01em;
}
.browser-url svg {
color: #5f6368;
}
/* 视口 */
.browser-viewport {
position: relative;
width: 1024px;
height: 640px;
background: #f5f7fa;
overflow: hidden;
isolation: isolate;
}
.browser-overlay {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
.browser-overlay :deep(> *) {
pointer-events: auto;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<svg
class="fake-qrcode"
:width="size"
:height="size"
:viewBox="`0 0 ${GRID} ${GRID}`"
role="img"
aria-label="演示二维码(虚构,不可扫描)"
>
<rect :width="GRID" :height="GRID" fill="#ffffff" />
<!-- 数据模块:伪随机分布 -->
<rect
v-for="cell in cells"
:key="`${cell[0]}-${cell[1]}`"
:x="cell[0]"
:y="cell[1]"
width="1"
height="1"
fill="#111827"
/>
<!-- 三个定位角 -->
<g v-for="(pos, index) in finders" :key="index" :transform="`translate(${pos[0]}, ${pos[1]})`">
<rect width="7" height="7" fill="#111827" />
<rect x="1" y="1" width="5" height="5" fill="#ffffff" />
<rect x="2" y="2" width="3" height="3" fill="#111827" />
</g>
</svg>
</template>
<script setup>
/**
* 演示用假二维码(FakeQrcode)
*
* 为什么这样写:
* - 流程模拟里的「认领码 / 代支付码」需要二维码视觉,但不能依赖外部图片或真实生成库,
* 用 SVG 按 25x25 网格画出定位角 + 伪随机数据模块,视觉可信且零依赖。
* - 伪随机使用固定种子的线性同余,保证每次渲染图案一致(避免闪烁),seed 可让不同弹窗图案不同。
*/
import { computed } from 'vue'
const props = defineProps({
/** 渲染尺寸(px) */
size: { type: Number, default: 220 },
/** 图案种子:不同场景传不同值即可得到不同图案 */
seed: { type: Number, default: 7 }
})
const GRID = 25
const finders = [
[0, 0],
[GRID - 7, 0],
[0, GRID - 7]
]
/** 判断坐标是否落在定位角区域(含 1 格留白),这些区域不画数据点 */
function inFinder(x, y) {
return (
(x < 8 && y < 8) ||
(x >= GRID - 8 && y < 8) ||
(x < 8 && y >= GRID - 8)
)
}
/** 固定种子伪随机点阵 */
const cells = computed(() => {
let state = props.seed * 2654435761 % 2147483647
const next = () => {
state = (state * 48271) % 2147483647
return state / 2147483647
}
const list = []
for (let y = 0; y < GRID; y++) {
for (let x = 0; x < GRID; x++) {
if (inFinder(x, y)) continue
if (next() < 0.46) list.push([x, y])
}
}
return list
})
</script>
<style scoped>
.fake-qrcode {
display: block;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<div class="phone-frame" :class="`phone-tone-${statusTone}`">
<!-- 侧键:音量与电源,纯装饰增强真实感 -->
<span class="phone-key phone-key-mute" aria-hidden="true"></span>
<span class="phone-key phone-key-volume-up" aria-hidden="true"></span>
<span class="phone-key phone-key-volume-down" aria-hidden="true"></span>
<span class="phone-key phone-key-power" aria-hidden="true"></span>
<div class="phone-screen" :style="{ background: screenBg }">
<!-- 屏幕内容:由各流程的模拟页面填充,可从屏幕顶部铺满(自定义导航页需要) -->
<div class="phone-content">
<slot />
</div>
<!-- 灵动岛 + 状态栏:永远悬浮在内容上方 -->
<div class="phone-statusbar" aria-hidden="true">
<span class="statusbar-time">9:41</span>
<span class="phone-island"></span>
<span class="statusbar-icons">
<!-- 信号 / WiFi / 电量,内联 SVG 跟随 tone 变色 -->
<svg width="17" height="11" viewBox="0 0 17 11" fill="currentColor"><rect x="0" y="7" width="3" height="4" rx="0.8"/><rect x="4.5" y="5" width="3" height="6" rx="0.8"/><rect x="9" y="2.5" width="3" height="8.5" rx="0.8"/><rect x="13.5" y="0" width="3" height="11" rx="0.8"/></svg>
<svg width="16" height="11" viewBox="0 0 16 11" fill="currentColor"><path d="M8 9.4a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8Zm-3.1-2.6 1.4 1.4a2.4 2.4 0 0 1 3.4 0l1.4-1.4a4.4 4.4 0 0 0-6.2 0ZM2 3.9l1.4 1.4a6.5 6.5 0 0 1 9.2 0L14 3.9a8.5 8.5 0 0 0-12 0Z" transform="translate(0 -1.5)"/></svg>
<svg width="25" height="12" viewBox="0 0 25 12" fill="none"><rect x="0.5" y="0.5" width="21" height="11" rx="3.2" stroke="currentColor" opacity="0.4"/><rect x="2" y="2" width="17" height="8" rx="1.8" fill="currentColor"/><path d="M23 4v4c1-.3 1.6-1 1.6-2S24 4.3 23 4Z" fill="currentColor" opacity="0.4"/></svg>
</span>
</div>
<!-- 微信小程序胶囊按钮:所有小程序页面右上角常驻 -->
<div class="phone-capsule" :class="`capsule-tone-${capsuleTone}`" aria-hidden="true">
<span class="capsule-dots"><i></i><i></i><i></i></span>
<span class="capsule-divider"></span>
<span class="capsule-target"></span>
</div>
<!-- 覆盖层插槽:支付面板、弹窗等需要盖住整屏的内容 -->
<div class="phone-overlay">
<slot name="overlay" />
</div>
<!-- Home 指示条 -->
<span class="phone-home-indicator" :class="`indicator-tone-${indicatorTone}`" aria-hidden="true"></span>
</div>
</div>
</template>
<script setup>
/**
* 手机壳组件(PhoneFrame)
*
* 为什么这样写:
* - 流程模拟的所有小程序界面都放在这个「手机」里展示,壳体统一负责灵动岛、状态栏、
* 微信胶囊按钮和 Home 指示条,各流程只需要专注还原页面本身。
* - 屏幕逻辑宽度固定 375px(微信小程序 750rpx = 375px,即 1rpx = 0.5px),
* 这样复刻源码里的 rpx 尺寸时直接除以 2 即可,换算无歧义。
* - 状态栏/胶囊的深浅色由 props 控制,因为不同页面导航底色不同(白底页用深色文字,蓝底页用白色)。
*/
defineProps({
/** 状态栏文字深浅:light = 白字(深色导航用),dark = 黑字(浅色导航用) */
statusTone: { type: String, default: 'dark' },
/** 胶囊按钮深浅:light = 白底黑图标,dark = 半透明黑底白图标 */
capsuleTone: { type: String, default: 'light' },
/** Home 指示条深浅 */
indicatorTone: { type: String, default: 'dark' },
/** 屏幕兜底背景色 */
screenBg: { type: String, default: '#f8f8f8' }
})
</script>
<style scoped>
/* 手机壳体:钛金属质感边框 + 深色内衬 */
.phone-frame {
position: relative;
width: 399px;
padding: 12px;
border-radius: 58px;
background: linear-gradient(160deg, #3d4650, #14181d 42%, #2c343d 100%);
box-shadow:
inset 0 0 0 2px rgba(255, 255, 255, 0.08),
0 34px 80px rgba(10, 30, 46, 0.4),
0 8px 22px rgba(10, 30, 46, 0.28);
}
/* 侧键 */
.phone-key {
position: absolute;
width: 3px;
border-radius: 2px;
background: linear-gradient(180deg, #454f59, #1a1f25);
}
.phone-key-mute { left: -3px; top: 138px; height: 26px; }
.phone-key-volume-up { left: -3px; top: 186px; height: 48px; }
.phone-key-volume-down { left: -3px; top: 244px; height: 48px; }
.phone-key-power { right: -3px; top: 210px; height: 74px; }
/* 屏幕:逻辑尺寸 375 x 760 */
.phone-screen {
position: relative;
width: 375px;
height: 760px;
border-radius: 46px;
overflow: hidden;
isolation: isolate;
}
.phone-content {
position: absolute;
inset: 0;
z-index: 1;
}
/* 状态栏 */
.phone-statusbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 54px;
z-index: 6;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 30px 0;
pointer-events: none;
}
.phone-tone-dark .phone-statusbar { color: #10161d; }
.phone-tone-light .phone-statusbar { color: #ffffff; }
.statusbar-time {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
.statusbar-icons {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* 灵动岛 */
.phone-island {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
width: 116px;
height: 33px;
border-radius: 20px;
background: #04060a;
}
.phone-island::after {
content: '';
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 9px;
height: 9px;
border-radius: 50%;
background: radial-gradient(circle at 34% 30%, #24313f, #05070a 68%);
}
/* 微信胶囊 */
.phone-capsule {
position: absolute;
top: 62px;
right: 12px;
z-index: 6;
width: 87px;
height: 32px;
border-radius: 17px;
display: flex;
align-items: center;
justify-content: space-evenly;
pointer-events: none;
}
.capsule-tone-light {
background: rgba(255, 255, 255, 0.92);
border: 0.5px solid rgba(0, 0, 0, 0.08);
color: #06080c;
}
.capsule-tone-dark {
background: rgba(0, 0, 0, 0.22);
border: 0.5px solid rgba(255, 255, 255, 0.28);
color: #ffffff;
}
.capsule-dots {
display: inline-flex;
align-items: center;
gap: 3.5px;
}
.capsule-dots i {
width: 4.5px;
height: 4.5px;
border-radius: 50%;
background: currentColor;
}
.capsule-dots i:nth-child(2) { width: 5.5px; height: 5.5px; }
.capsule-divider {
width: 1px;
height: 18px;
background: currentColor;
opacity: 0.22;
}
.capsule-target {
position: relative;
width: 17px;
height: 17px;
border: 1.6px solid currentColor;
border-radius: 50%;
}
.capsule-target::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* 覆盖层:支付面板 / 弹窗 */
.phone-overlay {
position: absolute;
inset: 0;
z-index: 8;
pointer-events: none;
}
.phone-overlay :deep(> *) {
pointer-events: auto;
}
/* Home 指示条 */
.phone-home-indicator {
position: absolute;
bottom: 9px;
left: 50%;
transform: translateX(-50%);
width: 128px;
height: 5px;
border-radius: 3px;
z-index: 9;
pointer-events: none;
}
.indicator-tone-dark { background: rgba(8, 12, 18, 0.85); }
.indicator-tone-light { background: rgba(255, 255, 255, 0.9); }
</style>

View File

@@ -0,0 +1,78 @@
<template>
<header class="wx-navbar" :style="{ background: bg, color }">
<div class="wx-navbar-row">
<button v-if="showBack" type="button" class="wx-navbar-back" aria-label="返回" @click="emit('back')">
<svg width="12" height="21" viewBox="0 0 12 21" fill="none">
<path d="M10.5 1.5 2 10.5l8.5 9" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<span class="wx-navbar-title">{{ title }}</span>
</div>
</header>
</template>
<script setup>
/**
* 小程序标准导航栏(WxNavBar)
*
* 为什么这样写:
* - 复刻微信小程序默认导航(navigationStyle: default)的视觉:
* 状态栏下方 44px 高、标题居中 17px、左侧返回箭头,右侧空间留给胶囊按钮。
* - 顶部 padding 使用 PhoneFrame 的状态栏高度(54px),保证内容从正确位置开始。
* - 背景/文字颜色由 props 控制,对应 pages.json 里每页的 navigationBarBackgroundColor / TextStyle。
*/
defineProps({
title: { type: String, required: true },
/** 导航背景色,对应 navigationBarBackgroundColor */
bg: { type: String, default: '#f8fafc' },
/** 标题与返回键颜色,对应 navigationBarTextStyle */
color: { type: String, default: '#0b0f14' },
/** 是否显示返回箭头(栈顶首页不显示) */
showBack: { type: Boolean, default: true }
})
const emit = defineEmits(['back'])
</script>
<style scoped>
.wx-navbar {
position: relative;
z-index: 2;
padding-top: 54px;
flex-shrink: 0;
}
.wx-navbar-row {
position: relative;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.wx-navbar-back {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: inherit;
padding: 0;
}
.wx-navbar-title {
max-width: 180px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 17px;
font-weight: 500;
letter-spacing: 0.01em;
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<nav class="wx-tabbar" aria-label="小程序底部导航">
<button
v-for="(item, index) in items"
:key="item.label"
type="button"
class="wx-tabbar-item"
:style="{ color: index === active ? selectedColor : color }"
@click="emit('select', index)"
>
<component :is="item.icon" :size="23" :stroke-width="index === active ? 2.2 : 1.8" aria-hidden="true" />
<span>{{ item.label }}</span>
<i v-if="item.badge" class="wx-tabbar-badge">{{ item.badge }}</i>
</button>
</nav>
</template>
<script setup>
/**
* 小程序底部 TabBar(WxTabBar)
*
* 为什么这样写:
* - 复刻微信小程序 tabBar:白底、上分割线、图标在上文字在下、选中色高亮。
* - 图标传 lucide 组件引用(源码里是 PNG 图标,演示用等价线性图标还原视觉气质)。
* - 高度 56px + 底部 22px 安全区,给 Home 指示条留出空间,与真机观感一致。
*/
defineProps({
/** tab 配置:{ label, icon: Component, badge?: string } */
items: { type: Array, required: true },
/** 当前选中索引 */
active: { type: Number, default: 0 },
/** 选中颜色:患者端/医生端都是 #1575FC */
selectedColor: { type: String, default: '#1575fc' },
/** 未选中颜色 */
color: { type: String, default: '#666666' }
})
const emit = defineEmits(['select'])
</script>
<style scoped>
.wx-tabbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
height: 78px;
padding-bottom: 22px;
display: flex;
background: #ffffff;
border-top: 0.5px solid rgba(0, 0, 0, 0.08);
}
.wx-tabbar-item {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border: none;
background: transparent;
padding: 0;
font-size: 11px;
line-height: 1;
transition: color 0.2s ease;
}
.wx-tabbar-badge {
position: absolute;
top: 4px;
left: calc(50% + 6px);
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 9px;
background: #fa5151;
color: #fff;
font-size: 10px;
font-style: normal;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
</style>

201
src/views/demo/flows.js Normal file
View File

@@ -0,0 +1,201 @@
/**
* 流程模拟注册表
*
* 为什么这样写:
* - 把每个流程的元信息(名称、端别、讲解步骤)与对应的模拟组件集中登记,
* 壳页面(demo/index.vue)只按注册表渲染,新增流程只需在这里加一项。
* - 步骤文案与流程组件的屏幕一一对应:steps[i] 描述的就是组件在 step=i 时渲染的屏幕。
* - 患者端界面复刻自 xk-client-wx(惠佑康患者端),医生端复刻自 xk-doctor-wx(萧康互联网医院医生端)。
*/
import FullJourneyFlow from './flows/FullJourneyFlow.vue'
import OfflineRegisterFlow from './flows/OfflineRegisterFlow.vue'
import OnlineFollowupFlow from './flows/OnlineFollowupFlow.vue'
import PharmacyBuyFlow from './flows/PharmacyBuyFlow.vue'
import PharmacyFollowupFlow from './flows/PharmacyFollowupFlow.vue'
import OrderPayFlow from './flows/OrderPayFlow.vue'
import DoctorReceptionFlow from './flows/DoctorReceptionFlow.vue'
import PcPrescriptionFlow from './flows/PcPrescriptionFlow.vue'
import PcReceptionFlow from './flows/PcReceptionFlow.vue'
/** 流程展示顺序:跨端全景开场,患者端业务闭环随后,医生端协同居中,诊所 PC 端收尾 */
export const flowOrder = [
'full-journey',
'offline-register',
'online-followup',
'pharmacy-buy',
'pharmacy-followup',
'order-pay',
'doctor-reception',
'pc-prescription',
'pc-reception'
]
export const flowDemos = {
'full-journey': {
label: '全流程 · 一单串三端',
side: 'journey',
sideLabel: '跨端全景',
appLabel: '患者端 × 医生端 × 诊所 PC · 全链路演示',
accent: '#0891b2',
// mixed:帧类型由每个步骤的 frame 字段决定,壳页面按步在手机 / PC 画面间切换
frame: 'mixed',
intro: '一次就诊的完整旅程:患者微信扫码提交复诊,医生手机上秒收提醒随时接诊,回到诊室 PC 工作台专业开方,处方推回患者微信直接支付——三端数据同一条链路,一步不断。',
component: FullJourneyFlow,
steps: [
{ title: '打开微信 · 扫一扫', desc: '故事从患者的微信开始:点右上角「+」,选择「扫一扫」。', frame: 'phone', deviceLabel: '患者的手机 · 微信' },
{ title: '扫码进入小程序', desc: '对准诊所台卡上的小程序码,识别即进入惠佑康患者端,无需下载。', frame: 'phone', deviceLabel: '患者的手机 · 微信扫一扫' },
{ title: '首页 · 发起在线复诊', desc: '点击首页「在线复诊」入口卡,为家里老人发起一次线上复诊。', frame: 'phone', deviceLabel: '患者的手机 · 惠佑康小程序' },
{ title: '确认复诊信息 · 提交', desc: '就诊人、病情主诉、用药史一屏核对,点击「提交复诊申请」,申请直达医生队列。', frame: 'phone', deviceLabel: '患者的手机 · 惠佑康小程序' },
{ title: '医生手机 · 秒收提醒', desc: '镜头切到李国栋医生的手机:工作台「在线接诊」已亮起新申请角标,点击进入。', frame: 'phone', deviceLabel: '医生的手机 · 萧康互联网医院' },
{ title: '在线接诊 · 接下患者', desc: '张淑芬的复诊申请排在队首,病情摘要一目了然,点击卡片接诊。', frame: 'phone', deviceLabel: '医生的手机 · 萧康互联网医院' },
{ title: '诊室 PC · 开方工作台', desc: '回到诊室,接诊已同步到 PC 工作台:药品、诊断、医嘱都已备好,点击「发送处方」。', frame: 'pc', deviceLabel: '诊室电脑 · 诊所管理后台' },
{ title: '确认发送 · 药师审核', desc: '发送前再核对一遍患者、诊断、药品与金额,点击「确认发送」,处方进入药师审核。', frame: 'pc', deviceLabel: '诊室电脑 · 诊所管理后台' },
{ title: '患者微信 · 收到通知', desc: '镜头切回患者微信:处方通过审核,「服务通知」第一时间提醒,点击查看。', frame: 'phone', deviceLabel: '患者的手机 · 微信' },
{ title: '微信支付 · 确认付款', desc: '订单金额与处方一致(¥86.00),支付面板已唤起,点击「支付」完成付款。', frame: 'phone', deviceLabel: '患者的手机 · 惠佑康小程序' },
{ title: '支付完成 · 门店备药', desc: '一单走完三端:扫码、接诊、开方、支付全程留痕,门店开始按方备药。', frame: 'phone', deviceLabel: '患者的手机 · 惠佑康小程序' }
]
},
'offline-register': {
label: '线下挂号',
side: 'patient',
sideLabel: '患者端',
appLabel: '患者端小程序 · 惠佑康',
accent: '#1575fc',
intro: '从打开微信扫一扫开始:进小程序选医生、挑时段、确认信息,一分钟完成到店挂号,号源与费用全程透明。',
component: OfflineRegisterFlow,
steps: [
{ title: '打开微信 · 扫一扫', desc: '就医从微信开始:点右上角「+」,在菜单里选择「扫一扫」。' },
{ title: '扫码进入小程序', desc: '对准诊所前台的小程序码,识别即进入惠佑康患者端,无需下载安装。' },
{ title: '首页 · 进入挂号', desc: '小程序首页是所有服务的起点,点击「到店挂号」入口卡进入医生列表。' },
{ title: '医生列表 · 选择医生', desc: '出诊医生按科室聚合,卡片直接给出职称、擅长与问诊量,点击「挂号」按钮继续。' },
{ title: '医生详情 · 选择号源', desc: '查看医生出诊时段与剩余号源,约满的时段会置灰,点击可约时段的「挂号」。' },
{ title: '确认挂号信息', desc: '核对就诊人、就诊时间与挂号费用,勾选须知后点击「确认挂号」。' },
{ title: '挂号成功', desc: '挂号完成,到店报就诊序号即可候诊;记录随时可在「我的挂号」查询。' }
]
},
'online-followup': {
label: '在线复诊',
side: 'patient',
sideLabel: '患者端',
appLabel: '患者端小程序 · 惠佑康',
accent: '#1575fc',
intro: '复诊患者不用再跑医院:微信扫码进小程序,提交病情资料,医生接诊问诊后在线开方,续方建议直达手机。',
component: OnlineFollowupFlow,
steps: [
{ title: '打开微信 · 扫一扫', desc: '复诊入口就在微信里:点右上角「+」,选择「扫一扫」。' },
{ title: '扫码进入小程序', desc: '对准诊所台卡上的小程序码,识别即进入惠佑康患者端。' },
{ title: '首页 · 进入在线复诊', desc: '点击首页「在线复诊」入口卡,开始一次线上复诊。' },
{ title: '选择就诊人', desc: '选择本次复诊的就诊人,家庭成员的档案都在同一账号下管理,选好后点「下一步」。' },
{ title: '填写复诊单', desc: '描述本次病情主诉、勾选相关用药与健康信息,点击「下一步」提交复诊申请。' },
{ title: '提交成功 · 等待接诊', desc: '复诊申请已进入医生的待接诊队列,医生接诊后会收到消息提醒。' },
{ title: '医生问诊 · 在线沟通', desc: '医生接诊后先了解近况,输入框里已拟好回复,点击「发送」告知医生。' },
{ title: '医生开方 · 处方直达', desc: '确认病情稳定后医生开具电子处方,处方卡片直接出现在会话里,药师审核通过即可支付。' }
]
},
'pharmacy-buy': {
label: '药店购药',
side: 'patient',
sideLabel: '患者端',
appLabel: '患者端小程序 · 惠佑康',
accent: '#1575fc',
intro: '门店商品搬进小程序:微信扫柜台码进店,浏览、加购、结算一气呵成,非处方药下单即发。',
component: PharmacyBuyFlow,
steps: [
{ title: '打开微信 · 扫一扫', desc: '在药店柜台看到台卡:打开微信,点右上角「+」选择「扫一扫」。' },
{ title: '扫码进入药店小程序', desc: '识别药店小程序码,直接进入线上门店,货架和柜台同款同价。' },
{ title: '门店 · 浏览药品', desc: '进入购药页,左侧分类、右侧双列药品,OTC 与处方药标识清晰,点击商品查看详情。' },
{ title: '商品详情 · 加入购物车', desc: '查看价格、药品说明与门店服务保障,点击「加入购物车」。' },
{ title: '购物车 · 去结算', desc: '确认数量与合计金额,点击「去结算」生成订单。' },
{ title: '确认订单 · 提交', desc: '核对收货地址、金额明细与支付方式,点击「提交订单」进入支付环节。' }
]
},
'pharmacy-followup': {
label: '药店复诊',
side: 'patient',
sideLabel: '患者端',
appLabel: '患者端小程序 · 惠佑康',
accent: '#1575fc',
intro: '在药店购买处方药?微信扫码进店后先线上复诊:填写用药信息,医生审核开方后凭电子处方购药,合规又省事。',
component: PharmacyFollowupFlow,
steps: [
{ title: '打开微信 · 扫一扫', desc: '在药店咨询台扫码开始:点微信右上角「+」,选择「扫一扫」。' },
{ title: '扫码进入药店小程序', desc: '识别台卡小程序码进入线上门店,处方药购买入口就在商品页。' },
{ title: '选购处方药', desc: '处方药商品明确标注「处方药」,点击「立即购买」发起线上复诊开方。' },
{ title: '填写用药信息', desc: '医生助理逐条确认线下就诊、用药史与确诊疾病,回答完点击选项继续。' },
{ title: '提交申请 · 医生审核', desc: '回答完成后转接医生复诊,医生审核用药合理性,通常几分钟内完成。' },
{ title: '电子处方已开具', desc: '医生审核通过开具电子处方,凭处方即可继续完成购药下单。' }
]
},
'order-pay': {
label: '订单支付',
side: 'patient',
sideLabel: '患者端',
appLabel: '患者端小程序 · 惠佑康',
accent: '#1575fc',
intro: '处方审核通过后,微信服务通知第一时间提醒:点进订单,倒计时锁定号源与库存,微信支付一步完成。',
component: OrderPayFlow,
steps: [
{ title: '微信 · 收到服务通知', desc: '处方审核通过后,「服务通知」第一时间推送到微信,点击查看。' },
{ title: '服务通知 · 支付提醒', desc: '通知卡片写清就诊人、诊断与金额,点击「查看订单详情」直达订单。' },
{ title: '订单详情 · 待支付', desc: '订单处于待支付状态,倒计时内完成支付即可锁定名额,点击「去支付」。' },
{ title: '收银台 · 确认付款', desc: '核对就诊人、配送方式、药品清单与金额明细,点击「立即支付」。' },
{ title: '微信支付', desc: '唤起微信支付面板,确认金额后点击「支付」完成付款。' },
{ title: '支付成功', desc: '支付完成,订单进入履约流程,门店备药、物流状态都会同步到小程序。' }
]
},
'doctor-reception': {
label: '医生端 · 接诊开方',
side: 'doctor',
sideLabel: '医生端',
appLabel: '医生端小程序 · 萧康互联网医院',
accent: '#1575fc',
intro: '医生视角的完整闭环:工作台看板掌握待办,在线接诊、问诊沟通、开方流转一台手机全部完成。',
component: DoctorReceptionFlow,
steps: [
{ title: '工作台 · 待办一目了然', desc: '今日开方量、待接诊、处方金额都在工作台首屏,点击「在线接诊」处理新申请。' },
{ title: '在线接诊 · 接下患者', desc: '当前接诊列表展示患者摘要与状态,点击待接诊的患者卡片进入会话。' },
{ title: '问诊对话 · 了解病情', desc: '患者病情卡片随消息直达,医生确认后点击操作栏的「开方」进入开方页。' },
{ title: '在线开方 · 选药定量', desc: '录入临床诊断、添加药品与用法用量,金额实时合计,点击「发送处方」。' },
{ title: '处方发送 · 药师审核', desc: '处方进入药师审核流转,审核通过后自动推送给患者,等待支付购药。' }
]
},
'pc-prescription': {
label: 'PC 端 · 在线复诊开方',
side: 'pc',
sideLabel: '诊所 PC 端',
appLabel: '诊所管理后台 · 医生接诊工作台(PC)',
accent: '#4175ee',
frame: 'pc',
intro: '患者在小程序提交的复诊申请直达 PC 工作台:接诊、选药、定诊断、发处方一条龙,处方即刻推送患者微信,还能代付收款。',
component: PcPrescriptionFlow,
steps: [
{ title: '在线复诊 · 进入队列', desc: '患者小程序提交的复诊申请直达工作台队列,卡片标出「在线复诊」来源,病情资料已同步,点击「接诊」。' },
{ title: '接诊成功 · 进入开方', desc: '接诊后右侧切换为开方工作台,处方还是空的,点击工具栏的「添加商品」为患者选药。' },
{ title: '添加商品 · 选药定量', desc: '按名称或拼音检索门店药品,勾选需要的药品并调好数量,底部实时合计,点击「确定」。' },
{ title: '完善处方 · 诊断医嘱', desc: '药品进入处方表格,金额自动合计;补全临床诊断与用药医嘱后,点击右下角「发送处方」。' },
{ title: '确认发送 · 二次核对', desc: '系统弹出处方摘要再核对一遍患者、诊断、药品与金额,确认无误点击「确认发送」。' },
{ title: '发送成功 · 待患者支付', desc: '处方实时推送到患者微信,在线支付后门店按方发药;患者不便操作手机时,点击「代付收款」。' },
{ title: '代付收款 · 扫码即付', desc: '生成代支付小程序码,家属或患者扫码直接进入微信支付,订单实时更新为已支付,全程后台留痕。' }
]
},
'pc-reception': {
label: 'PC 端 · 线下接诊与代付',
side: 'pc',
sideLabel: '诊所 PC 端',
appLabel: '诊所管理后台 · 医生接诊工作台(PC)',
accent: '#4175ee',
frame: 'pc',
intro: '到店患者的全程都在 PC 工作台:一分钟建档自动挂号,认领码把档案送回患者微信,接诊开方打出门诊处方笺,代付码解决老人不会付款的最后一步。',
component: PcReceptionFlow,
steps: [
{ title: '接诊工作台 · 全景', desc: '左侧是今日就诊队列,右侧是接诊与开方主区。为到店新患者建档,点击「新建就诊人」。' },
{ title: '新建就诊人 · 一分钟建档', desc: '录入基础信息与健康史,可勾选免除挂号费;保存后系统自动完成挂号,点击「确定」。' },
{ title: '建档成功 · 自动挂号', desc: '新就诊人直接进入候诊队列。档案还没绑定患者微信,点击「二维码认领」生成认领码。' },
{ title: '就诊人二维码认领', desc: '选择未认领的就诊人生成小程序码,患者用微信扫一扫,档案与处方记录即归入其账号,点击「关闭」。' },
{ title: '线下接诊 · 开始看诊', desc: '患者候诊就绪,病史、过敏史都在档案里。医生点击「接诊」,进入线下开方工作台。' },
{ title: '门诊开方 · 处方笺', desc: '门诊普通处方笺上选药定量、录入诊断医嘱,费用实时合计,点击右下角「发送处方」。' },
{ title: '处方已发送 · 成功页', desc: '右区切换为发送成功页:订单号、诊断、金额一目了然。患者不便操作手机时,点击「代付收款」。' },
{ title: '医生代支付 · 扫码收款', desc: '生成代支付小程序码,家属或患者扫码直接进入微信支付,订单实时更新为已支付。' }
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,831 @@
<script setup>
/**
* 线下挂号流程模拟(患者端)
*
* 屏幕顺序:微信首页(扫一扫入口) → 扫码 → 小程序首页 → 医生列表 → 医生详情(选号源) → 确认挂号信息 → 挂号成功
* UI 复刻自 xk-client-wx:pages/home/home.vue、pages/Inquiries/Inquiries.vue、
* subPackages/doctor/doctor-detail.vue、RegisterConfirmStep、register-success/register-info。
* 所有尺寸按 rpx / 2 = px 换算,颜色取源码真实值(主色 #4175EE、强调橙 #FEA54C、金额红 #F44336)。
*/
import { computed } from 'vue'
import { ChevronDown, ChevronRight, CircleCheck, Search } from 'lucide-vue-next'
import PhoneFrame from '../components/PhoneFrame.vue'
import WxNavBar from '../components/WxNavBar.vue'
import PatientHomeScreen from './shared/PatientHomeScreen.vue'
import WeChatHomeScreen from './shared/WeChatHomeScreen.vue'
import WxScanScreen from './shared/WxScanScreen.vue'
const props = defineProps({
step: { type: Number, default: 0 },
direction: { type: String, default: 'forward' }
})
const emit = defineEmits(['advance'])
/** 屏幕 key:与步骤一一对应,Transition 据此判断是否换屏 */
const screenKey = computed(() => ['wx', 'scan', 'home', 'doctors', 'schedule', 'confirm', 'success'][props.step] || 'wx')
/** 扫一扫黑底、小程序首页蓝渐变头 → 状态栏白字;微信列表与其余浅色页面反之 */
const statusTone = computed(() => (props.step === 1 || props.step === 2 ? 'light' : 'dark'))
const capsuleTone = computed(() => (props.step === 1 || props.step === 2 ? 'dark' : 'light'))
/** 医生列表数据(虚构演示) */
const doctors = [
{
name: '李国栋',
title: '主治医师',
hospital: '萧康中医诊所 · 中医内科',
skill: '慢性胃炎、脾胃虚弱、失眠多梦的中医调理',
consults: '1286',
rate: '98%',
score: '4.9',
avatarBg: 'linear-gradient(135deg, #93c5fd, #3b82f6)'
},
{
name: '周慧敏',
title: '副主任医师',
hospital: '萧康中医诊所 · 呼吸内科',
skill: '慢性咳嗽、支气管炎、哮喘的诊治',
consults: '2043',
rate: '99%',
score: '5.0',
avatarBg: 'linear-gradient(135deg, #f9a8d4, #ec4899)'
},
{
name: '王建平',
title: '主任医师',
hospital: '萧康中医诊所 · 中医骨伤科',
skill: '颈肩腰腿痛、关节炎的针灸推拿治疗',
consults: '3157',
rate: '97%',
score: '4.8',
avatarBg: 'linear-gradient(135deg, #a7f3d0, #10b981)'
}
]
/** 医生详情页的号源时段(第三个约满,用于展示置灰态) */
const slots = [
{ time: '明天上午 08:00-12:00', price: '¥15.00', left: 12, total: 30, full: false },
{ time: '明天下午 14:00-17:30', price: '¥15.00', left: 3, total: 20, full: false },
{ time: '后天上午 08:00-12:00', price: '¥15.00', left: 0, total: 30, full: true }
]
/** 确认页 / 成功页的预约信息行 */
const confirmRows = [
{ label: '预约诊所', value: '萧康中医诊所' },
{ label: '预约科室', value: '中医内科' },
{ label: '预约医生', value: '李国栋' },
{ label: '预约序号', value: '12号' },
{ label: '预计就诊时间', value: '10月18日 上午 08:00-12:00' },
{ label: '挂号金额', value: '¥15.00', accent: true },
{ label: '挂号类型', value: '普通门诊' }
]
</script>
<template>
<PhoneFrame :status-tone="statusTone" :capsule-tone="capsuleTone" screen-bg="#f8fafc">
<Transition :name="`sim-push-${direction}`">
<!-- 第 1 屏:微信首页(右上角 + → 扫一扫) -->
<WeChatHomeScreen
v-if="screenKey === 'wx'"
key="wx"
mode="scan"
@scan="emit('advance', 1)"
/>
<!-- 第 2 屏:扫一扫(对准门店台卡) -->
<WxScanScreen
v-else-if="screenKey === 'scan'"
key="scan"
label="萧康中医诊所"
sub="扫码进入小程序 · 挂号问诊"
@scanned="emit('advance', 2)"
/>
<!-- 第 3 屏:小程序首页(点「到店挂号」) -->
<PatientHomeScreen
v-else-if="screenKey === 'home'"
key="home"
hotspot="register"
@tap-register="emit('advance', 3)"
/>
<!-- 第 4 屏:医生列表 -->
<div v-else-if="screenKey === 'doctors'" key="doctors" class="sim-screen doctors-screen">
<WxNavBar title="医生列表" @back="emit('advance', 2)" />
<div class="sim-screen-body">
<div class="inq-search">
<div class="inq-search-input">
<Search :size="15" aria-hidden="true" />
<span>请输入关键字</span>
</div>
</div>
<div class="inq-filter-bar">
<span>科室 <ChevronDown :size="13" aria-hidden="true" /></span>
<span>排序 <ChevronDown :size="13" aria-hidden="true" /></span>
<span>筛选 <ChevronDown :size="13" aria-hidden="true" /></span>
</div>
<p class="inq-title">选择坐诊医生</p>
<div
v-for="(doctor, index) in doctors"
:key="doctor.name"
class="inq-card"
>
<div class="inq-card-top">
<span class="inq-avatar" :style="{ background: doctor.avatarBg }">{{ doctor.name.slice(0, 1) }}</span>
<div class="inq-info">
<div class="inq-name-row">
<strong>{{ doctor.name }}</strong>
<span>{{ doctor.title }}</span>
</div>
<p>{{ doctor.hospital }}</p>
</div>
<button
type="button"
class="inq-cta"
:class="{ 'sim-hotspot': index === 0 }"
@click="index === 0 && emit('advance', 4)"
>挂号</button>
</div>
<p class="inq-skill">擅长:{{ doctor.skill }}</p>
<div class="inq-stats">
<span>问诊量 <i>{{ doctor.consults }}</i></span>
<span>接诊率 <i>{{ doctor.rate }}</i></span>
<span>综合评分 <i>{{ doctor.score }}</i></span>
</div>
</div>
</div>
</div>
<!-- 第 3 屏:医生详情 · 选号源 -->
<div v-else-if="screenKey === 'schedule'" key="schedule" class="sim-screen schedule-screen">
<WxNavBar title="医生详情" @back="emit('advance', 3)" />
<div class="sim-screen-body">
<div class="detail-card">
<div class="detail-head">
<div class="detail-head-copy">
<strong>李国栋</strong>
<p>主治医师 · 中医内科</p>
<p>萧康中医诊所</p>
</div>
<span class="detail-avatar">李</span>
</div>
<div class="detail-stats">
<span>问诊量 <i>1286</i></span>
<span>接诊率 <i>98%</i></span>
<span>综合评分 <i>4.9</i></span>
</div>
<p class="detail-skill">专业擅长:慢性胃炎、脾胃虚弱、失眠多梦、体虚乏力的中医辨证调理。</p>
<span class="detail-more">查看全部 <ChevronRight :size="13" aria-hidden="true" /></span>
</div>
<p class="appoin-title">预约挂号</p>
<div class="appoin-list">
<div v-for="(slot, index) in slots" :key="slot.time" class="appoin-row">
<div class="appoin-row-main">
<div class="appoin-copy">
<span class="appoin-time">{{ slot.time }}</span>
<span class="appoin-price">{{ slot.price }}</span>
</div>
<button
v-if="!slot.full"
type="button"
class="appoin-btn"
:class="{ 'sim-hotspot': index === 0 }"
@click="index === 0 && emit('advance', 5)"
>挂号</button>
<button v-else type="button" class="appoin-btn appoin-btn-full">约满</button>
</div>
<p class="appoin-rest">
剩余号 <i>{{ slot.left }}</i> <span>总号数 {{ slot.total }}</span>
</p>
</div>
</div>
</div>
</div>
<!-- 第 4 屏:确认挂号信息 -->
<div v-else-if="screenKey === 'confirm'" key="confirm" class="sim-screen confirm-screen">
<WxNavBar title="选择就诊人" @back="emit('advance', 4)" />
<div class="sim-screen-body confirm-body">
<div class="step-bar">
<div class="step-node step-node-done">
<span class="step-circle">1</span>
<span class="step-label">选择就诊人</span>
</div>
<span class="step-line"></span>
<div class="step-node step-node-active">
<span class="step-circle">2</span>
<span class="step-label">确认预约信息,支付挂号费</span>
</div>
</div>
<p class="block-title">预约信息</p>
<div class="info-card">
<div v-for="row in confirmRows" :key="row.label" class="info-row">
<span class="info-label">{{ row.label }}</span>
<span class="info-value" :class="{ 'info-value-accent': row.accent }">{{ row.value }}</span>
</div>
</div>
<p class="block-title">就诊人</p>
<div class="info-card">
<div class="info-row">
<span class="info-patient">张淑芬</span>
<span class="info-plate">12号</span>
</div>
<div class="info-row">
<span class="info-label">身份证号</span>
<span class="info-value">330***********1234</span>
</div>
<div class="info-row">
<span class="info-label">手机号</span>
<span class="info-value">138****6688</span>
</div>
</div>
</div>
<div class="confirm-footer">
<label class="agree-row">
<span class="agree-check"><CircleCheck :size="17" aria-hidden="true" /></span>
我已阅读并同意<i>《预约挂号须知》</i>
</label>
<div class="confirm-actions">
<button type="button" class="btn-prev" @click="emit('advance', 4)">上一步</button>
<button type="button" class="btn-confirm sim-hotspot" @click="emit('advance', 6)">确认挂号</button>
</div>
</div>
</div>
<!-- 第 5 屏:挂号成功(成功卡 + 挂号详情状态头) -->
<div v-else key="success" class="sim-screen success-screen">
<WxNavBar title="挂号详情" @back="emit('advance', 5)" />
<div class="sim-screen-body confirm-body">
<div class="success-card">
<CircleCheck :size="60" :stroke-width="1.6" class="success-icon" aria-hidden="true" />
<strong>挂号成功</strong>
</div>
<div class="state-bar">
<span class="state-left">
<CircleCheck :size="18" aria-hidden="true" />
已挂号成功
</span>
<span class="state-price">¥15.00</span>
</div>
<p class="block-title">预约信息</p>
<div class="info-card">
<div v-for="row in confirmRows.slice(0, 5)" :key="row.label" class="info-row">
<span class="info-label">{{ row.label }}</span>
<span class="info-value">{{ row.value }}</span>
</div>
</div>
<button type="button" class="btn-home">返回首页</button>
</div>
</div>
</Transition>
</PhoneFrame>
</template>
<style scoped>
/* ============ 第 2 屏:医生列表 ============ */
.doctors-screen {
background: #f8f8f8;
}
.inq-search {
padding: 8px 15px;
background: #fff;
}
.inq-search-input {
height: 33px;
border-radius: 999px;
background: #f5f5f5;
display: flex;
align-items: center;
gap: 7px;
padding: 0 14px;
color: #999;
font-size: 13px;
}
.inq-filter-bar {
display: flex;
background: #fff;
border-bottom: 1px solid #f1f5f9;
padding: 9px 0;
}
.inq-filter-bar span {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
font-size: 13px;
color: #475569;
}
.inq-title {
margin: 15px 20px 8px;
font-size: 16px;
font-weight: 700;
color: #232323;
}
/* 医生卡:顶部淡蓝渐变 */
.inq-card {
margin: 0 10px 12px;
border-radius: 8px;
padding: 11px 16px;
background: linear-gradient(180deg, #e2f1ff 0%, #fbfdff 24%, #ffffff 100%);
box-shadow: 0 0 9px rgba(0, 0, 0, 0.08);
}
.inq-card-top {
display: flex;
align-items: center;
gap: 10px;
}
.inq-avatar {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 19px;
font-weight: 700;
}
.inq-info {
flex: 1;
min-width: 0;
}
.inq-name-row {
display: flex;
align-items: baseline;
gap: 7px;
}
.inq-name-row strong {
font-size: 16px;
font-weight: 700;
color: #232323;
}
.inq-name-row span {
font-size: 10px;
color: #333;
}
.inq-info p {
margin: 3px 0 0;
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inq-cta {
flex-shrink: 0;
width: 52px;
height: 25px;
border: none;
border-radius: 13px;
background: #4175ee;
color: #fff;
font-size: 12px;
font-weight: 700;
}
.inq-skill {
margin: 9px 0 0;
font-size: 12px;
color: #232323;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.inq-stats {
margin-top: 9px;
display: flex;
gap: 18px;
font-size: 12px;
color: #666;
}
.inq-stats i {
font-style: normal;
color: #fea54c;
font-weight: 600;
}
/* ============ 第 3 屏:医生详情 ============ */
.schedule-screen {
background: #f8fafc;
}
.detail-card {
margin: 10px 10px 0;
background: #fff;
border-radius: 4px;
padding: 14px 16px;
}
.detail-head {
display: flex;
justify-content: space-between;
gap: 12px;
}
.detail-head-copy strong {
font-size: 20px;
font-weight: 600;
color: #1e293b;
}
.detail-head-copy p {
margin: 6px 0 0;
font-size: 14px;
color: #1e293b;
}
.detail-avatar {
flex-shrink: 0;
width: 72px;
height: 72px;
border-radius: 50%;
background: linear-gradient(135deg, #93c5fd, #3b82f6);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 28px;
font-weight: 700;
}
.detail-stats {
margin-top: 12px;
display: flex;
gap: 20px;
font-size: 12px;
color: #94a3b8;
}
.detail-stats i {
font-style: normal;
font-size: 14px;
color: #fea54c;
font-weight: 600;
}
.detail-skill {
margin: 12px 0 0;
font-size: 14px;
color: #1e293b;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.detail-more {
margin-top: 8px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
font-size: 14px;
color: #475569;
}
.appoin-title {
margin: 18px 20px 8px;
font-size: 16px;
font-weight: 700;
color: #232323;
}
.appoin-list {
margin: 0 10px;
background: #fff;
border-radius: 8px;
padding: 12px 10px;
}
.appoin-row {
padding: 8px 6px 10px;
}
.appoin-row + .appoin-row {
border-top: 1px solid #f1f5f9;
}
.appoin-row-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.appoin-copy {
display: flex;
align-items: baseline;
gap: 10px;
}
.appoin-time {
font-size: 13.5px;
color: #666;
}
.appoin-price {
font-size: 13px;
font-weight: 700;
color: #fea54c;
}
.appoin-btn {
width: 52px;
height: 21px;
border: none;
border-radius: 13px;
background: #4175ee;
color: #fff;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.appoin-btn-full {
background: #e0e1e3;
color: #232323;
cursor: not-allowed;
}
.appoin-rest {
margin: 6px 0 0;
font-size: 13px;
color: #666;
}
.appoin-rest i {
font-style: normal;
color: #4175ee;
font-weight: 600;
}
.appoin-rest span {
margin-left: 12px;
}
/* ============ 第 4 屏:确认挂号 ============ */
.confirm-screen,
.success-screen {
background: #f8fafc;
}
.confirm-body {
padding: 16px 0 120px;
}
/* 步骤条 */
.step-bar {
margin: 0 16px 18px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.step-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 150px;
}
.step-circle {
width: 24px;
height: 24px;
border-radius: 50%;
background: #e2e8f0;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.step-node-done .step-circle,
.step-node-active .step-circle {
background: #4175ee;
color: #fff;
}
.step-label {
font-size: 12px;
color: #475569;
text-align: center;
line-height: 1.4;
}
.step-line {
flex: 1;
height: 2px;
margin-top: 11px;
border-radius: 1px;
background: #4175ee;
}
.block-title {
margin: 0;
padding: 0 16px;
height: 40px;
display: flex;
align-items: center;
font-size: 16px;
font-weight: 700;
color: #666;
}
.info-card {
margin: 0 10px 6px;
background: #fff;
border-radius: 4px;
padding: 8px 16px;
}
.info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
gap: 12px;
}
.info-label {
font-size: 14px;
color: #666;
}
.info-value {
font-size: 14px;
font-weight: 700;
color: #232323;
text-align: right;
}
.info-value-accent {
color: #f44336;
}
.info-patient {
font-size: 14px;
color: #475569;
}
.info-plate {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
/* 底部固定栏 */
.confirm-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-top: 1px solid #f1f5f9;
padding: 10px 16px 26px;
z-index: 4;
}
.agree-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
}
.agree-check {
color: #2979ff;
display: flex;
}
.agree-row i {
font-style: normal;
color: #4175ee;
}
.confirm-actions {
margin-top: 10px;
display: flex;
gap: 10px;
}
.btn-prev {
width: 90px;
height: 43px;
border-radius: 32px;
border: 1px solid #4175ee;
background: #fff;
color: #4175ee;
font-size: 15px;
}
.btn-confirm {
flex: 1;
height: 43px;
border: none;
border-radius: 32px;
background: #4175ee;
color: #fff;
font-size: 16px;
font-weight: 500;
}
/* ============ 第 5 屏:挂号成功 ============ */
.success-card {
margin: 5px 10px 12px;
background: #fff;
border-radius: 8px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.success-icon {
color: #4175ee;
}
.success-card strong {
font-size: 20px;
font-weight: 700;
color: #232323;
}
.state-bar {
margin: 0 10px 6px;
height: 54px;
background: #fff;
border-radius: 4px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.state-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.state-left svg {
color: #2979ff;
}
.state-price {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.btn-home {
display: block;
width: 343px;
height: 43px;
margin: 22px auto 0;
border: none;
border-radius: 99px;
background: #4175ee;
color: #fff;
font-size: 16px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,900 @@
<script setup>
/**
* 在线复诊流程模拟(患者端)
*
* 屏幕顺序:微信首页(扫一扫) → 扫码 → 小程序首页 → 选择就诊人 → 填写复诊单 →
* 等待接诊(服务通知横幅) → 医生问诊(患者回复) → 医生开方(电子处方卡)
* UI 复刻自 xk-client-wx:PatientStep/RegisterPatientStep(就诊人卡片网格、主诉表单)、
* subPackages/chat/chat.vue + MessageBubble(聊天页 #F4F6F9 底、患者气泡 #0A84FF、医生白泡、电子处方卡)。
*
* 问诊与开方拆成两步:第 7 步医生提问、患者点「发送」回复;第 8 步医生结论 + 处方卡追加进会话,
* 两步共用聊天屏(key 相同不触发整屏推入),消息像真实会话一样增量出现。
*/
import { computed, ref } from 'vue'
import { CircleCheck, FileText, Mic, Plus, Stethoscope } from 'lucide-vue-next'
import PhoneFrame from '../components/PhoneFrame.vue'
import WxNavBar from '../components/WxNavBar.vue'
import PatientHomeScreen from './shared/PatientHomeScreen.vue'
import WeChatHomeScreen from './shared/WeChatHomeScreen.vue'
import WxScanScreen from './shared/WxScanScreen.vue'
const props = defineProps({
step: { type: Number, default: 0 },
direction: { type: String, default: 'forward' }
})
const emit = defineEmits(['advance'])
const screenKey = computed(() => ['wx', 'scan', 'home', 'patient', 'form', 'waiting', 'chat', 'chat'][props.step] || 'wx')
const statusTone = computed(() => (props.step === 1 || props.step === 2 ? 'light' : 'dark'))
const capsuleTone = computed(() => (props.step === 1 || props.step === 2 ? 'dark' : 'light'))
/** 就诊人列表(虚构演示),selected 支持点击切换,还原真实选择交互 */
const patients = [
{ name: '张淑芬', meta: '女 · 64岁' },
{ name: '王小虎', meta: '男 · 8岁' },
{ name: '李建国', meta: '男 · 66岁' }
]
const selectedPatient = ref(0)
/** 相关西药 chips:选中第一个 */
const drugs = ref([
{ name: '蓝芩口服液', on: true },
{ name: '香砂养胃丸', on: true },
{ name: '奥美拉唑肠溶胶囊', on: false }
])
function toggleDrug(index) {
drugs.value[index].on = !drugs.value[index].on
}
</script>
<template>
<PhoneFrame :status-tone="statusTone" :capsule-tone="capsuleTone" screen-bg="#f8fafc">
<Transition :name="`sim-push-${direction}`">
<!-- 第 1 屏:微信首页(右上角 + → 扫一扫) -->
<WeChatHomeScreen
v-if="screenKey === 'wx'"
key="wx"
mode="scan"
@scan="emit('advance', 1)"
/>
<!-- 第 2 屏:扫一扫 -->
<WxScanScreen
v-else-if="screenKey === 'scan'"
key="scan"
label="萧康中医诊所"
sub="扫码进入小程序 · 在线复诊"
@scanned="emit('advance', 2)"
/>
<!-- 第 3 屏:小程序首页(点「在线复诊」) -->
<PatientHomeScreen
v-else-if="screenKey === 'home'"
key="home"
hotspot="followup"
@tap-followup="emit('advance', 3)"
/>
<!-- 第 4 屏:选择就诊人 -->
<div v-else-if="screenKey === 'patient'" key="patient" class="sim-screen patient-screen">
<WxNavBar title="选择就诊人" @back="emit('advance', 2)" />
<div class="sim-screen-body patient-body">
<div class="step-bar">
<div class="step-node step-node-active">
<span class="step-circle">1</span>
<span class="step-label">选择就诊人</span>
</div>
<span class="step-line step-line-dim"></span>
<div class="step-node">
<span class="step-circle">2</span>
<span class="step-label">确认复诊信息</span>
</div>
</div>
<div class="ask-head">
<div class="ask-title-row">
<strong>您需要为谁复诊</strong>
<span class="ask-type-tag">在线复诊</span>
</div>
<p>请选择患者信息,信息仅用于医生诊断参考</p>
</div>
<div class="patient-grid">
<button
v-for="(patient, index) in patients"
:key="patient.name"
type="button"
class="patient-card"
:class="{ 'patient-card-on': index === selectedPatient }"
@click="selectedPatient = index"
>
<strong>{{ patient.name }}</strong>
<span>{{ patient.meta }}</span>
<i v-if="index === selectedPatient" class="patient-check"><CircleCheck :size="15" aria-hidden="true" /></i>
</button>
<button type="button" class="patient-card patient-card-add">
<Plus :size="26" aria-hidden="true" />
</button>
</div>
</div>
<div class="form-footer">
<button type="button" class="btn-next sim-hotspot" @click="emit('advance', 4)">下一步</button>
</div>
</div>
<!-- 第 5 屏:填写复诊单 -->
<div v-else-if="screenKey === 'form'" key="form" class="sim-screen patient-screen">
<WxNavBar title="选择就诊人" @back="emit('advance', 3)" />
<div class="sim-screen-body patient-body form-body">
<div class="ask-head">
<div class="ask-title-row">
<strong>填写病情资料</strong>
<span class="ask-type-tag">在线复诊</span>
</div>
<p>为 张淑芬(女 · 64岁)复诊,请如实描述当前情况</p>
</div>
<div class="form-card">
<p class="form-label"><i>*</i>患者主诉</p>
<div class="form-textarea">
胃部隐痛复发,饭后胀气明显,持续约一周;此前已在贵院确诊慢性胃炎,希望医生评估是否续用上次药方。
</div>
</div>
<div class="form-card">
<p class="form-label"><i>*</i>选择相关西药</p>
<div class="drug-chips">
<button
v-for="(drug, index) in drugs"
:key="drug.name"
type="button"
class="drug-chip"
:class="{ 'drug-chip-on': drug.on }"
@click="toggleDrug(index)"
>{{ drug.name }}</button>
</div>
<p class="form-hint">曾用药信息帮助医生判断续方安全性</p>
</div>
<div class="form-card">
<p class="form-label">健康信息</p>
<div class="radio-row">
<span>肝功能异常</span>
<span class="radio-group"><i class="radio-on"></i>无 <i class="radio-off"></i>有</span>
</div>
<div class="radio-row">
<span>药物过敏史</span>
<span class="radio-group"><i class="radio-on"></i>无 <i class="radio-off"></i>有</span>
</div>
</div>
</div>
<div class="form-footer">
<button type="button" class="btn-next sim-hotspot" @click="emit('advance', 5)">下一步</button>
</div>
</div>
<!-- 第 6 屏:等待接诊(聊天页 + 服务通知横幅) -->
<div v-else-if="screenKey === 'waiting'" key="waiting" class="sim-screen chat-screen">
<WxNavBar title="在线问诊" bg="#ffffff" color="#333333" @back="emit('advance', 4)" />
<div class="chat-doctor-bar">
<div class="chat-doctor-info">
<strong>李国栋</strong>
<span class="chat-online"><i></i>在线</span>
</div>
<span class="chat-detail-pill">详情</span>
</div>
<div class="sim-screen-body chat-body">
<div class="chat-notice">
发起问诊视为同意<i>《互联网诊疗风险告知及知情同意书》</i>
</div>
<div class="chat-time">09:41</div>
<div class="chat-system">复诊申请已提交,等待医生接诊</div>
<div class="msg-row msg-row-mine">
<div class="msg-bubble msg-bubble-mine">
胃部隐痛复发,饭后胀气明显,持续约一周;此前已确诊慢性胃炎,希望评估续方。
</div>
<span class="msg-avatar msg-avatar-patient">张</span>
</div>
</div>
<div class="chat-footer chat-footer-disabled">医生接诊后即可发送消息</div>
<!-- 服务通知横幅:微信推送样式,点击进入会话 -->
<button type="button" class="push-banner sim-banner-drop sim-hotspot" @click="emit('advance', 6)">
<span class="push-icon"><Stethoscope :size="18" aria-hidden="true" /></span>
<span class="push-copy">
<strong>服务通知</strong>
<small>李国栋医生已接诊,点击进入会话</small>
</span>
<span class="push-go">查看</span>
</button>
</div>
<!-- 第 7/8 屏:医生问诊 → 开方(同一聊天屏,消息随步骤增量出现) -->
<div v-else key="chat" class="sim-screen chat-screen">
<WxNavBar title="李国栋" bg="#ffffff" color="#333333" @back="emit('advance', 5)" />
<div class="chat-doctor-bar">
<div class="chat-doctor-info">
<strong>李国栋</strong>
<span class="chat-online"><i></i>在线</span>
</div>
<span class="chat-detail-pill">详情</span>
</div>
<div class="sim-screen-body chat-body">
<div class="chat-time">09:47</div>
<div class="chat-system">医生已接诊</div>
<div class="msg-row">
<span class="msg-avatar msg-avatar-doctor">李</span>
<div class="msg-bubble msg-bubble-other">
您好,我是李国栋医生。看到您的复诊资料了,最近饮食是否规律?夜间是否有反酸?
</div>
</div>
<!-- 患者回复与后续开方:第 8 步才出现 -->
<template v-if="step >= 7">
<div class="msg-row msg-row-mine sim-msg-in">
<div class="msg-bubble msg-bubble-mine">
饮食还算规律,偶尔夜里有点反酸,其他和上次差不多。
</div>
<span class="msg-avatar msg-avatar-patient">张</span>
</div>
<div class="msg-row sim-msg-in">
<span class="msg-avatar msg-avatar-doctor">李</span>
<div class="msg-bubble msg-bubble-other">
情况稳定,可以按原方案续用。我为您开具电子处方,注意饭后半小时服药。
</div>
</div>
<div class="msg-row sim-msg-in">
<span class="msg-avatar msg-avatar-doctor">李</span>
<div class="rx-card">
<div class="rx-head">
<FileText :size="16" aria-hidden="true" />
<span>电子处方</span>
</div>
<div class="rx-body">
<p class="rx-line"><span>诊断</span>慢性胃炎(复诊续方)</p>
<p class="rx-line"><span>药品</span>蓝芩口服液 ×2 · 香砂养胃丸 ×1</p>
<p class="rx-price">合计 <i>¥86.00</i></p>
</div>
<button type="button" class="rx-btn">查看详情</button>
</div>
</div>
<div class="chat-system">处方已通过药师审核,可前往支付</div>
</template>
</div>
<!-- 输入栏:问诊步预填草稿 + 发送即回复;开方后恢复占位态 -->
<div class="chat-footer">
<span class="chat-mic"><Mic :size="20" aria-hidden="true" /></span>
<template v-if="step === 6">
<span class="chat-input chat-input-draft">饮食还算规律,偶尔夜里有点反酸…</span>
<button type="button" class="chat-send sim-hotspot" @click="emit('advance', 7)">发送</button>
</template>
<template v-else>
<span class="chat-input">输入消息...</span>
<span class="chat-plus"><Plus :size="20" aria-hidden="true" /></span>
</template>
</div>
</div>
</Transition>
</PhoneFrame>
</template>
<style scoped>
/* ============ 选择就诊人 / 复诊单 ============ */
.patient-screen {
background: #f8fafc;
}
.patient-body {
padding: 16px 16px 110px;
}
.step-bar {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 20px;
}
.step-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
max-width: 140px;
}
.step-circle {
width: 24px;
height: 24px;
border-radius: 50%;
background: #e2e8f0;
color: #94a3b8;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.step-node-active .step-circle {
background: #4175ee;
color: #fff;
}
.step-label {
font-size: 12px;
color: #475569;
text-align: center;
}
.step-line {
flex: 1;
height: 2px;
margin-top: 11px;
background: #4175ee;
}
.step-line-dim {
background: #e2e8f0;
}
.ask-head {
margin-bottom: 16px;
}
.ask-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.ask-title-row strong {
font-size: 19px;
font-weight: 600;
color: #0f172a;
}
.ask-type-tag {
padding: 3px 8px;
border-radius: 4px;
background: linear-gradient(135deg, #4175ee, #298dff);
color: #fff;
font-size: 12px;
}
.ask-head p {
margin: 8px 0 0;
font-size: 12px;
color: #64748b;
}
/* 就诊人网格卡 */
.patient-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
.patient-card {
position: relative;
height: 62px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 4px;
padding: 0 10px;
overflow: hidden;
transition: border-color 0.2s ease, background 0.2s ease;
}
.patient-card strong {
font-size: 16px;
color: #0f172a;
font-weight: 600;
}
.patient-card span {
font-size: 13px;
color: #94a3b8;
}
.patient-card-on {
border-color: #536dfe;
background: rgba(83, 109, 254, 0.04);
}
.patient-check {
position: absolute;
right: 4px;
bottom: 4px;
color: #536dfe;
display: flex;
}
.patient-card-add {
align-items: center;
border-style: dashed;
color: #94a3b8;
}
/* 复诊表单 */
.form-body .ask-head {
margin-bottom: 12px;
}
.form-card {
background: #fff;
border-radius: 8px;
padding: 14px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.03);
}
.form-label {
margin: 0 0 10px;
font-size: 14.5px;
font-weight: 600;
color: #0f172a;
}
.form-label i {
color: #f44336;
font-style: normal;
margin-right: 3px;
}
.form-textarea {
background: #f8fafc;
border-radius: 6px;
padding: 11px;
font-size: 13.5px;
line-height: 1.7;
color: #334155;
}
.drug-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.drug-chip {
padding: 7px 13px;
border-radius: 999px;
border: 1px solid #e2e8f0;
background: #f8fafc;
color: #475569;
font-size: 13px;
transition: all 0.2s ease;
}
.drug-chip-on {
border-color: #4175ee;
background: rgba(65, 117, 238, 0.08);
color: #4175ee;
}
.form-hint {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 6px;
background: #f1f5f9;
font-size: 12px;
color: #64748b;
}
.radio-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
color: #334155;
}
.radio-group {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #475569;
}
.radio-group i {
width: 15px;
height: 15px;
border-radius: 50%;
display: inline-block;
}
.radio-on {
border: 4.5px solid #2979ff;
background: #fff;
}
.radio-off {
border: 1.5px solid #cbd5e1;
background: #fff;
margin-left: 12px;
}
/* 底部按钮 */
.form-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 12px 16px 28px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0), #f8fafc 36%);
z-index: 4;
}
.btn-next {
display: block;
width: 100%;
height: 45px;
border: none;
border-radius: 99px;
background: linear-gradient(135deg, #4175ee, #298dff);
color: #fff;
font-size: 16px;
font-weight: 500;
box-shadow: 0 8px 18px rgba(41, 141, 255, 0.32);
}
/* ============ 聊天页 ============ */
.chat-screen {
background: #f4f6f9;
}
.chat-doctor-bar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 15px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(6px);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.chat-doctor-info {
display: flex;
align-items: center;
gap: 9px;
}
.chat-doctor-info strong {
font-size: 16px;
font-weight: 600;
color: #333;
}
.chat-online {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.chat-online i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #52c41a;
}
.chat-detail-pill {
padding: 4px 11px;
border-radius: 999px;
background: #f0f5ff;
color: #0a84ff;
font-size: 12px;
}
.chat-body {
padding: 12px 12px 20px;
}
.chat-notice {
padding: 9px 12px;
border-radius: 6px;
background: #f5f5f5;
font-size: 12px;
color: #666;
line-height: 1.6;
}
.chat-notice i {
font-style: normal;
color: #0a84ff;
text-decoration: underline;
}
.chat-time {
margin: 12px auto 0;
width: fit-content;
padding: 2px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.03);
font-size: 11px;
color: #aaa;
}
.chat-system {
margin: 12px auto 0;
width: fit-content;
padding: 4px 12px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.05);
font-size: 11px;
color: #999;
}
/* 消息行与气泡 */
.msg-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 14px;
}
.msg-row-mine {
justify-content: flex-end;
}
.msg-avatar {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 15px;
font-weight: 600;
}
.msg-avatar-doctor {
background: linear-gradient(135deg, #93c5fd, #3b82f6);
}
.msg-avatar-patient {
background: linear-gradient(135deg, #fcd34d, #f59e0b);
}
.msg-bubble {
max-width: 72%;
padding: 9px 12px;
font-size: 15px;
line-height: 1.5;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.msg-bubble-mine {
background: #0a84ff;
color: #fff;
border-radius: 6px 2px 6px 6px;
}
.msg-bubble-other {
background: #fff;
color: #333;
border-radius: 2px 6px 6px 6px;
}
/* 电子处方卡片消息 */
.rx-card {
width: 240px;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.rx-head {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 12px;
background: #eff6ff;
color: #0a84ff;
font-size: 13.5px;
font-weight: 600;
}
.rx-body {
padding: 10px 12px;
}
.rx-line {
margin: 0 0 6px;
font-size: 12.5px;
color: #333;
line-height: 1.55;
}
.rx-line span {
color: #999;
margin-right: 8px;
}
.rx-price {
margin: 8px 0 0;
font-size: 12.5px;
color: #666;
}
.rx-price i {
font-style: normal;
font-size: 15px;
font-weight: 700;
color: #ef4444;
}
.rx-btn {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 12px;
height: 30px;
border: none;
border-radius: 15px;
background: #0a84ff;
color: #fff;
font-size: 13px;
}
/* 底部输入栏 */
.chat-footer {
position: relative;
z-index: 2;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 24px;
background: #f7f7f7;
border-top: 1px solid rgba(0, 0, 0, 0.05);
color: #666;
}
.chat-footer-disabled {
justify-content: center;
font-size: 14px;
color: #999;
padding-top: 14px;
}
.chat-mic,
.chat-plus {
display: flex;
color: #555;
}
.chat-input {
flex: 1;
height: 36px;
border-radius: 6px;
background: #fff;
display: flex;
align-items: center;
padding: 0 12px;
font-size: 14px;
color: #b9bec6;
}
/* 问诊步:输入框里是待发送的草稿,颜色用正文色 */
.chat-input-draft {
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-send {
flex-shrink: 0;
height: 34px;
padding: 0 14px;
border: none;
border-radius: 6px;
background: #07c160;
color: #fff;
font-size: 14px;
font-weight: 500;
}
/* 新消息进入:轻微上浮淡入,模拟真实会话的增量感 */
.sim-msg-in {
animation: msg-in 0.4s ease-out both;
}
@keyframes msg-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.sim-msg-in {
animation: none;
}
}
/* 服务通知横幅 */
.push-banner {
position: absolute;
top: 58px;
left: 10px;
right: 10px;
z-index: 7;
display: flex;
align-items: center;
gap: 10px;
padding: 11px 13px;
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.97);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22);
text-align: left;
}
.push-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 9px;
background: linear-gradient(135deg, #4175ee, #298dff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.push-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.push-copy strong {
font-size: 13px;
color: #111827;
}
.push-copy small {
font-size: 12px;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.push-go {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: #0a84ff;
}
</style>

View File

@@ -0,0 +1,916 @@
<script setup>
/**
* 订单支付流程模拟(患者端)
*
* 屏幕顺序:微信首页(点服务通知) → 服务通知(处方审核通过) → 待支付订单(倒计时) →
* 收银台(处方支付) → 微信支付面板(底部弹出) → 支付成功
* UI 复刻自 xk-client-wx:my/unpaid-order.vue(待支付卡、倒计时 #F44336、去支付 #536DFE)、
* my/record-pay.vue(就诊人标签胶囊 #536DFE、配送方式、金额明细、立即支付)、shop/paied.vue(付款成功卡)。
* 微信支付面板按微信收银台通用形态还原(绿色确认钮 #07C160)。
*
* 为什么收银台与支付面板共用一个屏幕 key:支付面板是「盖在收银台上」的底部弹层,
* 不是新页面,共用 key 可以避免整屏推入动画,只让面板自己滑出,和真机体验一致。
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Bell, ChevronRight, CircleCheck, X } from 'lucide-vue-next'
import PhoneFrame from '../components/PhoneFrame.vue'
import WxNavBar from '../components/WxNavBar.vue'
import WeChatHomeScreen from './shared/WeChatHomeScreen.vue'
const props = defineProps({
step: { type: Number, default: 0 },
direction: { type: String, default: 'forward' }
})
const emit = defineEmits(['advance'])
/** 步骤 → 屏幕:微信入口两屏在前;收银台与支付面板共用(面板是覆盖层) */
const screenKey = computed(() => {
if (props.step === 0) return 'wx'
if (props.step === 1) return 'notice'
if (props.step === 2) return 'order'
if (props.step <= 4) return 'cashier'
return 'done'
})
/** 待支付倒计时:从 14:59 实时递减,还原 u-count-down 的紧迫感 */
const countdown = ref(899)
let timer = 0
const countdownText = computed(() => {
const minutes = String(Math.floor(countdown.value / 60)).padStart(2, '0')
const seconds = String(countdown.value % 60).padStart(2, '0')
return { minutes, seconds }
})
onMounted(() => {
timer = window.setInterval(() => {
if (countdown.value > 0) countdown.value -= 1
}, 1000)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
/** 收银台药品清单(虚构演示) */
const drugs = [
{ name: '阿莫西林胶囊 0.25g×24粒', price: '¥12.80', count: 'x2', usage: '口服,一次2粒,一日3次' },
{ name: '蒲地蓝消炎口服液 10ml×12支', price: '¥24.30', count: 'x3', usage: '口服,一次1支,一日3次' }
]
</script>
<template>
<PhoneFrame status-tone="dark" capsule-tone="light" screen-bg="#f8fafc">
<Transition :name="`sim-push-${direction}`">
<!-- 第 1 屏:微信首页(点「服务通知」) -->
<WeChatHomeScreen
v-if="screenKey === 'wx'"
key="wx"
mode="notice"
notice-digest="惠佑康:处方审核通过,请及时完成支付"
@notice="emit('advance', 1)"
/>
<!-- 第 2 屏:服务通知会话(处方审核通过卡片) -->
<div v-else-if="screenKey === 'notice'" key="notice" class="sim-screen notice-screen">
<header class="notice-navbar">
<button type="button" class="notice-back" aria-label="返回" @click="emit('advance', 0)">‹</button>
服务通知
</header>
<div class="sim-screen-body notice-body">
<div class="notice-time">10:21</div>
<button type="button" class="notice-card sim-hotspot" @click="emit('advance', 2)">
<span class="notice-app">
<i class="notice-app-icon"><Bell :size="12" aria-hidden="true" /></i>
惠佑康健康服务
</span>
<strong class="notice-title">处方审核通过通知</strong>
<span class="notice-row"><i>就诊人</i>张淑芬</span>
<span class="notice-row"><i>诊断</i>慢性胃炎(复诊续方)</span>
<span class="notice-row"><i>应付金额</i><b>¥128.50</b></span>
<span class="notice-row"><i>温馨提示</i>请在 15 分钟内完成支付,超时订单自动取消</span>
<span class="notice-foot">查看订单详情<ChevronRight :size="14" aria-hidden="true" /></span>
</button>
</div>
</div>
<!-- 第 3 屏:待支付订单 -->
<div v-else-if="screenKey === 'order'" key="order" class="sim-screen order-screen">
<WxNavBar title="订单详情" @back="emit('advance', 1)" />
<div class="sim-screen-body order-body">
<div class="unpaid-card">
<strong class="unpaid-title">订单待支付</strong>
<p class="unpaid-desc">
已为您锁定本次复诊购药名额,请在
<span class="cd-chip">{{ countdownText.minutes }}</span>
<i class="cd-sep">:</i>
<span class="cd-chip">{{ countdownText.seconds }}</span>
内完成支付,超时名额自动取消。
</p>
<button type="button" class="btn-go-pay sim-hotspot" @click="emit('advance', 3)">去支付</button>
</div>
<div class="order-card">
<p class="order-card-title">问诊医生</p>
<div class="order-doctor">
<span class="order-avatar">李</span>
<div class="order-doctor-copy">
<strong>李国栋 <i>主治医师</i></strong>
<p>萧康互联网医院 · 中医内科</p>
</div>
<span class="order-type-tag">复诊开方</span>
</div>
</div>
<div class="order-card">
<p class="order-card-title">订单明细</p>
<div class="order-info-row"><span>订单编号</span><i>2026101809470012</i></div>
<div class="order-info-row"><span>下单时间</span><i>2026-10-18 09:47</i></div>
<div class="order-info-row"><span>应付金额</span><i class="order-amount">¥128.50</i></div>
</div>
</div>
<div class="order-footer">
<button type="button" class="btn-pay-now" @click="emit('advance', 3)">立即支付</button>
</div>
</div>
<!-- 第 2/3 屏:收银台(第 3 步叠加微信支付面板) -->
<div v-else-if="screenKey === 'cashier'" key="cashier" class="sim-screen cashier-screen">
<WxNavBar title="预约购药" @back="emit('advance', 2)" />
<div class="sim-screen-body cashier-body">
<div class="cash-card cash-patient">
<span class="cash-tag">就诊人</span>
<strong>张淑芬</strong>
<span class="cash-patient-meta">女 · 64岁</span>
</div>
<div class="cash-card cash-delivery">
<span class="delivery-opt delivery-opt-on">快递到家</span>
<span class="delivery-opt">到店自取</span>
</div>
<div class="cash-card cash-addr">
<span class="cash-tag">收货地址</span>
<div class="cash-addr-copy">
<strong>张淑芬 138****6688</strong>
<p>杭州市余杭区文一西路 968 号 3 幢 502 室</p>
</div>
<ChevronRight :size="16" class="cash-arrow" aria-hidden="true" />
</div>
<div class="cash-card">
<p class="cash-store">萧康大药房(余杭店)</p>
<div v-for="drug in drugs" :key="drug.name" class="cash-drug">
<div class="cash-drug-head">
<strong>{{ drug.name }}</strong>
<div class="cash-drug-amount">
<span>{{ drug.price }}</span>
<i>{{ drug.count }}</i>
</div>
</div>
<p class="cash-drug-usage">{{ drug.usage }}</p>
<span class="cash-manual">查看说明书</span>
</div>
<div class="cash-money-row"><span>商品总价(包含服务费)</span><i>¥98.50</i></div>
<div class="cash-money-row"><span>快递费</span><i>¥5.00</i></div>
<div class="cash-money-row"><span>诊疗费</span><i>¥25.00</i></div>
<div class="cash-money-row cash-money-total"><span>总金额</span><b>¥128.50</b></div>
</div>
</div>
<div class="cashier-footer">
<button
type="button"
class="btn-pay-main"
:class="{ 'sim-hotspot': step === 3 }"
@click="emit('advance', 4)"
>立即支付</button>
</div>
<!-- 微信支付面板:遮罩 + 底部弹层 -->
<Transition name="sim-dim">
<div v-if="step === 4" class="wxpay-dim" aria-hidden="true"></div>
</Transition>
<Transition name="sim-sheet">
<div v-if="step === 4" class="wxpay-sheet">
<div class="wxpay-head">
<button type="button" class="wxpay-close" aria-label="关闭" @click="emit('advance', 3)">
<X :size="18" aria-hidden="true" />
</button>
<span>请确认支付</span>
</div>
<p class="wxpay-amount"><i>¥</i>128.50</p>
<div class="wxpay-row"><span>收款方</span><i>萧康大药房(余杭店)</i></div>
<div class="wxpay-row">
<span>支付方式</span>
<i class="wxpay-way">零钱 <ChevronRight :size="14" aria-hidden="true" /></i>
</div>
<button type="button" class="btn-wxpay sim-hotspot" @click="emit('advance', 5)">支付</button>
</div>
</Transition>
</div>
<!-- 第 4 屏:支付成功 -->
<div v-else key="done" class="sim-screen done-screen">
<WxNavBar title="确认付款信息" :show-back="false" />
<div class="sim-screen-body done-body">
<div class="done-card">
<CircleCheck :size="60" :stroke-width="1.6" class="done-icon" aria-hidden="true" />
<strong>付款成功</strong>
<div class="done-actions">
<button type="button" class="done-btn-order">查看订单</button>
<button type="button" class="done-btn-home">返回首页</button>
</div>
</div>
<p class="done-tip">门店已开始备药,物流状态将同步到「我的订单」</p>
</div>
<div class="done-footer">
<button type="button" class="btn-done-confirm">确认</button>
</div>
</div>
</Transition>
</PhoneFrame>
</template>
<style scoped>
/* ============ 第 1 屏:待支付订单 ============ */
.order-screen {
background: #f8fafc;
}
.order-body {
padding: 12px 10px 90px;
}
/* ============ 服务通知会话页(微信推送形态) ============ */
.notice-screen {
background: #ededed;
}
.notice-navbar {
position: relative;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
color: #191919;
}
.notice-back {
position: absolute;
left: 10px;
top: 0;
bottom: 0;
border: none;
background: transparent;
color: #191919;
font-size: 26px;
line-height: 1;
padding: 0 8px;
}
.notice-body {
padding: 10px 12px;
}
.notice-time {
margin: 6px auto 12px;
width: fit-content;
font-size: 11.5px;
color: #9a9a9a;
}
/* 通知卡片:白底圆角,小程序名 + 标题 + 键值行 + 底部查看详情 */
.notice-card {
display: flex;
flex-direction: column;
width: 100%;
border: none;
border-radius: 10px;
background: #fff;
padding: 13px 15px 0;
text-align: left;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.notice-app {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #9a9a9a;
padding-bottom: 8px;
}
.notice-app-icon {
width: 18px;
height: 18px;
border-radius: 4px;
background: linear-gradient(135deg, #4175ee, #298dff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.notice-title {
font-size: 16.5px;
font-weight: 600;
color: #191919;
padding-bottom: 10px;
}
.notice-row {
display: flex;
font-size: 13.5px;
color: #191919;
line-height: 1.5;
padding-bottom: 7px;
}
.notice-row i {
flex-shrink: 0;
width: 68px;
font-style: normal;
color: #9a9a9a;
}
.notice-row b {
font-weight: 600;
color: #fa5151;
}
.notice-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 6px;
padding: 11px 0;
border-top: 0.5px solid #ececec;
font-size: 13.5px;
color: #576b95;
}
.unpaid-card {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 12px;
}
.unpaid-title {
font-size: 20px;
font-weight: 600;
color: #1e293b;
}
.unpaid-desc {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.7;
color: #475569;
}
.cd-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
padding: 0 3px;
border-radius: 4px;
background: #f44336;
color: #fff;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cd-sep {
font-style: normal;
color: #f44336;
font-weight: 700;
margin: 0 2px;
}
.btn-go-pay {
display: block;
width: 100%;
height: 41px;
margin-top: 16px;
border: none;
border-radius: 32px;
background: #536dfe;
color: #fff;
font-size: 16px;
font-weight: 500;
}
.order-card {
background: #fff;
border-radius: 8px;
padding: 14px 16px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
margin-bottom: 12px;
}
.order-card-title {
margin: 0 0 10px;
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.order-doctor {
display: flex;
align-items: center;
gap: 10px;
}
.order-avatar {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #93c5fd, #3b82f6);
color: #fff;
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.order-doctor-copy {
flex: 1;
min-width: 0;
}
.order-doctor-copy strong {
font-size: 15px;
color: #1e293b;
}
.order-doctor-copy strong i {
font-style: normal;
font-size: 12px;
font-weight: 400;
color: #64748b;
margin-left: 6px;
}
.order-doctor-copy p {
margin: 3px 0 0;
font-size: 12px;
color: #94a3b8;
}
.order-type-tag {
flex-shrink: 0;
padding: 3px 9px;
border-radius: 999px;
background: rgba(83, 109, 254, 0.08);
color: #536dfe;
font-size: 11px;
font-weight: 600;
}
.order-info-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 0;
font-size: 14px;
}
.order-info-row span {
color: #475569;
}
.order-info-row i {
font-style: normal;
color: #475569;
font-variant-numeric: tabular-nums;
}
.order-amount {
font-weight: 700;
color: #1e293b !important;
}
.order-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
height: 66px;
padding: 5px 14px 18px;
background: #fff;
}
.btn-pay-now {
display: block;
width: 100%;
height: 43px;
border: none;
border-radius: 32px;
background: #536dfe;
color: #fff;
font-size: 16px;
font-weight: 500;
}
/* ============ 第 2/3 屏:收银台 ============ */
.cashier-screen {
background: #f5f5f5;
}
.cashier-body {
padding: 10px 10px 90px;
}
.cash-card {
background: #fff;
border-radius: 6px;
padding: 13px 15px;
margin-bottom: 10px;
}
.cash-tag {
flex-shrink: 0;
width: 58px;
height: 19px;
border-radius: 15px;
background: #536dfe;
color: #fff;
font-size: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.cash-patient {
display: flex;
align-items: center;
gap: 12px;
}
.cash-patient strong {
font-size: 14px;
font-weight: 700;
color: #232323;
}
.cash-patient-meta {
font-size: 12px;
color: #999;
}
.cash-delivery {
display: flex;
gap: 10px;
padding: 10px 15px;
}
.delivery-opt {
flex: 1;
height: 34px;
border-radius: 6px;
background: #f5f6f8;
color: #666;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.delivery-opt-on {
background: rgba(83, 109, 254, 0.08);
color: #536dfe;
font-weight: 600;
border: 1px solid rgba(83, 109, 254, 0.5);
}
.cash-addr {
display: flex;
align-items: center;
gap: 12px;
}
.cash-addr-copy {
flex: 1;
min-width: 0;
}
.cash-addr-copy strong {
font-size: 14px;
font-weight: 700;
color: #232323;
}
.cash-addr-copy p {
margin: 4px 0 0;
font-size: 12px;
color: #232323;
}
.cash-arrow {
color: #c0c4cc;
}
.cash-store {
margin: 0 0 6px;
font-size: 14px;
font-weight: 700;
color: #232323;
}
.cash-drug {
padding: 10px 0;
border-bottom: 1px solid #f5f5f5;
}
.cash-drug-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.cash-drug-head strong {
font-size: 14px;
font-weight: 700;
color: #232323;
line-height: 1.4;
}
.cash-drug-amount {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
}
.cash-drug-amount span {
font-size: 14px;
font-weight: 700;
color: #232323;
}
.cash-drug-amount i {
font-style: normal;
font-size: 12px;
color: #999;
}
.cash-drug-usage {
margin: 6px 0 8px;
font-size: 12px;
color: #666;
}
.cash-manual {
display: inline-block;
padding: 6px 12px;
border-radius: 8px;
background: #ecf5ff;
color: #2b85e4;
font-size: 13px;
}
.cash-money-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
font-size: 14px;
}
.cash-money-row span {
font-weight: 600;
color: #232323;
}
.cash-money-row i {
font-style: normal;
color: #666;
}
.cash-money-total {
border-top: 1px solid #f5f5f5;
}
.cash-money-total b {
font-size: 17px;
font-weight: 700;
color: #536dfe;
}
.cashier-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
height: 57px;
padding: 5px 15px 14px;
background: #fff;
}
.btn-pay-main {
display: block;
width: 100%;
height: 42px;
border: none;
border-radius: 32px;
background: #536dfe;
color: #fff;
font-size: 16px;
font-weight: 500;
}
/* 微信支付面板 */
.wxpay-dim {
position: absolute;
inset: 0;
z-index: 8;
background: rgba(0, 0, 0, 0.55);
}
.wxpay-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 9;
background: #fff;
border-radius: 12px 12px 0 0;
padding: 0 20px 30px;
}
.wxpay-head {
position: relative;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
color: #333;
}
.wxpay-close {
position: absolute;
left: -6px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border: none;
background: transparent;
color: #888;
display: flex;
align-items: center;
justify-content: center;
}
.wxpay-amount {
margin: 8px 0 22px;
text-align: center;
font-size: 34px;
font-weight: 700;
color: #1a1a1a;
font-variant-numeric: tabular-nums;
}
.wxpay-amount i {
font-style: normal;
font-size: 20px;
margin-right: 2px;
}
.wxpay-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 0;
border-top: 1px solid #f2f2f2;
font-size: 14px;
}
.wxpay-row span {
color: #888;
}
.wxpay-row i {
font-style: normal;
color: #333;
}
.wxpay-way {
display: inline-flex;
align-items: center;
gap: 2px;
}
.btn-wxpay {
display: block;
width: 100%;
height: 44px;
margin-top: 18px;
border: none;
border-radius: 8px;
background: #07c160;
color: #fff;
font-size: 16px;
font-weight: 500;
}
/* ============ 第 4 屏:支付成功 ============ */
.done-screen {
background: #f8fafc;
}
.done-body {
padding: 5px 10px 90px;
}
.done-card {
background: #fff;
border-radius: 8px;
padding: 30px 24px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.done-icon {
color: #4175ee;
}
.done-card strong {
font-size: 20px;
font-weight: 700;
color: #232323;
}
.done-actions {
margin-top: 8px;
display: flex;
gap: 14px;
}
.done-btn-order {
width: 80px;
height: 27px;
border-radius: 4px;
border: 1px solid #707070;
background: #fff;
color: #232323;
font-size: 12px;
}
.done-btn-home {
width: 80px;
height: 27px;
border-radius: 4px;
border: 1px solid #1777ff;
background: #fff;
color: #1777ff;
font-size: 12px;
}
.done-tip {
margin: 14px 0 0;
text-align: center;
font-size: 12px;
color: #94a3b8;
}
.done-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
padding: 8px 16px 24px;
background: #f8fafc;
}
.btn-done-confirm {
display: block;
width: 100%;
height: 43px;
border: none;
border-radius: 99px;
background: #4175ee;
color: #fff;
font-size: 16px;
font-weight: 500;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,683 @@
<script setup>
/**
* 药店复诊流程模拟(患者端)
*
* 屏幕顺序:微信首页(扫一扫) → 扫码 → 处方药详情(需处方提示) → 用药信息(医生助理对话) →
* 转接医生审核 → 电子处方开具
* UI 复刻自 xk-client-wx:shop/orders-detail.vue(处方药标签 #D0E4FF/#4175EE、底部四钮)、
* follow-up/medication-info.vue 的 AssistantConsultationShell(助手蓝 #0a84ff、问答气泡与选项按钮)。
* 处方开具后点击「立即购药」接续「订单支付」流程。
*/
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { BadgeCheck, Bot, FileText, Home, Pill, ShoppingCart } from 'lucide-vue-next'
import PhoneFrame from '../components/PhoneFrame.vue'
import WxNavBar from '../components/WxNavBar.vue'
import WeChatHomeScreen from './shared/WeChatHomeScreen.vue'
import WxScanScreen from './shared/WxScanScreen.vue'
const props = defineProps({
step: { type: Number, default: 0 },
direction: { type: String, default: 'forward' }
})
const emit = defineEmits(['advance'])
const router = useRouter()
const screenKey = computed(() => ['wx', 'scan', 'rx-detail', 'assistant', 'review', 'prescription'][props.step] || 'wx')
/** 扫一扫是黑底相机页,状态栏切白字 */
const statusTone = computed(() => (props.step === 1 ? 'light' : 'dark'))
const capsuleTone = computed(() => (props.step === 1 ? 'dark' : 'light'))
/** 处方开具后进入支付流程,保持业务闭环 */
function goOrderPay() {
router.push('/demo/order-pay')
}
</script>
<template>
<PhoneFrame :status-tone="statusTone" :capsule-tone="capsuleTone" screen-bg="#f5f5f5">
<Transition :name="`sim-push-${direction}`">
<!-- 第 1 屏:微信首页(右上角 + → 扫一扫) -->
<WeChatHomeScreen
v-if="screenKey === 'wx'"
key="wx"
mode="scan"
@scan="emit('advance', 1)"
/>
<!-- 第 2 屏:扫一扫(对准药店台卡) -->
<WxScanScreen
v-else-if="screenKey === 'scan'"
key="scan"
label="萧康大药房(余杭店)"
sub="扫码进入小程序 · 处方药复诊购买"
@scanned="emit('advance', 2)"
/>
<!-- 第 3 屏:处方药商品详情 -->
<div v-else-if="screenKey === 'rx-detail'" key="rx-detail" class="sim-screen rx-screen">
<WxNavBar title="商品详情" />
<div class="sim-screen-body rx-body">
<div class="rx-tip-bar">处方药需凭处方购买,提交需求后医生在线复诊开方</div>
<div class="rx-hero">
<Pill :size="96" :stroke-width="1.1" aria-hidden="true" />
<span class="rx-cf-tag">处方药</span>
</div>
<div class="rx-info-card">
<p class="rx-name">阿莫西林胶囊 0.25g×24粒</p>
<div class="rx-price-row">
<span class="rx-price">¥12.80</span>
<span class="rx-sold">已售 3520</span>
</div>
<p class="rx-desc"><span>药品说明</span>用于敏感菌所致的呼吸道感染、咽炎、扁桃体炎等,需凭处方购买使用。</p>
</div>
<div class="rx-store-card">
<div class="rx-store-row"><span>药房</span>萧康大药房(余杭店)</div>
<div class="rx-store-row"><span>运费</span>运费5元 · 满99元包运费</div>
<div class="rx-store-row"><span>服务</span>正品保证 隐私发货 需药师审核发货</div>
</div>
</div>
<div class="rx-footer">
<span class="rx-foot-icon"><Home :size="19" aria-hidden="true" />首页</span>
<span class="rx-foot-icon"><ShoppingCart :size="19" aria-hidden="true" />购物车</span>
<button type="button" class="rx-btn-cart">加入购物车</button>
<button type="button" class="rx-btn-buy sim-hotspot" @click="emit('advance', 3)">立即购买</button>
</div>
</div>
<!-- 第 2 屏:用药信息(医生助理问答) -->
<div v-else-if="screenKey === 'assistant'" key="assistant" class="sim-screen assist-screen">
<div class="assist-header">
<span class="assist-title">购药用药确认</span>
</div>
<div class="sim-screen-body assist-body">
<div class="assist-notice">
继续操作视为同意<i>《互联网诊疗风险告知及知情同意书》</i>
</div>
<div class="assist-row">
<span class="assist-avatar"><Bot :size="20" aria-hidden="true" /></span>
<div class="assist-bubble">
您好! 我是医生助理, 已收到您的用药申请:「阿莫西林胶囊 0.25g×24粒」, 为了帮助医生判断准确, 请按照您的真实情况回答以下问题。
</div>
</div>
<div class="assist-row">
<span class="assist-avatar"><Bot :size="20" aria-hidden="true" /></span>
<div class="assist-bubble">您是否曾在线下就诊过?</div>
</div>
<div class="assist-row assist-row-mine">
<div class="assist-bubble assist-bubble-mine">是</div>
<span class="assist-avatar assist-avatar-user">张</span>
</div>
<div class="assist-row">
<span class="assist-avatar"><Bot :size="20" aria-hidden="true" /></span>
<div class="assist-bubble">您是否曾使用过此类药品?</div>
</div>
</div>
<div class="assist-options">
<button type="button" class="assist-opt assist-opt-primary sim-hotspot" @click="emit('advance', 4)">是</button>
<button type="button" class="assist-opt">否</button>
</div>
</div>
<!-- 第 3 屏:转接医生审核 -->
<div v-else-if="screenKey === 'review'" key="review" class="sim-screen assist-screen">
<div class="assist-header">
<span class="assist-title">购药用药确认</span>
</div>
<div class="sim-screen-body assist-body">
<div class="assist-row assist-row-mine">
<div class="assist-bubble assist-bubble-mine">是</div>
<span class="assist-avatar assist-avatar-user">张</span>
</div>
<div class="assist-row">
<span class="assist-avatar"><Bot :size="20" aria-hidden="true" /></span>
<div class="assist-bubble">请问您是因为什么疾病使用该药品呢?</div>
</div>
<div class="assist-row assist-row-mine">
<div class="assist-bubble assist-bubble-mine">急性咽炎</div>
<span class="assist-avatar assist-avatar-user">张</span>
</div>
<div class="assist-row">
<span class="assist-avatar"><Bot :size="20" aria-hidden="true" /></span>
<div class="assist-bubble">
您已确诊过此疾病,并使用过药,且无相关禁忌症和不良反应。请问您是否还有信息需要补充?如无,我将依据病情为您开具处方。
</div>
</div>
<div class="assist-row assist-row-mine">
<div class="assist-bubble assist-bubble-mine">无需补充,立即开方</div>
<span class="assist-avatar assist-avatar-user">张</span>
</div>
<div class="assist-row">
<span class="assist-avatar"><Bot :size="20" aria-hidden="true" /></span>
<div class="assist-bubble">
请稍等, 正在为您转接医生进行复诊开方...
<span class="sim-typing"><i></i><i></i><i></i></span>
</div>
</div>
</div>
<!-- 服务通知:处方已开具 -->
<button type="button" class="push-banner sim-banner-drop sim-hotspot" @click="emit('advance', 5)">
<span class="push-icon"><FileText :size="18" aria-hidden="true" /></span>
<span class="push-copy">
<strong>服务通知</strong>
<small>您的电子处方已开具,点击查看</small>
</span>
<span class="push-go">查看</span>
</button>
</div>
<!-- 第 4 屏:电子处方开具 -->
<div v-else key="prescription" class="sim-screen rx-detail-screen">
<WxNavBar title="处方详情" @back="emit('advance', 4)" />
<div class="sim-screen-body rx-detail-body">
<div class="rx-sheet">
<div class="rx-sheet-head">
<FileText :size="17" aria-hidden="true" />
<span>电子处方</span>
<i class="rx-audit-tag"><BadgeCheck :size="12" aria-hidden="true" />审核通过</i>
</div>
<div class="rx-sheet-body">
<div class="rx-sheet-row"><span>患者</span>张淑芬(女 · 64岁)</div>
<div class="rx-sheet-row"><span>诊断</span>急性咽炎(复诊)</div>
<div class="rx-drug-block">
<p class="rx-drug-rp">Rp.</p>
<div class="rx-drug-item">
<div class="rx-drug-name">
<strong>阿莫西林胶囊 0.25g×24粒</strong>
<span>x1</span>
</div>
<p class="rx-drug-usage">用法:口服,一次2粒,一日3次,饭后服用</p>
</div>
</div>
<div class="rx-sheet-row"><span>医师</span>李国栋(已签名)</div>
<div class="rx-sheet-row"><span>药师</span>王丽(审核通过)</div>
<div class="rx-sheet-total">
合计 <strong>¥12.80</strong>
</div>
</div>
</div>
<p class="rx-valid-tip">处方有效期 3 天,请及时完成购药</p>
</div>
<div class="rx-detail-footer">
<button type="button" class="btn-go-buy sim-hotspot" @click="goOrderPay">立即购药</button>
</div>
</div>
</Transition>
</PhoneFrame>
</template>
<style scoped>
/* ============ 第 1 屏:处方药详情 ============ */
.rx-screen {
background: #f5f5f5;
}
.rx-body {
padding-bottom: 70px;
}
.rx-tip-bar {
margin: 10px 10px 0;
padding: 10px 15px;
border-radius: 8px;
border-left: 3px solid #1890ff;
background: #e6f3ff;
color: #1890ff;
font-size: 13px;
}
.rx-hero {
position: relative;
height: 240px;
margin-top: 10px;
background: linear-gradient(160deg, #fef2f2, #ffffff 70%);
display: flex;
align-items: center;
justify-content: center;
color: #ef4444;
}
.rx-cf-tag {
position: absolute;
right: 10px;
bottom: 0;
width: 50px;
height: 20px;
border-radius: 4px 4px 0 0;
background: #d0e4ff;
color: #4175ee;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
}
.rx-info-card {
background: #fff;
padding: 10px 20px 14px;
margin-bottom: 14px;
}
.rx-name {
margin: 0;
font-size: 16px;
font-weight: 700;
color: #232323;
}
.rx-price-row {
margin-top: 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.rx-price {
font-size: 20px;
font-weight: 700;
color: #f93030;
}
.rx-sold {
font-size: 12px;
color: #999;
}
.rx-desc {
margin: 10px 0 0;
font-size: 12px;
color: #232323;
line-height: 1.7;
}
.rx-desc span {
color: #999;
margin-right: 8px;
}
.rx-store-card {
background: #fff;
padding: 6px 20px;
}
.rx-store-row {
display: flex;
gap: 14px;
padding: 8px 0;
font-size: 12px;
color: #232323;
}
.rx-store-row span {
flex-shrink: 0;
color: #999;
}
.rx-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
height: 74px;
padding: 6px 10px 20px;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.rx-foot-icon {
width: 50px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 11px;
color: #666;
}
.rx-btn-cart {
flex: 1;
height: 38px;
border: none;
border-radius: 22px;
background: #cbe0ff;
color: #4175ee;
font-size: 15px;
opacity: 0.55;
}
.rx-btn-buy {
flex: 1;
height: 38px;
border: none;
border-radius: 22px;
background: #4175ee;
color: #fff;
font-size: 15px;
}
/* ============ 第 2/3 屏:医生助理对话壳 ============ */
.assist-screen {
background: #f8fafc;
}
.assist-header {
position: relative;
z-index: 2;
padding: 62px 15px 10px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.assist-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.assist-body {
padding: 10px 15px 24px;
}
.assist-notice {
padding: 12px;
border-radius: 6px;
background: #f5f5f5;
font-size: 12px;
color: #666;
line-height: 1.6;
margin-bottom: 6px;
}
.assist-notice i {
font-style: normal;
color: #0a84ff;
text-decoration: underline;
}
.assist-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 14px;
}
.assist-row-mine {
justify-content: flex-end;
}
.assist-avatar {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 5px;
background: linear-gradient(135deg, #38bdf8, #0a84ff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
}
.assist-avatar-user {
background: linear-gradient(135deg, #fcd34d, #f59e0b);
}
.assist-bubble {
max-width: 74%;
padding: 9px 12px;
background: #fff;
border-radius: 2px 6px 6px 6px;
color: #333;
font-size: 15px;
line-height: 1.6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.assist-bubble-mine {
background: #0a84ff;
color: #fff;
border-radius: 6px 2px 6px 6px;
}
.assist-options {
position: relative;
z-index: 2;
display: flex;
gap: 12px;
padding: 12px 15px 28px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
.assist-opt {
flex: 1;
min-height: 44px;
border-radius: 22px;
border: 1px solid #ddd;
background: #fff;
color: #333;
font-size: 15px;
font-weight: 500;
}
.assist-opt-primary {
border: none;
background: #0a84ff;
color: #fff;
}
/* 服务通知横幅(与在线复诊一致的推送形态) */
.push-banner {
position: absolute;
top: 58px;
left: 10px;
right: 10px;
z-index: 7;
display: flex;
align-items: center;
gap: 10px;
padding: 11px 13px;
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.97);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22);
text-align: left;
}
.push-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 9px;
background: linear-gradient(135deg, #38bdf8, #0a84ff);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.push-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.push-copy strong {
font-size: 13px;
color: #111827;
}
.push-copy small {
font-size: 12px;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.push-go {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: #0a84ff;
}
/* ============ 第 4 屏:电子处方 ============ */
.rx-detail-screen {
background: #f8fafc;
}
.rx-detail-body {
padding: 12px 15px 90px;
}
.rx-sheet {
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04);
}
.rx-sheet-head {
display: flex;
align-items: center;
gap: 7px;
padding: 12px 14px;
background: #eff6ff;
color: #0a84ff;
font-size: 15px;
font-weight: 600;
}
.rx-audit-tag {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border-radius: 999px;
background: #f0fdf4;
color: #059669;
font-size: 11px;
font-style: normal;
font-weight: 600;
}
.rx-sheet-body {
padding: 6px 14px 14px;
}
.rx-sheet-row {
display: flex;
gap: 12px;
padding: 8px 0;
font-size: 13.5px;
color: #333;
}
.rx-sheet-row span {
flex-shrink: 0;
color: #999;
}
.rx-drug-block {
margin: 6px 0;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
}
.rx-drug-rp {
margin: 0 0 6px;
font-size: 15px;
font-weight: 700;
font-style: italic;
color: #0f172a;
}
.rx-drug-name {
display: flex;
align-items: center;
justify-content: space-between;
}
.rx-drug-name strong {
font-size: 14px;
color: #232323;
}
.rx-drug-name span {
font-size: 12px;
color: #999;
}
.rx-drug-usage {
margin: 6px 0 0;
font-size: 12px;
color: #64748b;
}
.rx-sheet-total {
margin-top: 8px;
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
text-align: right;
font-size: 13px;
color: #666;
}
.rx-sheet-total strong {
font-size: 18px;
color: #ef4444;
margin-left: 4px;
}
.rx-valid-tip {
margin: 12px 4px 0;
font-size: 12px;
color: #94a3b8;
text-align: center;
}
.rx-detail-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
padding: 10px 15px 26px;
background: #fff;
border-top: 1px solid #f1f5f9;
}
.btn-go-buy {
display: block;
width: 100%;
height: 42px;
border: none;
border-radius: 32px;
background: #536dfe;
color: #fff;
font-size: 16px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,507 @@
<template>
<div class="sim-screen home-screen">
<div class="sim-screen-body home-body">
<!-- 顶部蓝色渐变头(诊所模式):复刻 pages/home/home.vue 的 .header -->
<div class="home-header">
<span class="home-bg-circle" aria-hidden="true"></span>
<div class="home-nav-row">
<strong class="home-store-name">萧康中医诊所</strong>
<span class="home-switch-badge">切换</span>
</div>
<div class="home-location-bar">
<MapPin :size="13" aria-hidden="true" />
<span>杭州市余杭区文一西路 968 号 · 距您 1.2km</span>
</div>
</div>
<!-- banner 轮播卡:复刻 .banner-card(h300rpx 圆角32 蓝阴影) -->
<div class="home-banner">
<div class="home-banner-card">
<div class="banner-copy">
<strong>秋季养生 · 名医坐诊</strong>
<span>膏方调理季,老中医一对一问诊</span>
<i class="banner-cta">立即预约</i>
</div>
<Stethoscope class="banner-icon" :size="64" :stroke-width="1.2" aria-hidden="true" />
<span class="banner-dots" aria-hidden="true"><i class="dot-on"></i><i></i><i></i></span>
</div>
</div>
<!-- 金刚区:一行 5 个图标入口 -->
<div class="home-zone">
<div v-for="item in zoneItems" :key="item.label" class="zone-item">
<span class="zone-icon" :style="{ background: item.bg, color: item.color }">
<component :is="item.icon" :size="24" :stroke-width="1.9" aria-hidden="true" />
</span>
<span class="zone-label">{{ item.label }}</span>
</div>
</div>
<!-- 核心入口双卡:到店挂号(蓝)+ 在线复诊(绿),hotspot 由流程指定 -->
<div class="home-entries">
<button
type="button"
class="entry-card"
:class="{ 'sim-hotspot': hotspot === 'register' }"
@click="emit('tap-register')"
>
<span class="entry-title" style="color: #4175ee">到店挂号</span>
<span class="entry-sub">预约专家 不排队</span>
<CalendarCheck class="entry-icon" :size="42" :stroke-width="1.4" style="color: #4175ee" aria-hidden="true" />
</button>
<button
type="button"
class="entry-card"
:class="{ 'sim-hotspot': hotspot === 'followup' }"
@click="emit('tap-followup')"
>
<span class="entry-title" style="color: #00b578">在线复诊</span>
<span class="entry-sub">在线选药 极速达</span>
<ClipboardPlus class="entry-icon" :size="42" :stroke-width="1.4" style="color: #00b578" aria-hidden="true" />
</button>
</div>
<!-- 医生推荐列表:复刻医生卡(头像+职称 tag+擅长+底部统计栏) -->
<div class="home-section-head">
<strong>医生推荐</strong>
<span>更多 ›</span>
</div>
<div v-for="doctor in doctors" :key="doctor.name" class="home-doctor-card">
<div class="doctor-main">
<span class="doctor-avatar" :style="{ background: doctor.avatarBg }">
{{ doctor.name.slice(0, 1) }}
<i class="doctor-online-dot" aria-hidden="true"></i>
</span>
<div class="doctor-info">
<div class="doctor-name-row">
<strong>{{ doctor.name }}</strong>
<span class="doctor-title-tag">{{ doctor.title }}</span>
</div>
<p class="doctor-depart">{{ doctor.depart }} | 萧康中医诊所</p>
<span class="doctor-skill-tag">擅长:{{ doctor.skill }}</span>
</div>
</div>
<div class="doctor-stats">
<div><strong>{{ doctor.consults }}</strong><span>问诊量</span></div>
<div><strong>{{ doctor.rate }}</strong><span>接诊率</span></div>
<div><strong>{{ doctor.response }}</strong><span>平均响应</span></div>
</div>
</div>
</div>
<WxTabBar :items="tabItems" :active="0" selected-color="#1575fc" />
</div>
</template>
<script setup>
/**
* 患者端小程序首页(诊所模式)· 共享模拟屏
*
* 为什么这样写:
* - 「线下挂号」与「在线复诊」两个流程的第一屏都是首页,只是引导点击的入口不同,
* 抽成共享组件用 hotspot 属性控制哪张入口卡发光,避免两份重复的复刻代码。
* - 尺寸与颜色按 xk-client-wx/pages/home/home.vue 源码换算(rpx / 2 = px):
* 蓝渐变头 #298DFF→#6BAFFF、底圆角 60rpx→30px、入口卡标题 #4175EE / #00B578 等。
*/
import { CalendarCheck, ClipboardPlus, FileText, Home, LayoutGrid, MapPin, MessageCircle, Newspaper, Pill, Stethoscope, User } from 'lucide-vue-next'
import WxTabBar from '../../components/WxTabBar.vue'
defineProps({
/** 需要引导点击的入口:register = 到店挂号,followup = 在线复诊 */
hotspot: { type: String, default: 'register' }
})
const emit = defineEmits(['tap-register', 'tap-followup'])
/** 金刚区入口:图标底色参考小程序 icons8 彩色图标的气质 */
const zoneItems = [
{ label: '在线复诊', icon: Stethoscope, bg: '#e8f3ff', color: '#298dff' },
{ label: '到店挂号', icon: CalendarCheck, bg: '#eaefff', color: '#4175ee' },
{ label: '药店购药', icon: Pill, bg: '#e6f7f0', color: '#00b578' },
{ label: '我的处方', icon: FileText, bg: '#fff3e6', color: '#fea54c' },
{ label: '全部服务', icon: LayoutGrid, bg: '#f0f2f5', color: '#64748b' }
]
/** 推荐医生(虚构演示数据) */
const doctors = [
{
name: '李国栋',
title: '主治医师',
depart: '中医内科',
skill: '慢性胃炎、脾胃虚弱调理',
consults: '1286',
rate: '98%',
response: '5分钟',
avatarBg: 'linear-gradient(135deg, #93c5fd, #3b82f6)'
},
{
name: '周慧敏',
title: '副主任医师',
depart: '呼吸内科',
skill: '慢性咳嗽、支气管炎、哮喘',
consults: '2043',
rate: '99%',
response: '3分钟',
avatarBg: 'linear-gradient(135deg, #f9a8d4, #ec4899)'
}
]
/** 底部 tab:首页 / 资讯 / 消息 / 我的(selectedColor #1575FC 摘自 pages.json) */
const tabItems = [
{ label: '首页', icon: Home },
{ label: '资讯', icon: Newspaper },
{ label: '消息', icon: MessageCircle, badge: '2' },
{ label: '我的', icon: User }
]
</script>
<style scoped>
.home-screen {
background: #f5f7fa;
}
.home-body {
padding-bottom: 92px;
}
/* 蓝渐变头:#298DFF → #6BAFFF → 页面底色,底部大圆角 */
.home-header {
position: relative;
padding: 58px 16px 80px;
background: linear-gradient(180deg, #298dff 0%, #6bafff 80%, #f5f7fa 100%);
border-radius: 0 0 30px 30px;
overflow: hidden;
}
.home-bg-circle {
position: absolute;
right: -60px;
top: -50px;
width: 200px;
height: 200px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
filter: blur(2px);
}
.home-nav-row {
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.home-store-name {
color: #fff;
font-size: 18px;
font-weight: 800;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
max-width: 200px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.home-switch-badge {
padding: 3px 8px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.9);
color: #4175ee;
font-size: 10px;
font-weight: 600;
}
.home-location-bar {
position: relative;
margin-top: 12px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.3);
border: 1px solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(4px);
color: #fff;
font-size: 12px;
}
/* banner 卡:上移压住渐变头 */
.home-banner {
margin: -60px 16px 20px;
position: relative;
}
.home-banner-card {
position: relative;
height: 150px;
border-radius: 16px;
background: linear-gradient(120deg, #1e63d0 0%, #3f8cff 58%, #7cc4ff 100%);
box-shadow: 0 15px 35px rgba(41, 141, 255, 0.25);
overflow: hidden;
padding: 26px 24px;
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.banner-copy {
display: flex;
flex-direction: column;
gap: 7px;
color: #fff;
}
.banner-copy strong {
font-size: 19px;
font-weight: 800;
letter-spacing: 0.02em;
}
.banner-copy span {
font-size: 12px;
opacity: 0.85;
}
.banner-cta {
margin-top: 6px;
align-self: flex-start;
font-style: normal;
font-size: 11px;
font-weight: 600;
padding: 5px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #1e63d0;
}
.banner-icon {
color: rgba(255, 255, 255, 0.5);
margin-top: 8px;
}
.banner-dots {
position: absolute;
right: 14px;
bottom: 10px;
display: inline-flex;
gap: 4px;
}
.banner-dots i {
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.45);
}
.banner-dots .dot-on {
width: 12px;
border-radius: 3px;
background: #fff;
}
/* 金刚区 */
.home-zone {
display: flex;
padding: 0 16px 10px;
}
.zone-item {
width: 20%;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.zone-icon {
width: 50px;
height: 50px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.zone-label {
font-size: 12px;
color: #555;
font-weight: 500;
}
/* 核心入口双卡 */
.home-entries {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
}
.entry-card {
position: relative;
height: 90px;
border: none;
border-radius: 16px;
background: #fff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.03);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 5px;
padding-left: 16px;
text-align: left;
overflow: hidden;
}
.entry-title {
font-size: 18px;
font-weight: 800;
}
.entry-sub {
font-size: 11px;
color: #888;
}
.entry-icon {
position: absolute;
right: 8px;
top: 10px;
opacity: 0.28;
}
/* 医生推荐 */
.home-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 18px 12px;
}
.home-section-head strong {
font-size: 17px;
font-weight: 700;
color: #222;
}
.home-section-head span {
font-size: 12px;
color: #999;
}
.home-doctor-card {
margin: 0 16px 16px;
border-radius: 12px;
background: #fff;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
.doctor-main {
display: flex;
gap: 12px;
padding: 14px;
}
.doctor-avatar {
position: relative;
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22px;
font-weight: 700;
}
.doctor-online-dot {
position: absolute;
right: 1px;
bottom: 1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #00b578;
border: 2px solid #fff;
}
.doctor-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.doctor-name-row {
display: flex;
align-items: center;
gap: 8px;
}
.doctor-name-row strong {
font-size: 17px;
font-weight: 700;
color: #333;
}
.doctor-title-tag {
padding: 2px 6px;
border-radius: 4px;
background: #f0f2f5;
color: #666;
font-size: 11px;
}
.doctor-depart {
margin: 0;
font-size: 12px;
color: #888;
}
.doctor-skill-tag {
align-self: flex-start;
padding: 3px 8px;
border-radius: 6px;
background: rgba(65, 117, 238, 0.1);
color: #4175ee;
font-size: 11px;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.doctor-stats {
display: flex;
background: #f8fbff;
border-top: 1px solid #f0f0f0;
padding: 10px 0;
}
.doctor-stats div {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.doctor-stats strong {
font-size: 16px;
font-weight: 700;
color: #333;
font-variant-numeric: tabular-nums;
}
.doctor-stats span {
font-size: 10px;
color: #999;
}
</style>

View File

@@ -0,0 +1,320 @@
<script setup>
/**
* 微信首页(聊天列表)模拟屏:患者端流程的统一起点
*
* 两种引导模式:
* - mode="scan":右上角「+」展开菜单 → 点「扫一扫」进入扫码页(线下挂号 / 复诊 / 购药流程的入口)
* - mode="notice":直接点列表里的「服务通知」查看小程序推送(订单支付流程的入口)
*
* UI 按微信 8.x 形态复刻:#ededed 导航、白底会话列表、底部四 tab(微信绿 #07c160)。
*/
import { ref } from 'vue'
import { Bell, CircleUserRound, Compass, MessageCircle, Plus, ScanLine, Search, UserPlus, Users, Wallet } from 'lucide-vue-next'
const props = defineProps({
mode: { type: String, default: 'scan' },
/** 服务通知的摘要文案,各流程可按剧情覆盖 */
noticeDigest: { type: String, default: '惠佑康:您有一条新的就诊提醒' }
})
const emit = defineEmits(['scan', 'notice'])
/** 「+」菜单展开状态:仅 scan 模式使用 */
const menuOpen = ref(false)
const chats = [
{ name: '妈妈', digest: '记得帮我看看那个药怎么买', time: '09:32', avatarBg: 'linear-gradient(135deg, #fbbf24, #f59e0b)', short: '妈' },
{ name: '社区家庭医生群', digest: '王医生:本周随访安排已更新', time: '08:47', avatarBg: 'linear-gradient(135deg, #34d399, #10b981)', short: '医' },
{ name: '文件传输助手', digest: '[图片]', time: '昨天', avatarBg: 'linear-gradient(135deg, #38bdf8, #0ea5e9)', short: '文' }
]
function onPlusTap() {
if (props.mode !== 'scan') return
menuOpen.value = !menuOpen.value
}
</script>
<template>
<div class="sim-screen wechat-screen">
<!-- 微信导航 -->
<header class="wc-navbar">
<span class="wc-title">微信</span>
<span class="wc-nav-actions">
<Search :size="19" aria-hidden="true" />
<button
type="button"
class="wc-plus"
:class="{ 'sim-hotspot': mode === 'scan' && !menuOpen }"
aria-label="更多功能"
@click="onPlusTap"
>
<Plus :size="21" aria-hidden="true" />
</button>
</span>
</header>
<!-- 会话列表 -->
<div class="sim-screen-body wc-list">
<!-- 服务通知:小程序消息的统一入口 -->
<button
type="button"
class="wc-chat"
:class="{ 'sim-hotspot': mode === 'notice' }"
@click="mode === 'notice' && emit('notice')"
>
<span class="wc-avatar wc-avatar-notice" aria-hidden="true">
<Bell :size="20" />
</span>
<span class="wc-chat-main">
<strong>服务通知</strong>
<small>{{ noticeDigest }}</small>
</span>
<span class="wc-chat-side">
<i class="wc-time">10:21</i>
<i v-if="mode === 'notice'" class="wc-badge">1</i>
</span>
</button>
<button v-for="chat in chats" :key="chat.name" type="button" class="wc-chat">
<span class="wc-avatar" :style="{ background: chat.avatarBg }" aria-hidden="true">{{ chat.short }}</span>
<span class="wc-chat-main">
<strong>{{ chat.name }}</strong>
<small>{{ chat.digest }}</small>
</span>
<span class="wc-chat-side"><i class="wc-time">{{ chat.time }}</i></span>
</button>
</div>
<!-- 「+」下拉菜单 -->
<Transition name="wc-menu">
<div v-if="menuOpen" class="wc-menu" role="menu">
<span class="wc-menu-item"><Users :size="17" aria-hidden="true" />发起群聊</span>
<span class="wc-menu-item"><UserPlus :size="17" aria-hidden="true" />添加朋友</span>
<button type="button" class="wc-menu-item sim-hotspot" role="menuitem" @click="emit('scan')">
<ScanLine :size="17" aria-hidden="true" />扫一扫
</button>
<span class="wc-menu-item"><Wallet :size="17" aria-hidden="true" />收付款</span>
</div>
</Transition>
<!-- 微信底部 tab -->
<nav class="wc-tabbar" aria-hidden="true">
<span class="wc-tab wc-tab-on"><MessageCircle :size="21" /><i>微信</i></span>
<span class="wc-tab"><CircleUserRound :size="21" /><i>通讯录</i></span>
<span class="wc-tab"><Compass :size="21" /><i>发现</i></span>
<span class="wc-tab"><CircleUserRound :size="21" /><i>我</i></span>
</nav>
</div>
</template>
<style scoped>
.wechat-screen {
background: #ededed;
display: flex;
flex-direction: column;
}
.wc-navbar {
flex-shrink: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: #ededed;
color: #191919;
}
.wc-title {
font-size: 16.5px;
font-weight: 600;
}
.wc-nav-actions {
position: absolute;
right: 12px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 18px;
color: #191919;
}
.wc-plus {
border: none;
background: transparent;
color: #191919;
display: flex;
padding: 2px;
border-radius: 8px;
}
.wc-list {
background: #fff;
}
.wc-chat {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 11px 14px;
border: none;
background: #fff;
text-align: left;
}
.wc-chat + .wc-chat {
border-top: 0.5px solid #ececec;
}
.wc-avatar {
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 5px;
color: #fff;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
/* 服务通知头像:微信橙黄铃铛 */
.wc-avatar-notice {
background: linear-gradient(135deg, #fbbf5e, #fa9d3b);
}
.wc-chat-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.wc-chat-main strong {
font-size: 15.5px;
font-weight: 500;
color: #191919;
}
.wc-chat-main small {
font-size: 12.5px;
color: #9a9a9a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wc-chat-side {
flex-shrink: 0;
align-self: flex-start;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 5px;
padding-top: 2px;
}
.wc-time {
font-style: normal;
font-size: 11px;
color: #b2b2b2;
}
.wc-badge {
width: 17px;
height: 17px;
border-radius: 50%;
background: #fa5151;
color: #fff;
font-style: normal;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
}
/* 「+」下拉菜单:微信深灰浮层 */
.wc-menu {
position: absolute;
top: 78px;
right: 10px;
z-index: 8;
width: 142px;
padding: 4px 0;
border-radius: 9px;
background: #4c4c4c;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
}
.wc-menu::before {
content: "";
position: absolute;
top: -5px;
right: 14px;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #4c4c4c;
}
.wc-menu-item {
display: flex;
align-items: center;
gap: 11px;
padding: 11px 16px;
border: none;
background: transparent;
color: #fff;
font-size: 14px;
text-align: left;
}
.wc-menu-item + .wc-menu-item {
border-top: 0.5px solid rgba(255, 255, 255, 0.12);
}
.wc-menu-enter-active,
.wc-menu-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
transform-origin: top right;
}
.wc-menu-enter-from,
.wc-menu-leave-to {
opacity: 0;
transform: scale(0.92) translateY(-4px);
}
/* 微信 tabbar */
.wc-tabbar {
flex-shrink: 0;
display: flex;
padding: 6px 0 22px;
background: #f7f7f7;
border-top: 0.5px solid #e0e0e0;
}
.wc-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
color: #191919;
}
.wc-tab i {
font-style: normal;
font-size: 10.5px;
}
.wc-tab-on {
color: #07c160;
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup>
/**
* 微信「扫一扫」相机页模拟:黑色取景背景 + 扫描框 + 上下往返的扫描线
*
* 取景框里放一张门店台卡(FakeQrcode 生成的仿真小程序码),
* 点击台卡即模拟「识别成功」,由父流程推进到小程序页面。
*/
import { Image, Zap } from 'lucide-vue-next'
import FakeQrcode from '../../components/FakeQrcode.vue'
defineProps({
/** 台卡标题:如「萧康中医诊所」 */
label: { type: String, default: '萧康中医诊所' },
/** 台卡副题 */
sub: { type: String, default: '扫码进入小程序 · 挂号问诊' }
})
const emit = defineEmits(['scanned'])
</script>
<template>
<div class="sim-screen scan-screen">
<header class="scan-navbar">扫一扫</header>
<div class="scan-stage">
<!-- 取景框:四角亮标 + 扫描线 -->
<button type="button" class="scan-frame sim-hotspot" aria-label="模拟扫码成功" @click="emit('scanned')">
<i class="scan-corner scan-corner-tl" aria-hidden="true"></i>
<i class="scan-corner scan-corner-tr" aria-hidden="true"></i>
<i class="scan-corner scan-corner-bl" aria-hidden="true"></i>
<i class="scan-corner scan-corner-br" aria-hidden="true"></i>
<span class="scan-line" aria-hidden="true"></span>
<!-- 对准的门店台卡 -->
<span class="scan-card">
<FakeQrcode :size="112" seed="7" />
<strong>{{ label }}</strong>
<small>{{ sub }}</small>
</span>
</button>
<p class="scan-tip">将小程序码放入框内,即可自动识别</p>
</div>
<div class="scan-tools" aria-hidden="true">
<span class="scan-tool"><Zap :size="19" />轻触照亮</span>
<span class="scan-tool"><Image :size="19" />相册</span>
</div>
<nav class="scan-tabs" aria-hidden="true">
<span class="scan-tab scan-tab-on">扫码</span>
<span class="scan-tab">付款码</span>
<span class="scan-tab">识物</span>
</nav>
</div>
</template>
<style scoped>
.scan-screen {
background: #101418;
display: flex;
flex-direction: column;
color: #fff;
}
.scan-navbar {
flex-shrink: 0;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
color: #fff;
}
.scan-stage {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
}
.scan-frame {
position: relative;
width: 218px;
height: 218px;
border: none;
background: rgba(255, 255, 255, 0.02);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 2px;
}
.scan-corner {
position: absolute;
width: 20px;
height: 20px;
border: 3px solid #07c160;
}
.scan-corner-tl { left: 0; top: 0; border-right: none; border-bottom: none; }
.scan-corner-tr { right: 0; top: 0; border-left: none; border-bottom: none; }
.scan-corner-bl { left: 0; bottom: 0; border-right: none; border-top: none; }
.scan-corner-br { right: 0; bottom: 0; border-left: none; border-top: none; }
/* 扫描线:上下往返 */
.scan-line {
position: absolute;
left: 8px;
right: 8px;
top: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(7, 193, 96, 0.9), transparent);
animation: scan-sweep 2.2s ease-in-out infinite;
z-index: 2;
}
@keyframes scan-sweep {
0%, 100% { transform: translateY(12px); }
50% { transform: translateY(200px); }
}
@media (prefers-reduced-motion: reduce) {
.scan-line {
animation: none;
transform: translateY(104px);
}
}
/* 取景框里的门店台卡 */
.scan-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 12px 14px 10px;
border-radius: 10px;
background: #fff;
color: #111827;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
.scan-card strong {
font-size: 13px;
font-weight: 600;
}
.scan-card small {
font-size: 10.5px;
color: #6b7280;
}
.scan-tip {
margin: 0;
font-size: 12.5px;
color: rgba(255, 255, 255, 0.62);
}
.scan-tools {
flex-shrink: 0;
display: flex;
justify-content: center;
gap: 52px;
padding-bottom: 16px;
}
.scan-tool {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
font-size: 11px;
color: rgba(255, 255, 255, 0.72);
}
.scan-tabs {
flex-shrink: 0;
display: flex;
justify-content: center;
gap: 34px;
padding: 4px 0 26px;
}
.scan-tab {
font-size: 13.5px;
color: rgba(255, 255, 255, 0.6);
}
.scan-tab-on {
color: #07c160;
font-weight: 600;
}
</style>

1403
src/views/demo/index.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router'
import { ArrowLeft, ArrowRight, Calendar, CheckCircle2, Clock3, Play, ShieldCheck, Sparkles, Star } from 'lucide-vue-next'
import { homeCaseCards, homeMarqueeItems, homeServiceItems, metrics, siteConfig } from '@/site.config'
import AnimatedContent from '@/components/vue-bits/AnimatedContent.vue'
import CountUp from '@/components/vue-bits/CountUp.vue'
import DecryptedText from '@/components/vue-bits/DecryptedText.vue'
import BlurText from '@/components/vue-bits/BlurText.vue'
import Magnet from '@/components/vue-bits/Magnet.vue'
@@ -78,8 +79,9 @@ onBeforeUnmount(() => {
>
<source :src="siteConfig.hero.video.src" type="video/mp4">
</video>
<!-- 绸缎氛围层颜色与品牌青蓝(--brand)同色相,避免 teal 与 cyan 两个色系打架 -->
<div class="hero-silk-layer" aria-hidden="true">
<Silk color="#14B8A6" :speed="2.6" :scale="1.2" :noise-intensity="0.78" :rotation="0.16" />
<Silk color="#0EA5C4" :speed="2.6" :scale="1.2" :noise-intensity="0.78" :rotation="0.16" />
</div>
<div class="bottom-blur-overlay" aria-hidden="true"></div>
@@ -187,19 +189,19 @@ onBeforeUnmount(() => {
<section id="intro" ref="aboutRef" class="creator-about-section">
<div class="floating-medical-chip chip-a">
<Sparkles :size="20" aria-hidden="true" />
AI Alert
智能预警
</div>
<div class="floating-medical-chip chip-b">
<ShieldCheck :size="20" aria-hidden="true" />
Data Safe
数据安全
</div>
<div class="floating-medical-chip chip-c">
<CheckCircle2 :size="20" aria-hidden="true" />
Follow-up
随访提醒
</div>
<div class="floating-medical-chip chip-d">
<Star :size="20" aria-hidden="true" />
99.95%
99.95% 可用
</div>
<AnimatedContent class-name="about-inner" :distance="52" :duration="1" ease="power4.out">
@@ -309,8 +311,9 @@ onBeforeUnmount(() => {
</AnimatedContent>
<AnimatedContent class-name="contact-metrics" :distance="50" direction="horizontal" :reverse="true" :duration="0.9">
<!-- 指标数字滚动入场:进入视口后从 0 滚动到目标值 -->
<div v-for="item in metrics" :key="item.label">
<strong>{{ item.value }}</strong>
<strong><CountUp :value="item.value" /></strong>
<span>{{ item.label }}</span>
</div>
</AnimatedContent>

View File

@@ -3,8 +3,10 @@ import { computed, ref } from 'vue'
import { ArrowRight, BadgeCheck, Check, CircleDollarSign, ClipboardCheck, Headphones, MessageCircle, Play, ShieldCheck, Star, Video } from 'lucide-vue-next'
import { caseStudies, faqs, pricingFeatureGroups, pricingPlans, productFeatures, siteConfig } from '@/site.config'
import AnimatedContent from '@/components/vue-bits/AnimatedContent.vue'
import CountUp from '@/components/vue-bits/CountUp.vue'
import DecryptedText from '@/components/vue-bits/DecryptedText.vue'
import Magnet from '@/components/vue-bits/Magnet.vue'
import TiltCard from '@/components/vue-bits/TiltCard.vue'
const openFaq = ref(0)
const demoVideoPlaying = ref(false)
@@ -118,42 +120,45 @@ const reviews = [
</AnimatedContent>
<AnimatedContent :distance="72" direction="horizontal" :reverse="true" :duration="0.95" class-name="product-snapshot-shell">
<div class="product-snapshot">
<div class="snapshot-header">
<span>萧康云医工作台</span>
<strong>实时运行</strong>
</div>
<div class="snapshot-main">
<aside>
<span v-for="item in ['患者画像', '协同任务', '风险预警', '运营分析']" :key="item">{{ item }}</span>
</aside>
<div class="snapshot-content">
<div class="snapshot-alert">
<ShieldCheck :size="20" aria-hidden="true" />
<span>慢病高风险患者已自动分派给责任医生</span>
</div>
<div class="snapshot-cards">
<div>
<small>待处理随访</small>
<strong>246</strong>
<!-- 3D 倾斜驾驶舱:工作台快照跟随鼠标产生透视倾斜,数字滚动入场 -->
<TiltCard :max-tilt="8" :perspective="1200">
<div class="product-snapshot">
<div class="snapshot-header">
<span>萧康云医工作台</span>
<strong>实时运行</strong>
</div>
<div class="snapshot-main">
<aside>
<span v-for="item in ['患者画像', '协同任务', '风险预警', '运营分析']" :key="item">{{ item }}</span>
</aside>
<div class="snapshot-content">
<div class="snapshot-alert">
<ShieldCheck :size="20" aria-hidden="true" />
<span>慢病高风险患者已自动分派给责任医生</span>
</div>
<div>
<small>会诊申请</small>
<strong>38</strong>
<div class="snapshot-cards">
<div>
<small>待处理随访</small>
<strong><CountUp value="246" /></strong>
</div>
<div>
<small>会诊申请</small>
<strong><CountUp value="38" /></strong>
</div>
<div>
<small>质控通过率</small>
<strong><CountUp value="97.8%" /></strong>
</div>
</div>
<div>
<small>质控通过率</small>
<strong>97.8%</strong>
<div class="snapshot-bars" aria-hidden="true">
<span style="width: 76%"></span>
<span style="width: 92%"></span>
<span style="width: 58%"></span>
</div>
</div>
<div class="snapshot-bars" aria-hidden="true">
<span style="width: 76%"></span>
<span style="width: 92%"></span>
<span style="width: 58%"></span>
</div>
</div>
</div>
</div>
</TiltCard>
</AnimatedContent>
</div>
</section>

View File

@@ -1,10 +1,9 @@
<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 { ArrowRight, BadgeCheck, ListVideo, Smartphone } 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'
@@ -13,8 +12,8 @@ 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 }
// { key: 'doctor', label: '医生端', to: '/video/doctor', icon: Stethoscope },
// { key: 'manager', label: '小程序店长管理端', to: '/video/manager', icon: Store }
]
const currentKey = computed(() => {
@@ -64,18 +63,8 @@ function videoTo(id) {
: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>
<!-- 副标题直接输出:去掉乱码翻牌动画,让企业站文案安静可读 -->
<p class="video-hero-subtitle">{{ currentVideo.subtitle }}</p>
<nav class="video-role-tabs" aria-label="视频角色导航">
<RouterLink
@@ -97,9 +86,9 @@ function videoTo(id) {
<ArrowRight :size="18" aria-hidden="true" />
</RouterLink>
</Magnet>
<a href="#chapters" class="video-secondary-action">
查看章节
</a>
<RouterLink to="/demo/full-journey" class="video-secondary-action">
体验流程模拟
</RouterLink>
</div>
</AnimatedContent>
@@ -154,113 +143,113 @@ function videoTo(id) {
</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 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>
<!-- <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>
<!-- <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>
<!-- <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>
<!-- <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>
<!-- <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>
<!-- <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>