Files
2026-06-09 17:00:23 +08:00

638 lines
13 KiB
Vue

<template>
<view class="matching-page">
<view class="matching-shell">
<view class="top-visual">
<view class="network">
<view class="ring ring-large"></view>
<view class="ring ring-middle"></view>
<view class="ring ring-small"></view>
<view class="node node-top"></view>
<view class="node node-left"></view>
<view class="node node-right"></view>
<view
v-for="particle in particles"
:key="particle.id"
class="particle"
:style="particle.style"
></view>
</view>
</view>
<view class="middle-visual">
<view class="match-bubble">
<text>{{ profile.message }}</text>
<text class="typing-dots"></text>
<view class="bubble-tail"></view>
</view>
<view class="director-card">
<image class="director-image" src="/static/config-doctor.png" mode="aspectFit"></image>
</view>
<view class="intelligence-area">
<view class="scan-circle">
<view class="pulse-ring ring-one"></view>
<view class="pulse-ring ring-two"></view>
<view class="brain-core">
<view class="scan-bar"></view>
<view class="brain-icon"></view>
</view>
</view>
<view
v-for="(tag, index) in profile.tags"
:key="tag.label"
class="float-tag"
:class="[`tag-${tag.tone}`, `tag-pos-${index}`]"
>
<text>{{ tag.label }}</text>
</view>
</view>
</view>
<view class="bottom-progress">
<view class="progress-track">
<view class="progress-fill" :style="{ width: `${progress}%` }"></view>
</view>
<text class="progress-subtitle">{{ profile.subtitle }}</text>
<view class="security-icon"></view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { fetchMatchingProfile, type MatchingProfile } from '../../api/matching'
type Particle = {
id: number
style: Record<string, string>
}
const profile = reactive<MatchingProfile>({
message: '王主任正在为您智能匹配病例',
subtitle: '正在通过大模型计算最适合您的临床案例库...',
progressTarget: 92,
tags: []
})
const particles = ref<Particle[]>([])
const progress = ref(0)
let particleId = 0
let particleTimer: ReturnType<typeof setInterval> | null = null
let progressTimer: ReturnType<typeof setInterval> | null = null
const MATCHING_DURATION_MS = 10000
const PROGRESS_INTERVAL_MS = 100
function loadMatchingProfile() {
fetchMatchingProfile().then(result => {
Object.assign(profile, result)
startProgress()
})
}
function createParticle() {
const id = particleId++
const startX = Math.random() * 256
const startY = Math.random() * 256
const destX = (Math.random() - 0.5) * 150
const destY = (Math.random() - 0.5) * 150
const duration = 2 + Math.random() * 3
particles.value.push({
id,
style: {
left: `${startX}px`,
top: `${startY}px`,
'--particle-x': `${destX}px`,
'--particle-y': `${destY}px`,
animationDuration: `${duration}s`
}
})
setTimeout(() => {
particles.value = particles.value.filter(item => item.id !== id)
}, duration * 1000)
}
function startParticles() {
for (let index = 0; index < 12; index += 1) {
createParticle()
}
particleTimer = setInterval(createParticle, 300)
}
function startProgress() {
if (progressTimer) clearInterval(progressTimer)
progress.value = 0
const startedAt = Date.now()
progressTimer = setInterval(() => {
const elapsed = Date.now() - startedAt
const ratio = Math.min(1, elapsed / MATCHING_DURATION_MS)
progress.value = Math.round(profile.progressTarget * ratio)
if (ratio >= 1) {
if (progressTimer) clearInterval(progressTimer)
progressTimer = null
uni.redirectTo({
url: '/pages/cases/cases'
})
return
}
}, PROGRESS_INTERVAL_MS)
}
onMounted(() => {
loadMatchingProfile()
startParticles()
})
onUnmounted(() => {
if (particleTimer) clearInterval(particleTimer)
if (progressTimer) clearInterval(progressTimer)
})
</script>
<style>
page {
min-height: 100%;
background: #ffffff;
}
.matching-page {
min-height: 100vh;
background: #ffffff;
color: #191c21;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.matching-shell {
position: relative;
min-height: 100vh;
overflow: hidden;
background: #ffffff;
display: flex;
flex-direction: column;
}
.matching-shell::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-size: 40px 40px;
background-image: radial-gradient(circle, rgba(0, 71, 141, 0.08) 1px, transparent 1px);
pointer-events: none;
}
.top-visual {
position: relative;
z-index: 1;
height: 33.333vh;
min-height: 250px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.network {
position: relative;
width: 256px;
height: 256px;
}
.ring {
position: absolute;
left: 50%;
top: 50%;
border-radius: 50%;
transform: translate(-50%, -50%);
box-sizing: border-box;
}
.ring-large {
width: 224px;
height: 224px;
border: 1px solid rgba(194, 198, 212, 0.28);
animation: spin-reverse 15s linear infinite;
}
.ring-middle {
width: 192px;
height: 192px;
border: 2px solid rgba(0, 71, 141, 0.2);
animation: pulse-soft 2s ease-in-out infinite;
}
.ring-small {
width: 128px;
height: 128px;
border: 1px solid rgba(0, 105, 112, 0.3);
animation: spin 10s linear infinite;
}
.node {
position: absolute;
border-radius: 50%;
}
.node-top {
left: 50%;
top: 16px;
width: 16px;
height: 16px;
background: #00478d;
box-shadow: 0 0 15px rgba(0, 71, 141, 0.5);
transform: translateX(-50%);
}
.node-left {
left: 40px;
bottom: 40px;
width: 12px;
height: 12px;
background: #006970;
box-shadow: 0 0 10px rgba(0, 105, 112, 0.4);
}
.node-right {
right: 32px;
top: 80px;
width: 20px;
height: 20px;
background: #005eb8;
box-shadow: 0 0 12px rgba(0, 94, 184, 0.4);
}
.particle {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: #00478d;
box-shadow: 0 0 8px #00478d;
pointer-events: none;
animation-name: particle-move;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
.middle-visual {
position: relative;
z-index: 1;
flex: 1;
padding: 40px 20px 0;
display: flex;
flex-direction: column;
align-items: center;
}
.match-bubble {
position: relative;
max-width: 280px;
margin-bottom: 24px;
padding: 12px 16px;
border: 1px solid #c2c6d4;
border-radius: 8px;
background: #e1e2ea;
box-shadow: 0 4px 12px rgba(25, 28, 33, 0.06);
color: #424752;
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
.bubble-tail {
position: absolute;
left: 50%;
bottom: -8px;
width: 16px;
height: 16px;
border-right: 1px solid #c2c6d4;
border-bottom: 1px solid #c2c6d4;
background: #e1e2ea;
transform: translateX(-50%) rotate(45deg);
}
.typing-dots::after {
content: '';
animation: typing 1.5s steps(4) infinite;
}
.director-card {
width: 192px;
height: 192px;
margin-bottom: 32px;
padding: 8px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 8px 22px rgba(25, 28, 33, 0.12);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
animation: float-soft 6s ease-in-out infinite;
}
.director-image {
width: 100%;
height: 100%;
border-radius: 8px;
}
.intelligence-area {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.scan-circle {
position: relative;
width: 96px;
height: 96px;
display: flex;
align-items: center;
justify-content: center;
}
.pulse-ring {
position: absolute;
border-radius: 50%;
animation: pulse-ring 2s cubic-bezier(0.455, 0.03, 0.515, 0.955) infinite;
}
.ring-one {
width: 96px;
height: 96px;
background: rgba(0, 71, 141, 0.1);
}
.ring-two {
width: 76px;
height: 76px;
background: rgba(0, 71, 141, 0.2);
animation-delay: 0.5s;
}
.brain-core {
position: relative;
z-index: 2;
width: 64px;
height: 64px;
border: 1px solid rgba(0, 71, 141, 0.2);
border-radius: 50%;
background: #f9f9ff;
box-shadow: 0 6px 16px rgba(25, 28, 33, 0.08);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.scan-bar {
position: absolute;
left: -100%;
top: 0;
width: 200%;
height: 100%;
background: linear-gradient(to right, transparent, rgba(0, 71, 141, 0.4), transparent);
animation: scan-h 2s ease-in-out infinite;
}
.brain-icon {
position: relative;
z-index: 1;
width: 32px;
height: 32px;
background: #00478d;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M9%202a4%204%200%200%200-4%204v.1A5%205%200%200%200%203%2010a5%205%200%200%200%202%204v.1A4%204%200%200%200%209%2022h1V2H9zm6%200h-1v20h1a4%204%200%200%200%204-4v-.1a5%205%200%200%200%202-4A5%205%200%200%200%2019%206.1V6a4%204%200%200%200-4-4z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M9%202a4%204%200%200%200-4%204v.1A5%205%200%200%200%203%2010a5%205%200%200%200%202%204v.1A4%204%200%200%200%209%2022h1V2H9zm6%200h-1v20h1a4%204%200%200%200%204-4v-.1a5%205%200%200%200%202-4A5%205%200%200%200%2019%206.1V6a4%204%200%200%200-4-4z'/%3E%3C/svg%3E") center / contain no-repeat;
animation: pulse-soft 1.8s ease-in-out infinite;
}
.float-tag {
position: absolute;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid transparent;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-size: 10px;
line-height: 16px;
font-weight: 500;
opacity: 0;
animation: fade-float 1s forwards ease-out, float 4s ease-in-out infinite;
}
.tag-pos-0 {
left: 0;
top: -16px;
animation-delay: 0.2s, 1.2s;
}
.tag-pos-1 {
right: -8px;
top: 48px;
animation-delay: 0.6s, 1.6s;
}
.tag-pos-2 {
left: 24px;
top: 118px;
animation-delay: 1s, 2s;
}
.tag-pos-3 {
right: 0;
top: 92px;
animation-delay: 1.4s, 2.4s;
}
.tag-secondary {
border-color: rgba(0, 105, 112, 0.2);
background: rgba(122, 241, 252, 0.8);
color: #006e75;
}
.tag-primary {
border-color: rgba(0, 71, 141, 0.2);
background: rgba(214, 227, 255, 0.8);
color: #001b3d;
}
.tag-tertiary {
border-color: rgba(121, 49, 0, 0.2);
background: rgba(255, 219, 203, 0.8);
color: #341100;
}
.tag-neutral {
border-color: rgba(194, 198, 212, 0.3);
background: rgba(231, 232, 240, 0.9);
color: #424752;
}
.bottom-progress {
position: relative;
z-index: 1;
padding: 20px 20px 48px;
display: flex;
flex-direction: column;
align-items: center;
}
.progress-track {
width: 100%;
height: 8px;
margin-bottom: 16px;
border-radius: 999px;
background: #f3f4f6;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: #00478d;
transition: width 1s ease-out;
}
.progress-subtitle {
max-width: 280px;
text-align: center;
color: #727783;
font-size: 12px;
line-height: 16px;
font-weight: 500;
}
.security-icon {
width: 16px;
height: 16px;
margin-top: 32px;
background: #00478d;
opacity: 0.6;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%201a5%205%200%200%200-5%205v3H6a2%202%200%200%200-2%202v9a2%202%200%200%200%202%202h12a2%202%200%200%200%202-2v-9a2%202%200%200%200-2-2h-1V6a5%205%200%200%200-5-5zm-3%208V6a3%203%200%200%201%206%200v3H9z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20viewBox='0%200%2024%2024'%20xmlns='http://www.w3.org/2000/svg'%3E%3Cpath%20d='M12%201a5%205%200%200%200-5%205v3H6a2%202%200%200%200-2%202v9a2%202%200%200%200%202%202h12a2%202%200%200%200%202-2v-9a2%202%200%200%200-2-2h-1V6a5%205%200%200%200-5-5zm-3%208V6a3%203%200%200%201%206%200v3H9z'/%3E%3C/svg%3E") center / contain no-repeat;
}
@keyframes typing {
0%,
25% {
content: '';
}
50% {
content: '.';
}
75% {
content: '..';
}
100% {
content: '...';
}
}
@keyframes particle-move {
0% {
transform: translate(0, 0);
opacity: 0;
}
20%,
80% {
opacity: 1;
}
100% {
transform: translate(var(--particle-x), var(--particle-y));
opacity: 0;
}
}
@keyframes pulse-ring {
0% {
transform: scale(0.33);
opacity: 0.8;
}
80%,
100% {
transform: scale(1.5);
opacity: 0;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes float-soft {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-5px) scale(1.02);
}
}
@keyframes fade-float {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scan-h {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
@keyframes spin {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes spin-reverse {
from {
transform: translate(-50%, -50%) rotate(360deg);
}
to {
transform: translate(-50%, -50%) rotate(0deg);
}
}
@keyframes pulse-soft {
0%,
100% {
opacity: 0.75;
}
50% {
opacity: 1;
}
}
</style>