307 lines
8.8 KiB
Vue
307 lines
8.8 KiB
Vue
<script lang="ts" setup>
|
||
/**
|
||
* 公账逾期弹窗背景:警戒条、脉冲环、余烬粒子
|
||
* 跟随亮/暗主题,弹窗关闭即停 RAF,避免后台空转
|
||
*/
|
||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||
|
||
const props = defineProps<{
|
||
active?: boolean;
|
||
/** 后台配置:blue / yellow / red */
|
||
color?: 'blue' | 'yellow' | 'red';
|
||
/** 上传/等待页只保留上下警戒条,避免三角和表单叠在一起 */
|
||
tapesOnly?: boolean;
|
||
}>();
|
||
|
||
const containerRef = ref<HTMLDivElement | null>(null);
|
||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||
|
||
interface Ember {
|
||
x: number;
|
||
y: number;
|
||
vx: number;
|
||
vy: number;
|
||
life: number;
|
||
maxLife: number;
|
||
size: number;
|
||
}
|
||
|
||
let animationId = 0;
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
let themeObserver: MutationObserver | null = null;
|
||
let running = false;
|
||
|
||
function isDarkTheme() {
|
||
return (
|
||
document.documentElement.classList.contains('dark') ||
|
||
document.documentElement.getAttribute('data-theme') === 'dark'
|
||
);
|
||
}
|
||
|
||
function startLoop() {
|
||
if (running || !containerRef.value || !canvasRef.value) return;
|
||
const canvas = canvasRef.value;
|
||
const ctx = canvas.getContext('2d');
|
||
const container = containerRef.value;
|
||
if (!ctx) return;
|
||
running = true;
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
let cssW = 0;
|
||
let cssH = 0;
|
||
let last = 0;
|
||
let elapsed = 0;
|
||
let embers: Ember[] = [];
|
||
const MAX_EMBERS = 22;
|
||
|
||
function updateSize() {
|
||
const rect = container.getBoundingClientRect();
|
||
cssW = rect.width || 760;
|
||
cssH = rect.height || 460;
|
||
const pw = Math.round(cssW * dpr);
|
||
const ph = Math.round(cssH * dpr);
|
||
if (canvas.width !== pw || canvas.height !== ph) {
|
||
canvas.width = pw;
|
||
canvas.height = ph;
|
||
}
|
||
}
|
||
|
||
function theme() {
|
||
const dark = isDarkTheme();
|
||
const color = props.color === 'blue' || props.color === 'red' ? props.color : 'yellow';
|
||
if (color === 'blue') {
|
||
return {
|
||
dark,
|
||
warn: dark ? { r: 96, g: 165, b: 250 } : { r: 37, g: 99, b: 235 },
|
||
tape: dark ? { r: 59, g: 130, b: 246 } : { r: 37, g: 99, b: 235 },
|
||
ink: dark ? { r: 15, g: 23, b: 42 } : { r: 15, g: 23, b: 42 },
|
||
bg: dark ? 'rgba(10, 18, 32, 0.55)' : 'rgba(239, 246, 255, 0.42)',
|
||
};
|
||
}
|
||
if (color === 'red') {
|
||
return {
|
||
dark,
|
||
warn: dark ? { r: 248, g: 113, b: 113 } : { r: 220, g: 38, b: 38 },
|
||
tape: dark ? { r: 239, g: 68, b: 68 } : { r: 220, g: 38, b: 38 },
|
||
ink: dark ? { r: 23, g: 15, b: 15 } : { r: 28, g: 25, b: 23 },
|
||
bg: dark ? 'rgba(32, 10, 10, 0.55)' : 'rgba(254, 242, 242, 0.42)',
|
||
};
|
||
}
|
||
return {
|
||
dark,
|
||
warn: dark ? { r: 251, g: 191, b: 36 } : { r: 217, g: 119, b: 6 },
|
||
tape: dark ? { r: 245, g: 158, b: 11 } : { r: 217, g: 119, b: 6 },
|
||
ink: dark ? { r: 15, g: 23, b: 42 } : { r: 28, g: 25, b: 23 },
|
||
bg: dark ? 'rgba(23, 18, 10, 0.55)' : 'rgba(255, 251, 235, 0.35)',
|
||
};
|
||
}
|
||
|
||
function spawnEmber(): Ember {
|
||
return {
|
||
x: Math.random() * cssW,
|
||
y: cssH * (0.35 + Math.random() * 0.5),
|
||
vx: (Math.random() - 0.5) * 18,
|
||
vy: -12 - Math.random() * 22,
|
||
life: 1,
|
||
maxLife: 1.6 + Math.random() * 2.2,
|
||
size: 1.2 + Math.random() * 2.4,
|
||
};
|
||
}
|
||
|
||
function drawTape(
|
||
c: CanvasRenderingContext2D,
|
||
y: number,
|
||
h: number,
|
||
offset: number,
|
||
t: ReturnType<typeof theme>,
|
||
) {
|
||
c.save();
|
||
c.beginPath();
|
||
c.rect(0, y, cssW, h);
|
||
c.clip();
|
||
c.fillStyle = `rgb(${t.tape.r},${t.tape.g},${t.tape.b})`;
|
||
c.fillRect(0, y, cssW, h);
|
||
c.fillStyle = `rgba(${t.ink.r},${t.ink.g},${t.ink.b},0.82)`;
|
||
const stripe = 16;
|
||
const shift = offset % (stripe * 2);
|
||
for (let x = -h - stripe * 2 + shift; x < cssW + h; x += stripe * 2) {
|
||
c.beginPath();
|
||
c.moveTo(x, y);
|
||
c.lineTo(x + stripe, y);
|
||
c.lineTo(x + stripe + h, y + h);
|
||
c.lineTo(x + h, y + h);
|
||
c.closePath();
|
||
c.fill();
|
||
}
|
||
c.restore();
|
||
}
|
||
|
||
function drawTriangle(
|
||
c: CanvasRenderingContext2D,
|
||
cx: number,
|
||
cy: number,
|
||
size: number,
|
||
pulse: number,
|
||
t: ReturnType<typeof theme>,
|
||
) {
|
||
const s = size * pulse;
|
||
c.save();
|
||
c.shadowColor = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.55)`;
|
||
c.shadowBlur = 22;
|
||
c.beginPath();
|
||
c.moveTo(cx, cy - s);
|
||
c.lineTo(cx + s * 0.92, cy + s * 0.78);
|
||
c.lineTo(cx - s * 0.92, cy + s * 0.78);
|
||
c.closePath();
|
||
c.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.16)`;
|
||
c.fill();
|
||
c.lineWidth = 3;
|
||
c.strokeStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.95)`;
|
||
c.stroke();
|
||
c.shadowBlur = 0;
|
||
c.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.95)`;
|
||
c.beginPath();
|
||
c.roundRect(cx - 3, cy - s * 0.28, 6, s * 0.52, 2);
|
||
c.fill();
|
||
c.beginPath();
|
||
c.arc(cx, cy + s * 0.42, 4, 0, Math.PI * 2);
|
||
c.fill();
|
||
c.restore();
|
||
}
|
||
|
||
function draw(ts: number) {
|
||
if (!running) return;
|
||
updateSize();
|
||
if (cssW <= 0 || cssH <= 0) {
|
||
animationId = requestAnimationFrame(draw);
|
||
return;
|
||
}
|
||
if (!last) last = ts;
|
||
let dt = (ts - last) / 1000;
|
||
if (dt <= 0) dt = 0.016;
|
||
if (dt > 0.2) dt = 0.2;
|
||
last = ts;
|
||
elapsed += dt;
|
||
const t = theme();
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.save();
|
||
ctx.scale(dpr, dpr);
|
||
ctx.fillStyle = t.bg;
|
||
ctx.fillRect(0, 0, cssW, cssH);
|
||
if (!props.tapesOnly) {
|
||
// 三角/光环随面板高度缩放,避免矮屏占满上半区把正文挤乱
|
||
const tapeReserve = Math.round(Math.min(18, Math.max(10, cssH * 0.028)));
|
||
const cy = Math.round(
|
||
Math.min(Math.max(48, tapeReserve + 28 + cssH * 0.04), cssH * 0.16),
|
||
);
|
||
const triSize = Math.round(Math.min(36, Math.max(16, cssH * 0.045)));
|
||
const ringSpan = Math.min(150, Math.max(60, cssH * 0.18));
|
||
const cx = cssW / 2;
|
||
const glow = ctx.createRadialGradient(cx, cy, 8, cx, cy, Math.max(cssW, cssH) * 0.55);
|
||
glow.addColorStop(0, `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.22)`);
|
||
glow.addColorStop(0.45, `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.06)`);
|
||
glow.addColorStop(1, 'rgba(0,0,0,0)');
|
||
ctx.fillStyle = glow;
|
||
ctx.fillRect(0, 0, cssW, cssH);
|
||
for (let i = 0; i < 3; i++) {
|
||
const phase = (elapsed * 0.55 + i / 3) % 1;
|
||
const radius = triSize * 0.85 + phase * ringSpan;
|
||
const alpha = (1 - phase) * 0.35;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||
ctx.strokeStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},${alpha})`;
|
||
ctx.lineWidth = Math.max(1.5, triSize / 28);
|
||
ctx.stroke();
|
||
}
|
||
const pulse = 1 + Math.sin(elapsed * 3.2) * 0.06;
|
||
drawTriangle(ctx, cx, cy, triSize, pulse, t);
|
||
while (embers.length < MAX_EMBERS) embers.push(spawnEmber());
|
||
for (let i = embers.length - 1; i >= 0; i--) {
|
||
const p = embers[i]!;
|
||
p.life -= dt / p.maxLife;
|
||
p.x += p.vx * dt;
|
||
p.y += p.vy * dt;
|
||
if (p.life <= 0) {
|
||
embers.splice(i, 1);
|
||
continue;
|
||
}
|
||
ctx.save();
|
||
ctx.globalAlpha = p.life * 0.75;
|
||
ctx.shadowColor = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},0.8)`;
|
||
ctx.shadowBlur = 8;
|
||
ctx.fillStyle = `rgba(${t.warn.r},${t.warn.g},${t.warn.b},${p.life})`;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, p.size * Math.min(1, cssH / 520), 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
const tapeH = Math.round(Math.min(18, Math.max(12, cssH * 0.028)));
|
||
const shift = elapsed * 36;
|
||
drawTape(ctx, 0, tapeH, shift, t);
|
||
drawTape(ctx, cssH - tapeH, tapeH, -shift, t);
|
||
ctx.restore();
|
||
animationId = requestAnimationFrame(draw);
|
||
}
|
||
|
||
updateSize();
|
||
animationId = requestAnimationFrame(draw);
|
||
if (window.ResizeObserver) {
|
||
resizeObserver = new ResizeObserver(() => updateSize());
|
||
resizeObserver.observe(container);
|
||
}
|
||
themeObserver = new MutationObserver(() => undefined);
|
||
themeObserver.observe(document.documentElement, {
|
||
attributes: true,
|
||
attributeFilter: ['class', 'data-theme'],
|
||
});
|
||
}
|
||
|
||
function stopLoop() {
|
||
running = false;
|
||
cancelAnimationFrame(animationId);
|
||
animationId = 0;
|
||
resizeObserver?.disconnect();
|
||
resizeObserver = null;
|
||
themeObserver?.disconnect();
|
||
themeObserver = null;
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await nextTick();
|
||
if (props.active !== false) startLoop();
|
||
});
|
||
|
||
watch(
|
||
() => props.active,
|
||
(active) => {
|
||
if (active === false) stopLoop();
|
||
else nextTick(() => startLoop());
|
||
},
|
||
);
|
||
|
||
onUnmounted(() => {
|
||
stopLoop();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="containerRef" class="lock-canvas-wrap">
|
||
<canvas ref="canvasRef" class="lock-canvas" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.lock-canvas-wrap,
|
||
.lock-canvas {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.lock-canvas {
|
||
display: block;
|
||
pointer-events: none;
|
||
}
|
||
</style>
|