Files
smartmate/infra/schedule_preview_viewer.html
LoveLosita 07d307fe07 Version: 0.9.4.dev.260407
后端:
1.粗排结果/预览语义修复(task_item suggested 保真 + existing/嵌入识别补全)
- 更新conv/schedule_state.go:LoadScheduleState 补齐 event.rel_id / schedules.embedded_task_id / task_item.embedded_time 三种“已落位”信号;嵌入任务强制 existing + 继承 host slots;补充 task_item duration/name/slot helper;Diff 相关英文注释改中文
- 更新conv/schedule_preview.go:预览层新增 shouldMarkSuggestedInPreview,pending 任务与 source=task_item 的建议态任务统一输出 suggested
2.newAgent 状态快照增强(ScheduleState/OriginalScheduleState 跨轮恢复)
- 更新model/state_store.go:AgentStateSnapshot 新增 ScheduleState / OriginalScheduleState
- 更新model/graph_run_state.go:AgentGraphRunInput/AgentGraphState 接入两份 schedule 状态;恢复旧快照时自动补 original clone
- 更新service/agentsvc/agent_newagent.go:loadOrCreateRuntimeState 返回并恢复 schedule/original;runNewAgentGraph 透传到 graph
- 更新node/agent_nodes.go:saveAgentState 一并保存 schedule/original 到 Redis 快照 3.Execute 链路纠偏(只写内存不落库 + 完整打点 + 恢复消息去重)
- 更新node/execute.go:AlwaysExecute/confirm resume 路径取消 PersistScheduleChanges,仅保留内存写;新增 execute LLM 完整上下文日志;新增工具调用前后 state 摘要日志;thinking 模式改为 enabled
- 更新node/chat.go:pending resume 不再重复写入同一轮 user message
- 更新service/agentsvc/agent_newagent.go:新增 deliver preview write/state 摘要日志,便于排查 suggested 丢失问题
4.AlwaysExecute 贯通 Plan→Graph→Execute
- 更新node/plan.go:PlanNodeInput 新增 AlwaysExecute;plan_done 后支持自动确认直接进入执行
- 更新graph/common_graph.go:branchAfterPlan 支持 PhaseExecuting/PhaseDone 分支
5.排课上下文补强(显式注入 task_class_ids,减少 Execute 误 ask_user)
- 更新prompt/execute.go:Plan/ReAct 两种 execute prompt 都显式写入任务类 ID,声明“上下文已完整,无需追问”
- 更新node/rough_build.go:粗排完成 pinned block 显式标注任务类 ID,避免 Execute 找不到 ID 来源
6.流式输出与预览调试工具修复
- 更新stream/emitter.go:保留换行,修复 pseudo stream 分片后文本黏连/双换行问题
- 更新infra/schedule_preview_viewer.html:升级预览工具,支持 candidate_plans / hybrid_entries

前端:无
仓库:
1.更新了infra内的html,适应了获取日程接口
2026-04-07 21:13:59 +08:00

943 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SmartFlow 排程预览调试页</title>
<style>
:root {
--bg: #f4f6fb;
--panel: #ffffff;
--line: #dfe5f2;
--text: #1f2430;
--sub: #5b6477;
--accent: #2b6ef2;
--ok: #1f8f4f;
--warn: #d96b00;
--existing: #2962ff;
--suggested: #ff8a00;
--embedded: #1f8f4f;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
background: radial-gradient(circle at 0 0, #eef3ff 0, var(--bg) 45%);
color: var(--text);
min-height: 100vh;
}
.page {
display: grid;
grid-template-columns: 360px 1fr;
gap: 14px;
padding: 14px;
min-height: 100vh;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 8px 28px rgba(21, 40, 78, 0.08);
overflow: hidden;
}
.left { display: flex; flex-direction: column; }
.panel-header {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #f8faff 0%, #f5f8ff 100%);
}
.panel-header h1 { margin: 0; font-size: 16px; font-weight: 700; }
.panel-header p { margin: 6px 0 0; color: var(--sub); font-size: 12px; line-height: 1.45; }
.input-wrap {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
min-height: 0;
flex: 1;
}
textarea {
width: 100%;
min-height: 420px;
resize: vertical;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
font-family: "Consolas", "Monaco", monospace;
font-size: 12px;
line-height: 1.45;
color: #1f2430;
background: #fbfcff;
}
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
button {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
background: #fff;
color: var(--text);
}
button.primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.error { color: #c62828; font-size: 12px; min-height: 16px; }
.right { display: flex; flex-direction: column; min-height: 0; }
.meta {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
display: grid;
gap: 8px;
}
.meta-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
color: var(--sub);
font-size: 12px;
}
.meta label { font-weight: 600; color: var(--text); }
/* ── Tab bar ── */
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--line);
background: #f9faff;
}
.tab {
padding: 8px 18px;
font-size: 13px;
font-weight: 500;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: var(--sub);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.tab:hover { color: var(--accent); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: transparent;
}
.tab-count {
display: inline-block;
background: #e8eeff;
color: var(--accent);
border-radius: 10px;
padding: 1px 6px;
font-size: 11px;
font-weight: 600;
margin-left: 4px;
}
.tab.active .tab-count { background: #d0dcff; }
select {
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 10px;
background: #fff;
font-size: 13px;
}
.summary {
background: #f8fbff;
border: 1px dashed #c9d9ff;
border-radius: 10px;
padding: 8px 10px;
color: #2f3a4f;
font-size: 13px;
line-height: 1.45;
white-space: pre-wrap;
}
.legend {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
font-size: 12px;
color: var(--sub);
}
.legend.hidden { display: none; }
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.grid-wrap { overflow: auto; padding: 12px; flex: 1; min-height: 0; }
.week-grid {
min-width: 980px;
display: grid;
grid-template-columns: 74px repeat(7, minmax(120px, 1fr));
grid-template-rows: 38px repeat(12, 52px);
gap: 0;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: #fff;
position: relative;
}
.cell {
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 6px;
font-size: 12px;
color: var(--sub);
background: #fff;
}
.head {
background: #f7f9ff;
font-weight: 700;
color: #344058;
display: flex;
align-items: center;
justify-content: center;
}
.section {
background: #fafbff;
text-align: center;
padding-top: 10px;
font-size: 11px;
color: #56607b;
}
.slot { background: #fcfdff; }
.event {
margin: 2px;
border-radius: 8px;
border: 1px solid transparent;
color: #fff;
padding: 6px 7px;
overflow: hidden;
font-size: 11px;
line-height: 1.35;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
z-index: 2;
}
.event.existing {
background: linear-gradient(180deg, #4c80ff 0%, #2f63de 100%);
border-color: #1f53d7;
}
.event.suggested {
background: linear-gradient(180deg, #ff9f3b 0%, #f27d07 100%);
border-color: #dc6e00;
}
.event.task {
background: linear-gradient(180deg, #32b56b 0%, #1d924f 100%);
border-color: #177a42;
}
.event .title { font-weight: 700; margin-bottom: 3px; word-break: break-all; }
.event .meta-text { opacity: 0.95; }
.embedded-list { margin-top: 4px; display: grid; gap: 3px; }
.embedded-item {
background: rgba(255, 255, 255, 0.17);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
padding: 2px 4px;
font-size: 10px;
}
/* ── Hybrid-mode badges ── */
.badge-row { margin-top: 3px; display: flex; gap: 4px; flex-wrap: wrap; }
.ctx-tag {
display: inline-block;
padding: 1px 5px;
border-radius: 4px;
font-size: 10px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.38);
}
.embed-badge {
display: inline-block;
padding: 1px 5px;
border-radius: 4px;
font-size: 10px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 230, 100, 0.5);
color: #ffe97a;
}
.unplaced {
margin-top: 10px;
border: 1px dashed var(--line);
border-radius: 10px;
padding: 8px;
font-size: 12px;
color: #37445f;
background: #fcfdff;
}
.empty { padding: 18px; text-align: center; color: #7a849a; font-size: 13px; }
@media (max-width: 960px) {
.page { grid-template-columns: 1fr; }
textarea { min-height: 280px; }
}
</style>
</head>
<body>
<div class="page">
<!-- ── Left: JSON input ── -->
<div class="panel left">
<div class="panel-header">
<h1>排程预览 JSON 输入</h1>
<p>粘贴 <code>/api/v1/agent/schedule-preview</code> 响应,点击"解析并渲染"。<br>同时支持 <code>candidate_plans</code>(时间表视图)与 <code>hybrid_entries</code>(节次混合视图)。</p>
</div>
<div class="input-wrap">
<textarea id="jsonInput" spellcheck="false"></textarea>
<div class="btn-row">
<button class="primary" id="renderBtn">解析并渲染</button>
<button id="exampleBtn">填入示例</button>
<button id="clearBtn">清空</button>
</div>
<div class="error" id="errorText"></div>
</div>
</div>
<!-- ── Right: preview ── -->
<div class="panel right">
<div class="meta">
<div class="meta-row">
<label for="weekSelect">当前周</label>
<select id="weekSelect"></select>
<span id="conversationMeta">conversation_id: -</span>
<span id="traceMeta">trace_id: -</span>
<span id="timeMeta">generated_at: -</span>
</div>
<!-- Tab bar -->
<div class="tab-bar">
<button class="tab active" id="tabCandidate">
候选方案 <span class="tab-count" id="cntCandidate">0周</span>
</button>
<button class="tab" id="tabHybrid">
混合视图 <span class="tab-count" id="cntHybrid">0条</span>
</button>
</div>
<div class="summary" id="summaryText">这里会显示排程摘要。</div>
<!-- Legend: candidate mode -->
<div class="legend" id="legendCandidate">
<span><i class="dot" style="background:#2f63de"></i>existing课程/已有安排)</span>
<span><i class="dot" style="background:#f27d07"></i>suggested建议任务</span>
<span><i class="dot" style="background:#1d924f"></i>task普通任务</span>
<span><i class="dot" style="background:#1f8f4f"></i>嵌入任务(显示在课程块内)</span>
</div>
<!-- Legend: hybrid mode -->
<div class="legend hidden" id="legendHybrid">
<span><i class="dot" style="background:#2f63de"></i>existing已有课程</span>
<span><i class="dot" style="background:#f27d07"></i>suggested建议任务</span>
<span style="border:1px solid #ffe97a;border-radius:4px;padding:1px 6px;font-size:11px;color:#b08000;">可嵌入</span> 课程可接收嵌入任务
<span style="background:#e8eeff;border-radius:4px;padding:1px 6px;font-size:11px;color:#446;">High-Logic / Memory / Review / General</span> 认知类型标签
</div>
</div>
<div class="grid-wrap" id="gridWrap">
<div class="empty">先粘贴 JSON 再渲染课表。</div>
</div>
</div>
</div>
<script>
const DAYS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const SECTION_TIME = {
1: ["08:00", "08:45"],
2: ["08:55", "09:40"],
3: ["10:15", "11:00"],
4: ["11:10", "11:55"],
5: ["14:00", "14:45"],
6: ["14:55", "15:40"],
7: ["16:15", "17:00"],
8: ["17:10", "17:55"],
9: ["19:00", "19:45"],
10: ["19:55", "20:40"],
11: ["20:50", "21:35"],
12: ["21:45", "22:30"]
};
const START_TIME_TO_SECTION = Object.fromEntries(
Object.entries(SECTION_TIME).map(([s, [start]]) => [start, Number(s)])
);
// ── state ──
const state = {
raw: null,
mode: "candidate", // "candidate" | "hybrid"
weekMap: new Map(), // candidate_plans → week → events[]
hybridWeekMap: new Map() // hybrid_entries → week → entries[]
};
// ── DOM refs ──
const jsonInput = document.getElementById("jsonInput");
const renderBtn = document.getElementById("renderBtn");
const exampleBtn = document.getElementById("exampleBtn");
const clearBtn = document.getElementById("clearBtn");
const errorText = document.getElementById("errorText");
const weekSelect = document.getElementById("weekSelect");
const summaryText = document.getElementById("summaryText");
const conversationMeta = document.getElementById("conversationMeta");
const traceMeta = document.getElementById("traceMeta");
const timeMeta = document.getElementById("timeMeta");
const gridWrap = document.getElementById("gridWrap");
const tabCandidate = document.getElementById("tabCandidate");
const tabHybrid = document.getElementById("tabHybrid");
const cntCandidate = document.getElementById("cntCandidate");
const cntHybrid = document.getElementById("cntHybrid");
const legendCandidate = document.getElementById("legendCandidate");
const legendHybrid = document.getElementById("legendHybrid");
// ── helpers ──
function setError(msg) { errorText.textContent = msg || ""; }
function isObject(v) { return v && typeof v === "object" && !Array.isArray(v); }
// ── candidate_plans normalizer ──
function normalizeEvent(raw, index) {
const e = isObject(raw) ? raw : {};
const day = Number(e.day_of_week);
const span = Math.max(1, Number(e.span) || 1);
const guessedFrom = START_TIME_TO_SECTION[String(e.start_time || "").trim()];
const sectionFrom = Number.isFinite(guessedFrom) ? guessedFrom : null;
const sectionTo = sectionFrom !== null ? sectionFrom + span - 1 : null;
const embedded = isObject(e.embedded_task_info) ? e.embedded_task_info : null;
return {
__index: index,
id: e.id,
order: Number(e.order) || 0,
dayOfWeek: Number.isFinite(day) ? day : null,
name: String(e.name || "未命名"),
startTime: String(e.start_time || ""),
endTime: String(e.end_time || ""),
type: String(e.type || ""),
status: String(e.status || ""),
span,
sectionFrom,
sectionTo,
embeddedTaskInfo: embedded,
embeddedTasks: [],
// hybrid fields not used in this path
contextTag: "",
canBeEmbedded: false
};
}
// ── hybrid_entries normalizer ──
function normalizeHybridEntry(raw, index) {
const e = isObject(raw) ? raw : {};
const day = Number(e.day_of_week);
const sf = Number(e.section_from);
const st = Number(e.section_to);
const validSf = Number.isFinite(sf) && sf >= 1 && sf <= 12;
const validSt = Number.isFinite(st) && st >= 1 && st <= 12;
return {
__index: index,
id: e.event_id || e.task_item_id || null,
order: 0,
dayOfWeek: Number.isFinite(day) && day >= 1 && day <= 7 ? day : null,
name: String(e.name || "未命名"),
startTime: (validSf && SECTION_TIME[sf]) ? SECTION_TIME[sf][0] : "",
endTime: (validSt && SECTION_TIME[st]) ? SECTION_TIME[st][1] : "",
type: String(e.type || ""),
status: String(e.status || ""),
span: (validSf && validSt) ? st - sf + 1 : 1,
sectionFrom: validSf ? sf : null,
sectionTo: validSt ? st : null,
embeddedTaskInfo: null,
embeddedTasks: [],
// hybrid-specific
contextTag: String(e.context_tag || ""),
canBeEmbedded: Boolean(e.can_be_embedded),
blockForSuggested: Boolean(e.block_for_suggested),
taskItemId: e.task_item_id || null,
eventId: e.event_id || null
};
}
// ── parse API response ──
function parseInput(text) {
const root = JSON.parse(text);
if (!isObject(root)) throw new Error("根对象必须是 JSON Object。");
const payload = isObject(root.data) ? root.data : root;
const hasCandidates = Array.isArray(payload.candidate_plans);
const hasHybrid = Array.isArray(payload.hybrid_entries);
if (!hasCandidates && !hasHybrid) {
throw new Error("缺少 candidate_plans 或 hybrid_entries 数组。");
}
return {
conversationId: String(payload.conversation_id || ""),
traceId: String(payload.trace_id || ""),
generatedAt: String(payload.generated_at || ""),
summary: String(payload.summary || ""),
candidatePlans: hasCandidates ? payload.candidate_plans : [],
hybridEntries: hasHybrid ? payload.hybrid_entries : []
};
}
// ── build week maps ──
function buildWeekMap(candidatePlans) {
const map = new Map();
for (const plan of candidatePlans) {
const weekNo = Number(plan?.week);
if (!Number.isFinite(weekNo)) continue;
const events = Array.isArray(plan?.events) ? plan.events : [];
map.set(
weekNo,
events
.map((e, i) => normalizeEvent(e, i))
.filter((e) => Number.isFinite(e.dayOfWeek) && e.dayOfWeek >= 1 && e.dayOfWeek <= 7)
);
}
return map;
}
function buildHybridWeekMap(hybridEntries) {
const map = new Map();
for (const raw of hybridEntries) {
const weekNo = Number(raw?.week);
if (!Number.isFinite(weekNo)) continue;
const entry = normalizeHybridEntry(raw, 0);
if (!Number.isFinite(entry.dayOfWeek)) continue;
if (!map.has(weekNo)) map.set(weekNo, []);
map.get(weekNo).push(entry);
}
return map;
}
// ── merge overlapping events (candidate mode) ──
function mergeEmbeddedEvents(events) {
const slotMap = new Map();
for (const e of events) {
const key = `${e.dayOfWeek}_${e.sectionFrom}_${e.sectionTo}`;
if (!slotMap.has(key)) slotMap.set(key, []);
slotMap.get(key).push(e);
}
const merged = [];
for (const list of slotMap.values()) {
const course = list.find((e) => e.type === "course" || e.status === "existing");
const tasks = list.filter((e) => !course || e !== course);
if (course && tasks.length > 0) {
const taskLines = [];
if (course.embeddedTaskInfo?.name) {
taskLines.push({ id: course.embeddedTaskInfo.id, name: course.embeddedTaskInfo.name, type: course.embeddedTaskInfo.type || "task", from: "embedded_task_info" });
}
for (const t of tasks) {
taskLines.push({ id: t.id, name: t.name, type: t.type || "task", status: t.status || "", from: "overlap_event" });
}
course.embeddedTasks = taskLines;
merged.push(course);
continue;
}
if (course && course.embeddedTaskInfo?.name) {
course.embeddedTasks = [{ id: course.embeddedTaskInfo.id, name: course.embeddedTaskInfo.name, type: course.embeddedTaskInfo.type || "task", from: "embedded_task_info" }];
merged.push(course);
continue;
}
merged.push(...list);
}
return merged;
}
// ── merge overlapping entries (hybrid mode) ──
// existing 课程 canBeEmbedded=true 且同槽有 suggested 任务 → 嵌入显示
function mergeHybridEntries(entries) {
const slotMap = new Map();
for (const e of entries) {
const key = `${e.dayOfWeek}_${e.sectionFrom}_${e.sectionTo}`;
if (!slotMap.has(key)) slotMap.set(key, []);
slotMap.get(key).push(e);
}
const merged = [];
for (const list of slotMap.values()) {
const course = list.find((e) => e.type === "course" && e.status === "existing" && e.canBeEmbedded);
const others = list.filter((e) => e !== course);
if (course && others.length > 0) {
course.embeddedTasks = others.map((t) => ({
id: t.taskItemId,
name: t.name,
type: t.type || "task",
status: t.status || "",
contextTag: t.contextTag
}));
merged.push(course);
} else {
merged.push(...list);
}
}
return merged;
}
// ── lane assignment (shared) ──
function assignLanesByDay(events) {
const byDay = new Map();
for (const e of events) {
if (!byDay.has(e.dayOfWeek)) byDay.set(e.dayOfWeek, []);
byDay.get(e.dayOfWeek).push(e);
}
for (const [, dayEvents] of byDay) {
dayEvents.sort((a, b) => {
if (a.sectionFrom !== b.sectionFrom) return a.sectionFrom - b.sectionFrom;
if (a.sectionTo !== b.sectionTo) return a.sectionTo - b.sectionTo;
return a.order - b.order;
});
const laneEnds = [];
for (const e of dayEvents) {
let lane = -1;
for (let i = 0; i < laneEnds.length; i += 1) {
if (e.sectionFrom > laneEnds[i]) { lane = i; break; }
}
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0); }
laneEnds[lane] = e.sectionTo;
e.__lane = lane;
}
const laneCount = Math.max(1, laneEnds.length);
for (const e of dayEvents) e.__laneCount = laneCount;
}
}
// ── build grid DOM ──
function buildWeekGrid(events, mode) {
const weekGrid = document.createElement("div");
weekGrid.className = "week-grid";
// header row
const topLeft = document.createElement("div");
topLeft.className = "cell head";
topLeft.textContent = "节次";
weekGrid.appendChild(topLeft);
for (let i = 0; i < 7; i += 1) {
const head = document.createElement("div");
head.className = "cell head";
head.style.gridColumn = String(i + 2);
head.style.gridRow = "1";
head.textContent = DAYS[i];
weekGrid.appendChild(head);
}
// section rows + blank slots
for (let section = 1; section <= 12; section += 1) {
const [start, end] = SECTION_TIME[section];
const label = document.createElement("div");
label.className = "cell section";
label.style.gridColumn = "1";
label.style.gridRow = String(section + 1);
label.innerHTML = `<div>${section}</div><div>${start}${end}</div>`;
weekGrid.appendChild(label);
for (let day = 1; day <= 7; day += 1) {
const slot = document.createElement("div");
slot.className = "cell slot";
slot.style.gridColumn = String(day + 1);
slot.style.gridRow = String(section + 1);
weekGrid.appendChild(slot);
}
}
// event cards
const unplaced = [];
for (const e of events) {
if (!Number.isFinite(e.sectionFrom) || !Number.isFinite(e.sectionTo)) {
unplaced.push(e);
continue;
}
const rowStart = e.sectionFrom + 1;
const rowEnd = e.sectionTo + 2;
const col = e.dayOfWeek + 1;
const laneCount = e.__laneCount || 1;
const lane = e.__lane || 0;
const gap = 4;
const width = `calc((100% - ${(laneCount - 1) * gap}px) / ${laneCount})`;
const left = `calc(${lane} * (${width} + ${gap}px))`;
const card = document.createElement("div");
card.className = "event";
if (e.status === "existing" || e.type === "course") card.classList.add("existing");
else if (e.status === "suggested") card.classList.add("suggested");
else card.classList.add("task");
card.style.gridColumn = String(col);
card.style.gridRow = `${rowStart}/${rowEnd}`;
card.style.width = width;
card.style.marginLeft = left;
// title
const header = document.createElement("div");
header.className = "title";
header.textContent = e.name;
card.appendChild(header);
// detail line
const detail = document.createElement("div");
detail.className = "meta-text";
if (mode === "hybrid") {
detail.textContent = `${e.sectionFrom}${e.sectionTo} ${e.type || "-"} ${e.status || "-"}`;
} else {
detail.textContent = `${e.startTime || "-"} ${e.endTime || "-"} ${e.type || "-"} ${e.status || "-"}`;
}
card.appendChild(detail);
// hybrid badges (context_tag / can_be_embedded)
if (mode === "hybrid" && (e.contextTag || e.canBeEmbedded)) {
const badgeRow = document.createElement("div");
badgeRow.className = "badge-row";
if (e.contextTag) {
const ct = document.createElement("span");
ct.className = "ctx-tag";
ct.textContent = e.contextTag;
badgeRow.appendChild(ct);
}
if (e.canBeEmbedded) {
const eb = document.createElement("span");
eb.className = "embed-badge";
eb.textContent = "可嵌入";
badgeRow.appendChild(eb);
}
card.appendChild(badgeRow);
}
// embedded tasks
if (Array.isArray(e.embeddedTasks) && e.embeddedTasks.length > 0) {
const list = document.createElement("div");
list.className = "embedded-list";
for (const task of e.embeddedTasks) {
const item = document.createElement("div");
item.className = "embedded-item";
const ctPart = task.contextTag ? ` [${task.contextTag}]` : "";
const statPart = task.status ? `${task.status}` : "";
item.textContent = `嵌入:${task.name}${ctPart}${statPart}`;
list.appendChild(item);
}
card.appendChild(list);
}
weekGrid.appendChild(card);
}
const container = document.createElement("div");
container.appendChild(weekGrid);
if (unplaced.length > 0) {
const box = document.createElement("div");
box.className = "unplaced";
const rows = unplaced.map((e) => {
const coord = mode === "hybrid"
? `节次=${e.sectionFrom ?? "?"}${e.sectionTo ?? "?"}`
: `${e.startTime || "?"}${e.endTime || "?"}`;
return `- D${e.dayOfWeek} ${e.name} (${coord})`;
});
box.innerHTML = `<strong>未定位到节次的条目(坐标无效)</strong><br>${rows.join("<br>")}`;
container.appendChild(box);
}
return container;
}
// ── render week ──
function renderWeek(weekNo) {
const map = state.mode === "hybrid" ? state.hybridWeekMap : state.weekMap;
const raw = (map.get(weekNo) || []).map((e) => ({ ...e }));
const merged = state.mode === "hybrid"
? mergeHybridEntries(raw)
: mergeEmbeddedEvents(raw);
assignLanesByDay(merged.filter((e) => Number.isFinite(e.sectionFrom) && Number.isFinite(e.sectionTo)));
gridWrap.innerHTML = "";
if (merged.length === 0) {
gridWrap.innerHTML = `<div class="empty">第 ${weekNo} 周没有可显示的事件。</div>`;
return;
}
gridWrap.appendChild(buildWeekGrid(merged, state.mode));
}
// ── populate week selector ──
function fillWeekSelector(weeks) {
const currentWeek = Number(weekSelect.value);
weekSelect.innerHTML = "";
for (const week of weeks) {
const op = document.createElement("option");
op.value = String(week);
op.textContent = `${week}`;
weekSelect.appendChild(op);
}
// keep current week if available in new map
if (weeks.includes(currentWeek)) weekSelect.value = String(currentWeek);
if (weeks.length > 0) renderWeek(Number(weekSelect.value));
}
// ── switch tab mode ──
function switchMode(newMode) {
state.mode = newMode;
tabCandidate.classList.toggle("active", newMode === "candidate");
tabHybrid.classList.toggle("active", newMode === "hybrid");
legendCandidate.classList.toggle("hidden", newMode !== "candidate");
legendHybrid.classList.toggle("hidden", newMode !== "hybrid");
const map = newMode === "hybrid" ? state.hybridWeekMap : state.weekMap;
if (map.size === 0) {
weekSelect.innerHTML = "";
const label = newMode === "hybrid" ? "hybrid_entries" : "candidate_plans";
gridWrap.innerHTML = `<div class="empty">${label} 为空,当前没有可渲染的周课表。</div>`;
return;
}
const weeks = Array.from(map.keys()).sort((a, b) => a - b);
fillWeekSelector(weeks);
}
// ── fill meta header ──
function fillMeta(parsed) {
conversationMeta.textContent = `conversation_id: ${parsed.conversationId || "-"}`;
traceMeta.textContent = `trace_id: ${parsed.traceId || "-"}`;
timeMeta.textContent = `generated_at: ${parsed.generatedAt || "-"}`;
summaryText.textContent = parsed.summary || "(无摘要)";
}
// ── update tab count badges ──
function updateTabCounts(weekMap, hybridWeekMap, hybridTotal) {
cntCandidate.textContent = `${weekMap.size}`;
cntHybrid.textContent = `${hybridWeekMap.size} 周 · ${hybridTotal}`;
}
// ── main render entry ──
function renderFromInput() {
setError("");
try {
const parsed = parseInput(jsonInput.value.trim());
const weekMap = buildWeekMap(parsed.candidatePlans);
const hybridWeekMap = buildHybridWeekMap(parsed.hybridEntries);
state.raw = parsed;
state.weekMap = weekMap;
state.hybridWeekMap = hybridWeekMap;
fillMeta(parsed);
updateTabCounts(weekMap, hybridWeekMap, parsed.hybridEntries.length);
// auto-select tab: prefer current mode if it has data, else pick whichever has data
let targetMode = state.mode;
const currentHasData = targetMode === "hybrid" ? hybridWeekMap.size > 0 : weekMap.size > 0;
if (!currentHasData) {
targetMode = weekMap.size > 0 ? "candidate" : (hybridWeekMap.size > 0 ? "hybrid" : state.mode);
}
switchMode(targetMode);
} catch (err) {
setError(`解析失败:${err.message || err}`);
}
}
// ── example data ──
function setExample() {
jsonInput.value = JSON.stringify({
status: "10000",
info: "success",
data: {
conversation_id: "demo-conv-001",
trace_id: "demo-trace-001",
summary: "本周建议将高强度任务集中在上午,课程空档用于轻量复习。",
generated_at: "2026-04-07T15:12:00+08:00",
candidate_plans: [
{
week: 13,
events: [
{ day_of_week: 1, name: "高等数学", start_time: "08:00", end_time: "09:40", type: "course", span: 2, status: "existing" },
{ day_of_week: 1, name: "数电错题整理", start_time: "08:00", end_time: "09:40", type: "task", span: 2, status: "suggested" },
{ day_of_week: 1, name: "数据结构复习", start_time: "10:15", end_time: "11:55", type: "task", span: 2, status: "suggested" },
{ day_of_week: 2, name: "英语阅读", start_time: "19:00", end_time: "20:40", type: "task", span: 2, status: "suggested" },
{
day_of_week: 3, name: "离散数学", start_time: "14:00", end_time: "15:40",
type: "course", span: 2, status: "existing",
embedded_task_info: { id: 99, name: "离散小测回顾", type: "task" }
}
]
}
],
hybrid_entries: [
{ week: 13, day_of_week: 1, section_from: 1, section_to: 2, name: "高等数学", type: "course", status: "existing", event_id: 101, can_be_embedded: false, block_for_suggested: true },
{ week: 13, day_of_week: 1, section_from: 1, section_to: 2, name: "数电错题整理", type: "task", status: "suggested", task_item_id: 201, context_tag: "Review", block_for_suggested: true },
{ week: 13, day_of_week: 1, section_from: 3, section_to: 4, name: "数据结构复习", type: "task", status: "suggested", task_item_id: 202, context_tag: "High-Logic", block_for_suggested: true },
{ week: 13, day_of_week: 2, section_from: 9, section_to: 10, name: "英语阅读", type: "task", status: "suggested", task_item_id: 203, context_tag: "Memory", block_for_suggested: true },
{ week: 13, day_of_week: 3, section_from: 5, section_to: 6, name: "离散数学", type: "course", status: "existing", event_id: 102, can_be_embedded: true, block_for_suggested: false },
{ week: 13, day_of_week: 3, section_from: 5, section_to: 6, name: "离散小测回顾", type: "task", status: "suggested", task_item_id: 204, context_tag: "Review", block_for_suggested: false },
{ week: 13, day_of_week: 4, section_from: 7, section_to: 8, name: "操作系统", type: "course", status: "existing", event_id: 103, can_be_embedded: false, block_for_suggested: true },
{ week: 13, day_of_week: 5, section_from: 3, section_to: 4, name: "概率论刷题", type: "task", status: "suggested", task_item_id: 205, context_tag: "High-Logic", block_for_suggested: true }
]
}
}, null, 2);
}
// ── event listeners ──
renderBtn.addEventListener("click", renderFromInput);
weekSelect.addEventListener("change", () => renderWeek(Number(weekSelect.value)));
tabCandidate.addEventListener("click", () => { if (state.mode !== "candidate") switchMode("candidate"); });
tabHybrid.addEventListener("click", () => { if (state.mode !== "hybrid") switchMode("hybrid"); });
exampleBtn.addEventListener("click", () => { setExample(); renderFromInput(); });
clearBtn.addEventListener("click", () => {
jsonInput.value = "";
setError("");
state.raw = null;
state.weekMap = new Map();
state.hybridWeekMap = new Map();
summaryText.textContent = "这里会显示排程摘要。";
conversationMeta.textContent = "conversation_id: -";
traceMeta.textContent = "trace_id: -";
timeMeta.textContent = "generated_at: -";
cntCandidate.textContent = "0周";
cntHybrid.textContent = "0条";
weekSelect.innerHTML = "";
gridWrap.innerHTML = `<div class="empty">先粘贴 JSON 再渲染课表。</div>`;
});
// init with example
setExample();
</script>
</body>
</html>