JackWPP's picture
feat: 添加Markdown报告功能,支持复制和下载操作,优化界面布局
4de2d46
const state = {
sessions: [],
current: null,
currentId: null,
events: [],
filtered: [],
statusFilters: new Set(),
roleFilters: new Set(),
search: "",
autoRefresh: false,
refreshTimer: null,
lastTs: null,
seen: new Set(),
selectedKey: null,
};
const elements = {
sessions: document.getElementById("sessions"),
refreshBtn: document.getElementById("refresh-btn"),
sessionTitle: document.getElementById("session-title"),
sessionMeta: document.getElementById("session-meta"),
statTotal: document.getElementById("stat-total"),
statIo: document.getElementById("stat-io"),
statFallback: document.getElementById("stat-fallback"),
statInvalid: document.getElementById("stat-roles"),
statusFilters: document.getElementById("status-filters"),
roleFilters: document.getElementById("role-filters"),
search: document.getElementById("search"),
events: document.getElementById("events"),
detail: document.getElementById("detail-card"),
liveLabel: document.getElementById("live-label"),
autoRefresh: document.getElementById("auto-refresh"),
gameKey: document.getElementById("game-key"),
voteBoard: document.getElementById("vote-board"),
skillBoard: document.getElementById("skill-board"),
memoryBoard: document.getElementById("memory-board"),
copyMdBtn: document.getElementById("copy-md-btn"),
downloadMdBtn: document.getElementById("download-md-btn"),
};
function safeText(el, text) {
if (!el) return;
el.textContent = text ?? "";
}
function asString(value) {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
try {
return JSON.stringify(value);
} catch (err) {
return String(value);
}
}
function escapeHtml(value) {
return asString(value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function getStatus(e) {
return e?.status || e?.event?.status || "";
}
function getRole(e) {
return e?.role || e?.event?.role || "";
}
function inferKind(e) {
if (!e) return "";
if (e.kind) return e.kind;
if (e.final_result !== undefined || Object.prototype.hasOwnProperty.call(e, "final_result")) return "interact";
if (e.event !== undefined || Object.prototype.hasOwnProperty.call(e, "event")) return "perceive";
return "";
}
function formatDate(ts) {
const num = typeof ts === "number" ? ts : Number.parseFloat(ts);
if (!num) return "-";
const date = new Date(num * 1000);
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
}
function statusLabel(status) {
const m = {
start: "开始",
night: "夜晚",
day: "白天",
discuss: "发言",
vote: "投票",
vote_result: "投票结果",
skill: "技能",
skill_result: "技能结果",
night_info: "夜间信息",
wolf_speech: "狼人交流",
sheriff_election: "上警选择",
sheriff_speech: "警上发言",
sheriff_pk: "警上PK",
sheriff_vote: "警上投票",
sheriff_speech_order: "发言顺序",
sheriff: "警徽转移",
hunter: "开枪状态",
hunter_result: "开枪结果",
result: "结算",
};
return m[status] || status || "未知";
}
function kindLabel(kind) {
if (kind === "perceive") return "输入";
if (kind === "interact") return "输出";
return kind || "未知";
}
function setLive(active) {
elements.liveLabel.textContent = active ? "实时" : "未开启";
}
async function fetchJson(url) {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
return res.json();
}
async function loadSessions() {
const data = await fetchJson("./api/sessions");
state.sessions = data.sessions || [];
renderSessions();
}
function renderSessions() {
elements.sessions.innerHTML = "";
if (!state.sessions.length) {
elements.sessions.innerHTML = `<div class="session-meta">暂无对局日志。</div>`;
return;
}
state.sessions.forEach((session) => {
const item = document.createElement("div");
item.className = "session";
if (state.currentId && state.currentId === session.id) {
item.classList.add("active");
}
const roles = Array.isArray(session.roles) ? session.roles.join(", ") : "";
item.innerHTML = `
<div class="session-id">${escapeHtml(session.id)}</div>
<div class="session-meta">${escapeHtml(roles || "未知")} - ${escapeHtml(session.events)} 条</div>
<div class="session-meta">更新于 ${formatDate(session.updated_at)}</div>
`;
item.addEventListener("click", () => selectSession(session.id));
elements.sessions.appendChild(item);
});
}
function _eventKey(e) {
const parts = [
e.ts,
e.kind,
e.status,
e.role,
e.name,
e.round,
e.final_result,
e.final_skillTargetPlayer,
];
return parts.map((x) => (x === undefined || x === null ? "" : String(x))).join("|");
}
function _ingestEvents(events) {
let changed = false;
for (const e of events || []) {
const key = _eventKey(e);
if (state.seen.has(key)) continue;
state.seen.add(key);
state.events.push(e);
const ts = typeof e.ts === "number" ? e.ts : Number.parseFloat(e.ts);
if (Number.isFinite(ts)) {
state.lastTs = state.lastTs === null ? ts : Math.max(state.lastTs, ts);
}
changed = true;
}
state.events.sort((a, b) => {
const ta = typeof a.ts === "number" ? a.ts : Number.parseFloat(a.ts);
const tb = typeof b.ts === "number" ? b.ts : Number.parseFloat(b.ts);
return (Number.isFinite(ta) ? ta : 0) - (Number.isFinite(tb) ? tb : 0);
});
return changed;
}
async function selectSession(sessionId) {
try {
const data = await fetchJson(`./api/session/${encodeURIComponent(sessionId)}`);
state.current = data;
state.currentId = data.session_id;
state.events = data.events || [];
state.seen = new Set();
state.lastTs = null;
for (const e of state.events) state.seen.add(_eventKey(e));
if (state.events.length) state.lastTs = state.events[state.events.length - 1]?.ts ?? null;
state.filtered = state.events;
state.statusFilters.clear();
state.roleFilters.clear();
state.search = "";
if (elements.search) elements.search.value = "";
renderSessions();
renderHeader();
renderStats();
renderFilters();
renderDash();
applyFilters();
} catch (err) {
console.error(err);
safeText(elements.sessionMeta, `加载/渲染失败: ${err?.message || err}`);
}
}
function renderHeader() {
if (!state.current) {
safeText(elements.sessionTitle, "请选择一个对局");
safeText(elements.sessionMeta, "尚未加载数据");
return;
}
safeText(elements.sessionTitle, state.current.session_id);
const last = state.events[state.events.length - 1];
safeText(elements.sessionMeta, `${state.events.length} 条 - 最近 ${formatDate(last?.ts)}`);
}
function renderStats() {
const stats = state.current?.stats;
if (!stats) {
safeText(elements.statTotal, "-");
safeText(elements.statIo, "-");
safeText(elements.statFallback, "-");
safeText(elements.statInvalid, "-");
return;
}
safeText(elements.statTotal, stats.total);
safeText(elements.statIo, `${stats.perceive ?? "-"} / ${stats.interact ?? "-"}`);
safeText(elements.statFallback, stats.fallback);
safeText(elements.statInvalid, stats.invalid);
}
function renderFilters() {
if (!elements.statusFilters || !elements.roleFilters) return;
const statuses = new Set();
const roles = new Set();
state.events.forEach((e) => {
const status = getStatus(e);
const role = getRole(e);
if (status) statuses.add(status);
if (role) roles.add(role);
});
elements.statusFilters.innerHTML = "";
elements.roleFilters.innerHTML = "";
Array.from(statuses).sort().forEach((status) => {
const chip = createChip(status, state.statusFilters, () => applyFilters());
elements.statusFilters.appendChild(chip);
});
Array.from(roles).sort().forEach((role) => {
const chip = createChip(role, state.roleFilters, () => applyFilters());
elements.roleFilters.appendChild(chip);
});
}
function createChip(label, set, onToggle) {
const chip = document.createElement("div");
chip.className = "chip";
chip.textContent = label;
chip.addEventListener("click", () => {
if (set.has(label)) {
set.delete(label);
chip.classList.remove("active");
} else {
set.add(label);
chip.classList.add("active");
}
onToggle();
});
return chip;
}
function deriveKeyFacts(events) {
const out = {
round: null,
sheriff: null,
lastNight: null,
lastExile: null,
};
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
if (out.round === null) {
const r = typeof e.round === "number" ? e.round : Number.parseInt(e.round, 10);
if (Number.isFinite(r)) out.round = r;
}
const st = e.state || e.memory?.state;
if (!out.sheriff && st && st.sheriff) out.sheriff = asString(st.sheriff);
const fact = e.fact;
if (!out.sheriff && fact && fact.type === "sheriff" && fact.name) out.sheriff = asString(fact.name);
const kind = inferKind(e);
const status = getStatus(e);
if (!out.lastNight && kind === "perceive" && status === "night_info") {
out.lastNight = asString(e.event?.message);
}
if (!out.lastExile && kind === "perceive" && status === "vote_result") {
out.lastExile = asString(e.event?.name || e.event?.message);
}
}
return out;
}
function deriveVotes(events) {
const rounds = new Map(); // round -> {votes: [{who,to,kind}], counts: Map}
for (const e of events) {
if (inferKind(e) !== "perceive") continue;
const status = getStatus(e);
if (status !== "vote" && status !== "sheriff_vote") continue;
let r = typeof e.round === "number" ? e.round : Number.parseInt(e.round, 10);
if (!Number.isFinite(r)) r = -1;
const who = asString(e.event?.name || e.event?.voter || e.event?.from || e.name);
const to = asString(e.event?.message || e.event?.target || e.message);
if (!who || !to) continue;
if (!rounds.has(r)) rounds.set(r, { votes: [], counts: new Map() });
const bucket = rounds.get(r);
bucket.votes.push({ who, to, kind: status });
bucket.counts.set(to, (bucket.counts.get(to) || 0) + 1);
}
return rounds;
}
function deriveSkills(events) {
const items = [];
for (let i = 0; i < events.length; i++) {
const e = events[i];
const status = getStatus(e);
if (inferKind(e) === "interact" && status === "skill") {
const result = asString(e.final_result);
const target = asString(e.final_skillTargetPlayer);
let judge = "";
for (let j = i + 1; j < events.length; j++) {
const n = events[j];
if (inferKind(n) === "perceive" && getStatus(n) === "skill_result") {
judge = asString(n.event?.message || n.event?.name);
break;
}
}
items.push({ round: e.round, ts: e.ts, result, target, judge });
}
}
return items;
}
function deriveLatestMemory(events) {
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
const mem = e.memory;
if (mem && (mem.summary || (mem.history_tail && mem.history_tail.length))) return mem;
}
return null;
}
function renderDash() {
if (!elements.gameKey || !elements.voteBoard || !elements.skillBoard || !elements.memoryBoard) return;
if (!state.currentId) {
safeText(elements.gameKey, "未加载");
safeText(elements.voteBoard, "未加载");
safeText(elements.skillBoard, "未加载");
safeText(elements.memoryBoard, "未加载");
return;
}
const key = deriveKeyFacts(state.events);
safeText(
elements.gameKey,
[
`回合: ${key.round ?? "-"}`,
`警长: ${key.sheriff ?? "-"}`,
`最近夜间信息: ${key.lastNight ? asString(key.lastNight).slice(0, 160) : "-"}`,
`最近放逐: ${key.lastExile || "-"}`,
].join("\n")
);
const rounds = deriveVotes(state.events);
const lastRounds = Array.from(rounds.keys()).sort((a, b) => a - b).slice(-3);
if (!lastRounds.length) {
safeText(elements.voteBoard, "暂无投票记录");
} else {
const blocks = [];
for (const r of lastRounds) {
const bucket = rounds.get(r);
const counts = Array.from(bucket.counts.entries())
.sort((a, b) => b[1] - a[1])
.map(([t, c]) => `${asString(t)}(${c})`)
.join(" ");
const lines = bucket.votes
.slice(-20)
.map((v) => `${asString(v.who)} -> ${asString(v.to)}${v.kind === "sheriff_vote" ? " (警上)" : ""}`);
blocks.push(`第${r}天\n统计: ${counts || "-"}\n${lines.join("\n")}`);
}
safeText(elements.voteBoard, blocks.join("\n\n"));
}
const skills = deriveSkills(state.events).slice(-10);
if (!skills.length) {
safeText(elements.skillBoard, "暂无技能交互");
} else {
safeText(
elements.skillBoard,
skills
.map((s) => {
const parts = [`第${s.round ?? "-"}轮`, `我: ${asString(s.result) || "-"}`];
if (s.target) parts.push(`目标: ${s.target}`);
if (s.judge) parts.push(`裁判: ${asString(s.judge).slice(0, 140)}`);
return parts.join(" | ");
})
.join("\n")
);
}
const mem = deriveLatestMemory(state.events);
if (!mem) {
safeText(elements.memoryBoard, "暂无记忆快照(请跑一轮 interact 后会出现)");
} else {
const summary = mem.summary ? `摘要:\n${mem.summary}\n\n` : "";
const tail = (mem.history_tail || []).slice(-30).join("\n");
safeText(elements.memoryBoard, `${summary}最近对话(尾部):\n${tail || "-"}`);
}
}
function applyFilters() {
const query = state.search.trim().toLowerCase();
state.filtered = state.events.filter((e) => {
const status = getStatus(e);
const role = getRole(e);
if (state.statusFilters.size && !state.statusFilters.has(status)) return false;
if (state.roleFilters.size && !state.roleFilters.has(role)) return false;
if (!query) return true;
const event = e.event || {};
const haystack = [
inferKind(e),
status,
role,
e.name,
event.name,
event.message,
e.final_result,
e.final_skillTargetPlayer,
e.fallback_reason,
]
.map(asString)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
renderEvents();
}
function renderEvents() {
if (!elements.events) return;
elements.events.innerHTML = "";
if (!state.filtered.length) {
elements.events.innerHTML = `<div class="session-meta">没有匹配的事件。</div>`;
return;
}
state.filtered.forEach((event, index) => {
const item = document.createElement("div");
item.className = "event";
const kind = inferKind(event) || "perceive";
const status = getStatus(event);
const role = getRole(event);
const ev = event.event || {};
const line = (() => {
if (kind === "perceive") {
if (status === "vote") {
return `${asString(ev.name) || "-"} 投票: ${asString(ev.message).slice(0, 40)}`;
}
if (status === "vote_result") return `放逐结果: ${asString(ev.name || ev.message).slice(0, 60)}`;
if (status === "night_info") return `夜间信息: ${asString(ev.message).slice(0, 60)}`;
if (status === "skill_result") return `技能结果: ${asString(ev.message || ev.name).slice(0, 60)}`;
if (status === "discuss" || status === "sheriff_speech" || status === "wolf_speech") {
return `${asString(ev.name) || "主持人"}: ${asString(ev.message).slice(0, 70)}`;
}
return `${asString(ev.name) || "主持人"}: ${asString(ev.message).slice(0, 70)}`;
}
if (status === "vote") return `我投: ${asString(event.final_result || "-").slice(0, 40)}`;
if (status === "skill") return `我用技能: ${asString(event.final_result || "-").slice(0, 60)}`;
return `我说: ${asString(event.final_result || "-").slice(0, 70)}`;
})();
const key = _eventKey(event);
item.dataset.key = key;
item.innerHTML = `
<div class="event-header">
<span>${escapeHtml(kindLabel(kind))} · ${escapeHtml(statusLabel(status))} - 第${escapeHtml(event.round ?? "-")}轮</span>
<span>${escapeHtml(formatDate(event.ts))}</span>
</div>
<div class="event-meta">
<span class="badge">${escapeHtml(role || "role?")}</span>
<span class="badge">${escapeHtml(event.name || "name?")}</span>
<span class="badge">${escapeHtml(line)}</span>
${
event.fallback_used
? `<span class="badge">fallback: ${escapeHtml(event.fallback_reason || "yes")}</span>`
: ""
}
</div>
`;
item.addEventListener("click", () => {
document.querySelectorAll(".event.active").forEach((el) => el.classList.remove("active"));
item.classList.add("active");
state.selectedKey = key;
renderDetail(event, index);
});
elements.events.appendChild(item);
});
if (state.selectedKey) {
const active = Array.from(document.querySelectorAll(".event")).find(
(el) => el.dataset.key === state.selectedKey
);
if (active) active.classList.add("active");
}
}
function renderDetail(event) {
if (!elements.detail) return;
if (!event) {
elements.detail.classList.add("empty");
elements.detail.innerHTML = `<div class="detail-empty">点击左侧事件查看详情。</div>`;
return;
}
const payload = event.payload || {};
const promptPreview = payload.prompt_preview || "";
const kind = inferKind(event) || "perceive";
const status = getStatus(event);
const ev = event.event || {};
const fact = event.fact || {};
const st = event.state || {};
const mem = event.memory || {};
const memState = mem.state || {};
elements.detail.classList.remove("empty");
const choices = Array.isArray(event.choices) ? event.choices.join(", ") : asString(event.choices);
elements.detail.innerHTML = `
${detailRow("类型", kindLabel(kind))}
${detailRow("状态", statusLabel(status))}
${detailRow("角色", getRole(event))}
${detailRow("我的名字", event.name)}
${detailRow("轮次", event.round)}
${detailRow("警长(事件)", st.sheriff ?? "-")}
${detailRow("警长(记忆)", memState.sheriff ?? "-")}
${detailRow("我的输出", event.final_result)}
${detailRow("技能目标", event.final_skillTargetPlayer ?? "-")}
${detailRow("候选列表", choices)}
${detailRow("是否兜底", event.fallback_used ? "是" : "否")}
${detailRow("兜底原因", event.fallback_reason || "-")}
${detailRow("模型原始输出", event.llm_raw || event.attempt1_raw || "-")}
${detailRow("模型重试输出", event.llm_retry_raw || event.attempt2_raw || "-")}
${detailRow("Prompt 预览", promptPreview || "-")}
${detailRow("输入来源", ev.name || "-")}
${detailRow("输入内容", ev.message || "-")}
${detailRow("关键事实", fact)}
${detailRow("记忆摘要", mem.summary || "-")}
${detailRow("记忆(尾部)", (mem.history_tail || []).join("\\n") || "-")}
`;
}
function detailRow(label, value) {
const rendered = (() => {
if (value === null || value === undefined || value === "") return "-";
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch (err) {
return String(value);
}
})();
return `
<div class="detail-row">
<div class="detail-label">${escapeHtml(label)}</div>
<div class="detail-value">${escapeHtml(rendered)}</div>
</div>
`;
}
function clipText(value, maxLen) {
const s = asString(value);
if (!maxLen || maxLen <= 0) return s;
if (s.length <= maxLen) return s;
return `${s.slice(0, Math.max(0, maxLen - 1))}…`;
}
function buildMarkdownReport() {
if (!state.currentId) return "";
const now = new Date();
const stats = state.current?.stats || {};
const key = deriveKeyFacts(state.events);
const voteRounds = deriveVotes(state.events);
const skillItems = deriveSkills(state.events);
const latestMem = deriveLatestMemory(state.events);
const lines = [];
lines.push(`# 狼人杀对局调试报告`);
lines.push("");
lines.push(`- 对局ID: \`${state.currentId}\``);
lines.push(`- 导出时间: ${now.toLocaleString()}`);
lines.push(`- 说明: 本报告基于调试台当前已加载事件(默认最多 5000 条;若对局更长可能未包含最早部分)`);
if (stats && Object.keys(stats).length) {
lines.push(
`- 事件数: ${stats.total ?? "-"}(输入 ${stats.perceive ?? "-"} / 输出 ${stats.interact ?? "-"})`
);
lines.push(`- 兜底次数: ${stats.fallback ?? "-"}`);
lines.push(`- 无效次数: ${stats.invalid ?? "-"}`);
}
lines.push("");
lines.push(`## 对局关键`);
lines.push(`- 回合: ${key.round ?? "-"}`);
lines.push(`- 警长: ${key.sheriff ?? "-"}`);
lines.push(`- 最近夜间信息: ${clipText(key.lastNight || "-", 240)}`);
lines.push(`- 最近放逐: ${clipText(key.lastExile || "-", 240)}`);
lines.push("");
lines.push(`## 投票汇总(从裁判广播提取,可能不完整)`);
const rounds = Array.from(voteRounds.keys()).sort((a, b) => a - b);
if (!rounds.length) {
lines.push(`暂无投票记录`);
} else {
for (const r of rounds) {
const bucket = voteRounds.get(r);
const counts = Array.from(bucket.counts.entries())
.sort((a, b) => b[1] - a[1])
.map(([t, c]) => `${asString(t)}(${c})`)
.join(" ");
lines.push("");
lines.push(`### 第${r}天`);
lines.push(`- 统计: ${counts || "-"}`);
lines.push(`- 明细:`);
for (const v of bucket.votes) {
lines.push(` - ${asString(v.who)} -> ${asString(v.to)}${v.kind === "sheriff_vote" ? " (警上)" : ""}`);
}
}
}
lines.push("");
lines.push(`## 技能过程(我)`);
if (!skillItems.length) {
lines.push(`暂无技能交互`);
} else {
for (const s of skillItems) {
const parts = [`第${s.round ?? "-"}轮`, `我: ${clipText(s.result || "-", 240)}`];
if (s.target) parts.push(`目标: ${clipText(s.target, 80)}`);
if (s.judge) parts.push(`裁判: ${clipText(s.judge, 240)}`);
lines.push(`- ${parts.join(" | ")}`);
}
}
lines.push("");
lines.push(`## 记忆快照(最新)`);
if (!latestMem) {
lines.push(`暂无记忆快照(至少跑过一次 interact 才会有)`);
} else {
if (latestMem.summary) {
lines.push(`### 摘要`);
lines.push("```");
lines.push(asString(latestMem.summary).trim());
lines.push("```");
lines.push("");
}
lines.push(`### 最近对话(尾部)`);
lines.push("```");
lines.push((latestMem.history_tail || []).map((x) => asString(x)).join("\n").trim() || "-");
lines.push("```");
}
lines.push("");
lines.push(`## 我的输出时间线(interact)`);
const interacts = state.events.filter((e) => inferKind(e) === "interact");
if (!interacts.length) {
lines.push(`暂无输出记录`);
} else {
for (const e of interacts) {
const status = getStatus(e);
const round = typeof e.round === "number" ? e.round : Number.parseInt(e.round, 10);
const label = statusLabel(status);
const out = clipText(e.final_result || "-", 240);
const extra =
status === "skill" && e.final_skillTargetPlayer ? ` | 目标: ${asString(e.final_skillTargetPlayer)}` : "";
const fb = e.fallback_used ? ` | 兜底: ${asString(e.fallback_reason || "yes")}` : "";
lines.push(`- 第${Number.isFinite(round) ? round : "-"}轮 | ${label} | ${out}${extra}${fb}`);
}
}
lines.push("");
lines.push(`## 场上信息提取(perceive)`);
const speaks = state.events.filter((e) => inferKind(e) === "perceive");
if (!speaks.length) {
lines.push(`暂无输入记录`);
} else {
for (const e of speaks) {
const status = getStatus(e);
const round = typeof e.round === "number" ? e.round : Number.parseInt(e.round, 10);
const label = statusLabel(status);
const from = asString(e.event?.name || e.name || "主持人");
const msg = clipText(e.event?.message || e.event?.name || "-", 240);
lines.push(`- 第${Number.isFinite(round) ? round : "-"}轮 | ${label} | ${from}: ${msg}`);
}
}
return lines.join("\n").trim() + "\n";
}
async function copyMarkdownReport() {
if (!state.currentId) {
window.alert("请先选择一个对局,再复制Markdown。");
return;
}
const md = buildMarkdownReport();
if (!md) return;
try {
await navigator.clipboard.writeText(md);
flashButton(elements.copyMdBtn, "已复制", 1200);
} catch (err) {
try {
const ta = document.createElement("textarea");
ta.value = md;
ta.style.position = "fixed";
ta.style.left = "-9999px";
ta.style.top = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
flashButton(elements.copyMdBtn, "已复制", 1200);
} catch (err2) {
window.alert("复制失败:浏览器未授予剪贴板权限。你也可以用“下载Markdown”。");
}
}
}
function downloadMarkdownReport() {
if (!state.currentId) {
window.alert("请先选择一个对局,再下载Markdown。");
return;
}
const md = buildMarkdownReport();
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${state.currentId}.md`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 500);
flashButton(elements.downloadMdBtn, "已下载", 1200);
}
function flashButton(btn, text, ms) {
if (!btn) return;
const old = btn.textContent;
btn.textContent = text;
btn.disabled = true;
setTimeout(() => {
btn.textContent = old;
btn.disabled = false;
}, ms || 1000);
}
async function pollCurrent() {
if (!state.currentId) return;
const after = state.lastTs;
const url = after ? `./api/session/${encodeURIComponent(state.currentId)}/delta?after_ts=${after}` : `./api/session/${encodeURIComponent(state.currentId)}`;
const data = await fetchJson(url);
state.current = data;
const changed = _ingestEvents(data.events || []);
if (changed) {
renderHeader();
renderStats();
renderFilters();
renderDash();
applyFilters();
} else {
renderStats();
}
}
function setAutoRefresh(active) {
state.autoRefresh = active;
if (state.refreshTimer) {
clearInterval(state.refreshTimer);
state.refreshTimer = null;
}
if (active) {
state.refreshTimer = setInterval(async () => {
await loadSessions();
await pollCurrent();
}, 5000);
setLive(true);
} else {
setLive(false);
}
}
elements.refreshBtn.addEventListener("click", () => {
loadSessions();
pollCurrent();
});
elements.search.addEventListener("input", (e) => {
state.search = e.target.value || "";
applyFilters();
});
elements.autoRefresh.addEventListener("change", (e) => {
setAutoRefresh(e.target.checked);
});
if (elements.copyMdBtn) {
elements.copyMdBtn.addEventListener("click", () => {
copyMarkdownReport();
});
}
if (elements.downloadMdBtn) {
elements.downloadMdBtn.addEventListener("click", () => {
downloadMarkdownReport();
});
}
loadSessions().catch((err) => {
elements.sessions.innerHTML = `<div class="session-meta">加载失败: ${err.message}</div>`;
});