feat: 病历模块优化、AI辅助问诊
This commit is contained in:
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
714
apps/web-antd/src/components/loading/PrescriptionLoading.vue
Normal file
@@ -0,0 +1,714 @@
|
||||
<template>
|
||||
<!--
|
||||
AI 加载动画:处方=药丸;病历=病历本
|
||||
type + text 由调用方传入;嵌入抽屉时默认不显示主题切换
|
||||
-->
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="loading-wrapper"
|
||||
:class="{ 'loading-wrapper--dark': isDark }"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
>
|
||||
<canvas ref="canvasRef" class="loading-canvas"></canvas>
|
||||
<button
|
||||
v-if="showToggle"
|
||||
class="theme-toggle-btn"
|
||||
:title="isDark ? '切换到亮色模式' : '切换到暗色模式'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<span class="theme-icon">
|
||||
<svg
|
||||
v-if="!isDark"
|
||||
class="sun"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="moon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
</span>
|
||||
<!-- <span class="theme-label">{{ isDark ? '亮色' : '暗色' }}</span>-->
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AI 生成加载动画
|
||||
* - type=prescription:中心画胶囊药丸
|
||||
* - type=medical_record:中心画病历本
|
||||
* - text:底部文案(可动态传)
|
||||
*/
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export type AiLoadingType = 'prescription' | 'medical_record';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 动画类型:处方 / 病历 */
|
||||
type?: AiLoadingType;
|
||||
/** 底部文案 */
|
||||
text?: string;
|
||||
/** 是否显示主题切换(嵌入业务抽屉建议关闭) */
|
||||
showToggle?: boolean;
|
||||
}>(),
|
||||
{
|
||||
type: 'prescription',
|
||||
text: '',
|
||||
showToggle: false,
|
||||
},
|
||||
);
|
||||
|
||||
/** 未传文案时按 type 给默认值 */
|
||||
const displayText = computed(() => {
|
||||
const t = String(props.text || '').trim();
|
||||
if (t) return t;
|
||||
return props.type === 'medical_record'
|
||||
? 'AI正在生成病历'
|
||||
: 'AI正在生成处方';
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const isDark = ref(false);
|
||||
/** 画布循环里读取最新文案/类型,避免闭包过期 */
|
||||
const liveText = ref(displayText.value);
|
||||
const liveType = ref<AiLoadingType>(props.type);
|
||||
|
||||
watch(
|
||||
displayText,
|
||||
(v) => {
|
||||
liveText.value = v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(
|
||||
() => props.type,
|
||||
(v) => {
|
||||
liveType.value = v || 'prescription';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function detectAppDark(): boolean {
|
||||
return (
|
||||
document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.documentElement.getAttribute('class')?.includes('dark') === true
|
||||
);
|
||||
}
|
||||
|
||||
function syncThemeFromApp() {
|
||||
if (localStorage.getItem('prescription-loading-theme')) {
|
||||
isDark.value = localStorage.getItem('prescription-loading-theme') === 'dark';
|
||||
return;
|
||||
}
|
||||
isDark.value = detectAppDark();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
localStorage.setItem(
|
||||
'prescription-loading-theme',
|
||||
isDark.value ? 'dark' : 'light',
|
||||
);
|
||||
resetParticles?.();
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
size: number;
|
||||
color: { r: number; g: number; b: number };
|
||||
type: 'circle' | 'cross';
|
||||
}
|
||||
|
||||
interface ThemeColors {
|
||||
textColor: string;
|
||||
ringColor: string;
|
||||
glowInner: string;
|
||||
glowMiddle: string;
|
||||
glowOuter: string;
|
||||
particleBright: number;
|
||||
iconShadow: string;
|
||||
}
|
||||
|
||||
let animationId = 0;
|
||||
let resetParticles: (() => void) | null = null;
|
||||
let themeObserver: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let removeWindowResize: (() => void) | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
syncThemeFromApp();
|
||||
if (!containerRef.value || !canvasRef.value) return;
|
||||
|
||||
const canvas = canvasRef.value;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const container = containerRef.value;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let cssWidth = 0;
|
||||
let cssHeight = 0;
|
||||
let prevW = 0;
|
||||
let prevH = 0;
|
||||
let startTime: number | null = null;
|
||||
let lastTime: number | null = null;
|
||||
let particles: Particle[] = [];
|
||||
const MAX_PARTICLES = 28;
|
||||
|
||||
resetParticles = () => {
|
||||
particles = [];
|
||||
};
|
||||
|
||||
function updateSize() {
|
||||
const rect = container.getBoundingClientRect();
|
||||
cssWidth = rect.width || 300;
|
||||
cssHeight = rect.height || 280;
|
||||
const pw = Math.round(cssWidth * dpr);
|
||||
const ph = Math.round(cssHeight * dpr);
|
||||
if (canvas.width !== pw || canvas.height !== ph) {
|
||||
canvas.width = pw;
|
||||
canvas.height = ph;
|
||||
if (
|
||||
prevW &&
|
||||
(Math.abs(cssWidth - prevW) > 2 || Math.abs(cssHeight - prevH) > 2)
|
||||
) {
|
||||
particles = [];
|
||||
}
|
||||
prevW = cssWidth;
|
||||
prevH = cssHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function createParticle(cx: number, cy: number, s: number): Particle {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = s * (0.15 + Math.random() * 0.45);
|
||||
const speed = s * (0.03 + Math.random() * 0.08);
|
||||
const colors = [
|
||||
{ r: 61, g: 214, b: 208 },
|
||||
{ r: 74, g: 195, b: 190 },
|
||||
{ r: 93, g: 173, b: 226 },
|
||||
{ r: 46, g: 180, b: 170 },
|
||||
{ r: 100, g: 200, b: 215 },
|
||||
];
|
||||
const color = colors[Math.floor(Math.random() * colors.length)]!;
|
||||
return {
|
||||
x: cx + Math.cos(angle) * distance,
|
||||
y: cy + Math.sin(angle) * distance,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
maxLife: 1.8 + Math.random() * 2.5,
|
||||
size: 1.2 + Math.random() * 2.8,
|
||||
color,
|
||||
type: Math.random() < 0.25 ? 'cross' : 'circle',
|
||||
};
|
||||
}
|
||||
|
||||
function updateParticles(dt: number, cx: number, cy: number, s: number) {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i]!;
|
||||
p.life -= dt / p.maxLife;
|
||||
p.x += p.vx * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.vx *= 0.997;
|
||||
p.vy *= 0.997;
|
||||
if (p.life <= 0) particles.splice(i, 1);
|
||||
}
|
||||
while (particles.length < MAX_PARTICLES) {
|
||||
const p = createParticle(cx, cy, s);
|
||||
const ang = Math.random() * Math.PI * 2;
|
||||
p.x = cx + Math.cos(ang) * s * 0.12 * Math.random();
|
||||
p.y = cy + Math.sin(ang) * s * 0.12 * Math.random();
|
||||
particles.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
function drawParticle(
|
||||
c: CanvasRenderingContext2D,
|
||||
p: Particle,
|
||||
bright: number,
|
||||
) {
|
||||
const alpha = p.life;
|
||||
const { r, g, b } = p.color;
|
||||
const size = p.size * (0.7 + 0.3 * p.life);
|
||||
const br = Math.min(255, Math.round(r * bright));
|
||||
const bg = Math.min(255, Math.round(g * bright));
|
||||
const bb = Math.min(255, Math.round(b * bright));
|
||||
c.save();
|
||||
c.globalAlpha = alpha;
|
||||
if (p.type === 'cross') {
|
||||
const len = size * 1.8;
|
||||
const w = size * 0.55;
|
||||
c.strokeStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(p.x - len, p.y);
|
||||
c.lineTo(p.x + len, p.y);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(p.x, p.y - len);
|
||||
c.lineTo(p.x, p.y + len);
|
||||
c.stroke();
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.35, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
} else {
|
||||
c.shadowColor = `rgba(${br},${bg},${bb},${alpha * 0.7})`;
|
||||
c.shadowBlur = size * 2.5;
|
||||
c.fillStyle = `rgba(${br},${bg},${bb},${alpha})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.shadowBlur = 0;
|
||||
c.fillStyle = `rgba(255,255,255,${alpha * 0.6})`;
|
||||
c.beginPath();
|
||||
c.arc(p.x, p.y, size * 0.4, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 处方药丸(处方) */
|
||||
function drawPill(
|
||||
c: CanvasRenderingContext2D,
|
||||
px: number,
|
||||
py: number,
|
||||
pw: number,
|
||||
ph: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const cx = px + pw / 2;
|
||||
const cy = py + ph / 2;
|
||||
const hw = (pw / 2) * pulse;
|
||||
const hh = (ph / 2) * pulse;
|
||||
const adjY = cy + floatOff;
|
||||
const dx = cx - hw;
|
||||
const dy = adjY - hh;
|
||||
const dw = hw * 2;
|
||||
const dh = hh * 2;
|
||||
const rad = hh;
|
||||
const path = new Path2D();
|
||||
path.roundRect(dx, dy, dw, dh, rad);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = hh * 0.7;
|
||||
c.shadowOffsetY = hh * 0.25;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
c.save();
|
||||
c.clip(path);
|
||||
const leftGrad = c.createLinearGradient(dx, 0, dx + dw * 0.5, 0);
|
||||
leftGrad.addColorStop(0, '#FDFBF7');
|
||||
leftGrad.addColorStop(0.7, '#F5F1EA');
|
||||
leftGrad.addColorStop(1, '#EDE8DF');
|
||||
c.fillStyle = leftGrad;
|
||||
c.fillRect(dx, dy, dw * 0.5, dh);
|
||||
|
||||
const rightGrad = c.createLinearGradient(dx + dw * 0.5, 0, dx + dw, 0);
|
||||
rightGrad.addColorStop(0, '#5DD9D1');
|
||||
rightGrad.addColorStop(0.5, '#3DD6D0');
|
||||
rightGrad.addColorStop(1, '#2CB8B0');
|
||||
c.fillStyle = rightGrad;
|
||||
c.fillRect(dx + dw * 0.5, dy, dw * 0.5, dh);
|
||||
|
||||
c.strokeStyle = 'rgba(180,180,180,0.5)';
|
||||
c.lineWidth = Math.max(0.6, hh * 0.06);
|
||||
c.beginPath();
|
||||
c.moveTo(dx + dw * 0.5, dy + 1);
|
||||
c.lineTo(dx + dw * 0.5, dy + dh - 1);
|
||||
c.stroke();
|
||||
|
||||
const hy = dy + dh * 0.15;
|
||||
const hh2 = dh * 0.38;
|
||||
const hp = new Path2D();
|
||||
hp.roundRect(dx + dw * 0.06, hy, dw * 0.88, hh2, hh2 / 2);
|
||||
c.fillStyle = 'rgba(255,255,255,0.45)';
|
||||
c.fill(hp);
|
||||
c.restore();
|
||||
|
||||
c.strokeStyle = 'rgba(200,200,200,0.5)';
|
||||
c.lineWidth = Math.max(0.8, hh * 0.08);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** 病历本(病历) */
|
||||
function drawMedicalRecord(
|
||||
c: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
scale: number,
|
||||
floatOff: number,
|
||||
pulse: number,
|
||||
shadowCol: string,
|
||||
) {
|
||||
c.save();
|
||||
const w = scale * 0.28 * pulse;
|
||||
const h = scale * 0.34 * pulse;
|
||||
const x = cx - w / 2;
|
||||
const y = cy - h / 2 + floatOff;
|
||||
const r = Math.max(4, scale * 0.018);
|
||||
const path = new Path2D();
|
||||
path.roundRect(x, y, w, h, r);
|
||||
|
||||
c.shadowColor = shadowCol;
|
||||
c.shadowBlur = scale * 0.035;
|
||||
c.shadowOffsetY = scale * 0.012;
|
||||
c.fillStyle = '#ffffff';
|
||||
c.fill(path);
|
||||
c.shadowColor = 'transparent';
|
||||
c.shadowBlur = 0;
|
||||
c.shadowOffsetY = 0;
|
||||
|
||||
// 左侧装订条
|
||||
c.fillStyle = '#3DD6D0';
|
||||
c.fillRect(x, y + r * 0.2, w * 0.12, h - r * 0.4);
|
||||
|
||||
// 横线(像病历行)
|
||||
c.strokeStyle = 'rgba(61,214,208,0.35)';
|
||||
c.lineWidth = Math.max(1, scale * 0.004);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ly = y + h * (0.32 + i * 0.14);
|
||||
c.beginPath();
|
||||
c.moveTo(x + w * 0.22, ly);
|
||||
c.lineTo(x + w * 0.88, ly);
|
||||
c.stroke();
|
||||
}
|
||||
// 顶部标题条
|
||||
c.fillStyle = 'rgba(61,214,208,0.2)';
|
||||
c.beginPath();
|
||||
c.roundRect(x + w * 0.22, y + h * 0.12, w * 0.5, h * 0.08, 2);
|
||||
c.fill();
|
||||
|
||||
c.strokeStyle = 'rgba(180,200,200,0.55)';
|
||||
c.lineWidth = Math.max(0.8, scale * 0.005);
|
||||
c.stroke(path);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function drawCross(
|
||||
c: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
size: number,
|
||||
alpha: number,
|
||||
rot: number,
|
||||
) {
|
||||
c.save();
|
||||
c.translate(x, y);
|
||||
c.rotate(rot);
|
||||
c.globalAlpha = alpha;
|
||||
const len = size;
|
||||
const w = size * 0.3;
|
||||
c.strokeStyle = '#3DD6D0';
|
||||
c.lineWidth = w;
|
||||
c.lineCap = 'round';
|
||||
c.beginPath();
|
||||
c.moveTo(-len, 0);
|
||||
c.lineTo(len, 0);
|
||||
c.stroke();
|
||||
c.beginPath();
|
||||
c.moveTo(0, -len);
|
||||
c.lineTo(0, len);
|
||||
c.stroke();
|
||||
c.fillStyle = '#ffffff';
|
||||
c.beginPath();
|
||||
c.arc(0, 0, size * 0.25, 0, Math.PI * 2);
|
||||
c.fill();
|
||||
c.restore();
|
||||
}
|
||||
|
||||
function getTheme(): ThemeColors {
|
||||
const dark = isDark.value;
|
||||
return {
|
||||
textColor: dark ? '#d1d5db' : '#4a5568',
|
||||
ringColor: dark ? 'rgba(255,255,255,0.25)' : 'rgba(180,195,200,0.45)',
|
||||
glowInner: dark ? 'rgba(61,214,208,0.15)' : 'rgba(61,214,208,0.13)',
|
||||
glowMiddle: dark ? 'rgba(61,214,208,0.05)' : 'rgba(61,214,208,0.04)',
|
||||
glowOuter: 'rgba(61,214,208,0)',
|
||||
particleBright: dark ? 1.4 : 1.0,
|
||||
iconShadow: dark ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.12)',
|
||||
};
|
||||
}
|
||||
|
||||
function draw(timestamp: number) {
|
||||
updateSize();
|
||||
if (cssWidth <= 0 || cssHeight <= 0) {
|
||||
animationId = requestAnimationFrame(draw);
|
||||
return;
|
||||
}
|
||||
if (startTime === null) {
|
||||
startTime = timestamp;
|
||||
lastTime = timestamp;
|
||||
}
|
||||
const elapsed = (timestamp - startTime) / 1000;
|
||||
let dt = (timestamp - (lastTime ?? timestamp)) / 1000;
|
||||
if (dt <= 0) dt = 0.016;
|
||||
if (dt > 0.2) dt = 0.2;
|
||||
lastTime = timestamp;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const cx = cssWidth / 2;
|
||||
const cy = cssHeight / 2 - cssHeight * 0.04;
|
||||
const scale = Math.min(cssWidth, cssHeight);
|
||||
const orbitR = scale * 0.26;
|
||||
const fontSize = Math.max(12, scale * 0.055);
|
||||
const textY = cy + orbitR + fontSize * 2.4;
|
||||
const text = liveText.value;
|
||||
const animType = liveType.value;
|
||||
|
||||
const dashOff = elapsed * scale * 0.025;
|
||||
const arcRot = elapsed * 3.5;
|
||||
const arcLen = (100 * Math.PI) / 180;
|
||||
const floatOff = Math.sin(elapsed * 2.5) * scale * 0.025;
|
||||
const pulseS = 1 + Math.sin(elapsed * 3.7) * 0.04;
|
||||
const textAlpha = 0.55 + 0.45 * Math.sin(elapsed * 1.6);
|
||||
const theme = getTheme();
|
||||
|
||||
const grad = ctx.createRadialGradient(
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 0.15,
|
||||
cx,
|
||||
cy,
|
||||
orbitR * 1.6,
|
||||
);
|
||||
grad.addColorStop(0, theme.glowInner);
|
||||
grad.addColorStop(0.5, theme.glowMiddle);
|
||||
grad.addColorStop(1, theme.glowOuter);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash([scale * 0.018, scale * 0.04]);
|
||||
ctx.lineDashOffset = -dashOff;
|
||||
ctx.strokeStyle = theme.ringColor;
|
||||
ctx.lineWidth = Math.max(1, scale * 0.006);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
const arcS = arcRot;
|
||||
const arcE = arcRot + arcLen;
|
||||
const fx = cx + orbitR * Math.cos(arcE);
|
||||
const fy = cy + orbitR * Math.sin(arcE);
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.8)';
|
||||
ctx.shadowBlur = scale * 0.04;
|
||||
ctx.strokeStyle = '#3DD6D0';
|
||||
ctx.lineWidth = Math.max(2, scale * 0.014);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, orbitR, arcS, arcE);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(255,255,255,0.9)';
|
||||
ctx.shadowBlur = scale * 0.03;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(fx, fy, Math.max(2.5, scale * 0.014), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 中心图标:按 type 切换
|
||||
if (animType === 'medical_record') {
|
||||
drawMedicalRecord(ctx, cx, cy, scale, floatOff, pulseS, theme.iconShadow);
|
||||
} else {
|
||||
const pillW = scale * 0.34;
|
||||
const pillH = scale * 0.1;
|
||||
drawPill(
|
||||
ctx,
|
||||
cx - pillW / 2,
|
||||
cy - pillH / 2,
|
||||
pillW,
|
||||
pillH,
|
||||
floatOff,
|
||||
pulseS,
|
||||
theme.iconShadow,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ang = elapsed * 1.8 + (i * Math.PI * 2) / 3;
|
||||
const crossOrbitR = orbitR * 1.02;
|
||||
const rx = cx + crossOrbitR * Math.cos(ang);
|
||||
const ry = cy + crossOrbitR * Math.sin(ang);
|
||||
const rs = Math.max(4, scale * 0.022);
|
||||
const ra = 0.4 + 0.35 * Math.sin(elapsed * 3.2 + i * 1.7);
|
||||
drawCross(ctx, rx, ry, rs, ra, elapsed * 0.9 + i);
|
||||
}
|
||||
|
||||
updateParticles(dt, cx, cy, scale);
|
||||
for (const p of particles) {
|
||||
drawParticle(ctx, p, theme.particleBright);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = textAlpha;
|
||||
ctx.fillStyle = theme.textColor;
|
||||
ctx.font = `${fontSize}px "PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.shadowColor = 'rgba(61,214,208,0.25)';
|
||||
ctx.shadowBlur = fontSize * 0.6;
|
||||
ctx.fillText(text, cx, textY);
|
||||
ctx.restore();
|
||||
|
||||
const charW = fontSize * 0.55;
|
||||
const dotsStartX = cx + (text.length / 2) * charW + fontSize * 0.5;
|
||||
const dotR = Math.max(2, fontSize * 0.16);
|
||||
const dotSpacing = fontSize * 0.7;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const phase = (elapsed * 2.8 + i * 1.0) % 3;
|
||||
let brightness = phase < 1 ? phase : phase < 2 ? 2 - phase : 0;
|
||||
brightness = brightness * brightness * (3 - 2 * brightness);
|
||||
const alpha = 0.2 + brightness * 0.75;
|
||||
const dx = dotsStartX + i * dotSpacing * 2.2;
|
||||
ctx.save();
|
||||
ctx.shadowColor = `rgba(61,214,208,${alpha * 0.8})`;
|
||||
ctx.shadowBlur = dotR * 3;
|
||||
ctx.fillStyle = `rgba(61,214,208,${alpha})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(dx, textY, dotR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
updateSize();
|
||||
animationId = requestAnimationFrame(draw);
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
resizeObserver = new ResizeObserver(() => updateSize());
|
||||
resizeObserver.observe(container);
|
||||
} else {
|
||||
const onResize = () => updateSize();
|
||||
window.addEventListener('resize', onResize);
|
||||
removeWindowResize = () => window.removeEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
// 跟随应用 dark class(未手动锁定主题时)
|
||||
themeObserver = new MutationObserver(() => {
|
||||
if (!localStorage.getItem('prescription-loading-theme')) {
|
||||
syncThemeFromApp();
|
||||
}
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
removeWindowResize?.();
|
||||
removeWindowResize = null;
|
||||
themeObserver?.disconnect();
|
||||
themeObserver = null;
|
||||
resetParticles = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 220px;
|
||||
//background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.loading-wrapper--dark {
|
||||
//background: #1e2227;
|
||||
}
|
||||
.loading-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.theme-toggle-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #4a5568;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.25s;
|
||||
}
|
||||
.loading-wrapper--dark .theme-toggle-btn {
|
||||
background: rgba(30, 34, 39, 0.9);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.theme-toggle-btn:hover {
|
||||
border-color: #3dd6d0;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.theme-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user