@
性能与用户体验全面优化
前端性能:
- 移除 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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user