性能与用户体验全面优化

    前端性能:
    - 移除 Three.js 依赖(~600KB),天气粒子改为纯 CSS 动画 + Canvas 2D
    - Google Fonts 切换为 next/font 自托管,消除跨域字体请求
    - 合并 ScanTerminalLightTheme.module.css (37KB) 到主 CSS,亮/暗主题统一用 CSS 变量
    - 新增 /api/dashboard/init 聚合端点,首次加载 4 次往返 → 1 次
    - 添加 Service Worker 静态资源缓存,修复 PWA manifest 配置

    用户体验:
    - 新增全局错误边界 error.tsx / global-error.tsx,崩溃不再白屏
    - 决策卡和城市详情的更新时间改为相对时间("15秒前"),每秒自动刷新
    - 数据陈旧时状态标签从青色切换为琥珀色提示

    DEB 算法增强:
    - 市场扫描路径接入 Open-Meteo 多模型数据(ECMWF/GFS/ICON/JMA/HRDPS 等)
    - MAE 计算加入时间衰减(decay_factor=0.85),近期模型误差权重更高

    Scope-risk: MEDIUM — 全量 170 测试通过,前端 TypeScript/build 通过,ruff 零告警
    Tested: python -m pytest -q (170 passed), npx tsc --noEmit (0 errors), npm run build (success), ruff check .
@
This commit is contained in:
2569718930@qq.com
2026-05-14 21:31:05 +08:00
parent 37494a7192
commit 6c08a68413
21 changed files with 2062 additions and 1705 deletions
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { CSSProperties, useEffect, useRef } from "react";
import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
export interface IntradaySignalMetric {
@@ -24,6 +23,42 @@ function getToneColor(tone: string) {
return "#9FB2C7";
}
function hexToRgba(hex: string, alpha: number) {
const sanitized = hex.replace("#", "");
const numeric = Number.parseInt(sanitized.padEnd(6, "0"), 16);
const r = (numeric >> 16) & 255;
const g = (numeric >> 8) & 255;
const b = numeric & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/* ── Draw a rounded-rect path (polyfill-safe) ── */
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number | [number, number, number, number],
) {
const corners = typeof r === "number" ? [r, r, r, r] : r;
const [tl, tr, br, bl] = corners;
ctx.beginPath();
ctx.moveTo(x + tl, y);
ctx.lineTo(x + w - tr, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + tr);
ctx.lineTo(x + w, y + h - br);
ctx.quadraticCurveTo(x + w, y + h, x + w - br, y + h);
ctx.lineTo(x + bl, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - bl);
ctx.lineTo(x, y + tl);
ctx.quadraticCurveTo(x, y, x + tl, y);
ctx.closePath();
}
/* ── Component ── */
export function IntradaySignalScene({
metrics,
score,
@@ -38,163 +73,173 @@ export function IntradaySignalScene({
const host = containerRef.current;
if (!host) return;
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: "low-power",
});
renderer.setClearColor(0x000000, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
const canvas = document.createElement("canvas");
canvas.style.cssText = "width:100%;height:100%;display:block;";
host.appendChild(canvas);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);
camera.position.set(0, 2.8, 7.2);
camera.lookAt(0, 1.2, 0);
const ambient = new THREE.AmbientLight(0xbfe8ff, 1.25);
const keyLight = new THREE.PointLight(0x67e8f9, 22, 18, 2);
keyLight.position.set(-3.8, 5.6, 4.8);
const warmLight = new THREE.PointLight(0xf59e0b, 12, 16, 2);
warmLight.position.set(4.2, 2.8, 4);
scene.add(ambient, keyLight, warmLight);
const stage = new THREE.Group();
scene.add(stage);
const floor = new THREE.Mesh(
new THREE.CylinderGeometry(3.3, 3.8, 0.12, 48),
new THREE.MeshStandardMaterial({
color: new THREE.Color(score >= 0 ? "#10263b" : "#2c1d12"),
emissive: new THREE.Color(score >= 0 ? "#0e7490" : "#b45309"),
emissiveIntensity: 0.18 + clamp(Math.abs(score) / 8, 0, 0.24),
metalness: 0.2,
roughness: 0.78,
}),
);
floor.position.y = -0.12;
stage.add(floor);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.8, 0.03, 18, 100),
new THREE.MeshBasicMaterial({
color: new THREE.Color(score >= 0 ? "#4DA3FF" : "#F59E0B"),
transparent: true,
opacity: 0.5,
}),
);
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.03;
stage.add(ring);
const barGeometry = new THREE.BoxGeometry(0.8, 1, 0.8);
const capGeometry = new THREE.SphereGeometry(0.16, 16, 16);
const bars: Array<{
mesh: THREE.Mesh;
cap: THREE.Mesh;
glow: THREE.Mesh;
baseY: number;
targetHeight: number;
}> = [];
const xPositions = [-1.8, -0.6, 0.6, 1.8];
metrics.slice(0, 4).forEach((metric, index) => {
const height = 0.5 + ((metric.fill ?? 20) / 100) * 2.8;
const color = new THREE.Color(getToneColor(metric.tone));
const material = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 0.22,
metalness: 0.14,
roughness: 0.38,
});
const mesh = new THREE.Mesh(barGeometry, material);
mesh.position.set(xPositions[index] || 0, height / 2, 0);
mesh.scale.y = height;
stage.add(mesh);
const cap = new THREE.Mesh(
capGeometry,
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.95,
}),
);
cap.position.set(mesh.position.x, height + 0.2, 0);
stage.add(cap);
const glow = new THREE.Mesh(
new THREE.CylinderGeometry(0.46, 0.58, 0.08, 32),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.22,
}),
);
glow.position.set(mesh.position.x, 0.06, 0);
stage.add(glow);
bars.push({
mesh,
cap,
glow,
baseY: cap.position.y,
targetHeight: height,
});
});
const ctx = canvas.getContext("2d")!;
let animationId = 0;
const resize = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
const rect = host.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
host.appendChild(renderer.domElement);
const clock = new THREE.Clock();
let frameId = 0;
const renderFrame = () => {
frameId = window.requestAnimationFrame(renderFrame);
const elapsed = clock.getElapsedTime();
stage.rotation.y = Math.sin(elapsed * 0.35) * 0.16;
ring.material.opacity = 0.38 + Math.sin(elapsed * 0.8) * 0.08;
bars.forEach((bar, index) => {
const pulse = prefersReducedMotion
? 0
: Math.sin(elapsed * 1.5 + index * 0.8) * 0.08;
bar.cap.position.y = bar.baseY + pulse;
bar.glow.scale.x = 1 + Math.sin(elapsed * 1.2 + index) * 0.06;
bar.glow.scale.z = 1 + Math.sin(elapsed * 1.2 + index) * 0.06;
});
renderer.render(scene, camera);
};
const observer = new ResizeObserver(resize);
observer.observe(host);
frameId = window.requestAnimationFrame(renderFrame);
let startTime = performance.now();
const render = (now: number) => {
animationId = requestAnimationFrame(render);
const W = host.clientWidth;
const H = host.clientHeight;
if (W < 1 || H < 1) return;
ctx.clearRect(0, 0, W, H);
const elapsed = prefersReducedMotion ? 0 : (now - startTime) / 1000;
/* ── Layout ── */
const cx = W / 2;
const floorY = Math.max(H - 28, H * 0.78);
const rx = clamp(Math.min(W / 2 - 24, 160), 60, 160);
const ry = clamp(rx * 0.28, 12, 26);
const isPositive = score >= 0;
const accentColor = isPositive ? "#4DA3FF" : "#F59E0B";
const floorGlowColor = isPositive ? "#0e7490" : "#b45309";
const floorBaseColor = isPositive ? "#10263b" : "#2c1d12";
/* ── Floor ── */
const floorGrad = ctx.createRadialGradient(cx, floorY, 0, cx, floorY, rx);
floorGrad.addColorStop(0, hexToRgba(floorGlowColor, 0.55));
floorGrad.addColorStop(0.5, floorBaseColor);
floorGrad.addColorStop(1, "#060e1a");
ctx.beginPath();
ctx.ellipse(cx, floorY, rx, ry, 0, 0, Math.PI * 2);
ctx.fillStyle = floorGrad;
ctx.fill();
/* Floor glow overlay */
const floorGlow = ctx.createRadialGradient(cx, floorY, 0, cx, floorY, rx * 0.6);
floorGlow.addColorStop(0, hexToRgba(accentColor, 0.08));
floorGlow.addColorStop(1, "transparent");
ctx.beginPath();
ctx.ellipse(cx, floorY, rx * 0.6, ry * 0.6, 0, 0, Math.PI * 2);
ctx.fillStyle = floorGlow;
ctx.fill();
/* ── Ring (oscillating opacity) ── */
const ringOpacity = 0.38 + Math.sin(elapsed * 0.8) * 0.1;
ctx.beginPath();
ctx.ellipse(cx, floorY, rx - 10, Math.max(ry - 3, 8), 0, 0, Math.PI * 2);
ctx.strokeStyle = hexToRgba(accentColor, ringOpacity);
ctx.lineWidth = 2.5;
ctx.stroke();
/* ── Bar positions ── */
const barSpacing = rx * 0.48;
const xPositions = [
cx - barSpacing,
cx - barSpacing / 3,
cx + barSpacing / 3,
cx + barSpacing,
];
const barWidth = Math.min(26, rx * 0.16);
const limitedMetrics = metrics.slice(0, 4);
/* ── Draw bars ── */
limitedMetrics.forEach((metric, i) => {
const fill = metric.fill ?? 20;
const height = Math.max(12, 14 + (fill / 100) * (floorY - 55));
const bx = xPositions[i];
const by = floorY - height;
const color = getToneColor(metric.tone);
const baseGlow = prefersReducedMotion
? 1
: 1 + Math.sin(elapsed * 1.2 + i) * 0.06;
/* Base glow ellipse */
const glowGrad = ctx.createRadialGradient(bx, floorY, 0, bx, floorY, barWidth * 1.2);
glowGrad.addColorStop(0, hexToRgba(color, 0.35));
glowGrad.addColorStop(1, "transparent");
ctx.beginPath();
ctx.ellipse(bx, floorY, barWidth * baseGlow, 7 * baseGlow, 0, 0, Math.PI * 2);
ctx.fillStyle = glowGrad;
ctx.fill();
/* Bar body (rounded top) */
const barGrad = ctx.createLinearGradient(bx, by, bx, floorY);
barGrad.addColorStop(0, color);
barGrad.addColorStop(0.55, color);
barGrad.addColorStop(1, hexToRgba(color, 0.3));
roundRect(ctx, bx - barWidth / 2, by, barWidth, height, [4, 4, 0, 0]);
ctx.fillStyle = barGrad;
ctx.fill();
/* Bar glow outline */
ctx.strokeStyle = hexToRgba(color, 0.15);
ctx.lineWidth = 1;
roundRect(ctx, bx - barWidth / 2 - 1, by - 1, barWidth + 2, height + 2, [5, 5, 0, 0]);
ctx.stroke();
/* Cap (pulse offset per bar) */
const pulse = prefersReducedMotion
? 0
: Math.sin(elapsed * 1.5 + i * 0.8) * 3;
const capY = by + pulse;
const capR = Math.max(5, barWidth * 0.22);
/* Cap outer glow */
const capGlow = ctx.createRadialGradient(bx, capY, 0, bx, capY, capR * 3);
capGlow.addColorStop(0, hexToRgba(color, 0.2));
capGlow.addColorStop(1, "transparent");
ctx.beginPath();
ctx.arc(bx, capY, capR * 3, 0, Math.PI * 2);
ctx.fillStyle = capGlow;
ctx.fill();
/* Cap body */
ctx.beginPath();
ctx.arc(bx, capY, capR, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = capR * 2;
ctx.fill();
ctx.shadowBlur = 0;
});
/* ── Ambient sparkle dots (only when not reduced motion) ── */
if (!prefersReducedMotion) {
for (let i = 0; i < 6; i++) {
const angle = elapsed * 0.25 + i * 1.05;
const dist = rx * 0.7 + Math.sin(elapsed * 0.15 + i * 2.3) * 10;
const sx = cx + Math.cos(angle) * dist;
const sy = floorY - ry * 0.4 + Math.sin(angle) * dist * 0.35;
const sparkleSize = 1.5 + Math.sin(elapsed * 2 + i * 1.7) * 0.8;
ctx.beginPath();
ctx.arc(sx, sy, Math.max(0.5, sparkleSize), 0, Math.PI * 2);
ctx.fillStyle = hexToRgba(accentColor, 0.12 + Math.sin(elapsed * 1.3 + i) * 0.06);
ctx.fill();
}
}
}
animationId = requestAnimationFrame(render);
return () => {
observer.disconnect();
window.cancelAnimationFrame(frameId);
stage.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose());
} else {
child.material.dispose();
}
}
});
renderer.dispose();
if (renderer.domElement.parentNode === host) {
host.removeChild(renderer.domElement);
cancelAnimationFrame(animationId);
if (canvas.parentNode === host) {
host.removeChild(canvas);
}
};
}, [metrics, prefersReducedMotion, score]);
@@ -211,7 +256,7 @@ export function IntradaySignalScene({
<div key={metric.key} className="intraday-scene-chip">
<span
className="intraday-scene-chip-dot"
style={{ backgroundColor: getToneColor(metric.tone) }}
style={{ backgroundColor: getToneColor(metric.tone) } as CSSProperties}
/>
<div className="intraday-scene-chip-copy">
<strong>{metric.label}</strong>