- 订单双视图:商品订单/处方/挂号列表新增卡片视图(CardList)、视图切换组件 view-mode-switch、stat-islands 统计岛、constants 字典 - 工作台:新增工作日历 Widget、待办日历、即将到访预约、公告滚动 NoticeTicker、日历面板 CalendarPanel - 医生排班:新增排班 API、ScheduleDrawer 抽屉、schedule-calendar 组件、门店设置弹窗 - 日志与通知:新增排班变更日志页、排班变更通知视图 - 挂号提醒:新增挂号语音播报资源与 register-notify 工具 - 桌面端:新增 apps/desktop 壳及 desktop 工具方法 - 其他:处方/订单导出、聊天设置与 WebSocket 等小幅优化
715 lines
19 KiB
Vue
715 lines
19 KiB
Vue
<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>
|