后端: 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,适应了获取日程接口
943 lines
34 KiB
HTML
943 lines
34 KiB
HTML
<!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>
|