migration: preserve frontend shell pages and styles

This commit is contained in:
leefer
2026-07-31 15:08:57 +08:00
parent 38de3de0a3
commit dec3cd1236
92 changed files with 10758 additions and 9449 deletions
+1943
View File
File diff suppressed because it is too large Load Diff
+672
View File
@@ -0,0 +1,672 @@
(function exposeHeavenLoading(global) {
"use strict";
const PAPER = "#fdfcf8";
const PAPER_CENTER = "#f1e8d9";
const NODE_TEXT = "#fffaf0";
const INK = "#68493d";
const INK_BRIGHT = "#963f37";
const GOLD = "#80533e";
const GOLD_BRIGHT = "#b64e43";
const CINNABAR = "#b94038";
const DIM = "rgba(68,57,49,0.62)";
const PARTICLE_COLORS = ["#a94b42", "#456b62", "#506b85"];
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
const ELEMENT_COLORS = {
: "#4f7a4a",
: "#b3483d",
: "#96702c",
: "#70685b",
: "#496d92",
};
const QI6 = [
{ name: "厥阴风木", element: "木" },
{ name: "少阴君火", element: "火" },
{ name: "少阳相火", element: "火" },
{ name: "太阴湿土", element: "土" },
{ name: "阳明燥金", element: "金" },
{ name: "太阳寒水", element: "水" },
];
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
const TRIGRAMS = [
{ name: "乾", bits: [1, 1, 1], angle: -90 },
{ name: "兑", bits: [1, 1, 0], angle: -135 },
{ name: "离", bits: [1, 0, 1], angle: 180 },
{ name: "震", bits: [1, 0, 0], angle: 135 },
{ name: "巽", bits: [0, 1, 1], angle: -45 },
{ name: "坎", bits: [0, 1, 0], angle: 0 },
{ name: "艮", bits: [0, 0, 1], angle: 45 },
{ name: "坤", bits: [0, 0, 0], angle: 90 },
];
const SIXIANG = [
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
];
const HEXAGRAM_NAMES = [
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
];
const HEX_TOTAL = 12500;
const FORTUNE_TOTAL = 12800;
const HEX_STAGES = [
[0, 1800, "太 极", "无极而太极,动而生阳"],
[1800, 3300, "两 仪", "一阴一阳之谓道"],
[3300, 4700, "四 象", "阴阳消长,太少相生"],
[4700, 6800, "八 卦", "天地定位,山泽通气"],
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
];
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const smooth = (start, end, value) => {
const progress = clamp01((value - start) / Math.max(1, end - start));
return progress * progress * (3 - 2 * progress);
};
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
const point = (cx, cy, radius, degrees) => {
const radians = degrees * Math.PI / 180;
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
};
class HeavenLoadingCanvas {
constructor(canvas) {
this.canvas = canvas;
this.context = canvas.getContext("2d");
this.width = 0;
this.height = 0;
this.dpr = 1;
this.scene = "hexagram";
this.data = {};
this.startedAt = 0;
this.frameId = 0;
this.running = false;
this.completingAt = 0;
this.completionResolve = null;
this.completionTimer = 0;
this.resizeObserver = new ResizeObserver(() => this.resize());
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
}
createStars(count) {
let seed = 24681357;
const random = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
return Array.from({ length: count }, () => ({
x: random(),
y: random(),
radius: 0.3 + random() * 1.3,
phase: random() * Math.PI * 2,
speed: 0.00015 + random() * 0.0004,
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
}));
}
start(scene, data = {}) {
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
if (this.running && this.scene === nextScene) {
this.data = data;
return;
}
this.stop();
this.scene = nextScene;
this.data = data;
this.startedAt = performance.now();
this.running = true;
this.canvas.dataset.scene = this.scene;
this.canvas.dataset.running = "true";
this.canvas.dataset.looping = "true";
this.resizeObserver.observe(this.canvas);
this.resize();
if (this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
} else {
this.frameId = requestAnimationFrame((now) => this.frame(now));
}
}
complete() {
if (!this.running || this.reducedMotion) {
this.stop();
return Promise.resolve();
}
if (this.completionResolve) return this.completionPromise;
this.completingAt = performance.now();
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
return this.completionPromise;
}
stop() {
if (this.frameId) cancelAnimationFrame(this.frameId);
this.frameId = 0;
this.running = false;
this.completingAt = 0;
if (this.completionTimer) global.clearTimeout(this.completionTimer);
this.completionTimer = 0;
this.resizeObserver.disconnect();
this.canvas.dataset.running = "false";
this.canvas.dataset.looping = "false";
if (this.completionResolve) this.completionResolve();
this.completionResolve = null;
this.completionPromise = null;
}
resize() {
const rect = this.canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width === this.width && height === this.height) return;
this.width = width;
this.height = height;
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
if (this.running && this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
}
}
frame(now) {
if (!this.running) return;
if (this.completingAt) {
const duration = this.scene === "fortune" ? 1800 : 1700;
const progress = clamp01((now - this.completingAt) / duration);
this.drawCompletion(progress, now);
if (progress >= 1) {
this.stop();
return;
}
} else {
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
const elapsed = Math.max(0, now - this.startedAt);
const timeline = elapsed % total;
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
this.draw(timeline, now);
}
this.frameId = requestAnimationFrame((time) => this.frame(time));
}
draw(time, now) {
if (this.width <= 1 || this.height <= 1) return;
this.drawBackground(now);
if (this.scene === "fortune") this.drawFortune(time, now);
else this.drawHexagram(time, now);
}
drawBackground(now) {
const { context: ctx, width, height } = this;
const cx = width / 2;
const cy = height * 0.44;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
gradient.addColorStop(0, PAPER_CENTER);
gradient.addColorStop(0.52, "#faf7ef");
gradient.addColorStop(1, PAPER);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
for (const star of this.stars) {
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
const alpha = twinkle * 0.5;
ctx.globalAlpha = alpha;
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
const y = ((star.y + now * star.speed) % 1) * height;
ctx.fillRect(star.x * width, y, star.radius, star.radius);
}
ctx.globalAlpha = 1;
}
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
if (!text || alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
else ctx.fillText(text, x, y);
ctx.restore();
}
node(x, y, radius, color, alpha = 1, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = glow;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
}
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
if (alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - my, x2 - mx);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
ctx.closePath();
ctx.fill();
ctx.restore();
}
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = INK;
ctx.shadowColor = GOLD;
ctx.shadowBlur = glow;
if (yang) {
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
} else {
const gap = width * 0.18;
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
}
ctx.restore();
}
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
const gap = lineWidth * 1.7;
const top = cy - (bits.length - 1) * gap / 2;
bits.forEach((bit, index) => {
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
});
}
stageAlpha(time, start, end, fade = 300, hold = false) {
const enter = smooth(start, start + fade, time);
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
}
fortuneStages() {
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
return [
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
];
}
drawFooter(time, now, total, stages, scene) {
const { context: ctx, width, height } = this;
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
* (1 - smooth(stage[1] - 250, stage[1], time));
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
const baseSlotWidth = 34;
const baseSlotHeight = 5;
const baseSlotGap = 12;
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
const fit = Math.min(1, (width - 28) / baseTotalWidth);
const slotWidth = baseSlotWidth * fit;
const slotHeight = baseSlotHeight * fit;
const slotGap = baseSlotGap * fit;
const totalWidth = slotWidth * 6 + slotGap * 5;
const filled = Math.min(6, Math.floor(time / (total / 6)));
for (let index = 0; index < 6; index += 1) {
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
const y = height - 56;
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
ctx.save();
ctx.globalAlpha = 0.16;
ctx.strokeStyle = GOLD;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, slotWidth, slotHeight);
ctx.restore();
if (index < filled) {
ctx.save();
ctx.globalAlpha = 0.9;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.fillRect(x, y, slotWidth, slotHeight);
ctx.restore();
} else if (index === filled) {
ctx.save();
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
ctx.fillStyle = color;
const progress = (time % (total / 6)) / (total / 6);
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
ctx.restore();
}
}
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
}
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
const breath = 1 + 0.006 * Math.sin(now / 620);
TRIGRAMS.forEach((trigram, index) => {
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
if (progress <= 0) return;
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
});
}
drawHexagram(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.44;
const scale = Math.min(width, height);
if (time < 1800) {
const alpha = this.stageAlpha(time, 0, 1800);
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
for (let ring = 0; ring < 3; ring += 1) {
const progress = ((now / 1500) + ring / 3) % 1;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
if (time >= 1800 && time < 3300) {
const alpha = this.stageAlpha(time, 1800, 3300);
const progress = easeOut((time - 1850) / 850);
const yaoWidth = scale * 0.19 * progress;
const yaoLine = Math.max(scale * 0.013, 5);
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
}
if (time >= 3300 && time < 4700) {
const alpha = this.stageAlpha(time, 3300, 4700);
const distance = scale * 0.085;
const yaoWidth = Math.max(scale * 0.055, 28);
const yaoLine = Math.max(scale * 0.009, 3.5);
SIXIANG.forEach((symbol, index) => {
const progress = easeOut((time - 3330 - index * 160) / 520);
if (progress <= 0) return;
const x = cx + symbol.dx * distance;
const y = cy + symbol.dy * distance;
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
});
}
const trigramRadius = scale * 0.215;
const trigramWidth = Math.max(scale * 0.052, 26);
const trigramLine = Math.max(scale * 0.0075, 3);
if (time >= 4700 && time < 6800) {
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
}
if (time >= 6800 && time < 10800) {
const alpha = this.stageAlpha(time, 6800, 10800, 350);
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
const ringRadius = scale * 0.365;
const hexWidth = Math.max(scale * 0.026, 13);
const hexLine = Math.max(scale * 0.0042, 1.6);
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
for (let index = 0; index < 64; index += 1) {
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
this.node(x, y, 1.4, GOLD, alpha * 0.14);
if (index < count) {
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
if (freshness > 0) {
const ctx = this.context;
const gradient = ctx.createLinearGradient(cx, cy, x, y);
gradient.addColorStop(0, "rgba(128,83,62,0)");
gradient.addColorStop(1, GOLD);
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
}
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
}
}
if (count > 0) {
const current = count - 1;
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
const pop = 1 + 0.22 * (1 - popTime);
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
this.label(`${current + 1}`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
}
}
if (time >= 10800) {
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
const progress = easeOut((time - 10850) / 1150);
const radius = scale * 0.365 * (1 - progress);
for (let index = 0; index < 64 && radius >= 8; index += 1) {
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
}
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
}
drawFortune(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.44;
const scale = Math.min(width, height);
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
if (time >= 11000) {
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
const progress = easeOut((time - 11050) / 1200);
const radius = scale * 0.30 * (1 - progress);
QI6.forEach((qi, index) => {
const [x, y] = point(cx, cy, radius, -90 + index * 60);
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
});
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
}
drawFiveMovements(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 0, 2100);
const radius = scale * 0.17;
const nodeRadius = Math.max(scale * 0.018, 9);
const elements = [
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
];
const positions = {};
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
elements.forEach(([element, degrees], index) => {
const progress = easeOut((time - 500 - index * 170) / 500);
if (progress <= 0) return;
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
positions[element] = [x, y];
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
const direction = element === "土" ? "中央土" : { : "东方木", : "南方火", : "西方金", : "北方水" }[element];
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
});
const order = ["木", "火", "土", "金", "水"];
order.forEach((element, index) => {
const from = positions[element];
const to = positions[order[(index + 1) % order.length]];
if (!from || !to) return;
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
});
}
drawStems(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 2100, 3900);
const stems = "甲乙丙丁戊己庚辛壬癸";
const movements = ["土", "金", "水", "木", "火"];
const radius = scale * 0.30;
for (let index = 0; index < 10; index += 1) {
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
if (progress <= 0) continue;
const [x, y] = point(cx, cy, radius, -90 + index * 36);
const element = movements[index % 5];
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
}
for (let index = 0; index < 5; index += 1) {
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
const angle = -90 + index * 36;
const [x1, y1] = point(cx, cy, radius, angle);
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
}
}
drawBranches(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 3900, 5800);
const branches = "子丑寅卯辰巳午未申酉戌亥";
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
const radius = scale * 0.31;
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
for (let index = 0; index < 12; index += 1) {
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
const [x, y] = point(cx, cy, radius, branchAngle(index));
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
}
qiNames.forEach((name, index) => {
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
const element = QI6.find((item) => item.name === name)?.element || "土";
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
});
}
drawSixQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 5800, 7900);
const radius = scale * 0.27;
const drift = now * 0.004;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
QI6.forEach((qi, index) => {
const progress = easeOut((time - 5850 - index * 180) / 550);
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
});
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
}
drawAnnualQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 7900, 11000, 350);
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
const zaiquan = sixQi.zaiquan || "在泉气化";
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
const radius = scale * 0.30;
QI6.forEach((qi, index) => {
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
const [x, y] = point(cx, cy, radius, -90 + index * 60);
const current = index + 1 === currentStep;
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
if (current) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
ctx.strokeStyle = CINNABAR;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
}
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
});
}
drawCompletion(progress, now) {
this.drawBackground(now);
if (this.scene === "fortune") {
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
} else {
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
}
}
}
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
})(window);
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
(function exposePageRegistry(global) {
"use strict";
const pages = [
["sentimentCycleView", "情绪周期", "sentiment", "market", "authenticated", true],
["limitPool", "涨停池", "pools", "market", "authenticated", false],
["brokenView", "炸板池", "pools", "market", "authenticated", false],
["downView", "跌停板", "pools", "market", "authenticated", false],
["yesterdayView", "昨日涨停", "pools", "market", "authenticated", false],
["performanceView", "涨停表现", "pools", "market", "authenticated", false],
["ladderView", "市场天梯", "ladder", "market", "authenticated", false],
["rotationView", "板块轮动", "rotation", "market", "authenticated", false],
["auctionView", "集合竞价", "auction", "market", "authenticated", false],
["themeLibraryView", "题材库", "themes", "market", "authenticated", false],
["popularityView", "人气热榜", "popularity", "market", "authenticated", false],
["dragonView", "龙虎榜", "dragon_tiger", "market", "authenticated", false],
["screenerView", "智能选股", "screener", "intelligence", "member", false],
["mentorView", "问师", "mentor", "intelligence", "member", false],
["heavenView", "问天", "heaven", "intelligence", "member", false],
["reviewWorkspaceView", "我的复盘", "review", "personal", "authenticated", false],
].map(([id, title, feature, group, access, isDefault]) => Object.freeze({
id,
title,
feature,
group,
access,
default: isDefault,
desktop_scroll: "page",
mobile_layout: "dedicated",
}));
const internalPages = [
Object.freeze({
id: "screenerTrackingView",
title: "策略持续跟踪",
feature: "screener",
group: "intelligence",
access: "member",
internal: true,
navigation_alias: "screenerView",
}),
];
const all = [...pages, ...internalPages];
const byId = new Map(all.map((page) => [page.id, page]));
const defaultPage = pages.find((page) => page.default);
const aliases = Object.freeze({ sectorView: "rotationView", breadthView: "limitPool" });
global.XiaobaiPages = Object.freeze({
schemaVersion: 1,
pages: Object.freeze(pages),
internalPages: Object.freeze(internalPages),
all: Object.freeze(all),
defaultPage,
aliases,
resolve(id) {
return aliases[id] || id;
},
get(id) {
return byId.get(id) || null;
},
has(id) {
return byId.has(id);
},
inGroup(id, group) {
return byId.get(id)?.group === group;
},
});
})(window);
+272
View File
@@ -0,0 +1,272 @@
window.XiaobaiPageModules.register("auction", ["auctionView"], {
enter: ["loadAuction"],
leave: ["clearAuction"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2217-2481 */
async function loadAuctionCenter(force = false) {
if (state.auctionLoading) return;
state.auctionLoading = true;
const button = document.querySelector("#auctionRefreshButton");
button.disabled = true;
setText("auctionDateLabel", "正在读取竞价数据");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
state.auctionData = await apiRequest(`/api/auction?${query}`);
renderAuctionCenter();
scheduleAuctionTransition(state.auctionData.meta || {});
} catch (error) {
document.querySelector("#auctionSummary").innerHTML = "";
document.querySelector("#auctionThemeCarry").innerHTML = "";
document.querySelector("#auctionNewThemes").innerHTML = "";
document.querySelector("#auctionAmountTrend").innerHTML = "";
document.querySelector("#auctionAmountCompare").innerHTML = "";
document.querySelector("#auctionTableBody").innerHTML = "";
document.querySelector("#auctionEmpty").hidden = false;
setText("auctionDateLabel", error.message || "竞价数据暂不可用");
showToast(error.message || "竞价数据加载失败");
} finally {
state.auctionLoading = false;
button.disabled = false;
}
}
function renderAuctionCenter() {
const payload = state.auctionData;
if (!payload) return;
const summary = payload.summary || {};
renderAuctionPhase(payload.meta || {});
setText(
"auctionDateLabel",
`${payload.meta?.carried_forward ? "最近有效竞价" : "竞价日期"} ${payload.meta?.trade_date || "--"}`,
);
document.querySelector("#auctionSummary").innerHTML = [
["竞价覆盖", `${formatNumber(summary.stock_count, 0)}`, ""],
["重点异动", `${formatNumber(summary.focus_count, 0)}`, "up"],
["竞价一字", `${formatNumber(summary.one_price_count, 0)}`, ""],
["竞价成交额", `${formatNumber(summary.amount_billion, 2)} 亿`, ""],
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
setText("auctionFocusCount", number(summary.focus_count));
setText("auctionAllCount", number(summary.candidate_count));
setText("auctionOnePriceCount", number(summary.one_price_count));
setText("auctionWatchlistCount", number(payload.watchlist_rows?.length));
renderAuctionInsights(payload);
renderAuctionTable();
}
function renderAuctionInsights(payload) {
const themes = payload.themes || {};
const carry = themes.carry || [];
const tone = { "强承接": "strong", "有承接": "steady", "分歧": "mixed", "承接弱": "weak" };
setText("auctionThemeBaseline", `基于 ${payload.candidate_meta?.baseline_date || "--"}`);
document.querySelector("#auctionThemeCarry").innerHTML = carry.length
? carry.map((item) => `
<div class="auction-theme-row">
<strong class="auction-theme-name">${escapeHtml(item.name)}</strong>
<span class="auction-theme-info">${escapeHtml(item.leader || "--")} · 昨日 ${number(item.prior_limit_count)} 只涨停</span>
<span class="auction-theme-status ${tone[item.status] || "mixed"}">${escapeHtml(item.status)}</span>
<span class="auction-theme-median ${item.median_change == null ? "" : changeClass(item.median_change)}">${item.median_change == null ? "暂无有效候选" : `${signed(item.median_change)}%`}<small>中位</small></span>
</div>`).join("")
: '<div class="auction-inline-empty">暂无昨日强势题材基线</div>';
const newThemes = themes.new_themes || [];
document.querySelector("#auctionNewThemes").innerHTML = newThemes.length
? newThemes.map((item) => `<span title="${escapeHtml((item.leaders || []).join("、"))}">${escapeHtml(item.name)} <strong>${number(item.stock_count)}</strong></span>`).join("")
: '<small>尚未形成多股共振的新线索</small>';
const history = payload.amount_history || [];
const maximum = Math.max(...history.map((item) => number(item.amount_billion)), 1);
const priorFive = history.slice(Math.max(0, history.length - 6), Math.max(0, history.length - 1));
const fiveDayAverage = priorFive.length
? priorFive.reduce((sum, item) => sum + number(item.amount_billion), 0) / priorFive.length
: null;
document.querySelector("#auctionAmountTrend").innerHTML = history.length
? history.map((item, index) => {
const height = Math.max(8, number(item.amount_billion) / maximum * 100);
const current = index === history.length - 1 ? " current" : "";
return `<div class="auction-amount-day${current}" title="${escapeHtml(item.trade_date)} · ${formatNumber(item.amount_billion, 2)} 亿 · ${number(item.stock_count)} 只">
<span style="height:${height.toFixed(1)}%"></span><small>${escapeHtml(String(item.trade_date || "").slice(5))}</small>
</div>`;
}).join("") + (fiveDayAverage === null ? "" : `<div class="auction-amount-average" style="bottom:${(20 + Math.min(fiveDayAverage / maximum, 1) * 82).toFixed(1)}px"><small>5日均 ${formatNumber(fiveDayAverage, 1)}</small></div>`)
: '<div class="auction-inline-empty">历史竞价量能尚未形成</div>';
setText("auctionAmountValue", `${formatNumber(payload.summary?.amount_billion, 2)} 亿`);
const comparison = [
["较昨日", payload.summary?.amount_change_previous],
["较5日均值", payload.summary?.amount_change_5d],
];
document.querySelector("#auctionAmountCompare").innerHTML = comparison.map(([label, value]) => `
<span>${label}<strong class="${value == null ? "" : changeClass(value)}">${value == null ? "--" : `${signed(value)}%`}</strong></span>
`).join("");
}
function renderAuctionPhase(meta) {
const phase = meta.phase || "archive";
const available = Boolean(meta.available);
const copy = {
pending: ["竞价尚未开始", "9:15 进入观察期,9:25 读取最终竞价结果。", "下一阶段 09:15"],
observing: ["竞价观察期", "此阶段先观察盘前变化,系统将在 9:25 自动读取最终结果。", "09:25 定格"],
selection: available
? ["竞价筛选窗口", "最终竞价结果已经定格,请在 9:30 前完成筛选。", "有效至 09:30"]
: ["等待最终竞价", "9:25 数据尚未到达,系统正在自动重试。", "即将更新"],
finalized: ["今日竞价已定格", "9:30 后停止更新,仅保留用于复盘、回测与智能选股。", "已冻结"],
archive: ["历史竞价归档", "当前展示所选交易日的最终竞价结果。", "归档数据"],
}[phase] || ["竞价状态", "当前竞价状态待确认。", "--"];
const notice = document.querySelector("#auctionPhaseNotice");
notice.dataset.phase = phase;
setText("auctionPhaseTitle", copy[0]);
setText("auctionPhaseDetail", copy[1]);
setText("auctionPhaseTime", copy[2]);
const refresh = document.querySelector("#auctionRefreshButton");
refresh.hidden = phase !== "selection";
refresh.disabled = state.auctionLoading;
}
function clearAuctionTimer() {
if (state.auctionTimer) clearTimeout(state.auctionTimer);
state.auctionTimer = null;
}
function scheduleAuctionTransition(meta) {
clearAuctionTimer();
if (state.activeView !== "auctionView") return;
let delay = 0;
if (["selection", "finalized"].includes(meta.phase) && !meta.available) {
delay = 10_000;
} else if (meta.next_transition_at) {
const transitionAt = new Date(meta.next_transition_at).getTime();
if (Number.isFinite(transitionAt)) delay = Math.max(800, transitionAt - Date.now() + 500);
}
if (!delay) return;
state.auctionTimer = setTimeout(() => {
state.auctionTimer = null;
if (state.activeView === "auctionView") loadAuctionCenter(true);
}, Math.min(delay, 2_147_000_000));
}
function renderAuctionTable() {
const rows = currentAuctionRows();
const columns = auctionColumns();
const head = document.querySelector("#auctionTableHead");
head.innerHTML = columns.map((column) => {
const sorted = column.sortKey === state.auctionSortKey;
const arrow = !column.sortKey ? "" : `<span class="arr">${sorted ? (state.auctionSortDirection === "desc" ? "▼" : "▲") : "↕"}</span>`;
return `<th class="${column.numeric ? "number num " : ""}${column.sortKey ? "sortable " : ""}${sorted ? "sorted" : ""}"${column.sortKey ? ` data-auction-sort="${column.sortKey}"` : ""}>${column.label}${arrow}</th>`;
}).join("");
const body = document.querySelector("#auctionTableBody");
body.innerHTML = rows.map((row) => `<tr data-code="${escapeHtml(row.code)}">${columns.map((column) => renderAuctionCell(row, column.key)).join("")}</tr>`).join("");
bindStockRows(body);
const datasetCopy = {
focus: ["重点异动", "优先查看市场核心与显著预期差"],
onePrice: ["竞价一字", "竞价封于当日真实涨停价,不参与普通异动评分"],
watchlist: ["我的自选", "仅展示当前账号关注标的的竞价反馈"],
all: ["全部候选", "昨日涨停、炸板与热榜前20候选"],
}[state.auctionDataset] || ["竞价异动", ""];
setText("auctionWorkspaceTitle", datasetCopy[0]);
setText("auctionWorkspaceSubtitle", datasetCopy[1]);
document.querySelector("#auctionExpectationControls").hidden = state.auctionDataset === "onePrice";
const empty = document.querySelector("#auctionEmpty");
const phase = state.auctionData?.meta?.phase || "archive";
empty.textContent = phase === "selection" && !state.auctionData?.meta?.available
? "正在等待 9:25 最终竞价数据"
: state.auctionDataset === "watchlist"
? "当前账号还没有可观察的自选股"
: state.auctionDataset === "onePrice"
? "当前没有竞价封于涨停价的股票"
: "没有符合条件的竞价候选";
empty.hidden = rows.length > 0;
}
function currentAuctionRows() {
const datasets = {
focus: state.auctionData?.focus_rows || [],
onePrice: state.auctionData?.one_price_rows || [],
watchlist: state.auctionData?.watchlist_rows || [],
all: state.auctionData?.rows || [],
};
let rows = [...(datasets[state.auctionDataset] || [])];
const filter = state.auctionFilter;
const labels = { above: "超预期", matched: "符合预期", below: "低于预期" };
if (labels[filter]) rows = rows.filter((item) => item.expectation === labels[filter]);
if (state.auctionQuery) {
rows = rows.filter((item) => `${item.code} ${item.name} ${item.sector}`.toLocaleLowerCase("zh-CN").includes(state.auctionQuery));
}
const key = state.auctionSortKey;
const direction = state.auctionSortDirection === "asc" ? 1 : -1;
if (key) {
rows.sort((left, right) => {
const leftValue = left[key];
const rightValue = right[key];
if (leftValue == null && rightValue == null) return 0;
if (leftValue == null) return 1;
if (rightValue == null) return -1;
const result = typeof leftValue === "number" || typeof rightValue === "number"
? number(leftValue) - number(rightValue)
: String(leftValue).localeCompare(String(rightValue), "zh-CN", { numeric: true });
return result * direction;
});
}
return rows.slice(0, 300);
}
function auctionColumns() {
const base = [
{ key: "stock", label: "股票" },
{ key: "context", label: "方向与来源" },
{ key: "identity", label: "市场身份" },
];
const metrics = [
{ key: "score", label: "关注分", numeric: true, sortKey: "attention_score" },
{ key: "expectation", label: "预期判断" },
{ key: "change", label: "竞价涨幅(%", numeric: true, sortKey: "change" },
{ key: "amount", label: "竞价额(百万)", numeric: true, sortKey: "amount_million" },
{ key: "volume", label: "量比", numeric: true, sortKey: "volume_ratio" },
];
return state.auctionDataset === "onePrice" ? [...base, ...metrics.slice(2)] : [...base, ...metrics];
}
function renderAuctionCell(row, key) {
const unavailable = row.available === false;
const onePrice = Boolean(row.is_one_price);
const expectationTone = { "超预期": "above", "符合预期": "matched", "低于预期": "below" };
if (key === "stock") return `<td><span class="auction-stock-cell-v2"><strong class="sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>`;
if (key === "context") return `<td><span class="auction-context-cell-v2"><strong>${escapeHtml(row.sector || "其他")}</strong>${renderAuctionSources(row.source_label || (state.auctionDataset === "watchlist" ? "我的自选" : "全市场"))}</span></td>`;
if (key === "identity") return `<td>${renderAuctionCoreTags(row.core_tags)}</td>`;
if (unavailable) return key === "expectation"
? '<td><span class="table-muted">暂无竞价</span></td>'
: `<td class="${["score", "change", "amount", "volume"].includes(key) ? "number num" : ""}"></td>`;
if (key === "score") return `<td class="number num auction-score">${onePrice ? "" : formatNumber(row.attention_score, 1)}</td>`;
if (key === "expectation") {
const tag = onePrice
? '<span class="auction-one-price-tag">竞价一字</span>'
: `<span class="auction-expectation ${expectationTone[row.expectation] || "matched"}">${escapeHtml(row.expectation || "符合预期")}</span>`;
return `<td>${tag}</td>`;
}
if (key === "change") return `<td class="number num ${changeClass(row.change)}">${signed(row.change)}</td>`;
if (key === "amount") return `<td class="number num">${formatNumber(row.amount_million, 2)}</td>`;
if (key === "volume") return `<td class="number num auction-volume-ratio">${formatNumber(row.volume_ratio, 2)}</td>`;
return "<td></td>";
}
function renderAuctionSources(value) {
const sources = String(value || "").split(/[·、/]/).map((item) => item.trim()).filter(Boolean).slice(0, 3);
return `<small class="auction-source-tags-v2">${sources.map((source) => `<b>${escapeHtml(source)}</b>`).join("")}</small>`;
}
function renderAuctionCoreTags(tags) {
const values = Array.isArray(tags) ? tags : [];
return values.length
? `<span class="auction-core-tags">${values.slice(0, 2).map((tag) => `<b>${escapeHtml(tag)}</b>`).join("")}</span>`
: '<span class="auction-identity-empty" aria-label="无市场身份"></span>';
}
function exportAuctionRows() {
const rows = currentAuctionRows();
exportRows("集合竞价", rows, [
["股票代码", "code"], ["股票名称", "name"], ["行业", "sector"], ["来源", "source_label"],
["市场身份", "core_tags"], ["关注分", "attention_score"], ["预期判断", "expectation"],
["竞价涨幅%", "change"], ["竞价额百万", "amount_million"], ["量比", "volume_ratio"],
]);
}
/* PRESERVATION-SOURCE-END app.js:2217-2481 */
+375
View File
@@ -0,0 +1,375 @@
window.XiaobaiPageModules.register("dragon_tiger", ["dragonView"], {
enter: ["loadDragonTiger"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2660-3028 */
function selectDragonViewMode(mode) {
state.dragonViewMode = mode === "profiles" ? "profiles" : "daily";
document.querySelectorAll("[data-dragon-view-mode]").forEach((button) => {
const active = button.dataset.dragonViewMode === state.dragonViewMode;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
if (state.dragonViewMode === "profiles") {
document.querySelector("#dragonDailyContent").hidden = true;
document.querySelector("#dragonEmptyState").hidden = true;
document.querySelector("#dragonProfilesContent").hidden = false;
if (state.hotMoneyProfiles) renderHotMoneyProfiles();
else loadHotMoneyProfiles();
} else {
document.querySelector("#dragonProfilesContent").hidden = true;
if (state.dragonTiger) renderDragonTiger();
else loadDragonTiger();
}
}
async function loadHotMoneyProfiles(force = false) {
if (!force && state.hotMoneyProfiles) {
renderHotMoneyProfiles();
return;
}
setStatus("正在加载游资档案");
try {
const query = new URLSearchParams();
if (force) query.set("force", "1");
const suffix = query.size ? `?${query}` : "";
state.hotMoneyProfiles = await apiRequest(`/api/dragon-tiger/profiles${suffix}`);
renderHotMoneyProfiles();
const count = number(state.hotMoneyProfiles.summary?.profile_count);
setStatus(`游资档案已加载 · 共 ${count}`);
} catch (error) {
showToast(error.message || "游资档案加载失败");
setStatus("游资档案加载失败");
}
}
function renderHotMoneyProfiles() {
const payload = state.hotMoneyProfiles;
if (!payload) return;
const profiles = payload.profiles || [];
const summary = payload.summary || {};
const query = state.hotMoneyProfileQuery;
const visible = profiles.filter((profile) => {
if (!query) return true;
return [profile.name, profile.description, ...(profile.organizations || [])]
.join(" ")
.toLocaleLowerCase("zh-CN")
.includes(query);
});
if (!visible.some((profile) => profile.id === state.selectedHotMoneyProfileId)) {
state.selectedHotMoneyProfileId = visible[0]?.id || "";
}
const selected = visible.find((profile) => profile.id === state.selectedHotMoneyProfileId) || null;
setText("dragonDateLabel", `收录 ${number(summary.profile_count)}`);
setText("hotMoneyProfileResultCount", query ? `${visible.length} / ${profiles.length}` : `${profiles.length}`);
document.querySelector("#hotMoneyProfileSummary").innerHTML = [
["收录游资", number(summary.profile_count)],
["已有简介", number(summary.described_count)],
["关联席位", number(summary.organization_count)],
].map(([label, value]) => `<span><small>${label}</small><strong>${value}</strong></span>`).join("");
const list = document.querySelector("#hotMoneyProfileList");
list.innerHTML = visible.length ? visible.map((profile, index) => `
<button class="hot-money-profile-row-v2 ${profile.id === state.selectedHotMoneyProfileId ? "selected" : ""}"
type="button" role="option" aria-selected="${profile.id === state.selectedHotMoneyProfileId}"
data-hot-money-profile="${escapeHtml(profile.id)}">
<span class="hot-money-profile-index-v2">${String(index + 1).padStart(2, "0")}</span>
<span class="hot-money-profile-monogram-v2">${escapeHtml(profile.name.slice(0, 2))}</span>
<span class="hot-money-profile-row-copy-v2">
<strong>${escapeHtml(profile.name)}</strong>
<small>${escapeHtml(profile.description || "暂未收录简介")}</small>
</span>
<span class="hot-money-profile-seat-count-v2">${number(profile.organization_count)} 席</span>
</button>`).join("") : `
<div class="hot-money-profile-list-empty-v2">
<i data-lucide="search-x" aria-hidden="true"></i>
<span>${profiles.length ? "没有符合条件的游资档案" : "游资名录暂不可用"}</span>
</div>`;
const detail = document.querySelector("#hotMoneyProfileDetail");
if (!selected) {
detail.innerHTML = `
<div class="hot-money-profile-empty-v2">
<i data-lucide="contact" aria-hidden="true"></i>
<strong>${profiles.length ? "选择一位游资查看档案" : "暂无可展示的游资档案"}</strong>
</div>`;
} else {
const organizations = selected.organizations || [];
detail.innerHTML = `
<header class="hot-money-profile-detail-head-v2">
<span class="hot-money-profile-avatar-v2">${escapeHtml(selected.name.slice(0, 2))}</span>
<div>
<small>游资档案</small>
<h3>${escapeHtml(selected.name)}</h3>
<span>${organizations.length ? `关联 ${organizations.length} 个公开席位` : "暂无关联席位"}</span>
</div>
</header>
<section class="hot-money-profile-section-v2">
<h4>人物简介</h4>
<p class="${selected.description ? "" : "is-empty"}">${escapeHtml(selected.description || "名录暂未收录该游资的公开简介。")}</p>
</section>
<section class="hot-money-profile-section-v2 hot-money-profile-org-section-v2">
<div class="hot-money-profile-section-title-v2">
<h4>关联营业部</h4>
<span>${organizations.length} 个</span>
</div>
<div class="hot-money-profile-organizations-v2">
${organizations.length ? organizations.map((organization) => `
<span><i data-lucide="building-2" aria-hidden="true"></i>${escapeHtml(organization)}</span>
`).join("") : '<p class="is-empty">名录暂未收录关联营业部。</p>'}
</div>
</section>
${payload.meta?.notice ? `<p class="hot-money-profile-notice-v2">${escapeHtml(payload.meta.notice)}</p>` : ""}`;
}
refreshIcons();
}
async function loadDragonTiger(force = false) {
const requestedDate = elements.tradeDate.value;
if (
!force
&& ["success", "empty", "partial", "unavailable"].includes(state.dragonTiger?.meta?.status)
&& (state.dragonTiger?.meta?.requested_date || state.dragonTiger?.meta?.trade_date) === requestedDate
) {
renderDragonTiger();
return;
}
setStatus("正在加载龙虎榜");
try {
const query = new URLSearchParams({ trade_date: requestedDate });
if (force) query.set("force", "1");
const payload = await apiRequest(`/api/dragon-tiger?${query}`);
state.dragonTiger = payload;
renderDragonTiger();
const statusLabel = payload.meta.status === "error"
? "龙虎榜数据暂不可用"
: payload.meta.status === "empty"
? "当日暂无公开游资明细"
: payload.meta.status === "partial"
? "当日有龙虎榜,暂无命名游资明细"
: payload.meta.status === "unavailable" ? "龙虎榜数据暂不可用" : "龙虎榜明细";
setStatus(`${statusLabel} · 龙虎榜已加载`);
} catch (error) {
showToast(error.message || "龙虎榜加载失败");
setStatus("龙虎榜加载失败");
}
}
function renderDragonTiger() {
const payload = state.dragonTiger;
if (!payload) return;
const summary = payload.summary || {};
if (state.dragonViewMode === "daily") setText("dragonDateLabel", `数据日期 ${payload.meta.trade_date}`);
const status = payload.meta?.status || "empty";
const hasRecognizedTraders = (payload.traders || []).some((item) => item.identity_type === "trader" && item.recognized !== false);
const showEmptyState = !hasRecognizedTraders
&& !(payload.unclassified_seats || []).length
&& ["empty", "error", "unavailable"].includes(status);
const dailyVisible = state.dragonViewMode === "daily";
document.querySelector("#dragonProfilesContent").hidden = dailyVisible;
document.querySelector("#dragonEmptyState").hidden = !dailyVisible || !showEmptyState;
document.querySelector("#dragonDailyContent").hidden = !dailyVisible || showEmptyState;
if (showEmptyState) {
const unavailable = ["error", "unavailable"].includes(status);
setText("dragonEmptyTitle", unavailable ? "龙虎榜数据暂不可用" : `${payload.meta?.trade_date || "该交易日"} 暂无龙虎榜明细`);
setText("dragonEmptyDescription", unavailable
? "当前数据暂未完成更新,可稍后重新检查或查看前一交易日。"
: "龙虎榜明细通常在交易日盘后陆续披露,可稍后刷新或查看前一交易日。");
}
document.querySelector("#dragonSummary").innerHTML = [
["上榜游资", `${number(summary.trader_count)}`, ""],
["操作明细", `${number(summary.operation_count)}`, ""],
["席位净买入", formatMoneyMillion(summary.seat_net_buy_million), changeClass(summary.seat_net_buy_million)],
["活跃股票", `${number(summary.active_stock_count)}`, ""],
].map(([label, value, className]) => `<div class="dragon-metric"><span>${label}</span><strong class="${className}">${value}</strong></div>`).join("");
renderDragonTraderList();
renderUnclassifiedSeats();
}
function renderDragonTraderList() {
const payload = state.dragonTiger;
if (!payload) return;
let traders = [...(payload.traders || [])].filter((item) => item.identity_type === "trader" && item.recognized !== false);
if (state.dragonFilter === "buy") traders = traders.filter((item) => number(item.net_buy_million) > 0);
if (state.dragonFilter === "sell") traders = traders.filter((item) => number(item.net_buy_million) < 0);
if (state.dragonFilter === "unclassified") traders = [];
if (state.dragonQuery) {
traders = traders.filter((item) => {
const searchable = [
item.name,
...(item.operations || []).flatMap((operation) => [operation.code, operation.name, operation.seat_name]),
].join(" ").toLowerCase();
return searchable.includes(state.dragonQuery);
});
}
const container = document.querySelector("#dragonTraderList");
let emptyMessage = "没有符合当前条件的游资操作";
if (!Array.isArray(payload.traders)) emptyMessage = "龙虎榜数据格式暂不可用,请稍后重试";
else if (["error", "unavailable"].includes(payload.meta?.status)) emptyMessage = "龙虎榜数据暂不可用,请稍后重试";
else if (payload.meta?.status === "empty") emptyMessage = "该交易日暂无游资每日明细";
else if (payload.meta?.status === "partial") emptyMessage = `当日有 ${number(payload.summary?.official_stock_count)} 只股票上榜,但暂无可识别的游资明细`;
if (!traders.some((item) => item.id === state.selectedDragonTraderId)) {
state.selectedDragonTraderId = traders[0]?.id || "";
}
const cardMarkup = traders.map((trader, index) => {
const description = trader.description || `${number(trader.stock_count)} 只股票,${number(trader.operation_count)} 笔操作`;
return `
<article class="dragon-trader-card dealing ${trader.id === state.selectedDragonTraderId ? "selected" : ""}" data-dragon-card="${escapeHtml(trader.id)}" aria-hidden="true" style="--deal-delay:${Math.min(index * 38, 650)}ms">
<span class="dragon-card-rank">${String(index + 1).padStart(2, "0")}</span>
<span class="dragon-card-monogram">${escapeHtml(trader.name.slice(0, 2))}</span>
<span class="dragon-card-copy"><strong>${escapeHtml(trader.name)}</strong><q title="${escapeHtml(description)}">${escapeHtml(description)}</q></span>
<span class="dragon-card-stats"><small>${number(trader.stock_count)} 股 · ${number(trader.operation_count)} 笔</small><b class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</b></span>
</article>`;
}).join("");
const hitZoneMarkup = traders.map((trader) => `
<button type="button" class="dragon-card-hit-zone" data-dragon-trader="${escapeHtml(trader.id)}" aria-label="查看 ${escapeHtml(trader.name)} 当日操作" aria-pressed="${trader.id === state.selectedDragonTraderId}"></button>
`).join("");
container.innerHTML = traders.length
? `${cardMarkup}<div class="dragon-card-hit-layer">${hitZoneMarkup}</div>`
: emptyStateHtml(state.dragonFilter === "unclassified" ? "待归类席位请在下方管理" : emptyMessage, { className: "dragon-empty" });
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.addEventListener("animationend", () => card.classList.remove("dealing"), { once: true });
});
container.querySelectorAll("[data-dragon-trader]").forEach((hitZone) => {
const setHovered = (hovered) => {
container.querySelector(`[data-dragon-card="${CSS.escape(hitZone.dataset.dragonTrader)}"]`)?.classList.toggle("hovered", hovered);
};
hitZone.addEventListener("pointerenter", () => setHovered(true));
hitZone.addEventListener("pointerleave", () => setHovered(false));
hitZone.addEventListener("focus", () => setHovered(true));
hitZone.addEventListener("blur", () => setHovered(false));
hitZone.addEventListener("click", () => {
state.selectedDragonTraderId = hitZone.dataset.dragonTrader;
container.querySelectorAll("[data-dragon-card]").forEach((card) => {
card.classList.toggle("selected", card.dataset.dragonCard === state.selectedDragonTraderId);
});
container.querySelectorAll("[data-dragon-trader]").forEach((item) => {
item.setAttribute("aria-pressed", String(item.dataset.dragonTrader === state.selectedDragonTraderId));
});
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
});
});
requestAnimationFrame(() => layoutDragonCards(container));
renderDragonTraderDetail(traders.find((item) => item.id === state.selectedDragonTraderId));
}
function layoutDragonCards(container = document.querySelector("#dragonTraderList")) {
if (!container) return;
const cards = [...container.querySelectorAll(".dragon-trader-card")];
const hitZones = [...container.querySelectorAll(".dragon-card-hit-zone")];
if (!cards.length) return;
const compact = window.innerWidth <= 720;
const cardWidth = compact ? 148 : 176;
const available = Math.max(cardWidth, container.clientWidth - (compact ? 30 : 72));
const spread = Math.min(available - cardWidth, compact ? 310 : 1050);
const step = cards.length > 1 ? Math.min(cardWidth + 14, spread / (cards.length - 1)) : 0;
const center = (cards.length - 1) / 2;
container.style.setProperty("--dragon-card-width", `${cardWidth}px`);
cards.forEach((card, index) => {
const x = (index - center) * step;
card.style.setProperty("--card-x", `${x.toFixed(2)}px`);
card.style.setProperty("--card-rotation", "0deg");
card.style.setProperty("--card-y", "0px");
card.style.zIndex = String(index + 1);
const hitZone = hitZones[index];
if (hitZone) {
const zoneWidth = index === cards.length - 1 ? cardWidth : Math.max(18, step);
hitZone.style.left = `calc(50% + ${(x - cardWidth / 2).toFixed(2)}px)`;
hitZone.style.width = `${zoneWidth.toFixed(2)}px`;
}
});
}
function renderDragonTraderDetail(trader) {
const container = document.querySelector("#dragonTraderDetail");
if (!trader) {
container.hidden = true;
renderEmptyState(container, "选择一位游资查看操作明细", { className: "dragon-empty" });
return;
}
container.hidden = false;
container.innerHTML = `
<header class="dragon-detail-header">
<div><span>当日操作明细</span><h3>${escapeHtml(trader.name)}</h3><p>${escapeHtml(trader.description || "按当日公开龙虎榜席位汇总")}</p></div>
<dl><div><dt>买入</dt><dd class="up">${formatMoneyMillion(trader.buy_million)}</dd></div><div><dt>卖出</dt><dd class="down">${formatMoneyMillion(trader.sell_million)}</dd></div><div><dt>净额</dt><dd class="${changeClass(trader.net_buy_million)}">${formatMoneyMillion(trader.net_buy_million)}</dd></div></dl>
</header>
<div class="trader-operations table-frame tbl-wrap">
<table class="data-table tbl dragon-operation-table">
<colgroup><col class="dragon-col-index"><col class="dragon-col-stock"><col class="dragon-col-direction"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-number"><col class="dragon-col-seat"><col class="dragon-col-reason"></colgroup>
<thead><tr><th class="row-number num">序号</th><th>股票</th><th>方向</th><th class="number num">涨幅(%</th><th class="number num">买入(百万)</th><th class="number num">卖出(百万)</th><th class="number num">净额(百万)</th><th>关联席位</th><th class="reason-column">标签 / 上榜原因</th></tr></thead>
<tbody>${(trader.operations || []).map((operation, index) => `
<tr data-code="${escapeHtml(operation.code)}">
<td class="row-number num">${index + 1}</td>
<td><strong class="sname">${escapeHtml(operation.name)}</strong><span class="scode">${escapeHtml(operation.code)}</span></td>
<td><span class="direction-label ${changeClass(operation.net_buy_million)}">${escapeHtml(operation.direction)}</span></td>
<td class="number num ${operation.change == null ? "" : changeClass(operation.change)}">${operation.change == null ? "" : signed(operation.change)}</td>
<td class="number num">${operation.buy_million == null ? "" : formatNumber(operation.buy_million, 2)}</td>
<td class="number num">${operation.sell_million == null ? "" : formatNumber(operation.sell_million, 2)}</td>
<td class="number num ${operation.net_buy_million == null ? "" : changeClass(operation.net_buy_million)}">${operation.net_buy_million == null ? "" : signed(operation.net_buy_million)}</td>
<td class="seat-cell" title="${escapeHtml(operation.seat_name)}">${escapeHtml(operation.seat_name)}</td>
<td class="reason-column" title="${escapeHtml([operation.tag, operation.reason].filter((item) => item && item !== "--").join(" · "))}">${escapeHtml(operation.tag && operation.tag !== "--" ? operation.tag : operation.reason && operation.reason !== "--" ? operation.reason : "")}</td>
</tr>`).join("")}</tbody>
</table>
</div>`;
bindStockRows(container);
markAutoSortableHeaders(container);
}
function renderUnclassifiedSeats() {
const seats = state.dragonTiger?.unclassified_seats || [];
const canManage = state.user?.role === "admin";
document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0;
document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0;
if (!seats.length && state.dragonFilter === "unclassified") {
state.dragonFilter = "all";
document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.dragonFilter === "all");
});
renderDragonTraderList();
}
setText("unclassifiedCount", `${seats.length}`);
const list = document.querySelector("#unclassifiedSeatList");
list.innerHTML = seats.map((seat, index) => `
<form class="unclassified-seat-row" data-unclassified-index="${index}">
<span class="unclassified-seat-name" title="${escapeHtml(seat.seat_name)}">${escapeHtml(seat.seat_name)}</span>
<span class="unclassified-seat-stats">${number(seat.operation_count)} 笔 · ${number(seat.stock_count)} 股</span>
<strong class="${changeClass(seat.net_buy_million)}">${formatMoneyMillion(seat.net_buy_million)}</strong>
<input type="text" maxlength="50" placeholder="输入游资名" aria-label="${escapeHtml(seat.seat_name)}的游资名" required>
<button class="button" type="submit">归类</button>
</form>
`).join("") || emptyStateHtml("当前席位均已归类");
list.querySelectorAll(".unclassified-seat-row").forEach((form) => {
form.addEventListener("submit", saveSeatAlias);
});
}
function dragonIdentityLabel(type) {
return { trader: "游资", institution: "机构", channel: "通道", unclassified: "待归类" }[type] || "席位";
}
async function saveSeatAlias(event) {
event.preventDefault();
const form = event.currentTarget;
const seat = state.dragonTiger?.unclassified_seats?.[number(form.dataset.unclassifiedIndex)];
const alias = form.querySelector("input").value.trim();
if (!seat || !alias) {
showToast("请输入游资名");
return;
}
const button = form.querySelector("button");
button.disabled = true;
try {
await apiRequest("/api/seat-aliases", "POST", { seat_name: seat.seat_name, alias });
state.dragonTiger = null;
await loadDragonTiger();
showToast(`已将席位归类为 ${alias}`);
} catch (error) {
showToast(error.message);
button.disabled = false;
}
}
/* PRESERVATION-SOURCE-END app.js:2660-3028 */
+723
View File
@@ -0,0 +1,723 @@
(function exposeHeavenLoading(global) {
"use strict";
// Theme palettes share the original animation geometry and timing.
const LOADING_PALETTES = {
dark: {
paper: "#05060d",
paperCenter: "#10142a",
paperMiddle: "#0b0e1e",
nodeText: "#f7e3b4",
ink: "#e6c37a",
inkBright: "#f7e3b4",
gold: "#e6c37a",
goldBright: "#f7e3b4",
cinnabar: "#d8564a",
dim: "rgba(216,205,180,0.55)",
particles: ["#e6c37a", "#d8564a", "#6d7fa8"],
},
light: {
paper: "#eef1f4",
paperCenter: "#fffefa",
paperMiddle: "#f4f2eb",
nodeText: "#493a20",
ink: "#8a641d",
inkBright: "#624612",
gold: "#946b1d",
goldBright: "#765315",
cinnabar: "#b94f46",
dim: "rgba(52,58,67,0.62)",
particles: ["#946b1d", "#b94f46", "#73859c"],
},
};
let PAPER;
let PAPER_CENTER;
let PAPER_MIDDLE;
let NODE_TEXT;
let INK;
let INK_BRIGHT;
let GOLD;
let GOLD_BRIGHT;
let CINNABAR;
let DIM;
let PARTICLE_COLORS;
const applyLoadingPalette = () => {
const theme = document.documentElement.dataset.theme === "light" ? "light" : "dark";
const palette = LOADING_PALETTES[theme];
PAPER = palette.paper;
PAPER_CENTER = palette.paperCenter;
PAPER_MIDDLE = palette.paperMiddle;
NODE_TEXT = palette.nodeText;
INK = palette.ink;
INK_BRIGHT = palette.inkBright;
GOLD = palette.gold;
GOLD_BRIGHT = palette.goldBright;
CINNABAR = palette.cinnabar;
DIM = palette.dim;
PARTICLE_COLORS = palette.particles;
return theme;
};
applyLoadingPalette();
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",serif';
const ELEMENT_COLORS = {
: "#4f7a4a",
: "#b3483d",
: "#96702c",
: "#70685b",
: "#496d92",
};
const QI6 = [
{ name: "厥阴风木", element: "木" },
{ name: "少阴君火", element: "火" },
{ name: "少阳相火", element: "火" },
{ name: "太阴湿土", element: "土" },
{ name: "阳明燥金", element: "金" },
{ name: "太阳寒水", element: "水" },
];
const STEP_RANGES = ["大寒 — 春分", "春分 — 小满", "小满 — 大暑", "大暑 — 秋分", "秋分 — 小雪", "小雪 — 大寒"];
const TRIGRAMS = [
{ name: "乾", bits: [1, 1, 1], angle: -90 },
{ name: "兑", bits: [1, 1, 0], angle: -135 },
{ name: "离", bits: [1, 0, 1], angle: 180 },
{ name: "震", bits: [1, 0, 0], angle: 135 },
{ name: "巽", bits: [0, 1, 1], angle: -45 },
{ name: "坎", bits: [0, 1, 0], angle: 0 },
{ name: "艮", bits: [0, 0, 1], angle: 45 },
{ name: "坤", bits: [0, 0, 0], angle: 90 },
];
const SIXIANG = [
{ name: "太阳", bits: [1, 1], dx: 0, dy: -1 },
{ name: "少阴", bits: [1, 0], dx: 1, dy: 0 },
{ name: "太阴", bits: [0, 0], dx: 0, dy: 1 },
{ name: "少阳", bits: [0, 1], dx: -1, dy: 0 },
];
const HEXAGRAM_NAMES = [
"坤", "剥", "比", "观", "豫", "晋", "萃", "否", "谦", "艮", "蹇", "渐", "小过", "旅", "咸", "遁",
"师", "蒙", "坎", "涣", "解", "未济", "困", "讼", "升", "蛊", "井", "巽", "恒", "鼎", "大过", "姤",
"复", "颐", "屯", "益", "震", "噬嗑", "随", "无妄", "明夷", "贲", "既济", "家人", "丰", "革", "同人", "临",
"损", "节", "中孚", "归妹", "睽", "兑", "履", "泰", "大畜", "需", "小畜", "大壮", "大有", "夬", "乾",
];
const HEX_TOTAL = 12500;
const FORTUNE_TOTAL = 12800;
const HEX_STAGES = [
[0, 1800, "太 极", "无极而太极,动而生阳"],
[1800, 3300, "两 仪", "一阴一阳之谓道"],
[3300, 4700, "四 象", "阴阳消长,太少相生"],
[4700, 6800, "八 卦", "天地定位,山泽通气"],
[6800, 10800, "六 十 四 卦", "卦者挂也,悬物象以示人"],
[10800, HEX_TOTAL, "归 一", "万物负阴而抱阳,冲气以为和"],
];
const clamp01 = (value) => Math.max(0, Math.min(1, value));
const smooth = (start, end, value) => {
const progress = clamp01((value - start) / Math.max(1, end - start));
return progress * progress * (3 - 2 * progress);
};
const easeOut = (value) => 1 - Math.pow(1 - clamp01(value), 3);
const hexBits = (index) => Array.from({ length: 6 }, (_, bit) => (index >> (5 - bit)) & 1);
const point = (cx, cy, radius, degrees) => {
const radians = degrees * Math.PI / 180;
return [cx + Math.cos(radians) * radius, cy + Math.sin(radians) * radius];
};
class HeavenLoadingCanvas {
constructor(canvas) {
this.canvas = canvas;
this.context = canvas.getContext("2d");
this.width = 0;
this.height = 0;
this.dpr = 1;
this.scene = "hexagram";
this.data = {};
this.startedAt = 0;
this.frameId = 0;
this.running = false;
this.completingAt = 0;
this.completionResolve = null;
this.completionTimer = 0;
this.resizeObserver = new ResizeObserver(() => this.resize());
this.reducedMotion = global.matchMedia("(prefers-reduced-motion: reduce)").matches;
this.theme = document.documentElement.dataset.theme || "dark";
this.stars = this.createStars(this.reducedMotion ? 48 : 150);
}
createStars(count) {
let seed = 24681357;
const random = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
return Array.from({ length: count }, () => ({
x: random(),
y: random(),
radius: 0.3 + random() * 1.3,
phase: random() * Math.PI * 2,
speed: 0.00015 + random() * 0.0004,
colorIndex: Math.floor(random() * PARTICLE_COLORS.length),
}));
}
start(scene, data = {}) {
this.theme = applyLoadingPalette();
const nextScene = scene === "fortune" ? "fortune" : "hexagram";
if (this.running && this.scene === nextScene) {
this.data = data;
return;
}
this.stop();
this.scene = nextScene;
this.data = data;
this.startedAt = performance.now();
this.running = true;
this.canvas.dataset.scene = this.scene;
this.canvas.dataset.running = "true";
this.canvas.dataset.looping = "true";
this.resizeObserver.observe(this.canvas);
this.resize();
if (this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
} else {
this.frameId = requestAnimationFrame((now) => this.frame(now));
}
}
complete() {
if (!this.running || this.reducedMotion) {
this.stop();
return Promise.resolve();
}
if (this.completionResolve) return this.completionPromise;
this.completingAt = performance.now();
this.completionPromise = new Promise((resolve) => { this.completionResolve = resolve; });
this.completionTimer = global.setTimeout(() => this.stop(), 2200);
return this.completionPromise;
}
stop() {
if (this.frameId) cancelAnimationFrame(this.frameId);
this.frameId = 0;
this.running = false;
this.completingAt = 0;
if (this.completionTimer) global.clearTimeout(this.completionTimer);
this.completionTimer = 0;
this.resizeObserver.disconnect();
this.canvas.dataset.running = "false";
this.canvas.dataset.looping = "false";
if (this.completionResolve) this.completionResolve();
this.completionResolve = null;
this.completionPromise = null;
}
resize() {
const rect = this.canvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width === this.width && height === this.height) return;
this.width = width;
this.height = height;
this.dpr = Math.min(global.devicePixelRatio || 1, 2);
this.canvas.width = Math.round(width * this.dpr);
this.canvas.height = Math.round(height * this.dpr);
this.context.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
if (this.running && this.reducedMotion) {
this.draw(this.scene === "fortune" ? 10950 : 10600, performance.now());
}
}
frame(now) {
if (!this.running) return;
if (this.completingAt) {
const duration = this.scene === "fortune" ? 1800 : 1700;
const progress = clamp01((now - this.completingAt) / duration);
this.drawCompletion(progress, now);
if (progress >= 1) {
this.stop();
return;
}
} else {
const total = this.scene === "fortune" ? FORTUNE_TOTAL : HEX_TOTAL;
const elapsed = Math.max(0, now - this.startedAt);
const timeline = elapsed % total;
this.canvas.dataset.cycle = String(Math.floor(elapsed / total));
this.draw(timeline, now);
}
this.frameId = requestAnimationFrame((time) => this.frame(time));
}
draw(time, now) {
if (this.width <= 1 || this.height <= 1) return;
this.drawBackground(now);
if (this.scene === "fortune") this.drawFortune(time, now);
else this.drawHexagram(time, now);
}
drawBackground(now) {
const currentTheme = document.documentElement.dataset.theme || "dark";
if (currentTheme !== this.theme) this.theme = applyLoadingPalette();
const { context: ctx, width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.75);
gradient.addColorStop(0, PAPER_CENTER);
gradient.addColorStop(0.52, PAPER_MIDDLE);
gradient.addColorStop(1, PAPER);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
for (const star of this.stars) {
const twinkle = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(star.phase + now * 0.0012));
const alpha = twinkle * 0.5;
ctx.globalAlpha = alpha;
ctx.fillStyle = PARTICLE_COLORS[star.colorIndex];
const y = ((star.y + now * star.speed) % 1) * height;
ctx.fillRect(star.x * width, y, star.radius, star.radius);
}
ctx.globalAlpha = 1;
}
label(text, x, y, size, color = INK, alpha = 1, weight = "", maxWidth) {
if (!text || alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.font = `${weight ? `${weight} ` : ""}${size}px ${SERIF}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (maxWidth) ctx.fillText(text, x, y, maxWidth);
else ctx.fillText(text, x, y);
ctx.restore();
}
node(x, y, radius, color, alpha = 1, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = glow;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
line(x1, y1, x2, y2, color, alpha = 1, width = 1) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
}
curvedArrow(x1, y1, x2, y2, mx, my, color, alpha) {
if (alpha <= 0) return;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(mx, my, x2, y2);
ctx.stroke();
const angle = Math.atan2(y2 - my, x2 - mx);
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - 7 * Math.cos(angle - 0.42), y2 - 7 * Math.sin(angle - 0.42));
ctx.lineTo(x2 - 7 * Math.cos(angle + 0.42), y2 - 7 * Math.sin(angle + 0.42));
ctx.closePath();
ctx.fill();
ctx.restore();
}
drawYao(cx, cy, width, lineWidth, yang, alpha, glow = 0) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = INK;
ctx.shadowColor = GOLD;
ctx.shadowBlur = glow;
if (yang) {
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, width, lineWidth);
} else {
const gap = width * 0.18;
ctx.fillRect(cx - width / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
ctx.fillRect(cx + gap / 2, cy - lineWidth / 2, (width - gap) / 2, lineWidth);
}
ctx.restore();
}
drawGua(cx, cy, width, lineWidth, bits, alpha, glow = 0) {
const gap = lineWidth * 1.7;
const top = cy - (bits.length - 1) * gap / 2;
bits.forEach((bit, index) => {
this.drawYao(cx, top + (bits.length - 1 - index) * gap, width, lineWidth, bit === 1, alpha, glow);
});
}
stageAlpha(time, start, end, fade = 300, hold = false) {
const enter = smooth(start, start + fade, time);
return hold ? enter : enter * (1 - smooth(end - fade, end, time));
}
fortuneStages() {
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
return [
[0, 2100, "五 运", "木火土金水,五运相袭,周而复始"],
[2100, 3900, "十 干 化 运", "甲己土 · 乙庚金 · 丙辛水 · 丁壬木 · 戊癸火"],
[3900, 5800, "十 二 支 化 气", "子午少阴 · 丑未太阴 · 寅申少阳 · 卯酉阳明 · 辰戌太阳 · 巳亥厥阴"],
[5800, 7900, "六 气 环 布", "风寒暑湿燥火,分主六步,以应岁时"],
[7900, 11000, "岁 运 合 参", `${pillar}年 · 中运${movement} · ${sitian}司天`],
[11000, FORTUNE_TOTAL, "归 一", "谨守病机,无失气宜"],
];
}
drawFooter(time, now, total, stages, scene) {
const { context: ctx, width, height } = this;
const stage = [...stages].reverse().find((item) => time >= item[0]) || stages[0];
const labelAlpha = smooth(stage[0], stage[0] + 300, time)
* (1 - smooth(stage[1] - 250, stage[1], time));
this.label(stage[2], width / 2, height - 108, 19, GOLD, 0.55 + 0.45 * labelAlpha, "600");
this.label(stage[3], width / 2, height - 84, 12.5, DIM, (0.4 + 0.4 * labelAlpha) * (scene === "fortune" ? 0.85 : 0.8), "", width - 32);
const baseSlotWidth = 34;
const baseSlotHeight = 5;
const baseSlotGap = 12;
const baseTotalWidth = baseSlotWidth * 6 + baseSlotGap * 5;
const fit = Math.min(1, (width - 28) / baseTotalWidth);
const slotWidth = baseSlotWidth * fit;
const slotHeight = baseSlotHeight * fit;
const slotGap = baseSlotGap * fit;
const totalWidth = slotWidth * 6 + slotGap * 5;
const filled = Math.min(6, Math.floor(time / (total / 6)));
for (let index = 0; index < 6; index += 1) {
const x = width / 2 - totalWidth / 2 + index * (slotWidth + slotGap);
const y = height - 56;
const color = scene === "fortune" ? ELEMENT_COLORS[QI6[index].element] : GOLD;
ctx.save();
ctx.globalAlpha = 0.16;
ctx.strokeStyle = GOLD;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, slotWidth, slotHeight);
ctx.restore();
if (index < filled) {
ctx.save();
ctx.globalAlpha = 0.9;
ctx.fillStyle = color;
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.fillRect(x, y, slotWidth, slotHeight);
ctx.restore();
} else if (index === filled) {
ctx.save();
ctx.globalAlpha = 0.35 + 0.3 * Math.sin(now / 200);
ctx.fillStyle = color;
const progress = (time % (total / 6)) / (total / 6);
ctx.fillRect(x, y, slotWidth * progress, slotHeight);
ctx.restore();
}
}
const dots = ".".repeat(1 + Math.floor(now / 450) % 3);
const loadingText = scene === "fortune" ? "推 演 运 气 · 加 载 中" : "推 演 天 机 · 加 载 中";
this.label(`${loadingText}${dots}`, width / 2, height - 32, 13, GOLD, 0.75);
}
drawTrigramRing(cx, cy, radius, width, lineWidth, alpha, now, entering, time) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
const breath = 1 + 0.006 * Math.sin(now / 620);
TRIGRAMS.forEach((trigram, index) => {
const progress = entering ? easeOut((time - 4700 - index * 130) / 700) : 1;
if (progress <= 0) return;
const [x, y] = point(cx, cy, radius * breath * progress, trigram.angle);
this.drawGua(x, y, width, lineWidth, trigram.bits, alpha * progress, alpha * progress * 8);
const nameAlpha = entering ? alpha * clamp01((time - 4700 - index * 130 - 480) / 500) : alpha;
this.label(trigram.name, x, y + lineWidth * 5.2, 13, GOLD, nameAlpha * (0.55 + 0.2 * Math.sin(now / 700 + index)));
});
}
drawHexagram(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const scale = Math.min(width, Math.max(1, height - 150));
if (time < 1800) {
const alpha = this.stageAlpha(time, 0, 1800);
this.node(cx, cy, 5.5 * (1 + 0.12 * Math.sin(now / 260)), GOLD_BRIGHT, alpha, 34);
for (let ring = 0; ring < 3; ring += 1) {
const progress = ((now / 1500) + ring / 3) % 1;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = (1 - progress) * 0.22 * alpha;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, 8 + progress * scale * 0.13, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
if (time >= 1800 && time < 3300) {
const alpha = this.stageAlpha(time, 1800, 3300);
const progress = easeOut((time - 1850) / 850);
const yaoWidth = scale * 0.19 * progress;
const yaoLine = Math.max(scale * 0.013, 5);
this.drawYao(cx, cy - yaoLine * 2.6, yaoWidth, yaoLine, true, alpha, 14);
this.drawYao(cx, cy + yaoLine * 2.6, yaoWidth, yaoLine, false, alpha, 14);
this.node(cx, cy, 4, GOLD_BRIGHT, alpha * (1 - progress) * 0.9);
}
if (time >= 3300 && time < 4700) {
const alpha = this.stageAlpha(time, 3300, 4700);
const distance = scale * 0.085;
const yaoWidth = Math.max(scale * 0.055, 28);
const yaoLine = Math.max(scale * 0.009, 3.5);
SIXIANG.forEach((symbol, index) => {
const progress = easeOut((time - 3330 - index * 160) / 520);
if (progress <= 0) return;
const x = cx + symbol.dx * distance;
const y = cy + symbol.dy * distance;
this.drawGua(x, y, yaoWidth * progress, yaoLine, symbol.bits, alpha * progress, 10);
this.label(symbol.name, x, y + yaoLine * 5.4, 12, GOLD, alpha * progress * 0.55);
});
}
const trigramRadius = scale * 0.215;
const trigramWidth = Math.max(scale * 0.052, 26);
const trigramLine = Math.max(scale * 0.0075, 3);
if (time >= 4700 && time < 6800) {
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth, trigramLine, this.stageAlpha(time, 4700, 6800), now, true, time);
}
if (time >= 6800 && time < 10800) {
const alpha = this.stageAlpha(time, 6800, 10800, 350);
this.drawTrigramRing(cx, cy, trigramRadius, trigramWidth * 0.85, trigramLine * 0.85, alpha * 0.42, now, false, time);
const ringRadius = scale * 0.365;
const hexWidth = Math.max(scale * 0.026, 13);
const hexLine = Math.max(scale * 0.0042, 1.6);
const count = Math.floor(clamp01((time - 7000) / 3600) * 64);
for (let index = 0; index < 64; index += 1) {
const [x, y] = point(cx, cy, ringRadius, -90 + index * 360 / 64);
this.node(x, y, 1.4, GOLD, alpha * 0.14);
if (index < count) {
const freshness = Math.max(0, 1 - (count - 1 - index) / 5);
if (freshness > 0) {
const ctx = this.context;
const gradient = ctx.createLinearGradient(cx, cy, x, y);
gradient.addColorStop(0, "rgba(230,195,122,0)");
gradient.addColorStop(1, GOLD);
this.line(cx, cy, x, y, gradient, alpha * freshness * 0.35);
}
this.drawGua(x, y, hexWidth, hexLine, hexBits(index), alpha * (0.55 + 0.45 * freshness), freshness * 9);
}
}
if (count > 0) {
const current = count - 1;
const popTime = clamp01((time - (7000 + current * 3600 / 64)) / 130);
const pop = 1 + 0.22 * (1 - popTime);
this.drawGua(cx, cy - scale * 0.028, scale * 0.085 * pop, Math.max(scale * 0.011, 4.5), hexBits(current), alpha, 16);
this.label(HEXAGRAM_NAMES[current], cx, cy + scale * 0.062, Math.max(20, scale * 0.042), GOLD_BRIGHT, alpha, "600");
this.label(`${current + 1}`, cx, cy + scale * 0.105, 13, GOLD, alpha * 0.55);
}
}
if (time >= 10800) {
const alpha = this.stageAlpha(time, 10800, HEX_TOTAL, 420);
const progress = easeOut((time - 10850) / 1150);
const radius = scale * 0.365 * (1 - progress);
for (let index = 0; index < 64 && radius >= 8; index += 1) {
const [x, y] = point(cx, cy, radius, -90 + index * 360 / 64);
this.drawGua(x, y, Math.max(scale * 0.026, 13), Math.max(scale * 0.0042, 1.6), hexBits(index), (1 - progress) * 0.7 * alpha);
}
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, HEX_TOTAL, HEX_STAGES, "hexagram");
}
drawFortune(time, now) {
const { width, height } = this;
const cx = width / 2;
const cy = height * 0.4;
const scale = Math.min(width, Math.max(1, height - 150));
if (time < 2100) this.drawFiveMovements(time, now, cx, cy, scale);
if (time >= 2100 && time < 3900) this.drawStems(time, cx, cy, scale);
if (time >= 3900 && time < 5800) this.drawBranches(time, cx, cy, scale);
if (time >= 5800 && time < 7900) this.drawSixQi(time, now, cx, cy, scale);
if (time >= 7900 && time < 11000) this.drawAnnualQi(time, now, cx, cy, scale);
if (time >= 11000) {
const alpha = this.stageAlpha(time, 11000, FORTUNE_TOTAL, 420);
const progress = easeOut((time - 11050) / 1200);
const radius = scale * 0.30 * (1 - progress);
QI6.forEach((qi, index) => {
const [x, y] = point(cx, cy, radius, -90 + index * 60);
if (radius > 8) this.node(x, y, Math.max(scale * 0.011, 6), ELEMENT_COLORS[qi.element], (1 - progress) * 0.8 * alpha, 8);
});
this.node(cx, cy, 3 + progress * 6, GOLD_BRIGHT, alpha * (0.3 + 0.7 * progress), 12 + progress * 40);
}
this.drawFooter(time, now, FORTUNE_TOTAL, this.fortuneStages(), "fortune");
}
drawFiveMovements(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 0, 2100);
const radius = scale * 0.17;
const nodeRadius = Math.max(scale * 0.018, 9);
const elements = [
["木", 180], ["火", -90], ["金", 0], ["水", 90], ["土", null],
];
const positions = {};
this.node(cx, cy, 5 + 1.5 * Math.sin(now / 260), GOLD_BRIGHT, alpha * (1 - easeOut((time - 200) / 800)), 30);
elements.forEach(([element, degrees], index) => {
const progress = easeOut((time - 500 - index * 170) / 500);
if (progress <= 0) return;
const x = degrees === null ? cx : cx + Math.cos(degrees * Math.PI / 180) * radius * progress;
const y = degrees === null ? cy : cy + Math.sin(degrees * Math.PI / 180) * radius * progress;
positions[element] = [x, y];
this.node(x, y, nodeRadius * progress, ELEMENT_COLORS[element], alpha * progress, 16);
this.label(element, x, y + 0.5, Math.round(nodeRadius * 1.15), NODE_TEXT, alpha * progress, "600");
const direction = element === "土" ? "中央土" : { : "东方木", : "南方火", : "西方金", : "北方水" }[element];
this.label(direction, x, y + nodeRadius + 14, 12, ELEMENT_COLORS[element], alpha * progress * 0.75);
});
const order = ["木", "火", "土", "金", "水"];
order.forEach((element, index) => {
const from = positions[element];
const to = positions[order[(index + 1) % order.length]];
if (!from || !to) return;
const progress = smooth(1450 + index * 130, 1700 + index * 130, time);
const mx = (from[0] + to[0]) / 2 + (cx - (from[0] + to[0]) / 2) * 0.25;
const my = (from[1] + to[1]) / 2 + (cy - (from[1] + to[1]) / 2) * 0.25;
this.curvedArrow(from[0], from[1], to[0], to[1], mx, my, GOLD, alpha * progress * 0.4);
});
}
drawStems(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 2100, 3900);
const stems = "甲乙丙丁戊己庚辛壬癸";
const movements = ["土", "金", "水", "木", "火"];
const radius = scale * 0.30;
for (let index = 0; index < 10; index += 1) {
const progress = smooth(2150 + index * 90, 2450 + index * 90, time);
if (progress <= 0) continue;
const [x, y] = point(cx, cy, radius, -90 + index * 36);
const element = movements[index % 5];
this.node(x, y, 3, ELEMENT_COLORS[element], alpha * progress, 8);
this.label(stems[index], x, y - 14, 15, ELEMENT_COLORS[element], alpha * progress, "600");
}
for (let index = 0; index < 5; index += 1) {
const progress = smooth(3150 + index * 110, 3450 + index * 110, time);
const angle = -90 + index * 36;
const [x1, y1] = point(cx, cy, radius, angle);
const [x2, y2] = point(cx, cy, radius, -90 + (index + 5) * 36);
this.line(x1, y1, x2, y2, ELEMENT_COLORS[movements[index]], alpha * progress * 0.45);
const [labelX, labelY] = point(cx, cy, scale * 0.055, angle + 90);
this.label(movements[index], labelX, labelY, 16, ELEMENT_COLORS[movements[index]], alpha * progress, "600");
}
}
drawBranches(time, cx, cy, scale) {
const alpha = this.stageAlpha(time, 3900, 5800);
const branches = "子丑寅卯辰巳午未申酉戌亥";
const qiNames = ["少阴君火", "太阴湿土", "少阳相火", "阳明燥金", "太阳寒水", "厥阴风木"];
const radius = scale * 0.31;
const branchAngle = (index) => -90 + ((index - 6 + 12) % 12) * 30;
for (let index = 0; index < 12; index += 1) {
const progress = smooth(3950 + index * 70, 4220 + index * 70, time);
const [x, y] = point(cx, cy, radius, branchAngle(index));
this.node(x, y, 2.5, GOLD, alpha * progress, 6);
this.label(branches[index], x, y - 13, 14, GOLD, alpha * progress * 0.9);
}
qiNames.forEach((name, index) => {
const progress = smooth(4900 + index * 130, 5200 + index * 130, time);
const [x1, y1] = point(cx, cy, radius, branchAngle(index));
const [x2, y2] = point(cx, cy, radius, branchAngle(index + 6));
const element = QI6.find((item) => item.name === name)?.element || "土";
this.line(x1, y1, x2, y2, ELEMENT_COLORS[element], alpha * progress * 0.4);
const [labelX, labelY] = point(cx, cy, radius + scale * 0.055, branchAngle(index));
this.label(name, labelX, labelY, 12, ELEMENT_COLORS[element], alpha * progress, "600");
});
}
drawSixQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 5800, 7900);
const radius = scale * 0.27;
const drift = now * 0.004;
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * 0.13;
ctx.strokeStyle = GOLD;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
QI6.forEach((qi, index) => {
const progress = easeOut((time - 5850 - index * 180) / 550);
const [x, y] = point(cx, cy, radius * progress, -90 + index * 60 + drift);
const nodeRadius = Math.max(scale * 0.015, 8) * progress;
this.node(x, y, nodeRadius, ELEMENT_COLORS[qi.element], alpha * progress, 14);
this.label(qi.name, x, y - nodeRadius - 12, 13, ELEMENT_COLORS[qi.element], alpha * progress, "600");
this.label(["初之气", "二之气", "三之气", "四之气", "五之气", "终之气"][index], x, y + nodeRadius + 12, 10.5, DIM, alpha * progress * 0.9);
});
this.node(cx, cy, 4 + Math.sin(now / 300), GOLD_BRIGHT, alpha * 0.9, 24);
}
drawAnnualQi(time, now, cx, cy, scale) {
const alpha = this.stageAlpha(time, 7900, 11000, 350);
const sixQi = this.data.sixQi || {};
const pillar = this.data.yearPillar || "岁运";
const movement = this.data.movement || "中运合参";
const sitian = sixQi.sitian || "司天气候";
const zaiquan = sixQi.zaiquan || "在泉气化";
const currentStep = Math.max(1, Math.min(6, Number(sixQi.step) || 1));
const qiElement = (name) => QI6.find((item) => item.name === name)?.element || "土";
const movementElement = ["木", "火", "土", "金", "水"].find((element) => movement.includes(element)) || "土";
this.label("司 天", cx, cy - scale * 0.212, 11, DIM, alpha * smooth(7950, 8450, time));
this.label(sitian, cx, cy - scale * 0.178, 17, ELEMENT_COLORS[qiElement(sitian)], alpha * smooth(7950, 8450, time), "600");
this.label(zaiquan, cx, cy + scale * 0.178, 17, ELEMENT_COLORS[qiElement(zaiquan)], alpha * smooth(8200, 8700, time), "600");
this.label("在 泉", cx, cy + scale * 0.212, 11, DIM, alpha * smooth(8200, 8700, time));
this.label(pillar, cx, cy - scale * 0.012, Math.max(22, scale * 0.052), GOLD_BRIGHT, alpha * smooth(8500, 9100, time), "600");
this.label(`${pillar}年 · 中运${movement}`, cx, cy + scale * 0.052, 14, ELEMENT_COLORS[movementElement], alpha * smooth(8500, 9100, time), "600", scale * 0.62);
const radius = scale * 0.30;
QI6.forEach((qi, index) => {
const progress = smooth(9200 + index * 260, 9480 + index * 260, time);
const [x, y] = point(cx, cy, radius, -90 + index * 60);
const current = index + 1 === currentStep;
const pulse = current ? 0.5 + 0.5 * Math.sin(now / 230) : 0;
this.node(x, y, Math.max(scale * 0.011, 6) + (current ? 2.5 : 0), ELEMENT_COLORS[qi.element], alpha * progress, 12 + pulse * 14);
if (current) {
const ctx = this.context;
ctx.save();
ctx.globalAlpha = alpha * (0.35 + pulse * 0.35);
ctx.strokeStyle = CINNABAR;
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(x, y, Math.max(scale * 0.02, 11) + pulse * 3, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
this.label("当今", x, y - Math.max(scale * 0.038, 21), 10.5, CINNABAR, alpha * progress, "600");
}
const stepName = `${index + 1 === 6 ? "终" : ["初", "二", "三", "四", "五"][index]}之气`;
this.label(`${stepName} · ${qi.name}`, x, y + Math.max(scale * 0.03, 17), 11.5, current ? GOLD_BRIGHT : ELEMENT_COLORS[qi.element], alpha * progress * (current ? 1 : 0.85), current ? "600" : "");
if (current) this.label(STEP_RANGES[index], x, y + Math.max(scale * 0.052, 33), 10, DIM, alpha * progress);
});
}
drawCompletion(progress, now) {
this.drawBackground(now);
if (this.scene === "fortune") {
this.drawFortune(11000 + progress * (FORTUNE_TOTAL - 11000), now);
} else {
this.drawHexagram(10800 + progress * (HEX_TOTAL - 10800), now);
}
}
}
global.HeavenLoadingCanvas = HeavenLoadingCanvas;
})(window);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
window.XiaobaiPageModules.register("ladder", ["ladderView"]);
/* PRESERVATION-SOURCE-BEGIN app.js:2125-2216 */
function renderLadderMini(ladders) {
const container = document.querySelector("#ladderMini");
const highest = ladders.length ? Math.max(...ladders.map((item) => number(item.level))) : 0;
setText("maxHeight", highest ? `最高 ${highest}` : "暂无");
container.innerHTML = ladders.slice(0, 5).map((group) => {
const allNames = group.stocks.map((stock) => stock.name).filter(Boolean);
const visibleNames = allNames.slice(0, 3).join("、");
const suffix = allNames.length > 3 ? ` <em>等 ${number(group.count)} 只</em>` : "";
return `<div class="pool-side-group">
<div><strong>${escapeHtml(group.label)}</strong><small>${number(group.count)} 只</small></div>
<p title="${escapeHtml(allNames.join("、"))}">${escapeHtml(visibleNames || "--")}${suffix}</p>
</div>`;
}).join("") || emptyStateHtml("暂无梯队数据");
}
function renderSectorMini(sectors) {
document.querySelector("#sectorMini").innerHTML = sectors.slice(0, 7).map((sector) => `
<div class="pool-hot-row"><strong title="${escapeHtml(sector.name)}">${escapeHtml(sector.name)}</strong><span>${number(sector.count)}</span></div>
`).join("") || emptyStateHtml("暂无板块数据");
}
function renderLadderBoard(ladders) {
const container = document.querySelector("#ladderBoard");
const insights = document.querySelector("#ladderInsights");
const ordered = [...ladders].sort((left, right) => number(right.level) - number(left.level));
const maxLevel = ordered.length ? Math.max(...ordered.map((group) => number(group.level))) : 0;
const topVisibleLevel = Math.max(5, maxLevel);
const groupMap = new Map(ordered.map((group) => [number(group.level), group]));
const displayGroups = Array.from({ length: topVisibleLevel }, (_, index) => {
const level = topVisibleLevel - index;
return groupMap.get(level) || { level, label: level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}`, count: 0, stocks: [] };
});
const total = ordered.reduce((sum, group) => sum + number(group.count), 0);
const spaceStocks = ordered.find((group) => number(group.level) === maxLevel)?.stocks || [];
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("ladderDateRange", `数据日期 ${currentDate}`);
container.innerHTML = displayGroups.map((group) => {
const level = number(group.level);
const limit = level === 1 || level === 2 ? 8 : 99;
const expanded = state.expandedLadderLevels.has(level);
const groupStocks = [...(group.stocks || [])].sort((left, right) => {
if (state.ladderSortMode === "open") {
return number(left.open_times) - number(right.open_times)
|| String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
}
return String(left.first_time || "99:99:99").localeCompare(String(right.first_time || "99:99:99"));
});
const stocks = expanded ? groupStocks : groupStocks.slice(0, limit);
const remaining = Math.max(0, groupStocks.length - stocks.length);
const label = group.label || (level === 1 ? "首板" : level === 5 && maxLevel < 5 ? "5板+" : `${level}`);
const color = { 1: "#2563eb", 2: "#16a34a", 3: "#d97706", 4: "#e04536" }[level] || "#9ca3af";
return `
<section class="market-ladder-tier ${number(group.count) ? "" : "is-gap"}" data-ladder-level-card="${level}">
<div class="market-ladder-label" style="--tier-color:${color}"><div class="market-ladder-level"><span class="market-ladder-dot"></span>${escapeHtml(label)}</div><div class="market-ladder-count">${number(group.count)} 只</div>${number(group.count) && level > 1 ? `<div class="market-ladder-rate">${escapeHtml(label)} · <b>${formatNumber(number(group.count) / Math.max(number(groupMap.get(level - 1)?.count), 1) * 100, 1)}%</b></div>` : ""}</div>
<div class="market-ladder-stocks">${stocks.length ? stocks.map((stock) => {
const onePrice = String(stock.first_time || "").startsWith("09:25") && number(stock.open_times) === 0;
const broken = number(stock.open_times) >= 6;
const amount = number(stock.seal_amount_million) ? `封单 ${formatNumber(stock.seal_amount_million, 0)}` : `成交 ${formatNumber(stock.amount_billion, 1)} 亿`;
return `<button type="button" class="market-ladder-stock" data-code="${escapeHtml(stock.code)}" aria-label="查看 ${escapeHtml(stock.name)} ${escapeHtml(stock.code)}详情">
<span class="market-ladder-stock-first"><strong>${escapeHtml(stock.name)}</strong><small class="stock-code">${escapeHtml(stock.code)}</small><span class="market-ladder-tags">${onePrice ? '<em class="market-ladder-tag one-price">一字</em>' : ""}${broken ? `<em class="market-ladder-tag broken">烂板×${number(stock.open_times)}</em>` : ""}</span></span>
<span class="market-ladder-stock-second"><b>${escapeHtml(stock.sector || stock.reason || "其他")}</b><small>${stock.first_time && stock.first_time !== "--" ? escapeHtml(stock.first_time) : "时间待校正"}</small><small>${amount}</small></span>
</button>`;
}).join("") : `<div class="market-ladder-gap-note">${level >= maxLevel ? `断层 · ${escapeHtml(label)}及以上空缺` : "该层暂时空缺"}</div>`}${groupStocks.length > limit ? `<button class="market-ladder-more" type="button" data-ladder-level="${level}">${expanded ? "收起" : `展开剩余 ${remaining} 只`}<i data-lucide="chevron-${expanded ? "up" : "down"}"></i></button>` : ""}</div>
</section>`;
}).join("");
const structureRows = displayGroups.filter((group) => number(group.count) || number(group.level) <= maxLevel + 1);
const maxCount = Math.max(1, ...structureRows.map((group) => number(group.count)));
const rateRows = (state.dashboard?.limit_performance || []).map((row) => ({
label: `${row.label || (number(row.level) === 1 ? "昨日首板" : `昨日${number(row.level)}`)} → 今日`,
value: clamp(number(row.advance_rate), 0, 100),
}));
const previousMax = Math.max(0, ...(state.dashboard?.yesterday_limits || []).map((row) => number(row.prior_streak)));
const spaceChange = previousMax && maxLevel < previousMax ? `较昨日 ${previousMax} 板 ↓ 空间压缩` : previousMax && maxLevel > previousMax ? `较昨日 ${previousMax} 板 ↑ 高度抬升` : "高度与昨日接近";
const spaceNote = maxLevel >= 5 ? "高位梯队仍有辨识度,重点观察承接而非单看高度。" : maxLevel >= 3 ? "空间位于中段,梯队延续性比绝对高度更重要。" : "高度受到压缩,先观察首板向二板的结构修复。";
const strongestGroup = structureRows.reduce((best, group) => number(group.count) > number(best?.count) ? group : best, structureRows[0]);
insights.innerHTML = `
<section class="market-ladder-insight-card market-ladder-apex-card"><header><h3>空间板</h3><span>市场高度</span></header><div class="market-ladder-apex"><div><strong>${maxLevel ? `${maxLevel}` : "--"}</strong><em>${escapeHtml(spaceChange)}</em></div><p>${spaceStocks.length ? spaceStocks.map((stock) => `<b>${escapeHtml(stock.name)}</b>${escapeHtml(stock.sector || "其他")}`).join(" · ") : "暂无空间板"}</p></div><p>${spaceNote}</p></section>
<section class="market-ladder-insight-card"><header><h3>梯队结构</h3><span>完整度</span></header><div class="market-ladder-pyramid">${structureRows.map((group) => `<div class="market-ladder-pyramid-row ${number(group.count) ? "" : "is-gap"}"><span>${escapeHtml(group.label || `${number(group.level)}`)}</span><i><b style="width:${Math.max(number(group.count) ? 8 : 100, number(group.count) / maxCount * 100)}%"></b></i><strong>${number(group.count) ? `${number(group.count)}` : "断层"}</strong></div>`).join("")}</div><p>断层越少,梯队从低位向高位传导越连贯。当前腰部为 <b>${escapeHtml(strongestGroup?.label || "--")}</b>。</p></section>
<section class="market-ladder-insight-card"><header><h3>晋级率参考</h3><span>昨日梯队 → 今日</span></header><div class="market-ladder-rate-list">${rateRows.length ? rateRows.map((row) => `<div><span>${escapeHtml(row.label)}</span><i><b class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}" style="width:${Math.max(row.value, row.value > 0 ? 2 : 0)}%"></b></i><strong class="${row.value === 0 ? "is-zero" : row.value < 20 ? "is-low" : ""}">${formatNumber(row.value, 1)}%</strong></div>`).join("") : '<div class="empty-state">暂无可比梯队</div>'}</div><small class="market-ladder-source">数据来自“涨停表现”页 · 昨日梯队样本</small></section>`;
container.querySelectorAll("[data-ladder-level]").forEach((button) => {
button.addEventListener("click", () => {
const level = number(button.dataset.ladderLevel);
if (state.expandedLadderLevels.has(level)) state.expandedLadderLevels.delete(level);
else state.expandedLadderLevels.add(level);
renderLadderBoard(state.dashboard?.ladders || []);
});
});
bindStockRows(container);
refreshIcons();
}
/* PRESERVATION-SOURCE-END app.js:2125-2216 */
File diff suppressed because it is too large Load Diff
+497
View File
@@ -0,0 +1,497 @@
window.XiaobaiPageModules.register("mentor", ["mentorView"], {
enter: ["loadMentor"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:4337-4827 */
async function loadMentorSetup(force = false) {
const requestedDate = elements.tradeDate.value.replaceAll("-", "");
if (!force && state.mentorSetup?.requestedDate === requestedDate) {
renderMentorWorkspace();
return;
}
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/mentors/setup?${query}`);
payload.requestedDate = requestedDate;
if (!payload.preferences_configured) {
payload.mentors.sort((first, second) => {
if (Boolean(first.private) !== Boolean(second.private)) return first.private ? -1 : 1;
return String(first.name || "").localeCompare(String(second.name || ""), "zh-CN");
});
payload.mentors.forEach((mentor, index) => { mentor.sort_order = index; });
}
state.mentorSetup = payload;
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
state.mentorMessages = await loadMentorMessages();
renderMentorWorkspace();
} catch (error) {
showMentorNotice(error.message || "问师模块加载失败");
showToast(error.message || "问师模块加载失败");
}
}
function renderMentorWorkspace() {
const setup = state.mentorSetup;
if (!setup) return;
const selected = setup.mentors.find((item) => item.id === state.selectedMentorId) || null;
setText("mentorDataDate", `数据日期 ${displayCompactDate(setup.trade_date)}`);
setText("activeMentorName", selected?.name || "--");
setText("mobileActiveMentorName", selected?.name || "选择思维模型");
document.querySelector("#activeMentorBadges").innerHTML = selected ? renderMentorBadges(selected, true) : "";
setText("activeMentorEvidence", selected?.evidence?.note || selected?.description || "--");
document.querySelector("#activeMentorFocus").innerHTML = (selected?.focus || []).slice(0, 4)
.map((item) => `<span>${escapeHtml(item)}</span>`).join("");
renderMentorDirectory();
renderMentorMessages();
}
function renderMentorDirectory() {
const mentors = state.mentorSetup?.mentors || [];
const query = state.mentorQuery;
const filtered = mentors.filter((mentor) => {
if (state.mentorSortMode) return true;
if (state.mentorGrade !== "all" && mentor.evidence?.grade !== state.mentorGrade) return false;
if (!query) return true;
const haystack = [
mentor.name,
mentor.description,
mentor.tagline,
mentor.evidence?.label,
mentor.evidence?.note,
...(mentor.focus || []),
].filter(Boolean).join(" ").toLocaleLowerCase("zh-CN");
return haystack.includes(query);
});
setText("mentorCount", filtered.length === mentors.length ? `${mentors.length}` : `${filtered.length} / ${mentors.length}`);
const sortToggle = document.querySelector("#mentorSortToggle");
sortToggle.classList.toggle("active", state.mentorSortMode);
sortToggle.setAttribute("aria-pressed", String(state.mentorSortMode));
sortToggle.querySelector("span").textContent = state.mentorSortMode ? "完成" : "整理";
document.querySelector("#mentorSortHint").hidden = !state.mentorSortMode;
document.querySelector("#mentorSearchInput").disabled = state.mentorSortMode;
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.disabled = state.mentorSortMode;
});
const container = document.querySelector("#mentorList");
container.classList.toggle("is-sorting", state.mentorSortMode);
container.innerHTML = filtered.map((mentor) => {
const group = mentors.filter((item) => Boolean(item.pinned) === Boolean(mentor.pinned));
const groupIndex = group.findIndex((item) => item.id === mentor.id);
return `
<article class="mentor-option ${mentor.id === state.selectedMentorId ? "active" : ""} ${mentor.pinned ? "is-pinned" : ""}"
data-mentor-card="${escapeHtml(mentor.id)}" draggable="${state.mentorSortMode && !state.mentorSavingPreferences}">
<button type="button" class="mentor-option-main" data-mentor-id="${escapeHtml(mentor.id)}" aria-pressed="${mentor.id === state.selectedMentorId}" ${state.mentorLoading ? "disabled" : ""}>
<span class="mentor-option-copy">
<span class="mentor-option-heading">
<strong>${escapeHtml(mentor.name)}</strong>
<span class="mentor-option-badges">${renderMentorBadges(mentor)}</span>
</span>
<em title="${escapeHtml(mentor.description || "")}">${escapeHtml(mentor.description || mentor.tagline || "思维模型")}</em>
<span class="mentor-option-meta">
${mentor.evidence?.label ? `<span class="mentor-evidence-source" title="${escapeHtml(mentor.evidence?.note || "素材说明")}">${escapeHtml(mentor.evidence.label)}</span>` : ""}
${(mentor.focus || []).slice(0, 2).map((item) => `<span>#${escapeHtml(item)}</span>`).join("")}
</span>
</span>
</button>
<span class="mentor-option-tools">
<button type="button" class="mentor-pin-button ${mentor.pinned ? "active" : ""}" data-mentor-pin="${escapeHtml(mentor.id)}"
aria-label="${mentor.pinned ? "取消置顶" : "置顶"}${escapeHtml(mentor.name)}" title="${mentor.pinned ? "取消置顶" : "置顶"}" ${state.mentorSavingPreferences ? "disabled" : ""}>
<i data-lucide="pin"></i>
</button>
${state.mentorSortMode ? `
<button type="button" class="mentor-order-button" data-mentor-move="up" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="上移${escapeHtml(mentor.name)}" title="上移" ${groupIndex <= 0 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-up"></i></button>
<button type="button" class="mentor-order-button" data-mentor-move="down" data-mentor-target="${escapeHtml(mentor.id)}" aria-label="下移${escapeHtml(mentor.name)}" title="下移" ${groupIndex >= group.length - 1 || state.mentorSavingPreferences ? "disabled" : ""}><i data-lucide="chevron-down"></i></button>
` : ""}
</span>
</article>
`;
}).join("");
document.querySelector("#mentorListEmpty").hidden = filtered.length > 0;
document.querySelectorAll("[data-mentor-id]").forEach((button) => {
button.addEventListener("click", () => selectMentor(button.dataset.mentorId));
});
document.querySelectorAll("[data-mentor-pin]").forEach((button) => {
button.addEventListener("click", () => toggleMentorPin(button.dataset.mentorPin));
});
document.querySelectorAll("[data-mentor-move]").forEach((button) => {
button.addEventListener("click", () => moveMentor(button.dataset.mentorTarget, button.dataset.mentorMove));
});
document.querySelectorAll("[data-mentor-card]").forEach((card) => {
card.addEventListener("dragstart", handleMentorDragStart);
card.addEventListener("dragover", handleMentorDragOver);
card.addEventListener("drop", handleMentorDrop);
card.addEventListener("dragend", clearMentorDragState);
});
refreshIcons();
}
function toggleMentorSortMode() {
state.mentorSortMode = !state.mentorSortMode;
if (state.mentorSortMode) {
state.mentorQuery = "";
state.mentorGrade = "all";
document.querySelector("#mentorSearchInput").value = "";
document.querySelectorAll("[data-mentor-grade]").forEach((button) => {
button.classList.toggle("active", button.dataset.mentorGrade === "all");
});
}
renderMentorDirectory();
}
async function toggleMentorPin(mentorId) {
if (state.mentorSavingPreferences) return;
const mentors = state.mentorSetup?.mentors || [];
const index = mentors.findIndex((item) => item.id === mentorId);
if (index < 0) return;
const [mentor] = mentors.splice(index, 1);
mentor.pinned = !mentor.pinned;
if (mentor.pinned) {
mentors.unshift(mentor);
} else {
const firstUnpinned = mentors.findIndex((item) => !item.pinned);
mentors.splice(firstUnpinned < 0 ? mentors.length : firstUnpinned, 0, mentor);
}
normalizeMentorOrder();
renderMentorWorkspace();
await persistMentorPreferences();
}
async function moveMentor(mentorId, direction) {
if (state.mentorSavingPreferences) return;
const mentors = state.mentorSetup?.mentors || [];
const index = mentors.findIndex((item) => item.id === mentorId);
if (index < 0) return;
const step = direction === "up" ? -1 : 1;
const targetIndex = index + step;
if (targetIndex < 0 || targetIndex >= mentors.length) return;
if (Boolean(mentors[index].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
[mentors[index], mentors[targetIndex]] = [mentors[targetIndex], mentors[index]];
normalizeMentorOrder();
renderMentorDirectory();
await persistMentorPreferences();
}
function handleMentorDragStart(event) {
if (!state.mentorSortMode || state.mentorSavingPreferences) {
event.preventDefault();
return;
}
state.mentorDragId = event.currentTarget.dataset.mentorCard || "";
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", state.mentorDragId);
event.currentTarget.classList.add("is-dragging");
}
function handleMentorDragOver(event) {
const source = state.mentorSetup?.mentors.find((item) => item.id === state.mentorDragId);
const target = state.mentorSetup?.mentors.find((item) => item.id === event.currentTarget.dataset.mentorCard);
if (!source || !target || Boolean(source.pinned) !== Boolean(target.pinned)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
event.currentTarget.classList.add("is-drag-over");
}
async function handleMentorDrop(event) {
event.preventDefault();
const sourceId = state.mentorDragId || event.dataTransfer.getData("text/plain");
const targetId = event.currentTarget.dataset.mentorCard || "";
clearMentorDragState();
if (!sourceId || !targetId || sourceId === targetId) return;
const mentors = state.mentorSetup?.mentors || [];
const sourceIndex = mentors.findIndex((item) => item.id === sourceId);
const targetIndex = mentors.findIndex((item) => item.id === targetId);
if (sourceIndex < 0 || targetIndex < 0) return;
if (Boolean(mentors[sourceIndex].pinned) !== Boolean(mentors[targetIndex].pinned)) return;
const [mentor] = mentors.splice(sourceIndex, 1);
const insertionIndex = mentors.findIndex((item) => item.id === targetId);
mentors.splice(insertionIndex, 0, mentor);
normalizeMentorOrder();
renderMentorDirectory();
await persistMentorPreferences();
}
function clearMentorDragState() {
state.mentorDragId = "";
document.querySelectorAll(".mentor-option.is-dragging, .mentor-option.is-drag-over").forEach((item) => {
item.classList.remove("is-dragging", "is-drag-over");
});
}
function normalizeMentorOrder() {
(state.mentorSetup?.mentors || []).forEach((mentor, index) => {
mentor.sort_order = index;
});
}
async function persistMentorPreferences() {
const mentors = state.mentorSetup?.mentors || [];
state.mentorSavingPreferences = true;
renderMentorDirectory();
try {
await apiRequest("/api/mentors/preferences", "POST", {
order: mentors.map((item) => item.id),
pinned: mentors.filter((item) => item.pinned).map((item) => item.id),
});
} catch (error) {
showToast(error.message || "问师顺序保存失败");
await loadMentorSetup(true);
} finally {
state.mentorSavingPreferences = false;
renderMentorDirectory();
}
}
function renderMentorBadges(mentor, expanded = false) {
const badges = [];
if (mentor.private) {
badges.push('<span class="mentor-badge private" title="仅管理员本人可见"><i data-lucide="lock-keyhole"></i>仅自己</span>');
}
const grade = mentor.evidence?.grade;
if (grade) {
badges.push(`<span class="mentor-badge evidence grade-${escapeHtml(grade.toLowerCase())}" title="${escapeHtml(mentor.evidence?.note || "素材等级")}">${escapeHtml(grade)}</span>`);
}
return badges.join("");
}
function toggleMentorDirectory(open) {
const mobileOpen = Boolean(open) && window.innerWidth <= 720;
state.mentorDirectoryOpen = mobileOpen;
const sidebar = document.querySelector("#mentorView .mentor-sidebar");
const backdrop = document.querySelector("#mentorDirectoryBackdrop");
const toggle = document.querySelector("#mentorDirectoryToggle");
sidebar.classList.toggle("is-open", mobileOpen);
backdrop.hidden = !mobileOpen;
toggle.setAttribute("aria-expanded", String(mobileOpen));
document.body.classList.toggle("mentor-directory-open", mobileOpen);
if (mobileOpen) requestAnimationFrame(() => document.querySelector("#mentorSearchInput").focus());
}
async function selectMentor(mentorId) {
if (mentorId === state.selectedMentorId) {
toggleMentorDirectory(false);
return;
}
state.selectedMentorId = mentorId;
state.mentorMessages = [];
hideMentorNotice();
renderMentorWorkspace();
toggleMentorDirectory(false);
state.mentorMessages = await loadMentorMessages();
renderMentorMessages();
}
function renderMentorMessages() {
const container = document.querySelector("#mentorMessages");
const selected = state.mentorSetup?.mentors.find((item) => item.id === state.selectedMentorId);
if (!state.mentorMessages.length && !state.mentorLoading) {
container.innerHTML = `
<div class="mentor-empty-state">
<span class="mentor-empty-mark" aria-hidden="true"><i data-lucide="messages-square"></i></span>
<strong>向「${escapeHtml(selected?.name || "问师")}」请教</strong>
<p>${escapeHtml(selected?.tagline || selected?.description || "选择一个问题开始对话")}</p>
</div>
`;
refreshIcons();
} else {
container.innerHTML = state.mentorMessages.map((message) => `
<article class="mentor-message ${message.role} ${message.error ? "is-error" : ""}">
<div class="mentor-message-label">${message.role === "user" ? "我" : escapeHtml(selected?.name || "问师")}</div>
<div class="mentor-message-content">${message.role === "assistant" ? formatMentorAnswer(message.content) : escapeHtml(message.content)}</div>
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
${message.meta && !message.streaming ? `<small>${escapeHtml(message.meta)}</small>` : ""}
</article>
`).join("");
if (state.mentorLoading && !state.mentorMessages.some((message) => message.streaming)) {
container.insertAdjacentHTML("beforeend", `
<article class="mentor-message assistant loading-message">
<div class="mentor-message-label">${escapeHtml(selected?.name || "问师")}</div>
<p>正在读取复盘数据并推演...</p>
</article>
`);
}
}
document.querySelector("#clearMentorChatButton").disabled = !state.mentorMessages.length || state.mentorLoading;
document.querySelector("#mentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
document.querySelector("#sendMentorQuestion").disabled = state.mentorLoading || !state.selectedMentorId;
document.querySelector("#mentorSortToggle").disabled = state.mentorLoading;
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
}
async function sendMentorQuestion(event) {
event.preventDefault();
if (state.mentorLoading || !state.selectedMentorId) return;
const input = document.querySelector("#mentorQuestion");
const question = input.value.trim();
if (!question) return;
const history = state.mentorMessages.slice(-6).map((item) => ({
role: item.role,
content: item.content.slice(0, 3500),
}));
state.mentorMessages.push({ role: "user", content: question });
const responseMessage = { role: "assistant", content: "", streaming: true, meta: "" };
state.mentorMessages.push(responseMessage);
input.value = "";
state.mentorLoading = true;
state.mentorController = new AbortController();
hideMentorNotice();
renderMentorMessages();
renderMentorDirectory();
setStatus("问师正在读取复盘数据");
try {
await streamMentorRequest(
{
mentor_id: state.selectedMentorId,
trade_date: elements.tradeDate.value,
question,
history,
},
state.mentorController.signal,
(chunk) => {
responseMessage.content += chunk;
scheduleMentorRender();
},
(meta) => {
responseMessage.meta = `${displayCompactDate(meta.data_trade_date || elements.tradeDate.value)} · 回答完成`;
if (meta.notice) showMentorNotice(meta.notice);
},
);
responseMessage.streaming = false;
setStatus("问师回答完成");
} catch (error) {
responseMessage.streaming = false;
responseMessage.error = true;
if (!responseMessage.content) {
state.mentorMessages = state.mentorMessages.filter((item) => item !== responseMessage);
}
showMentorNotice(error.message || "问师回答失败");
showToast(error.message || "问师回答失败");
setStatus("问师回答失败");
} finally {
state.mentorLoading = false;
state.mentorController = null;
renderMentorMessages();
renderMentorDirectory();
input.focus();
}
}
let mentorRenderFrame = 0;
function scheduleMentorRender() {
if (mentorRenderFrame) return;
mentorRenderFrame = requestAnimationFrame(() => {
mentorRenderFrame = 0;
renderMentorMessages();
});
}
async function streamMentorRequest(body, signal, onDelta, onMeta) {
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
method: "POST",
body,
signal,
errorMessage: "问师暂不可用",
onEvent: (event) => {
if (event.type === "delta") onDelta(String(event.content || ""));
if (event.type === "meta") onMeta(event);
},
});
}
function useMentorQuickPrompt(prompt) {
const input = document.querySelector("#mentorQuestion");
input.value = prompt || "";
input.focus();
}
async function clearMentorConversation() {
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
try {
const query = new URLSearchParams({
mentor_id: state.selectedMentorId,
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
});
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
state.mentorMessages = [];
hideMentorNotice();
renderMentorMessages();
} catch (error) {
showToast(error.message || "对话记录清空失败");
}
}
async function loadMentorMessages() {
if (!state.selectedMentorId) return [];
try {
const query = new URLSearchParams({
mentor_id: state.selectedMentorId,
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
});
const payload = await apiRequest(`/api/mentors/messages?${query}`);
return (payload.items || []).filter(
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
).slice(-100);
} catch (error) {
showMentorNotice(error.message || "对话记录加载失败");
return [];
}
}
function showMentorNotice(message) {
const notice = document.querySelector("#mentorNotice");
notice.textContent = message;
notice.hidden = false;
}
function hideMentorNotice() {
document.querySelector("#mentorNotice").hidden = true;
}
function formatMentorAnswer(content) {
const blocks = [];
let listType = "";
let listItems = [];
const flushList = () => {
if (!listItems.length) return;
blocks.push(`<${listType} class="mentor-answer-list">${listItems.map((item) => `<li>${item}</li>`).join("")}</${listType}>`);
listItems = [];
listType = "";
};
String(content || "").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").split("\n").forEach((rawLine) => {
const line = rawLine.trim();
if (!line) {
flushList();
return;
}
const heading = line.match(/^#{1,3}\s+(.+)$/);
const bullet = line.match(/^[-*]\s+(.+)$/);
const ordered = line.match(/^\d+[.、]\s*(.+)$/);
if (heading) {
flushList();
blocks.push(`<strong class="mentor-answer-heading">${formatMentorInline(escapeHtml(heading[1]))}</strong>`);
} else if (/^-{3,}$/.test(line)) {
flushList();
blocks.push('<span class="mentor-answer-rule"></span>');
} else if (line.startsWith("> ")) {
flushList();
blocks.push(`<span class="mentor-answer-quote">${formatMentorInline(escapeHtml(line.slice(2)))}</span>`);
} else if (bullet || ordered) {
const nextType = bullet ? "ul" : "ol";
if (listType && listType !== nextType) flushList();
listType = nextType;
listItems.push(formatMentorInline(escapeHtml((bullet || ordered)[1])));
} else {
flushList();
blocks.push(`<p class="mentor-answer-paragraph">${formatMentorInline(escapeHtml(line))}</p>`);
}
});
flushList();
return blocks.join("");
}
function formatMentorInline(content) {
return content.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
}
/* PRESERVATION-SOURCE-END app.js:4337-4827 */
+409
View File
@@ -0,0 +1,409 @@
window.XiaobaiPageModules.register("pools", [
"limitPool",
"brokenView",
"downView",
"yesterdayView",
"performanceView",
]);
/* PRESERVATION-SOURCE-BEGIN app.js:1517-1915 */
function getVisibleStocks() {
if (!state.dashboard) return [];
let rows = [...(state.dashboard.limits || [])];
if (state.filter === "1") rows = rows.filter((row) => number(row.streak) === 1);
if (state.filter === "2") rows = rows.filter((row) => number(row.streak) === 2);
if (state.filter === "3") rows = rows.filter((row) => number(row.streak) >= 3);
if (state.query) {
rows = rows.filter((row) => {
const haystack = `${row.code} ${row.name} ${row.sector} ${row.reason}`.toLowerCase();
return haystack.includes(state.query);
});
}
return rows.sort((left, right) => compareRows(left, right));
}
function renderLimitTable() {
if (!state.dashboard) return;
const rows = getVisibleStocks();
const allRows = state.dashboard.limits || [];
const body = document.querySelector("#limitTableBody");
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num"><span class="pool-streak-tag tag red">${streakLabel(row.streak)}</span></td>
<td class="number num up">${signed(row.change)}</td>
<td class="number num">${formatNumber(row.price, 2)}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
<td class="number num muted">${escapeHtml(row.last_time || "")}</td>
<td class="number num">${limitOpenState(row)}</td>
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
<td class="number num">${formatLimitSealAmount(row.seal_amount_million)}</td>
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
</tr>
`).join("");
bindStockRows(body);
setText("resultCount", `${rows.length}`);
setText("limitPoolSubtitle", `${allRows.length} 只 · 数据日期 ${displayCompactDate(state.dashboard.meta?.trade_date || elements.tradeDate.value)}`);
setText("limitAllCount", allRows.length);
setText("limitFirstCount", allRows.filter((row) => number(row.streak) === 1).length);
setText("limitSecondCount", allRows.filter((row) => number(row.streak) === 2).length);
setText("limitThreePlusCount", allRows.filter((row) => number(row.streak) >= 3).length);
document.querySelector("#emptyState").hidden = rows.length !== 0;
updateSortHeaders();
}
function limitOpenState(row) {
const openTimes = number(row.open_times);
const firstTime = String(row.first_time || "");
if (firstTime.startsWith("09:25") && openTimes === 0) return '<span class="pool-state-tag one-word">一字</span>';
if (openTimes >= 6) return `<span class="pool-state-tag broken">烂板×${openTimes}</span>`;
return String(openTimes);
}
function formatLimitSealAmount(value) {
const amount = number(value);
if (!amount) return "";
return Math.round(amount).toLocaleString("zh-CN");
}
function renderBrokenTable(rows) {
const visibleRows = getVisibleBrokenRows(rows);
setText("brokenCount", `${rows.length}`);
setText("brokenMeta", ` · 触及涨停后未能封住 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
const body = document.querySelector("#brokenTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num ${changeClass(row.change)}" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
<td class="number num broken-limit-gap" data-sort-value="${row.limitGap}">${formatNumber(row.limitGap, 2)}</td>
<td class="number num">${formatNumber(row.price, 2)}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="number num muted">${escapeHtml(row.first_time || "")}</td>
<td class="number num" data-sort-value="${number(row.open_times)}">${brokenOpenState(row)}</td>
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
</tr>
`).join("");
bindStockRows(body);
document.querySelector("#brokenEmptyState").hidden = visibleRows.length !== 0;
updateBrokenSortHeaders();
}
function getVisibleBrokenRows(rows = state.dashboard?.broken || []) {
let visibleRows = rows.map((row) => ({ ...row, limitGap: brokenLimitGap(row) }));
if (state.brokenQuery) {
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.brokenQuery));
}
if (!state.brokenSortKey) return visibleRows;
return visibleRows.sort((left, right) => {
const result = number(left[state.brokenSortKey]) - number(right[state.brokenSortKey]);
return state.brokenSortDirection === "asc" ? result : -result;
});
}
function brokenLimitRate(row) {
const name = String(row.name || "").toUpperCase();
const code = String(row.code || "").replace(/\D/g, "");
if (name.includes("ST")) return 10;
if (/^(300|301|688|689)/.test(code)) return 20;
if (/^(4|8|92)/.test(code)) return 30;
return 10;
}
function brokenLimitGap(row) {
return Math.max(0, brokenLimitRate(row) - number(row.change));
}
function brokenOpenState(row) {
const openTimes = number(row.open_times);
return openTimes >= 6
? `<span class="broken-repeat-tag">反复炸 ×${openTimes}</span>`
: String(openTimes);
}
function changeBrokenSort(key) {
if (state.brokenSortKey === key) state.brokenSortDirection = state.brokenSortDirection === "asc" ? "desc" : "asc";
else {
state.brokenSortKey = key;
state.brokenSortDirection = "desc";
}
renderBrokenTable(state.dashboard?.broken || []);
}
function updateBrokenSortHeaders() {
document.querySelectorAll("#brokenTable th[data-broken-sort]").forEach((header) => {
header.classList.remove("sort-asc", "sort-desc", "sorted");
header.setAttribute("aria-sort", "none");
if (header.dataset.brokenSort === state.brokenSortKey) {
header.classList.add(state.brokenSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
header.setAttribute("aria-sort", state.brokenSortDirection === "asc" ? "ascending" : "descending");
}
const arrow = header.querySelector(".arr");
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.brokenSortDirection === "asc" ? "▲" : "▼") : "↕";
});
}
function renderDownTable(rows) {
const visibleRows = getVisibleDownRows(rows);
setText("downCount", `${rows.length}`);
setText("downMeta", ` · 观察退潮、高位风险与亏钱效应 · 数据日期 ${displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value)}`);
renderDownSectorCluster(rows);
const body = document.querySelector("#downTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num down" data-sort-value="${number(row.change)}">${signed(row.change)}</td>
<td class="number num">${formatNumber(row.price, 2)}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="number num">${formatNumber(row.turnover_rate, 2)}</td>
<td class="number num">${formatNumber(row.amount_billion, 2)}</td>
<td class="number num">${number(row.streak) > 0 ? number(row.streak) : ""}</td>
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
</tr>
`).join("");
bindStockRows(body);
document.querySelector("#downEmptyState").hidden = visibleRows.length !== 0;
updateDownSortHeaders();
}
function getVisibleDownRows(rows = state.dashboard?.down_limits || []) {
let visibleRows = [...rows];
if (state.downQuery) {
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.downQuery));
}
if (!state.downSortKey) return visibleRows;
return visibleRows.sort((left, right) => {
const result = number(left[state.downSortKey]) - number(right[state.downSortKey]);
return state.downSortDirection === "asc" ? result : -result;
});
}
function renderDownSectorCluster(rows) {
const counts = new Map();
rows.forEach((row) => {
const sector = String(row.sector || "其他").trim() || "其他";
if (sector === "其他") return;
counts.set(sector, (counts.get(sector) || 0) + 1);
});
const cluster = [...counts.entries()].sort((left, right) => right[1] - left[1])[0];
const element = document.querySelector("#downSectorCluster");
element.hidden = !cluster || cluster[1] < 2;
element.textContent = cluster && cluster[1] >= 2 ? `${cluster[0]}集中跌停 ×${cluster[1]}` : "";
}
function changeDownSort(key) {
if (state.downSortKey === key) state.downSortDirection = state.downSortDirection === "asc" ? "desc" : "asc";
else {
state.downSortKey = key;
state.downSortDirection = "asc";
}
renderDownTable(state.dashboard?.down_limits || []);
}
function updateDownSortHeaders() {
document.querySelectorAll("#downTable th[data-down-sort]").forEach((header) => {
header.classList.remove("sort-asc", "sort-desc", "sorted");
header.setAttribute("aria-sort", "none");
if (header.dataset.downSort === state.downSortKey) {
header.classList.add(state.downSortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
header.setAttribute("aria-sort", state.downSortDirection === "asc" ? "ascending" : "descending");
}
const arrow = header.querySelector(".arr");
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.downSortDirection === "asc" ? "▲" : "▼") : "↕";
});
}
function renderYesterdayTable(rows) {
const visibleRows = getVisibleYesterdayRows(rows);
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("yesterdayCount", `${rows.length}`);
setText("yesterdayMeta", ` · 昨日 ${previousDate} → 今日 ${currentDate}`);
renderYesterdaySummary(rows);
const body = document.querySelector("#yesterdayTableBody");
body.innerHTML = visibleRows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}">
<td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num">${number(row.prior_streak)}</td>
<td class="number num ${changeClass(row.current_change)}">${signed(row.current_change)}</td>
<td><span class="yesterday-outcome-tag ${yesterdayOutcomeClass(row.outcome)}">${escapeHtml(row.outcome)}</span></td>
<td class="number num">${number(row.current_streak) ? `<span class="yesterday-height-tag">${number(row.current_streak)}</span>` : ""}</td>
<td>${escapeHtml(row.sector || "其他")}</td>
<td class="pool-reason-cell" title="${escapeHtml(row.reason || "")}">${escapeHtml(row.reason || "")}</td>
</tr>
`).join("");
bindStockRows(body);
document.querySelector("#yesterdayEmptyState").hidden = visibleRows.length !== 0;
updateYesterdayControls();
}
function getVisibleYesterdayRows(rows = state.dashboard?.yesterday_limits || []) {
let visibleRows = rows.filter((row) => {
if (state.yesterdayFilter === "advance") return row.outcome === "晋级";
if (state.yesterdayFilter === "positive") return number(row.current_change) > 0;
if (state.yesterdayFilter === "fail") return row.outcome === "断板";
if (state.yesterdayFilter === "risk") return ["炸板", "跌停"].includes(row.outcome);
return true;
});
if (state.yesterdayQuery) {
visibleRows = visibleRows.filter((row) => `${row.code} ${row.name} ${row.sector}`.toLowerCase().includes(state.yesterdayQuery));
}
if (!state.yesterdaySortKey) return visibleRows;
return visibleRows.sort((left, right) => {
const result = number(left[state.yesterdaySortKey]) - number(right[state.yesterdaySortKey]);
return state.yesterdaySortDirection === "asc" ? result : -result;
});
}
function renderYesterdaySummary(rows) {
const total = rows.length;
const advance = rows.filter((row) => row.outcome === "晋级").length;
const positive = rows.filter((row) => number(row.current_change) > 0).length;
const fail = rows.filter((row) => row.outcome === "断板").length;
const risk = rows.filter((row) => ["炸板", "跌停"].includes(row.outcome)).length;
const rate = (value) => total ? value / total * 100 : 0;
setText("yesterdayAllCount", total);
setText("yesterdayAdvanceCount", advance);
setText("yesterdayAdvanceRate", `晋级率 ${formatNumber(rate(advance), 1)}%`);
setText("yesterdayPositiveCount", positive);
setText("yesterdayPositiveRate", `兑现率 ${formatNumber(rate(positive), 1)}%`);
setText("yesterdayFailCount", fail);
setText("yesterdayFailRate", `${formatNumber(rate(fail), 1)}%`);
setText("yesterdayRiskCount", risk);
setText("yesterdayRiskRate", `亏钱效应 ${formatNumber(rate(risk), 1)}%`);
}
function yesterdayOutcomeClass(outcome) {
return { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" }[outcome] || "fail";
}
function changeYesterdaySort(key) {
if (state.yesterdaySortKey === key) state.yesterdaySortDirection = state.yesterdaySortDirection === "asc" ? "desc" : "asc";
else {
state.yesterdaySortKey = key;
state.yesterdaySortDirection = "desc";
}
renderYesterdayTable(state.dashboard?.yesterday_limits || []);
}
function updateYesterdayControls() {
document.querySelectorAll("[data-yesterday-filter]").forEach((button) => {
const active = button.dataset.yesterdayFilter === state.yesterdayFilter;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
document.querySelectorAll("#yesterdayTable th[data-yesterday-sort]").forEach((header) => {
header.classList.remove("sort-asc", "sort-desc", "sorted");
header.setAttribute("aria-sort", "none");
if (header.dataset.yesterdaySort === state.yesterdaySortKey) {
header.classList.add(state.yesterdaySortDirection === "asc" ? "sort-asc" : "sort-desc", "sorted");
header.setAttribute("aria-sort", state.yesterdaySortDirection === "asc" ? "ascending" : "descending");
}
const arrow = header.querySelector(".arr");
if (arrow) arrow.textContent = header.classList.contains("sorted") ? (state.yesterdaySortDirection === "asc" ? "▲" : "▼") : "↕";
});
}
function renderPerformance(rows) {
rows = normalizePerformanceRows(rows);
const currentDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate.value);
const previousDate = displayCompactDate(state.dashboard?.meta?.previous_trade_date || "");
setText("performanceDateRange", `昨日 ${previousDate} → 今日 ${currentDate}`);
document.querySelector("#performanceCards").innerHTML = rows.map((row) => `
<article class="performance-stage-card" title="收红 ${formatNumber(row.positive_rate, 1)}% · 平均涨幅 ${signed(row.average_change)}%"
aria-label="${escapeHtml(row.label)},晋级率 ${formatNumber(row.advance_rate, 1)}%,晋级 ${number(row.advanced)} 只,共 ${number(row.count)} 只,收红率 ${formatNumber(row.positive_rate, 1)}%,平均涨幅 ${signed(row.average_change)}%">
<div class="performance-stage-label"><span>${escapeHtml(row.label)} → 今日</span><i class="performance-status-tag ${performanceRateState(row.advance_rate).className}">${performanceRateState(row.advance_rate).label}</i></div>
<strong class="performance-stage-rate ${performanceRateState(row.advance_rate).className}">${formatNumber(row.advance_rate, 1)}%</strong>
<span class="performance-stage-count">晋级 ${number(row.advanced)} / 共 ${number(row.count)} 只</span>
<div class="performance-stage-track" aria-hidden="true"><i class="${performanceRateState(row.advance_rate).className}" style="width:${Math.max(number(row.advance_rate), number(row.advance_rate) > 0 ? 2 : 0)}%"></i></div>
</article>
`).join("") || '<div class="performance-empty-state">暂无昨日涨停统计</div>';
renderPerformanceConclusion(rows);
renderMarketBreadth(state.dashboard?.overview || {});
}
function normalizePerformanceRows(rows) {
const groups = new Map();
(rows || []).forEach((row) => {
const level = Math.max(1, number(row.level));
const displayLevel = Math.min(level, 5);
const group = groups.get(displayLevel) || {
level: displayLevel,
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : `昨日${displayLevel}`,
count: 0,
advanced: 0,
positive: 0,
changeTotal: 0,
};
const count = number(row.count);
group.count += count;
group.advanced += number(row.advanced);
group.positive += count * number(row.positive_rate) / 100;
group.changeTotal += count * number(row.average_change);
groups.set(displayLevel, group);
});
return [...groups.values()]
.sort((left, right) => right.level - left.level)
.map((group) => ({
level: group.level,
label: group.label,
count: group.count,
advanced: group.advanced,
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
positive_rate: group.count ? group.positive / group.count * 100 : 0,
average_change: group.count ? group.changeTotal / group.count : 0,
}));
}
function performanceRateState(rate) {
const value = number(rate);
if (value === 0) return { label: "失效", className: "is-neutral" };
if (value < 20) return { label: "危险", className: "is-warning" };
return { label: "活跃", className: "is-active" };
}
function renderPerformanceConclusion(rows) {
const container = document.querySelector("#performanceConclusion");
if (!rows.length) {
container.innerHTML = '<div class="empty-state">暂无昨日梯队数据,暂不生成结论</div>';
return;
}
const sorted = [...rows].sort((left, right) => number(right.level) - number(left.level));
const highRows = sorted.filter((row) => number(row.level) >= 4);
const highAdvanced = highRows.reduce((total, row) => total + number(row.advanced), 0);
const highSamples = highRows.map((row) => escapeHtml(row.label)).join("、");
const strongest = [...rows].sort((left, right) => (
number(right.advance_rate) - number(left.advance_rate) || number(right.level) - number(left.level)
))[0];
const firstBoard = rows.find((row) => number(row.level) === 1);
const overview = state.dashboard?.overview || {};
const phase = overview.sentiment_phase || "观察";
const up = number(overview.up_count);
const down = number(overview.down_count);
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
const highText = highRows.length
? `高位晋级率<b class="${highAdvanced ? "up" : "is-neutral"}">${highAdvanced ? "仍有承接" : "全线失效"}</b>${highSamples}${highAdvanced ? `共晋级 ${highAdvanced}` : "今日均未晋级"}`
: "高位梯队暂无昨日样本,空间信号仍待确认;";
const strongestText = strongest
? `<b>${escapeHtml(strongest.label)}</b>晋级率最高,为 <b class="up">${formatNumber(strongest.advance_rate, 1)}%</b>${number(strongest.advanced)} 只晋级 / 共 ${number(strongest.count)} 只);`
: "暂无相对占优梯队;";
const firstBoardText = firstBoard
? `首板基数 ${number(firstBoard.count)} 只,晋级率 <b class="${performanceRateState(firstBoard.advance_rate).className}">${formatNumber(firstBoard.advance_rate, 1)}%</b>,低位接力${number(firstBoard.advance_rate) < 20 ? "胜率偏低" : "仍有活跃度"}`
: "首板梯队暂无有效样本;";
container.innerHTML = `
<div>· ${highText}</div>
<div>· ${strongestText}</div>
<div>· ${firstBoardText}</div>
<div>· 结论:<b>${stance}</b>,当前情绪周期「${escapeHtml(phase)}」。</div>
`;
}
/* PRESERVATION-SOURCE-END app.js:1517-1915 */
+82
View File
@@ -0,0 +1,82 @@
window.XiaobaiPageModules.register("popularity", ["popularityView"], {
enter: ["loadPopularity"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2584-2659 */
async function loadPopularity(force = false) {
if (state.popularityLoading) return;
state.popularityLoading = true;
const button = document.querySelector("#popularityRefreshButton");
button.disabled = true;
setText("popularityDateLabel", "正在读取人气榜");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
state.popularityData = await apiRequest(`/api/popularity?${query}`);
renderPopularity();
} catch (error) {
setText("popularityDateLabel", error.message || "人气榜暂不可用");
document.querySelector("#popularityTableBody").innerHTML = "";
document.querySelector("#popularityEmpty").hidden = false;
showToast(error.message || "人气榜加载失败");
} finally {
state.popularityLoading = false;
button.disabled = false;
}
}
function renderPopularity() {
const payload = state.popularityData;
if (!payload) return;
const summary = payload.summary || {};
setText("popularityDateLabel", `${payload.meta?.carried_forward ? "最近有效榜单" : "榜单日期"} ${payload.meta?.trade_date || "--"}`);
const topNames = (rows) => (rows || []).slice(0, 3).map((item) => item.name).filter(Boolean).join(" · ") || "--";
document.querySelector("#popularitySummary").innerHTML = [
["同花顺热度 Top3", topNames(payload.ths), `${number(summary.ths_count)} 只上榜`],
["东方财富热度 Top3", topNames(payload.dc), `${number(summary.dc_count)} 只上榜`],
["双榜共识", `${number(summary.dual_count)}`, "同时进入两榜,共识度更高"],
].map(([label, value, detail], index) => `<article class="${index === 2 ? "consensus" : ""}"><span>${label}</span><strong>${escapeHtml(value)}</strong><small>${escapeHtml(detail)}</small></article>`).join("");
renderPopularityTable();
}
function renderPopularityTable() {
const source = state.popularitySource;
let rows = [...(state.popularityData?.[source] || [])];
if (state.popularityQuery) {
rows = rows.filter((item) => `${item.code} ${item.name} ${(item.concepts || []).join(" ")}`.toLocaleLowerCase("zh-CN").includes(state.popularityQuery));
}
const combined = source === "combined";
const sourceName = source === "ths" ? "同花顺" : source === "dc" ? "东方财富" : "双榜综合";
setText("popularityTableTitle", `${sourceName}`);
setText("popularityTableNote", combined ? "按双榜排名综合排序 · 已隐藏重复的榜单状态" : "按榜单名次排序 · 状态显示是否同时进入另一榜");
const headers = [
["排名", "number num"], ["股票", ""], ["最新价(元)", "number num"], ["涨跌幅(%", "number num"],
...(source !== "dc" ? [["同花顺", "number num"]] : []),
...(source !== "ths" ? [["东方财富", "number num"]] : []),
["排名变化", "number num"], ["热门概念", ""], ...(!combined ? [["榜单状态", ""]] : []),
];
document.querySelector("#popularityTableHead").innerHTML = headers.map(([label, className]) => `<th scope="col" class="${className}">${label}</th>`).join("");
const body = document.querySelector("#popularityTableBody");
body.innerHTML = rows.map((row, index) => {
const thsRank = source === "ths" ? row.rank : row.ths_rank;
const dcRank = source === "dc" ? row.rank : row.dc_rank;
const move = row.rank_change;
const movement = move === null || move === undefined ? "新" : number(move) > 0 ? `${number(move)}` : number(move) < 0 ? `${Math.abs(number(move))}` : "持平";
return `<tr data-code="${escapeHtml(row.code)}">
<td class="number num popularity-rank-v2"><b>${index + 1}</b>${index < 3 ? '<span>热</span>' : ""}</td>
<td><div class="popularity-stock-v2"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><span class="stock-code scode">${escapeHtml(row.code)}</span></div></td>
<td class="number num">${row.price == null ? "" : formatNumber(row.price, 2)}</td>
<td class="number num ${row.change == null ? "" : changeClass(row.change)}">${row.change == null ? "" : signed(row.change)}</td>
${source !== "dc" ? `<td class="number num popularity-list-rank-v2">${thsRank ? number(thsRank) : ""}</td>` : ""}
${source !== "ths" ? `<td class="number num popularity-list-rank-v2">${dcRank ? number(dcRank) : ""}</td>` : ""}
<td class="number num popularity-movement-v2 ${number(move) > 0 ? "up" : number(move) < 0 ? "down" : ""}">${movement}</td>
<td class="popularity-concepts-v2" title="${escapeHtml((row.concepts || []).join("、"))}">${escapeHtml((row.concepts || []).slice(0, 3).join("、"))}</td>
${!combined ? `<td><span class="popularity-source-tag-v2 ${row.dual_source ? "dual" : ""}">${row.dual_source ? "双榜共识" : "单榜入选"}</span></td>` : ""}
</tr>`;
}).join("");
bindStockRows(body);
markAutoSortableHeaders(body.closest("table"));
document.querySelector("#popularityEmpty").hidden = rows.length > 0;
}
/* PRESERVATION-SOURCE-END app.js:2584-2659 */
+699
View File
@@ -0,0 +1,699 @@
window.XiaobaiPageModules.register("review", ["reviewWorkspaceView"], {
enter: ["loadReview"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:3029-3470 */
async function loadReviewWorkspace() {
try {
const [watchlistPayload, notesPayload, tradesPayload] = await Promise.all([
apiRequest(`/api/watchlist?trade_date=${encodeURIComponent(elements.tradeDate.value)}`),
apiRequest("/api/notes?scope=daily"),
apiRequest("/api/trades"),
]);
state.watchlist = watchlistPayload.items || [];
state.notes = notesPayload.items || [];
state.tradeEntries = tradesPayload.items || [];
state.tradeSummary = tradesPayload.summary || {};
setText("reviewDataDate", displayCompactDate(elements.tradeDate.value));
renderWatchlist();
renderNotesHistory(state.notes, document.querySelector("#notesHistory"), false);
setText("notesCount", `${state.notes.length}`);
renderTradeLog();
populateJournalForm();
} catch (error) {
showToast(error.message || "我的复盘加载失败");
}
}
function renderWatchlist() {
setText("watchlistCount", `${state.watchlist.length}`);
const body = document.querySelector("#watchlistTableBody");
body.innerHTML = state.watchlist.map((item) => `
<tr data-code="${escapeHtml(item.code)}"><td><span class="review-watch-mark ${escapeHtml(item.color)}" title="${escapeHtml(item.color)}">★</span></td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
<td>${escapeHtml(item.sector || "其他")}</td>
<td class="number num ${item.change == null ? "" : changeClass(item.change)}">${formatWatchMetric(item.change)}</td>
<td class="number num ${item.return_5d == null ? "" : changeClass(item.return_5d)}">${formatWatchMetric(item.return_5d)}</td>
<td class="number num"><strong class="watch-attention-score">${item.attention_score == null ? "" : formatNumber(item.attention_score, 1)}</strong></td>
<td><span class="watch-remark" title="${escapeHtml(item.remark || "尚未填写跟踪备注")}">${escapeHtml(item.remark || "尚未填写")}</span></td>
<td><span class="review-row-actions"><button class="table-action" type="button" data-watch-remark="${escapeHtml(item.code)}">备注</button>
<button class="table-action down" type="button" data-watch-delete="${escapeHtml(item.code)}" aria-label="移除 ${escapeHtml(item.name)}">移除</button></span></td></tr>
`).join("");
document.querySelector("#watchlistEmpty").hidden = state.watchlist.length > 0;
body.querySelectorAll("[data-watch-remark]").forEach((button) => {
button.addEventListener("click", () => {
const item = state.watchlist.find((row) => row.code === button.dataset.watchRemark);
openWatchlistDialog(item);
});
});
body.querySelectorAll("[data-watch-delete]").forEach((button) => {
button.addEventListener("click", () => removeWatchlist(button.dataset.watchDelete));
});
bindStockRows(body);
}
function formatWatchMetric(value) {
if (value == null || !Number.isFinite(Number(value))) return "";
return signed(value);
}
function openWatchlistDialog(item = null) {
clearTimeout(watchlistSearchTimer);
state.watchlistSelection = item ? {
code: item.code,
name: item.name,
sector: item.sector || "其他",
color: item.color || "red",
} : null;
state.watchlistSearchResults = [];
setText("watchlistDialogTitle", item ? "编辑跟踪备注" : "添加自选");
document.querySelector("#watchlistRemark").value = item?.remark || "";
document.querySelector("#watchlistSearchInput").value = "";
document.querySelector("#watchlistSearchResults").innerHTML = "";
syncWatchlistSelection(Boolean(item));
openModalDialog(elements.watchlistDialog);
requestAnimationFrame(() => (item ? document.querySelector("#watchlistRemark") : document.querySelector("#watchlistSearchInput")).focus());
}
function closeWatchlistDialog() {
clearTimeout(watchlistSearchTimer);
if (elements.watchlistDialog.open) elements.watchlistDialog.close();
}
function clearWatchlistSelection() {
state.watchlistSelection = null;
syncWatchlistSelection(false);
document.querySelector("#watchlistSearchInput").focus();
}
function syncWatchlistSelection(editing = false) {
const item = state.watchlistSelection;
document.querySelector("#watchlistSearchField").hidden = Boolean(item);
document.querySelector("#watchlistSelection").hidden = !item;
document.querySelector("#changeWatchlistSelection").hidden = editing;
document.querySelector("#saveWatchlist").disabled = !item;
if (!item) return;
setText("watchlistSelectionName", item.name || "--");
setText("watchlistSelectionCode", item.code || "--");
setText("watchlistSelectionSector", item.sector || "其他");
refreshIcons();
}
function scheduleWatchlistSearch() {
clearTimeout(watchlistSearchTimer);
const query = document.querySelector("#watchlistSearchInput").value.trim();
if (!query) {
document.querySelector("#watchlistSearchResults").innerHTML = "";
return;
}
document.querySelector("#watchlistSearchResults").innerHTML = '<div class="watchlist-search-status">正在查找股票</div>';
watchlistSearchTimer = setTimeout(() => runWatchlistSearch(query), 160);
}
async function runWatchlistSearch(query) {
const sequence = ++state.watchlistSearchRequestSequence;
try {
const params = new URLSearchParams({ q: query, trade_date: elements.tradeDate.value });
const payload = await apiRequest(`/api/search?${params}`);
if (sequence !== state.watchlistSearchRequestSequence) return;
state.watchlistSearchResults = payload.groups?.stocks || [];
document.querySelector("#watchlistSearchResults").innerHTML = state.watchlistSearchResults.map((item, index) => `
<button type="button" data-watchlist-result="${index}"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.industry || "其他")}</small></span><b>${escapeHtml(item.code)}</b></button>
`).join("") || '<div class="watchlist-search-status">没有找到匹配的股票</div>';
} catch (error) {
document.querySelector("#watchlistSearchResults").innerHTML = `<div class="watchlist-search-status">${escapeHtml(error.message || "搜索失败")}</div>`;
}
}
function handleWatchlistSearchResult(event) {
const button = event.target.closest("[data-watchlist-result]");
if (!button) return;
const item = state.watchlistSearchResults[number(button.dataset.watchlistResult)];
if (!item) return;
state.watchlistSelection = {
code: item.code,
name: item.name,
sector: item.industry || "其他",
color: "red",
};
syncWatchlistSelection(false);
}
async function saveWatchlistFromDialog(event) {
event.preventDefault();
const item = state.watchlistSelection;
if (!item) return;
const button = document.querySelector("#saveWatchlist");
button.disabled = true;
try {
await apiRequest("/api/watchlist", "POST", {
code: item.code,
name: item.name,
sector: item.sector || "其他",
color: item.color || "red",
remark: document.querySelector("#watchlistRemark").value.trim(),
});
closeWatchlistDialog();
await loadReviewWorkspace();
showToast(state.watchlist.some((row) => row.code === item.code) ? "自选跟踪已保存" : "已加入自选");
} catch (error) {
showToast(error.message || "自选保存失败");
button.disabled = false;
}
}
async function toggleActiveWatchlist() {
const stock = state.activeStock;
if (!stock?.code) return;
const isWatched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === stock.code));
try {
if (isWatched) {
await apiRequest(`/api/watchlist/${stock.code}`, "DELETE");
state.watchlist = state.watchlist.filter((item) => item.code !== stock.code);
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = null;
showToast("已移出自选");
} else {
const payload = await apiRequest("/api/watchlist", "POST", {
code: stock.code,
name: stock.name || "--",
sector: stock.sector || "其他",
color: "red",
});
state.watchlist = payload.items || state.watchlist;
if (state.stockDetail?.stock) state.stockDetail.stock.watchlist = state.watchlist.find((item) => item.code === stock.code);
showToast("已加入自选");
}
updateWatchButton();
renderWatchlist();
} catch (error) {
showToast(error.message);
}
}
function updateWatchButton() {
const code = state.activeStock?.code;
const watched = Boolean(state.stockDetail?.stock?.watchlist || state.watchlist.some((item) => item.code === code));
setText("watchStockButton", watched ? "移出自选" : "加入自选");
}
async function removeWatchlist(code) {
try {
await apiRequest(`/api/watchlist/${code}`, "DELETE");
state.watchlist = state.watchlist.filter((item) => item.code !== code);
renderWatchlist();
showToast("已移出自选");
} catch (error) {
showToast(error.message);
}
}
async function saveJournal(event) {
event.preventDefault();
try {
await apiRequest("/api/notes", "POST", {
trade_date: document.querySelector("#journalDate").value,
id: state.editingDailyNoteId || undefined,
summary: document.querySelector("#journalSummary").value,
content: document.querySelector("#journalContent").value,
plan: document.querySelector("#journalPlan").value,
});
await loadReviewWorkspace();
showToast("每日复盘已保存");
} catch (error) {
showToast(error.message);
}
}
function populateJournalForm() {
const selectedDate = document.querySelector("#journalDate").value.replaceAll("-", "");
const note = state.notes.find((item) => String(item.trade_date).replaceAll("-", "") === selectedDate);
state.editingDailyNoteId = number(note?.id);
document.querySelector("#journalSummary").value = note?.summary || "";
document.querySelector("#journalContent").value = note?.content || "";
document.querySelector("#journalPlan").value = note?.plan || "";
}
function openTradeLogDialog() {
resetTradeLogForm();
openModalDialog(elements.tradeLogDialog);
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
}
function closeTradeLogDialog() {
if (elements.tradeLogDialog.open) elements.tradeLogDialog.close();
else resetTradeLogForm();
}
async function saveTradeLog(event) {
event.preventDefault();
const button = document.querySelector("#saveTradeLog");
button.disabled = true;
try {
const payload = await apiRequest("/api/trades", "POST", {
id: state.editingTradeId || undefined,
trade_date: document.querySelector("#tradeLogDate").value,
code: document.querySelector("#tradeLogCode").value.trim(),
name: document.querySelector("#tradeLogName").value.trim(),
action: document.querySelector("#tradeLogAction").value,
price: document.querySelector("#tradeLogPrice").value,
quantity: document.querySelector("#tradeLogQuantity").value,
position_pct: document.querySelector("#tradeLogPosition").value,
pnl_amount: document.querySelector("#tradeLogPnlAmount").value,
pnl_pct: document.querySelector("#tradeLogPnlPct").value,
emotion: document.querySelector("#tradeLogEmotion").value,
tags: document.querySelector("#tradeLogTags").value,
thesis: document.querySelector("#tradeLogThesis").value,
execution: document.querySelector("#tradeLogExecution").value,
});
state.tradeEntries = payload.items || [];
state.tradeSummary = payload.summary || {};
renderTradeLog();
closeTradeLogDialog();
showToast("交易记录已保存");
} catch (error) {
showToast(error.message || "交易记录保存失败");
} finally {
button.disabled = false;
}
}
function resetTradeLogForm() {
state.editingTradeId = 0;
document.querySelector("#tradeLogForm").reset();
document.querySelector("#tradeLogDate").value = elements.tradeDate.value || todayString();
document.querySelector("#tradeLogQuantity").value = "0";
document.querySelector("#tradeLogPosition").value = "0";
setText("tradeLogDialogTitle", "交易日志");
setText("saveTradeLog", "保存交易");
}
function editTradeLog(id) {
const item = state.tradeEntries.find((entry) => number(entry.id) === id);
if (!item) return;
state.editingTradeId = id;
document.querySelector("#tradeLogDate").value = displayCompactDate(item.trade_date);
document.querySelector("#tradeLogCode").value = item.code;
document.querySelector("#tradeLogName").value = item.name;
document.querySelector("#tradeLogAction").value = item.action;
document.querySelector("#tradeLogPrice").value = item.price;
document.querySelector("#tradeLogQuantity").value = item.quantity;
document.querySelector("#tradeLogPosition").value = item.position_pct;
document.querySelector("#tradeLogPnlAmount").value = item.pnl_amount ?? "";
document.querySelector("#tradeLogPnlPct").value = item.pnl_pct ?? "";
document.querySelector("#tradeLogEmotion").value = item.emotion;
document.querySelector("#tradeLogTags").value = (item.tags || []).join(", ");
document.querySelector("#tradeLogThesis").value = item.thesis || "";
document.querySelector("#tradeLogExecution").value = item.execution || "";
setText("tradeLogDialogTitle", "编辑交易日志");
setText("saveTradeLog", "保存修改");
openModalDialog(elements.tradeLogDialog);
requestAnimationFrame(() => document.querySelector("#tradeLogCode").focus());
}
async function handleTradeLogAction(event) {
const button = event.target.closest("[data-trade-action]");
if (!button) return;
const id = number(button.dataset.tradeId);
if (button.dataset.tradeAction === "edit") {
editTradeLog(id);
return;
}
if (!window.confirm("确定删除这条交易记录吗?")) return;
try {
const payload = await apiRequest(`/api/trades/${id}`, "DELETE");
state.tradeEntries = payload.items || [];
state.tradeSummary = payload.summary || {};
if (state.editingTradeId === id) resetTradeLogForm();
renderTradeLog();
showToast("交易记录已删除");
} catch (error) {
showToast(error.message || "交易记录删除失败");
}
}
function renderTradeLog() {
const summary = state.tradeSummary || {};
setText("tradeLogCount", `${state.tradeEntries.length}`);
document.querySelector("#tradeLogSummary").innerHTML = [
["记录", `${number(summary.total)}`],
["已实现", `${number(summary.realized)}`],
["胜率", summary.win_rate == null ? "--" : `${formatNumber(summary.win_rate, 1)}%`],
["累计盈亏", summary.pnl_amount == null ? "--" : `${number(summary.pnl_amount) > 0 ? "+" : ""}${formatNumber(summary.pnl_amount, 2)}`],
["平均仓位", summary.average_position == null ? "--" : `${formatNumber(summary.average_position, 1)}%`],
].map(([label, value]) => `<div><span>${label}</span><strong>${value}</strong></div>`).join("");
document.querySelector("#tradeLogEmpty").hidden = state.tradeEntries.length > 0;
document.querySelector("#tradeLogTableBody").innerHTML = state.tradeEntries.map((item) => `
<tr data-code="${escapeHtml(item.code)}">
<td>${displayCompactDate(item.trade_date)}</td>
<td><span class="stock-cell"><strong class="sname">${escapeHtml(item.name)}</strong><small class="stock-code scode">${escapeHtml(item.code)}</small></span></td>
<td><span class="trade-action trade-action-${escapeHtml(item.action)}">${escapeHtml(item.action_label)}</span></td>
<td class="number num">${item.position_pct == null ? "" : formatNumber(item.position_pct, 1)}</td>
<td class="number num ${item.pnl_pct == null ? "" : changeClass(item.pnl_pct)}">${item.pnl_pct == null ? "" : signed(item.pnl_pct)}</td>
<td class="number num ${item.pnl_amount == null ? "" : changeClass(item.pnl_amount)}">${item.pnl_amount == null ? "" : signed(item.pnl_amount)}</td>
<td><span class="trade-emotion">${escapeHtml(item.emotion_label)}</span><div class="trade-tags">${(item.tags || []).map((tag) => `<em>${escapeHtml(tag)}</em>`).join("")}</div></td>
<td class="trade-copy" title="交易逻辑:${escapeHtml(item.thesis || "")};执行复核:${escapeHtml(item.execution || "")}"><strong>${escapeHtml(item.thesis || "")}</strong><small>${escapeHtml(item.execution || "尚未填写执行复核")}</small></td>
<td><div class="trade-row-actions"><button class="table-action" type="button" data-trade-action="edit" data-trade-id="${number(item.id)}">编辑</button><button class="table-action down" type="button" data-trade-action="delete" data-trade-id="${number(item.id)}">删除</button></div></td>
</tr>
`).join("");
bindStockRows(document.querySelector("#tradeLogTableBody"));
}
async function saveStockNote(event) {
event.preventDefault();
if (!state.activeStock?.code) return;
try {
await apiRequest("/api/notes", "POST", {
code: state.activeStock.code,
stock_name: state.activeStock.name || "--",
trade_date: elements.tradeDate.value,
content: document.querySelector("#stockNoteContent").value,
plan: document.querySelector("#stockNotePlan").value,
});
document.querySelector("#stockNoteContent").value = "";
document.querySelector("#stockNotePlan").value = "";
const payload = await apiRequest(`/api/notes?scope=stock&code=${encodeURIComponent(state.activeStock.code)}`);
state.stockDetail.notes = payload.items || [];
renderStockNotes(state.stockDetail.notes);
showToast("个股笔记已保存");
} catch (error) {
showToast(error.message);
}
}
async function saveReasonOverride(event) {
event.preventDefault();
if (!state.activeStock?.code) return;
const reason = document.querySelector("#reasonInput").value.trim();
try {
await apiRequest("/api/reasons", "POST", {
trade_date: elements.tradeDate.value,
code: state.activeStock.code,
reason,
});
state.activeStock.reason = reason;
for (const key of ["limits", "broken", "down_limits"]) {
const row = state.dashboard?.[key]?.find((item) => item.code === state.activeStock.code);
if (row) row.reason = reason;
}
setText("detailReason", reason);
renderDashboard();
showToast("事件逻辑已修订");
} catch (error) {
showToast(error.message);
}
}
function renderMoneyflow(flow) {
for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) {
const element = document.getElementById(id);
element.textContent = formatMoneyMillion(value);
element.className = changeClass(value);
}
}
function renderStockNotes(notes) {
renderNotesHistory(notes, document.querySelector("#stockNotes"), true);
}
function renderNotesHistory(notes, container, compact) {
container.innerHTML = notes.map((note) => `
<article class="note-row">
<div><time>${displayCompactDate(note.trade_date)}</time>${note.stock_name ? `<small>${escapeHtml(note.stock_name)}</small>` : ""}</div>
${!compact ? `<div class="note-block note-summary"><strong>盘面</strong><p>${escapeHtml(note.summary || "--")}</p></div>` : ""}
<div class="note-block"><strong>复盘</strong><p>${escapeHtml(note.content || "--")}</p></div>
<div class="note-block"><strong>计划</strong><p>${escapeHtml(note.plan || "--")}</p></div>
<button class="table-action down" type="button" data-note-delete="${number(note.id)}">删除</button>
</article>
`).join("") || emptyStateHtml("暂无复盘记录");
container.querySelectorAll("[data-note-delete]").forEach((button) => {
button.addEventListener("click", () => deleteNote(number(button.dataset.noteDelete), compact));
});
}
async function deleteNote(noteId, compact) {
try {
await apiRequest(`/api/notes/${noteId}`, "DELETE");
if (compact && state.activeStock) {
state.stockDetail.notes = state.stockDetail.notes.filter((note) => number(note.id) !== noteId);
renderStockNotes(state.stockDetail.notes);
} else {
await loadReviewWorkspace();
}
showToast("笔记已删除");
} catch (error) {
showToast(error.message);
}
}
/* PRESERVATION-SOURCE-END app.js:3029-3470 */
/* PRESERVATION-SOURCE-BEGIN app.js:7720-7968 */
async function loadAlerts(openDialog = false) {
try {
const query = new URLSearchParams({ status: state.alertFilter, as_of: todayString() });
const payload = await apiRequest(`/api/alerts?${query}`);
state.alerts = payload.items || [];
state.alertUnreadCount = number(payload.unread_count);
renderAlerts();
if (openDialog) openModalDialog(elements.alertsDialog);
} catch (error) {
if (openDialog) showToast(error.message || "提醒加载失败");
}
}
function openAlerts() {
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
document.querySelector("#alertDate").value ||= todayString();
openModalDialog(elements.alertsDialog);
loadAlerts();
}
function openStockReminder() {
const stock = state.activeStock || {};
document.querySelector("#alertTitle").value = `${stock.name || stock.code || "个股"}观察提醒`;
document.querySelector("#alertCode").value = stock.code || "";
document.querySelector("#alertDate").value = todayString();
if (elements.stockDialog.open) elements.stockDialog.close();
openAlerts();
document.querySelector("#alertContent").focus();
}
function selectAlertFilter(filter) {
state.alertFilter = filter === "unread" ? "unread" : "all";
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
});
loadAlerts();
}
async function saveAlert(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button[type='submit']");
button.disabled = true;
try {
const payload = await apiRequest("/api/alerts", "POST", {
title: document.querySelector("#alertTitle").value.trim(),
remind_date: document.querySelector("#alertDate").value,
code: document.querySelector("#alertCode").value.trim(),
content: document.querySelector("#alertContent").value.trim(),
});
event.currentTarget.reset();
document.querySelector("#alertDate").value = todayString();
state.alertFilter = "all";
state.alerts = payload.items || [];
state.alertUnreadCount = number(payload.unread_count);
renderAlerts();
showToast("提醒已保存");
} catch (error) {
showToast(error.message || "提醒保存失败");
} finally {
button.disabled = false;
}
}
async function markAllAlertsRead() {
try {
await apiRequest("/api/alerts/read-all", "POST", { as_of: todayString() });
await loadAlerts();
} catch (error) {
showToast(error.message || "提醒状态更新失败");
}
}
async function handleAlertAction(event) {
const button = event.target.closest("[data-alert-action]");
if (!button) return;
const id = number(button.dataset.alertId);
if (!id) return;
try {
if (button.dataset.alertAction === "delete") {
await apiRequest(`/api/alerts/${id}`, "DELETE");
} else {
await apiRequest(`/api/alerts/${id}/read`, "POST", {});
}
await loadAlerts();
} catch (error) {
showToast(error.message || "提醒操作失败");
}
}
function renderAlerts() {
const badge = document.querySelector("#alertBadge");
badge.hidden = state.alertUnreadCount <= 0;
badge.textContent = state.alertUnreadCount > 99 ? "99+" : String(state.alertUnreadCount);
document.querySelector("#alertButton").classList.toggle("has-alerts", state.alertUnreadCount > 0);
setText("alertListCount", `${state.alerts.length}`);
document.querySelectorAll("[data-alert-filter]").forEach((button) => {
button.classList.toggle("active", button.dataset.alertFilter === state.alertFilter);
});
document.querySelector("#markAllAlertsRead").disabled = state.alertUnreadCount <= 0;
const container = document.querySelector("#alertList");
container.innerHTML = state.alerts.map((item) => {
const upcoming = !item.due;
const kindLabel = item.kind === "manual" ? "自定提醒" : item.kind === "strategy_t5" ? "跟踪完成" : "策略反馈";
return `<article class="alert-item ${item.is_read ? "is-read" : "is-unread"} ${upcoming ? "is-upcoming" : ""}">
<div class="alert-item-icon"><i data-lucide="${upcoming ? "calendar-clock" : item.kind === "manual" ? "bell" : "chart-no-axes-combined"}"></i></div>
<div class="alert-item-copy">
<div><span>${escapeHtml(kindLabel)}</span><time>${displayCompactDate(item.available_date)}</time></div>
<strong>${escapeHtml(item.title)}</strong>
${item.content ? `<p>${escapeHtml(item.content)}</p>` : ""}
${item.code ? `<button class="stock-preview-trigger alert-stock-link" type="button" data-code="${escapeHtml(item.code)}">${escapeHtml(item.code)}</button>` : ""}
</div>
<div class="alert-item-actions">
${!item.is_read && !upcoming ? `<button class="icon-button" type="button" data-alert-action="read" data-alert-id="${number(item.id)}" title="标为已读" aria-label="标为已读"><i data-lucide="check"></i></button>` : ""}
<button class="icon-button" type="button" data-alert-action="delete" data-alert-id="${number(item.id)}" title="删除提醒" aria-label="删除提醒"><i data-lucide="trash-2"></i></button>
</div>
</article>`;
}).join("") || emptyStateHtml("暂无提醒");
bindStockRows(container);
refreshIcons();
}
async function openReviewAssistant() {
toggleHeaderCommandMenu(false);
toggleAccountDropdown(false);
openModalDialog(elements.assistantDialog);
updateAssistantControls();
if (!hasMemberAccess()) {
document.querySelector("#closeAssistantDialog").focus();
return;
}
try {
const payload = await apiRequest("/api/assistant/messages");
state.assistantMessages = payload.items || [];
renderAssistantMessages();
} catch (error) {
showToast(error.message || "对话记录加载失败");
}
document.querySelector("#assistantQuestion").focus();
}
function useAssistantPrompt(prompt) {
const input = document.querySelector("#assistantQuestion");
input.value = prompt;
input.focus();
}
async function sendAssistantQuestion(event) {
event.preventDefault();
if (state.assistantLoading) return;
const input = document.querySelector("#assistantQuestion");
const question = input.value.trim();
if (!question) return;
input.value = "";
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
state.assistantLoading = true;
state.assistantController = new AbortController();
updateAssistantControls();
renderAssistantMessages();
try {
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
const message = state.assistantMessages.at(-1);
if (message?.role === "assistant") message.content += chunk;
scheduleAssistantRender();
});
const message = state.assistantMessages.at(-1);
if (message) message.streaming = false;
setStatus("复盘助手回答完成");
} catch (error) {
const message = state.assistantMessages.at(-1);
if (message?.role === "assistant") {
message.streaming = false;
message.error = true;
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
}
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
} finally {
state.assistantLoading = false;
state.assistantController = null;
updateAssistantControls();
renderAssistantMessages();
input.focus();
}
}
async function streamAssistantRequest(question, signal, onDelta) {
await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
method: "POST",
body: { question, trade_date: elements.tradeDate.value },
signal,
errorMessage: "复盘助手暂不可用",
onEvent: (event) => {
if (event.type === "delta") onDelta(String(event.content || ""));
},
});
}
function stopAssistantResponse() {
state.assistantController?.abort();
}
async function clearAssistantConversation() {
if (state.assistantLoading || !state.assistantMessages.length) return;
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
try {
await apiRequest("/api/assistant/messages", "DELETE");
state.assistantMessages = [];
renderAssistantMessages();
} catch (error) {
showToast(error.message || "对话记录清空失败");
}
}
function scheduleAssistantRender() {
if (assistantRenderFrame) return;
assistantRenderFrame = requestAnimationFrame(() => {
assistantRenderFrame = 0;
renderAssistantMessages();
});
}
function renderAssistantMessages() {
const container = document.querySelector("#assistantMessages");
container.innerHTML = state.assistantMessages.map((message) => `
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
</article>
`).join("") || emptyStateHtml("可以从市场、策略或自己的交易记录开始复盘");
updateAssistantControls();
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
}
function updateAssistantControls() {
const unlocked = hasMemberAccess();
elements.assistantDialog.classList.toggle("member-locked", !unlocked);
document.querySelector("#assistantMemberGate").hidden = unlocked;
document.querySelector("#assistantMemberContent").setAttribute("aria-disabled", String(!unlocked));
document.querySelector("#assistantQuestion").disabled = !unlocked || state.assistantLoading;
document.querySelector("#sendAssistant").disabled = !unlocked || state.assistantLoading;
document.querySelector("#stopAssistant").hidden = !unlocked || !state.assistantLoading;
document.querySelector("#clearAssistantMessages").disabled = !unlocked || state.assistantLoading || !state.assistantMessages.length;
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
button.disabled = !unlocked || state.assistantLoading;
});
}
/* PRESERVATION-SOURCE-END app.js:7720-7968 */
+173
View File
@@ -0,0 +1,173 @@
window.XiaobaiPageModules.register("rotation", ["rotationView"], {
enter: ["loadRotation"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:1958-2124 */
async function loadRotationHistory(force = false) {
if (!state.dashboard || state.rotationLoading) return;
const key = `${elements.tradeDate.value}:9`;
if (!force && state.rotationHistoryKey === key && state.rotationHistory) {
renderRotationHistory();
return;
}
state.rotationLoading = true;
const container = document.querySelector("#rotationHistory");
renderEmptyState(container, "正在读取轮动历史");
try {
const query = new URLSearchParams({
trade_date: elements.tradeDate.value,
});
state.rotationHistory = await apiRequest(`/api/rotation/history?${query}`);
state.rotationHistoryKey = key;
renderRotationHistory();
} catch (error) {
renderEmptyState(container, error.message || "轮动历史加载失败");
showToast(error.message || "轮动历史加载失败");
} finally {
state.rotationLoading = false;
}
}
function renderRotationHistory() {
const rows = state.rotationHistory?.rows || [];
const selected = state.rotationSelectedSector;
const container = document.querySelector("#rotationHistory");
const tracker = document.querySelector("#rotationTracker");
if (!rows.length) {
renderEmptyState(container, "尚无连续交易日的板块数据");
setText("rotationHistoryRange", "暂无轮动历史");
tracker.hidden = true;
return;
}
const chronological = [...rows]
.sort((left, right) => String(left.trade_date).localeCompare(String(right.trade_date)))
.slice(-9);
const displayRows = state.rotationOrder === "latest" ? [...chronological].reverse() : chronological;
document.querySelectorAll("[data-rotation-order]").forEach((button) => {
button.classList.toggle("active", button.dataset.rotationOrder === state.rotationOrder);
});
setText(
"rotationHistoryRange",
`最近 ${chronological.length} 个交易日 · ${displayCompactDate(chronological[0].trade_date)}${displayCompactDate(chronological[chronological.length - 1].trade_date)} · ${state.rotationOrder === "latest" ? "由近到远,左侧为最新交易日" : "由远到近,右侧为最新交易日"}`,
);
setText("rotationSelectionHint", selected ? `已联动高亮 ${selected}` : "点击任意板块追踪其连续性");
if (selected) {
const sequence = displayRows.map((day) => {
const sector = (day.sectors || []).find((item) => item.name === selected);
return { tradeDate: day.trade_date, sector };
});
const appearances = sequence.filter((item) => item.sector);
const bestRank = appearances.length ? Math.min(...appearances.map((item) => number(item.sector.rank))) : 0;
tracker.hidden = false;
const continuity = appearances.length >= 3 ? "主线候选" : appearances.length === 1 ? "单日异动,持续性待验证" : "间断活跃";
tracker.innerHTML = `
<div class="rotation-tracker-copy"><strong>${escapeHtml(selected)}</strong><span>近 9 日在榜 <b>${appearances.length}</b> 天 · 最高排名 <b>#${bestRank || "--"}</b> · ${continuity}</span></div>
<div class="rotation-tracker-spark" aria-label="${escapeHtml(selected)}九日强度轨迹">
${sequence.map((item) => item.sector
? `<span style="--spark-height:${Math.max(18, clamp(number(item.sector.strength), 0, 100))}%" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 第 ${number(item.sector.rank)} 名 · 强度 ${formatNumber(item.sector.strength, 0)}"><i></i><small>#${number(item.sector.rank)}</small></span>`
: `<span class="missing" title="${escapeHtml(displayCompactDate(item.tradeDate))} · 未上榜"><i></i><small>--</small></span>`).join("")}
</div>
<button class="rotation-track-cancel" type="button">取消追踪</button>`;
tracker.querySelector(".rotation-track-cancel").addEventListener("click", () => {
state.rotationSelectedSector = "";
state.rotationSelectedDate = "";
renderRotationHistory();
loadRotationMembers("");
});
} else {
tracker.hidden = true;
tracker.innerHTML = "";
}
container.classList.toggle("tracking", Boolean(selected));
const latestTradeDate = chronological[chronological.length - 1].trade_date;
container.innerHTML = displayRows.map((day) => {
const hasSelected = selected && (day.sectors || []).some((sector) => sector.name === selected);
return `
<article class="rotation-day ${selected ? "has-selection" : ""} ${hasSelected ? "selected-day" : ""} ${day.trade_date === latestTradeDate ? "latest-day" : ""}">
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
const strength = clamp(number(sector.strength), 0, 100);
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
return `
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
</button>`;
}).join("")}</div>
</article>`;
}).join("");
container.querySelectorAll("[data-rotation-sector]").forEach((button) => {
button.addEventListener("click", () => {
const clickedSector = button.dataset.rotationSector;
const clickedDate = button.dataset.rotationDate;
const isSameSelection = clickedSector === state.rotationSelectedSector
&& clickedDate === state.rotationSelectedDate;
state.rotationSelectedSector = isSameSelection ? "" : clickedSector;
state.rotationSelectedDate = isSameSelection ? "" : clickedDate;
renderRotationHistory();
loadRotationMembers(state.rotationSelectedSector);
});
});
}
async function loadRotationMembers(sector, force = false) {
if (!sector) {
state.rotationMembers = null;
state.rotationMembersKey = "";
renderRotationMembers();
return;
}
const memberDate = state.rotationSelectedDate || elements.tradeDate.value;
const key = `${memberDate}:${sector}`;
if (!force && state.rotationMembersKey === key && state.rotationMembers) {
renderRotationMembers();
return;
}
state.rotationMembersLoading = true;
renderRotationMembers();
try {
const query = new URLSearchParams({ trade_date: memberDate, sector });
state.rotationMembers = await apiRequest(`/api/rotation/members?${query}`);
state.rotationMembersKey = key;
} catch (error) {
state.rotationMembers = { error: error.message || "成分股加载失败", rows: [] };
state.rotationMembersKey = key;
} finally {
state.rotationMembersLoading = false;
renderRotationMembers();
}
}
function renderRotationMembers() {
const body = document.querySelector("#rotationTableBody");
const empty = document.querySelector("#rotationMembersEmpty");
if (state.rotationMembersLoading) {
body.innerHTML = "";
empty.textContent = `正在核验${state.rotationSelectedSector}成分股`;
empty.hidden = false;
return;
}
const payload = state.rotationMembers;
const rows = payload?.rows || [];
if (!state.rotationSelectedSector || !payload || payload.error || !rows.length) {
body.innerHTML = "";
empty.textContent = payload?.error || (state.rotationSelectedSector ? "该板块暂无可用成分行情" : "点击上方任意板块查看成分股");
empty.hidden = false;
setText("rotationDetailTitle", "板块成分股");
setText("rotationDetailMeta", state.rotationSelectedSector || "--");
return;
}
empty.hidden = true;
setText("rotationDetailTitle", `${payload.meta?.sector_name || state.rotationSelectedSector}成分股`);
setText("rotationDetailMeta", `${displayCompactDate(payload.meta?.trade_date)} · ${number(payload.meta?.quoted_count)} / ${number(payload.meta?.member_count)}`);
body.innerHTML = rows.map((row, index) => `
<tr data-code="${escapeHtml(row.code)}"><td class="number num muted">${index + 1}</td><td class="stock-code">${escapeHtml(row.code)}</td><td class="stock-name">${escapeHtml(row.name)}</td>
<td class="number num ${row.quoted ? changeClass(row.change) : "muted"}" data-sort-value="${row.quoted ? number(row.change) : -999}">${row.quoted ? signed(row.change) : ""}</td>
<td class="number num">${row.quoted ? formatNumber(row.open, 2) : ""}</td><td class="number num">${row.quoted ? formatNumber(row.close, 2) : ""}</td>
<td class="number num" data-sort-value="${number(row.amount_billion)}">${row.quoted ? formatNumber(row.amount_billion, 2) : ""}</td><td>${row.quoted ? "正常交易" : "当日无行情"}</td></tr>
`).join("");
animateRows(body);
bindStockRows(body);
}
/* PRESERVATION-SOURCE-END app.js:1958-2124 */
+60
View File
@@ -0,0 +1,60 @@
(function exposePageModuleRuntime(global) {
"use strict";
const definitions = new Map();
let sealed = false;
function register(feature, viewIds, lifecycle = {}) {
if (sealed) throw new Error("Page module registry is already sealed");
if (!feature || !Array.isArray(viewIds) || !viewIds.length) {
throw new Error("Page modules require a feature and at least one view ID");
}
viewIds.forEach((viewId) => {
if (definitions.has(viewId)) throw new Error(`Duplicate page module: ${viewId}`);
definitions.set(viewId, Object.freeze({
feature,
viewId,
enter: Object.freeze([...(lifecycle.enter || [])]),
leave: Object.freeze([...(lifecycle.leave || [])]),
}));
});
}
function create(options) {
sealed = true;
const pages = options.pages;
const actions = Object.freeze({ ...(options.actions || {}) });
const missing = pages.all.filter((page) => !definitions.has(page.id)).map((page) => page.id);
if (missing.length) throw new Error(`Missing page modules: ${missing.join(", ")}`);
function run(actionNames, context) {
actionNames.forEach((actionName) => {
const action = actions[actionName];
if (typeof action !== "function") throw new Error(`Unknown page action: ${actionName}`);
action(context);
});
}
function beforeMount(viewId, previousView) {
actions.closeTransientUi?.({ viewId, previousView });
if (previousView && previousView !== viewId) {
run(definitions.get(previousView)?.leave || [], { viewId, previousView });
}
}
function afterMount(viewId, previousView) {
const context = { viewId, previousView };
actions.applyAccess?.(context);
run(definitions.get(viewId)?.enter || [], context);
}
return Object.freeze({
afterMount,
beforeMount,
get: (viewId) => definitions.get(viewId) || null,
has: (viewId) => definitions.has(viewId),
});
}
global.XiaobaiPageModules = Object.freeze({ create, register });
})(window);
File diff suppressed because it is too large Load Diff
+316
View File
@@ -0,0 +1,316 @@
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
enter: ["loadSentiment"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:1207-1516 */
async function loadSentimentHistory(force = false) {
if (!state.dashboard || state.sentimentLoading) return;
const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) {
renderSentimentHistory();
return;
}
state.sentimentLoading = true;
const notice = document.querySelector("#sentimentHistoryNotice");
notice.hidden = true;
try {
const query = new URLSearchParams({
trade_date: elements.tradeDate.value,
limit: String(state.sentimentRange),
});
state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`);
state.sentimentHistoryKey = key;
renderSentimentHistory();
} catch (error) {
notice.textContent = error.message || "情绪周期数据加载失败";
notice.hidden = false;
showToast(notice.textContent);
} finally {
state.sentimentLoading = false;
}
}
function renderSentimentHistory() {
const payload = state.sentimentHistory;
if (!payload) return;
const rows = payload.rows || [];
const latest = rows[rows.length - 1];
const body = document.querySelector("#sentimentHistoryBody");
const empty = document.querySelector("#sentimentHistoryEmpty");
empty.hidden = rows.length > 0;
body.innerHTML = [...rows].reverse().map((row) => {
return `
<tr class="${row.trade_date === latest?.trade_date ? "latest-row" : ""}">
<td class="sentiment-date-cell">${escapeHtml(displayCompactDate(row.trade_date))}</td>
<td class="number sentiment-score-cell ${sentimentScoreClass(row.score)}">${number(row.score)}</td>
<td><span class="sentiment-phase-badge ${sentimentPhaseClass(row.phase)}">${escapeHtml(row.phase)}</span></td>
<td><span class="sentiment-direction ${trendClass(row.direction)}">${escapeHtml(row.direction)}</span></td>
<td class="number">${number(row.limit_up_count)}</td>
<td class="number">${number(row.first_board_count)}</td>
<td class="number">${number(row.second_board_count)}</td>
<td class="number">${number(row.three_plus_count)}</td>
<td class="number">${number(row.max_height)}板</td>
<td class="number">${number(row.broken_count)}</td>
<td class="number">${number(row.limit_down_count)}</td>
<td class="number">${number(row.previous_limit_count)}</td>
<td class="number">${number(row.previous_positive_count)}</td>
<td class="number">${formatNumber(row.previous_positive_rate, 1)}%</td>
</tr>
`;
}).join("");
if (!latest) {
setText("sentimentHistoryDateRange", "暂无历史数据");
return;
}
setText(
"sentimentHistoryDateRange",
`${displayCompactDate(rows[0].trade_date)}${displayCompactDate(latest.trade_date)}`,
);
setText("sentimentCycleScore", number(latest.score));
setText("sentimentCycleLabel", latest.label);
setText("sentimentCycleDate", displayCompactDate(latest.trade_date));
setText("sentimentCyclePhase", latest.phase);
setText("sentimentCycleDirection", latest.direction);
const dayChange = number(latest.day_change);
const confidence = sentimentPhaseConfidence(latest);
setText("sentimentPhaseConfidence", `置信度 ${confidence}%`);
setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`);
setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`);
setText("sentimentLimitUp", number(latest.limit_up_count));
setText("sentimentBroken", number(latest.broken_count));
setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase));
setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`);
setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`);
setText("sentimentPeriodNote", `${state.sentimentRange} 个交易日,当前展示 ${rows.length}`);
const changeElement = document.querySelector("#sentimentDayChange");
changeElement.className = changeClass(dayChange);
setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)}`);
setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`);
setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`);
setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length}`);
const marker = document.querySelector("#sentimentCycleScoreMarker");
marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`;
document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
<article class="sentiment-component-item">
<div class="sentiment-component-main">
<strong>${escapeHtml(item.label)}</strong>
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
<b>${formatNumber(item.score, 1)} <em>× ${number(item.weight)}%</em></b>
</div>
<small>${escapeHtml(item.summary)}</small>
</article>
`).join("");
requestAnimationFrame(() => {
animateSentimentComponents();
animateSentimentTrendChart(rows);
bindSentimentChartTooltip(rows);
});
animateRows(body);
}
function animateSentimentComponents() {
document.querySelectorAll("#sentimentComponentList [data-component-score]").forEach((bar, index) => {
const width = `${number(bar.dataset.componentScore)}%`;
if (!motionEnabled()) {
bar.style.width = width;
return;
}
setTimeout(() => { bar.style.width = width; }, index * 70);
});
}
function animateSentimentTrendChart(rows) {
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
if (!motionEnabled()) {
drawSentimentTrendChart(rows, 1);
return;
}
const startedAt = performance.now();
const duration = 780;
const frame = (now) => {
const rawProgress = Math.min(1, (now - startedAt) / duration);
const progress = 1 - (1 - rawProgress) ** 3;
drawSentimentTrendChart(rows, progress);
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
else sentimentChartAnimationFrame = null;
};
sentimentChartAnimationFrame = requestAnimationFrame(frame);
}
function drawSentimentTrendChart(rows, progress = 1) {
const canvas = document.querySelector("#sentimentTrendChart");
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
const rect = canvas.getBoundingClientRect();
if (!rect.width) return;
const width = Math.max(320, rect.width);
const height = Math.max(220, rect.height);
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const context = canvas.getContext("2d");
const palette = currentChartPalette();
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = palette.background;
context.fillRect(0, 0, width, height);
const padding = { top: 18, right: 18, bottom: 34, left: 42 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const x = (index) => padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
const y = (score) => padding.top + (100 - clamp(score, 0, 100)) / 100 * chartHeight;
context.font = '10px "Microsoft YaHei UI", sans-serif';
context.textAlign = "right";
context.textBaseline = "middle";
for (let score = 0; score <= 100; score += 20) {
const lineY = y(score);
context.strokeStyle = score === 40 || score === 80 ? palette.zero : palette.grid;
context.lineWidth = 1;
context.beginPath();
context.moveTo(padding.left, lineY);
context.lineTo(width - padding.right, lineY);
context.stroke();
context.fillStyle = palette.axis;
context.fillText(String(score), padding.left - 8, lineY);
}
context.save();
context.beginPath();
context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18);
context.clip();
const finalPhase = rows[rows.length - 1]?.phase;
let phaseStart = rows.length - 1;
while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1;
if (["退潮", "冰点"].includes(finalPhase)) {
const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2;
context.fillStyle = palette.alertArea;
context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight);
context.fillStyle = palette.up;
context.font = '10px "Microsoft YaHei UI", sans-serif';
context.textAlign = "center";
context.textBaseline = "top";
context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4);
}
const movingAverage = rows.map((_row, index) => {
const start = Math.max(0, index - 4);
const sample = rows.slice(start, index + 1);
return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length;
});
context.beginPath();
movingAverage.forEach((score, index) => {
if (index === 0) context.moveTo(x(index), y(score));
else context.lineTo(x(index), y(score));
});
context.strokeStyle = palette.movingAverage;
context.lineWidth = 1.5;
context.setLineDash([5, 4]);
context.stroke();
context.setLineDash([]);
context.beginPath();
rows.forEach((row, index) => {
const pointX = x(index);
const pointY = y(row.score);
if (index === 0) context.moveTo(pointX, pointY);
else context.lineTo(pointX, pointY);
});
context.lineTo(x(rows.length - 1), padding.top + chartHeight);
context.lineTo(x(0), padding.top + chartHeight);
context.closePath();
context.fillStyle = palette.area;
context.fill();
context.beginPath();
rows.forEach((row, index) => {
const pointX = x(index);
const pointY = y(row.score);
if (index === 0) context.moveTo(pointX, pointY);
else context.lineTo(pointX, pointY);
});
context.strokeStyle = palette.line;
context.lineWidth = 2.5;
context.lineJoin = "round";
context.lineCap = "round";
context.stroke();
rows.forEach((row, index) => {
context.beginPath();
context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2);
context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line;
context.fill();
context.strokeStyle = palette.background;
context.lineWidth = 1.5;
context.stroke();
});
context.restore();
const labelStep = Math.max(1, Math.ceil(rows.length / 6));
context.textAlign = "center";
context.textBaseline = "top";
context.fillStyle = palette.axis;
rows.forEach((row, index) => {
if (index % labelStep !== 0 && index !== rows.length - 1) return;
const dateText = displayCompactDate(row.trade_date).slice(5);
context.fillText(dateText, x(index), height - padding.bottom + 10);
});
}
function bindSentimentChartTooltip(rows) {
const canvas = document.querySelector("#sentimentTrendChart");
const tooltip = document.querySelector("#sentimentChartTooltip");
if (!canvas || !tooltip || !rows.length) return;
canvas.onmousemove = (event) => {
const rect = canvas.getBoundingClientRect();
const padding = { left: 42, right: 18 };
const chartWidth = Math.max(1, rect.width - padding.left - padding.right);
const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth);
const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1));
const row = rows[index];
tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 <b>${number(row.score)}</b> · ${escapeHtml(row.phase)}`;
tooltip.hidden = false;
const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth);
tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`;
tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`;
};
canvas.onmouseleave = () => { tooltip.hidden = true; };
}
function sentimentScoreClass(score) {
const value = number(score);
return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral";
}
function sentimentPhaseClass(phase) {
return {
"冰点": "phase-ice",
"修复": "phase-repair",
"发酵": "phase-fermentation",
"高潮": "phase-climax",
"分化": "phase-divergence",
"退潮": "phase-retreat",
}[phase] || "phase-divergence";
}
function sentimentPhaseConfidence(row) {
const explicit = number(row?.confidence || row?.phase_confidence);
if (explicit > 0) return Math.round(clamp(explicit, 0, 100));
const historyEvidence = Math.min(12, number(row?.history_days) * 0.6);
const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8);
return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92));
}
function sentimentPhaseAdvice(phase) {
return {
"冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。",
"修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。",
"发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。",
"高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。",
"分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。",
"退潮": "情绪指标继续走弱。",
}[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
}
/* PRESERVATION-SOURCE-END app.js:1207-1516 */
+108
View File
@@ -0,0 +1,108 @@
window.XiaobaiPageModules.register("themes", ["themeLibraryView"], {
enter: ["loadThemes"],
});
/* PRESERVATION-SOURCE-BEGIN app.js:2482-2583 */
async function loadThemeLibrary(force = false) {
if (state.themeLoading) return;
state.themeLoading = true;
const button = document.querySelector("#themeRefreshButton");
button.disabled = true;
setText("themeDateLabel", "正在整理题材库");
try {
const query = new URLSearchParams({ trade_date: elements.tradeDate.value });
if (force) query.set("force", "1");
state.themeLibrary = await apiRequest(`/api/themes?${query}`);
renderThemeLibrary();
const available = (state.themeLibrary.items || []).some((item) => item.code === state.selectedThemeCode);
if (!available) state.selectedThemeCode = "";
const initialCode = state.selectedThemeCode || state.themeLibrary.items?.[0]?.code || "";
if (initialCode) await selectTheme(initialCode, true);
} catch (error) {
setText("themeDateLabel", error.message || "题材数据暂不可用");
renderEmptyState("themeDirectory", error.message || "题材数据加载失败");
showToast(error.message || "题材数据加载失败");
} finally {
state.themeLoading = false;
button.disabled = false;
}
}
function renderThemeLibrary() {
const payload = state.themeLibrary;
if (!payload) return;
const summary = payload.summary || {};
setText("themeDateLabel", `${payload.meta?.carried_forward ? "最近有效行情" : "行情日期"} ${payload.meta?.trade_date || "--"}`);
document.querySelector("#themeSummary").innerHTML = [
["收录题材", number(summary.theme_count), "个", ""],
["当日上涨", number(summary.up_count), "个", "up"],
["当日下跌", number(summary.down_count), "个", "down"],
["人气题材", number(summary.hot_count), "个", "warning"],
].map(([label, value, unit, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}<small>${unit}</small></strong></div>`).join("");
renderThemeDirectory();
}
function renderThemeDirectory() {
let items = [...(state.themeLibrary?.items || [])];
if (state.themeQuery) {
items = items.filter((item) => `${item.code} ${item.name}`.toLocaleLowerCase("zh-CN").includes(state.themeQuery));
}
setText("themeResultCount", `${items.length}`);
document.querySelector("#themeDirectory").innerHTML = items.map((item, index) => {
const active = item.code === state.selectedThemeCode;
return `
<button type="button" class="theme-directory-item-v2 ${active ? "active" : ""}" data-theme-code="${escapeHtml(item.code)}" aria-pressed="${active}">
<span class="theme-rank-v2">${index + 1}</span>
<span class="theme-directory-copy-v2"><strong class="market-preview-trigger" data-market-preview-type="theme" data-market-preview-id="${escapeHtml(item.code)}" title="悬停预览题材行情">${escapeHtml(item.name)}</strong><small>${number(item.member_count)} 只成分${item.hot_rank ? ` · 人气第 ${number(item.hot_rank)}` : ""}</small></span>
<b class="${changeClass(item.change)}">${item.has_quote ? `${signed(item.change)}%` : "--"}</b>
</button>`;
}).join("") || emptyStateHtml("没有匹配的题材");
}
async function selectTheme(code, keepSelection = false) {
if (!code) return;
state.selectedThemeCode = code;
if (!keepSelection) renderThemeDirectory();
document.querySelector("#themeDetailEmpty").hidden = false;
document.querySelector("#themeDetailContent").hidden = true;
setText("themeDetailEmpty", "正在读取题材详情");
try {
const query = new URLSearchParams({ code, trade_date: elements.tradeDate.value });
state.themeDetail = await apiRequest(`/api/themes/detail?${query}`);
renderThemeDetail();
} catch (error) {
setText("themeDetailEmpty", error.message || "题材详情加载失败");
showToast(error.message || "题材详情加载失败");
}
}
function renderThemeDetail() {
const payload = state.themeDetail;
if (!payload) return;
const theme = payload.theme || {};
const summary = payload.summary || {};
document.querySelector("#themeDetailEmpty").hidden = true;
document.querySelector("#themeDetailContent").hidden = false;
setText("themeDetailName", theme.name || "--");
setText("themeDetailCode", `${theme.code || "--"} · ${payload.meta?.trade_date || "--"}`);
setText("themeDetailChange", `${signed(theme.change)}%`);
document.querySelector("#themeDetailChange").className = changeClass(theme.change);
document.querySelector("#themeDetailMetrics").innerHTML = [
["成分股", `${number(summary.member_count)}`, ""],
["有行情", `${number(summary.quoted_count)}`, ""],
["上涨", `${number(summary.up_count)}`, "up"],
["下跌", `${number(summary.down_count)}`, "down"],
["换手率", `${formatNumber(theme.turnover_rate, 2)}%`, ""],
].map(([label, value, tone]) => `<div><span>${label}</span><strong class="${tone}">${value}</strong></div>`).join("");
setText("themeMemberCount", `有行情 ${number(summary.quoted_count)} / ${number(summary.member_count)}`);
const body = document.querySelector("#themeMemberTableBody");
body.innerHTML = (payload.members || []).map((row, index) => `
<tr data-code="${escapeHtml(row.code)}"><td class="row-number num muted">${index + 1}</td>
<td><span class="stock-cell"><strong class="stock-name sname">${escapeHtml(row.name)}</strong><small class="stock-code scode">${escapeHtml(row.code)}</small></span></td>
<td class="number num ${changeClass(row.change)}">${row.has_quote ? signed(row.change) : ""}</td>
<td class="number num">${row.has_quote ? formatNumber(row.price, 2) : ""}</td><td class="number num">${row.has_quote ? formatNumber(row.amount_billion, 2) : ""}</td></tr>`).join("");
bindStockRows(body);
renderThemeDirectory();
}
/* PRESERVATION-SOURCE-END app.js:2482-2583 */
+102
View File
@@ -0,0 +1,102 @@
(function exposeApiClient(global) {
"use strict";
let csrfTokenSupplier = () => "";
let unauthorizedHandler = () => {};
class ApiError extends Error {
constructor(message, status = 0, payload = null) {
super(message);
this.name = "ApiError";
this.status = status;
this.payload = payload;
}
}
function configure(options = {}) {
if (typeof options.csrfToken === "function") csrfTokenSupplier = options.csrfToken;
if (typeof options.onUnauthorized === "function") unauthorizedHandler = options.onUnauthorized;
}
function requestOptions(method, body, signal) {
const normalizedMethod = String(method || "GET").toUpperCase();
const options = { method: normalizedMethod, headers: {}, signal };
const csrfToken = csrfTokenSupplier();
if (!["GET", "HEAD", "OPTIONS"].includes(normalizedMethod) && csrfToken) {
options.headers["X-CSRF-Token"] = csrfToken;
}
if (body !== null && body !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(body);
}
return options;
}
async function parseJson(response) {
try {
return await response.json();
} catch (_error) {
return {};
}
}
function handleUnauthorized(response, url) {
if (response.status === 401 && !String(url).startsWith("/api/auth/")) {
unauthorizedHandler({ response, url });
}
}
async function request(url, method = "GET", body = null, options = {}) {
const response = await fetch(url, requestOptions(method, body, options.signal));
const payload = await parseJson(response);
handleUnauthorized(response, url);
if (!response.ok || payload.error) {
throw new ApiError(payload.error || "请求失败", response.status, payload);
}
return payload;
}
async function streamNdjson(url, options = {}) {
const response = await fetch(
url,
requestOptions(options.method || "POST", options.body, options.signal),
);
if (!response.ok) {
const payload = await parseJson(response);
handleUnauthorized(response, url);
throw new ApiError(
payload.error || options.errorMessage || "流式请求暂不可用",
response.status,
payload,
);
}
if (!response.body) throw new ApiError("当前浏览器不支持流式回答", response.status);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const consume = (line) => {
if (!line.trim()) return;
let event;
try {
event = JSON.parse(line);
} catch (_error) {
throw new ApiError("流式响应格式错误", response.status);
}
if (event.type === "error") {
throw new ApiError(event.error || options.errorMessage || "流式请求失败", response.status, event);
}
options.onEvent?.(event);
};
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
lines.forEach(consume);
if (done) break;
}
if (buffer.trim()) consume(buffer);
}
global.XiaobaiAPI = Object.freeze({ ApiError, configure, request, streamNdjson });
})(window);
+54
View File
@@ -0,0 +1,54 @@
(function exposeSharedComponents(global) {
"use strict";
const ui = global.XiaobaiUI;
if (!ui) throw new Error("XiaobaiUI must load before shared components");
function resolveElement(target, root = document) {
if (target instanceof Element) return target;
if (typeof target !== "string" || !target) return null;
return target.startsWith("#") ? root.querySelector(target) : root.getElementById?.(target);
}
function classNames(...values) {
return values.flatMap((value) => String(value || "").split(/\s+/)).filter(Boolean).join(" ");
}
function emptyStateHtml(message, options = {}) {
const classes = classNames("empty-state", options.className);
const attributes = options.role ? ` role="${ui.escapeHtml(options.role)}"` : "";
return `<div class="${ui.escapeHtml(classes)}"${attributes}>${ui.escapeHtml(message)}</div>`;
}
function renderEmptyState(target, message, options = {}) {
const element = resolveElement(target, options.root);
if (!element) return false;
element.innerHTML = emptyStateHtml(message, options);
return true;
}
function setText(target, value, options = {}) {
const element = resolveElement(target, options.root);
if (!element) return false;
element.textContent = value == null ? "" : String(value);
return true;
}
function renderCollection(target, items, renderItem, options = {}) {
const element = resolveElement(target, options.root);
if (!element) return 0;
const rows = Array.isArray(items) ? items : [];
element.innerHTML = rows.length
? rows.map((item, index) => renderItem(item, index)).join("")
: emptyStateHtml(options.emptyMessage || "", options.emptyOptions);
return rows.length;
}
global.XiaobaiComponents = Object.freeze({
classNames,
emptyStateHtml,
renderCollection,
renderEmptyState,
setText,
});
})(window);
+149
View File
@@ -0,0 +1,149 @@
/* PRESERVATION-SOURCE-BEGIN app.js:8905-9051 */
function exportStocks() {
exportRows("涨停池", getVisibleStocks(), [
["股票代码", "code"], ["股票名称", "name"], ["连板", "streak"], ["涨幅%", "change"],
["价格", "price"], ["所属板块", "sector"], ["涨停原因", "reason"], ["首封", "first_time"],
["最后封板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"],
["成交额亿", "amount_billion"], ["封单额万", "seal_amount_million"],
]);
}
function exportBroken() {
exportRows("炸板池", getVisibleBrokenRows(), [
["股票代码", "code"], ["股票名称", "name"], ["现价涨幅%", "change"], ["距涨停%", "limitGap"],
["价格", "price"], ["所属板块", "sector"], ["首次触板", "first_time"], ["开板次数", "open_times"],
["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
]);
}
function exportDown() {
exportRows("跌停板", getVisibleDownRows(), [
["股票代码", "code"], ["股票名称", "name"], ["跌幅%", "change"], ["价格", "price"],
["所属板块", "sector"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"],
]);
}
function exportYesterday() {
exportRows("昨日涨停", getVisibleYesterdayRows(), [
["股票代码", "code"], ["股票名称", "name"], ["昨日高度", "prior_streak"],
["今日涨幅%", "current_change"], ["今日结果", "outcome"], ["当前高度", "current_streak"],
["所属板块", "sector"],
]);
}
function exportLadder() {
const rows = (state.dashboard?.ladders || []).flatMap((group) => (group.stocks || []).map((stock) => ({
level: group.label || group.level,
...stock,
})));
exportRows("市场天梯", rows, [
["梯队", "level"], ["股票代码", "code"], ["股票名称", "name"], ["所属板块", "sector"],
["封板时间", "first_time"], ["开板次数", "open_times"], ["封单额万", "seal_amount_million"], ["成交额亿", "amount_billion"],
]);
}
function exportRotation() {
const sectorMap = new Map((state.dashboard?.sectors || []).map((sector) => [sector.name, sector]));
const rows = (state.dashboard?.sector_rotation || []).map((row) => ({
...row,
average_change: sectorMap.get(row.name)?.change ?? 0,
}));
exportRows("板块轮动", rows, [
["排名", "rank"], ["板块", "name"], ["趋势", "trend"], ["今日涨停", "count"],
["昨日涨停", "previous_count"], ["变化", "delta"], ["强度", "strength"],
["最高板", "max_streak"], ["平均涨幅%", "average_change"],
["领涨股", "leader"], ["涨停股成交额亿", "amount_billion"],
]);
}
function exportSentimentHistory() {
const rows = state.sentimentHistory?.rows || [];
if (!rows.length) {
showToast("暂无可导出的情绪周期数据");
return;
}
const exportRowsData = rows.map((row) => ({
...row,
breadth_score: row.components?.breadth?.score,
limit_ecology_score: row.components?.limit_ecology?.score,
profit_effect_score: row.components?.profit_effect?.score,
ladder_structure_score: row.components?.ladder_structure?.score,
liquidity_score: row.components?.liquidity?.score,
}));
exportRows("情绪周期", exportRowsData, [
["交易日", "trade_date"], ["情绪温度", "score"], ["周期阶段", "phase"], ["方向", "direction"],
["涨停", "limit_up_count"], ["首板", "first_board_count"], ["二板", "second_board_count"],
["三板以上", "three_plus_count"], ["连板高度", "max_height"], ["炸板", "broken_count"],
["跌停", "limit_down_count"], ["昨日涨停", "previous_limit_count"],
["昨日涨停红盘", "previous_positive_count"], ["昨日涨停红盘率%", "previous_positive_rate"],
["市场宽度", "breadth_score"], ["涨停生态", "limit_ecology_score"],
["赚钱效应", "profit_effect_score"], ["连板结构", "ladder_structure_score"],
["成交活跃度", "liquidity_score"],
]);
}
function exportDragonTiger() {
const rows = (state.dragonTiger?.traders || []).flatMap((trader) => (
(trader.operations || []).map((operation) => ({
trader_name: trader.name,
identity_type: dragonIdentityLabel(trader.identity_type),
...operation,
}))
));
exportRows("游资龙虎榜", rows, [
["游资或席位", "trader_name"], ["身份", "identity_type"], ["股票代码", "code"],
["股票名称", "name"], ["方向", "direction"], ["涨幅%", "change"],
["买入百万元", "buy_million"], ["卖出百万元", "sell_million"], ["净额百万元", "net_buy_million"],
["关联席位", "seat_name"], ["上榜原因", "reason"],
]);
}
function exportHotMoneyProfiles() {
const rows = state.hotMoneyProfiles?.profiles || [];
if (!rows.length) {
showToast("暂无可导出的游资档案");
return;
}
downloadCsv(
`游资档案-${todayString()}.csv`,
["游资名称", "简介", "关联营业部", "席位数量"],
rows.map((profile) => [
profile.name,
profile.description,
(profile.organizations || []).join(""),
number(profile.organization_count),
]),
);
}
function commonReviewColumns() {
return [["股票代码", "code"], ["股票名称", "name"], ["状态", "status"], ["涨跌幅%", "change"],
["价格", "price"], ["所属板块", "sector"], ["原因", "reason"], ["首次触板", "first_time"],
["最后触板", "last_time"], ["开板次数", "open_times"], ["换手率%", "turnover_rate"], ["成交额亿", "amount_billion"]];
}
function exportRows(label, rows, columns) {
const headers = columns.map(([header]) => header);
const data = rows.map((row) => columns.map(([, key]) => row[key] ?? ""));
downloadCsv(`${label}-${state.dashboard.meta.trade_date}.csv`, headers, data);
}
function downloadCsv(filename, headers, rows) {
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
const blob = new Blob(["\ufeff", lines.join("\r\n")], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
showToast(`已导出 ${rows.length} 条数据`);
}
function csvCell(value) {
let text = String(value ?? "");
if (/^[=+\-@]/.test(text)) text = `'${text}`;
return `"${text.replaceAll('"', '""')}"`;
}
/* PRESERVATION-SOURCE-END app.js:8905-9051 */
+194
View File
@@ -0,0 +1,194 @@
(function exposeApplicationShell(global) {
"use strict";
const SIDEBAR_STORAGE_KEY = "xiaobai-sidebar-collapsed";
function create(options) {
const state = options.state;
const registry = options.pages;
let initialized = false;
function toggleHeaderCommandMenu(force) {
const menu = document.querySelector("#headerCommandGroup");
const button = document.querySelector("#headerMenuButton");
if (!menu || !button) return;
const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open");
menu.classList.toggle("is-open", open);
button.setAttribute("aria-expanded", String(open));
}
function updateSidebarControl() {
const button = document.querySelector("#sidebarCollapseButton");
if (!button) return;
const automaticallyCollapsed = global.innerWidth <= 1023 && global.innerWidth > 720;
const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed;
button.setAttribute("aria-expanded", String(!collapsed));
button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏");
button.title = collapsed ? "展开侧栏" : "收起侧栏";
const label = button.querySelector("span");
if (label) label.textContent = collapsed ? "展开侧栏" : "收起侧栏";
}
function toggleSidebar() {
const collapsed = document.body.classList.toggle("sidebar-collapsed");
try {
global.localStorage.setItem(SIDEBAR_STORAGE_KEY, collapsed ? "1" : "0");
} catch (_error) {
// The shell remains usable when storage is unavailable.
}
updateSidebarControl();
}
function syncNavigation(viewId) {
const page = registry.get(viewId);
const navigationId = page?.navigation_alias || viewId;
const marketView = page?.group === "market";
document.body.dataset.activeView = viewId;
document.querySelectorAll(".module-tab").forEach((button) => {
button.classList.toggle("active", button.dataset.view === navigationId);
button.classList.toggle(
"mobile-active",
global.innerWidth <= 720
&& marketView
&& button.dataset.view === "limitPool"
&& viewId !== "limitPool",
);
});
const selector = document.querySelector("#mobileMarketSelector");
const select = document.querySelector("#mobileMarketViewSelect");
if (selector) selector.hidden = !marketView;
if (select && marketView) select.value = viewId;
toggleHeaderCommandMenu(false);
options.onNavigationSync?.(viewId);
}
function setStatus(text) {
const status = document.querySelector("#statusText");
if (status) status.textContent = text;
}
function setPageStatus(viewId, tradeDate = "") {
const page = registry.get(viewId);
const label = page?.title || "小白复盘";
setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`);
}
function openModalDialog(dialog) {
if (!(dialog instanceof HTMLDialogElement)) return;
document.querySelectorAll("dialog[open]").forEach((openDialog) => {
if (openDialog !== dialog) openDialog.close();
});
if (!dialog.open) dialog.showModal();
}
function mount(viewId, mountOptions = {}) {
const page = registry.get(viewId);
const view = document.getElementById(viewId);
if (!page || !view?.classList.contains("workspace-view")) return false;
const previousView = state.activeView;
options.onBeforeMount?.(viewId, previousView);
state.activeView = viewId;
document.querySelectorAll(".workspace-view").forEach((candidate) => {
const active = candidate.id === viewId;
candidate.classList.toggle("active-view", active);
candidate.classList.remove("view-entering");
if (active && options.motionEnabled?.()) {
void candidate.offsetWidth;
candidate.classList.add("view-entering");
candidate.addEventListener(
"animationend",
() => candidate.classList.remove("view-entering"),
{ once: true },
);
const body = candidate.querySelector("tbody");
if (body) options.animateRows?.(body);
}
});
syncNavigation(viewId);
setPageStatus(viewId, options.tradeDate?.() || "");
if (mountOptions.updateUrl !== false) {
const url = new URL(global.location.href);
url.searchParams.set("view", viewId);
url.hash = "";
global.history.replaceState(null, "", url);
}
global.scrollTo({ top: 0, behavior: "auto" });
options.onAfterMount?.(viewId, previousView);
return true;
}
function initialize() {
if (initialized) return;
initialized = true;
let collapsed = false;
try {
collapsed = global.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "1";
} catch (_error) {
collapsed = false;
}
document.body.classList.toggle("sidebar-collapsed", collapsed);
updateSidebarControl();
syncNavigation(state.activeView);
document.querySelectorAll(".module-tab").forEach((button) => {
button.addEventListener("click", () => options.onNavigate?.(button.dataset.view));
});
document.querySelectorAll("[data-open-view]").forEach((button) => {
button.addEventListener("click", () => options.onNavigate?.(button.dataset.openView));
});
document.querySelector("#mobileMarketViewSelect")?.addEventListener("change", (event) => {
options.onNavigate?.(event.target.value);
});
document.querySelector("#sidebarCollapseButton")?.addEventListener("click", toggleSidebar);
document.querySelector("#headerMenuButton")?.addEventListener("click", (event) => {
event.stopPropagation();
toggleHeaderCommandMenu();
});
document.querySelector("#headerCommandGroup")?.addEventListener("click", (event) => {
if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) {
toggleHeaderCommandMenu(false);
}
});
document.querySelector("#overviewToggle")?.addEventListener("click", (event) => {
const overview = document.querySelector(".overview-strip");
if (!overview) return;
const expanded = overview.dataset.overviewExpanded !== "true";
overview.dataset.overviewExpanded = String(expanded);
event.currentTarget.setAttribute("aria-expanded", String(expanded));
event.currentTarget.title = expanded ? "收起市场详情" : "展开市场详情";
const label = event.currentTarget.querySelector("span");
if (label) label.textContent = expanded ? "收起详情" : "展开详情";
event.currentTarget.querySelector("i")?.setAttribute(
"data-lucide",
expanded ? "chevron-up" : "chevron-down",
);
options.refreshIcons?.();
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") toggleHeaderCommandMenu(false);
});
global.addEventListener("resize", () => {
if (global.innerWidth > 720) toggleHeaderCommandMenu(false);
updateSidebarControl();
syncNavigation(state.activeView);
});
}
return Object.freeze({
initialize,
mount,
openModalDialog,
page: (viewId) => registry.get(viewId),
setPageStatus,
setStatus,
syncNavigation,
toggleHeaderCommandMenu,
toggleSidebar,
updateSidebarControl,
});
}
global.XiaobaiShell = Object.freeze({ create });
})(window);
+44
View File
@@ -0,0 +1,44 @@
(function exposeStateStore(global) {
"use strict";
function create(domains) {
const owners = new Map();
const stores = {};
Object.entries(domains).forEach(([domain, values]) => {
stores[domain] = { ...values };
Object.keys(values).forEach((key) => {
if (owners.has(key)) throw new Error(`Duplicate state field: ${key}`);
owners.set(key, domain);
});
});
const proxy = new Proxy({}, {
get(_target, key) {
if (key === "domain") return (name) => stores[name];
if (key === "domains") return Object.freeze({ ...stores });
if (typeof key !== "string" || !owners.has(key)) return undefined;
return stores[owners.get(key)][key];
},
set(_target, key, value) {
if (typeof key !== "string" || !owners.has(key)) {
throw new Error(`Unregistered application state field: ${String(key)}`);
}
stores[owners.get(key)][key] = value;
return true;
},
has(_target, key) {
return key === "domain" || key === "domains" || owners.has(key);
},
ownKeys() {
return [...owners.keys()];
},
getOwnPropertyDescriptor(_target, key) {
if (!owners.has(key)) return undefined;
return { enumerable: true, configurable: true };
},
});
return proxy;
}
global.XiaobaiState = Object.freeze({ create });
})(window);
+402
View File
@@ -0,0 +1,402 @@
/*
* Canonical frontend tokens.
*
* Ownership flows in one direction:
* primitive values -> semantic meaning -> component contracts.
* Historical variable names remain aliases so page CSS can migrate without
* changing the rendered interface.
*/
:root {
color-scheme: light;
/* Primitive tokens */
--color-white: #ffffff;
--color-gray-25: #fcfcfd;
--color-gray-50: #f8f9fb;
--color-gray-100: #f2f4f7;
--color-gray-200: #e5e8ee;
--color-gray-300: #d4d9e2;
--color-gray-500: #697386;
--color-gray-700: #344054;
--color-gray-900: #172033;
--color-shell-canvas: #f1f4f6;
--color-page-canvas: #f4f5f7;
--color-surface-muted: #f6f8fa;
--color-surface-subtle: #f8fafc;
--color-border: #e5e7eb;
--color-border-strong: #d1d5db;
--color-text-primary: #1f2937;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-action-base: #1769c2;
--color-action-base-hover: #10569f;
--color-action-base-soft: #eaf2fb;
--color-action: #2563eb;
--color-action-hover: #1d4ed8;
--color-action-soft: #eff4ff;
--color-action-line: #c7d8fb;
--color-market-up-base: #d33f49;
--color-market-up-base-soft: #fff0f1;
--color-market-up: #e04536;
--color-market-up-soft: #fdecea;
--color-market-down-base: #07805b;
--color-market-down-base-soft: #eaf7f2;
--color-market-down: #16a34a;
--color-market-down-soft: #e9f7ee;
--color-warning-base: #aa6800;
--color-warning-base-soft: #fff6e5;
--color-warning: #b45309;
--color-warning-soft: #fdf3e3;
--size-radius-sm: 5px;
--size-radius-md: 7px;
--size-radius-lg: 10px;
--size-control: 32px;
--size-sidebar: 200px;
--size-topbar: 46px;
--size-summary: 34px;
--size-statusbar: 30px;
--size-page-pad-y: 14px;
--size-page-pad-x: 16px;
--size-card-gap: 12px;
--elevation-card: 0 1px 2px rgba(16, 24, 40, .05);
--elevation-soft: 0 1px 2px rgba(22, 34, 46, .04), 0 5px 18px rgba(22, 34, 46, .035);
--elevation-raised: 0 4px 14px rgba(16, 24, 40, .06);
--elevation-float: 0 14px 38px rgba(16, 24, 40, .14);
--motion-instant: 100ms;
--motion-fast: 140ms;
--motion-medium: 200ms;
--motion-deliberate: 260ms;
--motion-slow: 560ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
/* Semantic tokens */
--canvas: var(--color-shell-canvas);
--surface: var(--color-white);
--surface-muted: var(--color-surface-muted);
--surface-subtle: var(--color-surface-subtle);
--surface-canvas: var(--color-page-canvas);
--surface-raised: var(--color-white);
--surface-selected: #eef4ff;
--border: var(--color-border);
--border-strong: var(--color-border-strong);
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--text-tertiary: var(--color-text-tertiary);
--action: var(--color-action-base);
--action-hover: var(--color-action-base-hover);
--action-soft: var(--color-action-base-soft);
--market-up: var(--color-market-up-base);
--market-up-soft: var(--color-market-up-base-soft);
--market-down: var(--color-market-down-base);
--market-down-soft: var(--color-market-down-base-soft);
--warning-color: var(--color-warning-base);
--warning-soft: var(--color-warning-base-soft);
--primary: var(--color-action);
--primary-hover: var(--color-action-hover);
--danger: var(--color-market-up);
--success: var(--color-market-down);
--warning: var(--color-warning);
/* Component tokens */
--card-bg: var(--surface-raised);
--card-border: var(--border);
--card-radius: var(--size-radius-lg);
--card-shadow: var(--elevation-card);
--control-height: var(--size-control);
--page-gap: var(--size-card-gap);
--radius-sm: var(--size-radius-sm);
--radius-md: var(--size-radius-md);
--radius-lg: var(--size-radius-lg);
--shadow-xs: var(--elevation-card);
--shadow-sm: var(--elevation-raised);
--shadow-float: var(--elevation-float);
--duration-fast: 150ms;
--duration-normal: 220ms;
--sidebar-width: var(--size-sidebar);
--topbar-height: var(--size-topbar);
--summary-height: var(--size-summary);
--statusbar-height: var(--size-statusbar);
--page-pad-y: var(--size-page-pad-y);
--page-pad-x: var(--size-page-pad-x);
--card-gap: var(--size-card-gap);
--workspace-height: calc(100vh - var(--topbar-height) - var(--statusbar-height));
--content-height: calc(var(--workspace-height) - var(--summary-height));
--table-wide: 1180px;
--table-medium: 930px;
--table-compact: 720px;
--col-rank: 44px;
--col-date: 94px;
--col-stock: 160px;
--col-number: 96px;
--col-action: 96px;
--col-text: 220px;
--right-rail-wide: 372px;
--pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap));
--sentiment-history-max-height: 510px;
--sentiment-history-min-height: 220px;
--primary-share: 1.45fr;
--secondary-share: .75fr;
--mobile-nav-height: 58px;
--mobile-header-height: 50px;
--mobile-tab-height: 54px;
--mobile-shell-pad: 8px;
--mobile-page-pad: 10px;
--mobile-min-width: 320px;
--space-4: 4px;
--font-aux: 10.5px;
--dragon-profile-list-width: 340px;
--dragon-profile-detail-min-height: 460px;
--dragon-profile-list-max-height: 320px;
--dragon-profile-row-min-height: 64px;
--dragon-profile-row-avatar-size: 36px;
--dragon-profile-avatar-size: 72px;
--dragon-profile-control-height: 33px;
--dragon-profile-gap: 12px;
--dragon-profile-panel-padding: 16px;
--dragon-profile-row-padding: 10px 12px;
--dragon-profile-title-font: 18px;
--dragon-profile-name-font: 13px;
--dragon-profile-body-font: 12px;
--dragon-profile-meta-font: 11px;
--dragon-profile-transition: 160ms ease;
--dragon-profile-border-width: 1px;
--dragon-profile-focus-width: 2px;
--dragon-profile-focus-offset: -2px;
--dragon-profile-radius-inset: 2px;
--dragon-profile-body-line-height: 1.75;
--dragon-profile-icon-stroke: 1;
--dragon-profile-weight-strong: 750;
--dragon-profile-weight-semibold: 600;
--chart-background: #fbfcfd;
--chart-grid: #e2e8ec;
--chart-axis: #6c7983;
--chart-zero: #aeb7c1;
--chart-line: #1d65c1;
--chart-average: #b7791f;
--chart-up: #c93f45;
--chart-down: #087a55;
--chart-up-volume: rgba(201, 63, 69, .58);
--chart-down-volume: rgba(8, 122, 85, .58);
--chart-area: rgba(37, 99, 235, .07);
--chart-alert-area: rgba(224, 69, 54, .05);
--chart-moving-average: #d1d5db;
--chart-repair: #f59e0b;
--chart-ma-10: #a76500;
--chart-ma-20: #626c78;
/* Compatibility aliases. Do not add new usage of these names. */
--xb-gray-25: var(--color-gray-25);
--xb-gray-50: var(--color-gray-50);
--xb-gray-100: var(--color-gray-100);
--xb-gray-200: var(--color-gray-200);
--xb-gray-300: var(--color-gray-300);
--xb-gray-500: var(--color-gray-500);
--xb-gray-700: var(--color-gray-700);
--xb-gray-900: var(--color-gray-900);
--xb-blue-50: #eef4ff;
--xb-blue-100: #dce8ff;
--xb-blue-500: var(--color-action);
--xb-blue-600: var(--color-action-hover);
--xb-red-50: #fff1f0;
--xb-red-500: var(--color-market-up);
--xb-green-50: #ecf9f1;
--xb-green-500: var(--color-market-down);
--xb-amber-50: #fff7e8;
--xb-amber-500: var(--color-warning);
--bg: var(--surface-canvas);
--card: var(--surface-raised);
--ink: var(--text-primary);
--sub: var(--text-secondary);
--faint: var(--text-tertiary);
--line: var(--border);
--line-soft: #eef0f3;
--line-strong: var(--border-strong);
--text: var(--text-primary);
--text-muted: var(--text-secondary);
--blue: var(--primary);
--blue-d: var(--primary-hover);
--blue-dark: var(--action-hover);
--blue-soft: var(--color-action-soft);
--blue-line: var(--color-action-line);
--up: var(--danger);
--up-soft: var(--color-market-up-soft);
--down: var(--success);
--down-soft: var(--color-market-down-soft);
--coral: var(--market-up);
--coral-soft: var(--market-up-soft);
--green: var(--market-down);
--green-soft: var(--market-down-soft);
--amber: var(--color-warning);
--amber-soft: var(--color-warning-soft);
--radius: var(--size-radius-lg);
--shadow: var(--elevation-card);
--shadow-soft: var(--elevation-soft);
--r2-blue: var(--primary);
--r2-blue-dark: var(--primary-hover);
--r2-blue-soft: var(--color-action-soft);
--r2-blue-line: var(--color-action-line);
--r2-up: var(--danger);
--r2-up-soft: var(--color-market-up-soft);
--r2-down: var(--success);
--r2-down-soft: var(--color-market-down-soft);
--r2-amber: var(--color-warning);
--r2-amber-soft: var(--color-warning-soft);
--r2-ink: var(--text-primary);
--r2-sub: var(--text-secondary);
--r2-faint: var(--text-tertiary);
--r2-line: var(--border);
--r2-line-soft: #eef0f3;
--r2-bg: var(--surface-canvas);
--r2-card: var(--surface-raised);
--r2-radius: var(--size-radius-lg);
--r2-shadow: var(--elevation-card);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif;
font-size: 14px;
}
:root[data-theme="dark"] {
color-scheme: dark;
--canvas: #121416;
--surface: #1b1e21;
--surface-muted: #202428;
--surface-subtle: #24282d;
--surface-canvas: var(--canvas);
--surface-raised: var(--surface);
--surface-selected: #23364a;
--border: #343a40;
--border-strong: #474f57;
--text-primary: #e8eaed;
--text-secondary: #adb5bd;
--text-tertiary: #7f8993;
--action: #6ca8e8;
--action-hover: #8bbcf0;
--action-soft: #23364a;
--market-up: #f06d73;
--market-up-soft: #40262a;
--market-down: #43bc8a;
--market-down-soft: #1d382f;
--warning-color: #e2ad58;
--warning-soft: #3d3220;
--primary: var(--action);
--primary-hover: var(--action-hover);
--danger: var(--market-up);
--success: var(--market-down);
--warning: var(--warning-color);
--card-bg: var(--surface);
--card-border: var(--border);
--xb-gray-25: var(--surface);
--xb-gray-50: var(--surface-muted);
--xb-gray-100: var(--canvas);
--xb-gray-200: var(--border);
--xb-gray-300: var(--border-strong);
--xb-gray-500: var(--text-secondary);
--xb-gray-700: #cbd1d7;
--xb-gray-900: var(--text-primary);
--xb-blue-50: var(--action-soft);
--xb-blue-100: #294866;
--xb-blue-500: var(--action);
--xb-blue-600: var(--action-hover);
--xb-red-50: var(--market-up-soft);
--xb-red-500: var(--market-up);
--xb-green-50: var(--market-down-soft);
--xb-green-500: var(--market-down);
--xb-amber-50: var(--warning-soft);
--xb-amber-500: var(--warning-color);
--bg: var(--canvas);
--card: var(--surface);
--ink: var(--text-primary);
--sub: var(--text-secondary);
--faint: var(--text-tertiary);
--line: var(--border);
--line-soft: #2a2f34;
--line-strong: var(--border-strong);
--text: var(--text-primary);
--text-muted: var(--text-secondary);
--blue: var(--action);
--blue-d: var(--action-hover);
--blue-dark: var(--action-hover);
--blue-soft: var(--action-soft);
--blue-line: #42698e;
--up: var(--market-up);
--up-soft: var(--market-up-soft);
--down: var(--market-down);
--down-soft: var(--market-down-soft);
--coral: var(--market-up);
--coral-soft: var(--market-up-soft);
--green: var(--market-down);
--green-soft: var(--market-down-soft);
--amber: var(--warning-color);
--amber-soft: var(--warning-soft);
--r2-blue: var(--action);
--r2-blue-dark: var(--action-hover);
--r2-blue-soft: var(--action-soft);
--r2-blue-line: #42698e;
--r2-up: var(--market-up);
--r2-up-soft: var(--market-up-soft);
--r2-down: var(--market-down);
--r2-down-soft: var(--market-down-soft);
--r2-amber: var(--warning-color);
--r2-amber-soft: var(--warning-soft);
--r2-ink: var(--text-primary);
--r2-sub: var(--text-secondary);
--r2-faint: var(--text-tertiary);
--r2-line: var(--border);
--r2-line-soft: #2a2f34;
--r2-bg: var(--canvas);
--r2-card: var(--surface);
--r2-shadow: var(--shadow-soft);
--dialog-ink: var(--text-primary);
--dialog-line: var(--border);
--heaven-paper: #191a18;
--heaven-paper-soft: #20211e;
--heaven-ink: #e5e0d4;
--heaven-muted: #aaa497;
--heaven-rule: #3d3a34;
--chart-background: #181b1e;
--chart-grid: #30363c;
--chart-axis: #a3adb6;
--chart-zero: #68737d;
--chart-line: #6ca8e8;
--chart-average: #e2ad58;
--chart-up: #f06d73;
--chart-down: #43bc8a;
--chart-up-volume: rgba(240, 109, 115, .52);
--chart-down-volume: rgba(67, 188, 138, .52);
--chart-area: rgba(108, 168, 232, .12);
--chart-alert-area: rgba(240, 109, 115, .09);
--chart-moving-average: #69737d;
--chart-repair: #e2ad58;
--chart-ma-10: #d39a45;
--chart-ma-20: #9aa5af;
--on-action: #101418;
--warning-line: #6d5a38;
--warning-line-strong: #66502d;
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
--dialog-backdrop: rgba(0, 0, 0, .62);
--ladder-level-1: #2d2426;
--ladder-level-2: #2b2822;
--ladder-level-3: #252a2d;
--ladder-level-4: #20282b;
--ladder-level-5: #202428;
--heat-strong-bg: #304f7a;
--heat-strong-ink: #f2f6fb;
--heat-warm-bg: #2b405f;
--heat-warm-ink: #dfeaf7;
--heat-mild-bg: #293440;
--heat-mild-ink: #c7d2dc;
--heaven-field-bg: #23241f;
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
}
+69
View File
@@ -0,0 +1,69 @@
(function exposeUiCore(global) {
"use strict";
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, number(value)));
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;",
})[character]);
}
function formatNumber(value, digits = 0) {
return new Intl.NumberFormat("zh-CN", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(number(value));
}
function formatTimestamp(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "--";
return parsed.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function displayCompactDate(value) {
const text = String(value || "").replaceAll("-", "");
if (text.length !== 8) return value || "--";
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
function localDateString(value) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function todayString() {
return localDateString(new Date());
}
function parseLocalDate(value) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
global.XiaobaiUI = Object.freeze({
clamp,
displayCompactDate,
escapeHtml,
formatNumber,
formatTimestamp,
localDateString,
number,
parseLocalDate,
todayString,
});
})(window);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long