Files
smartmate/infra/schedule_preview_viewer.html
Losita e5b27df80d Version: 0.7.2.dev.260322
feat(schedule-plan):  重构智能排程链路并修复粗排双节对齐问题

-  新增“对话级排程状态持久化”能力:引入 `agent_schedule_states` 模型/DAO,并接入启动迁移
-  智能排程图升级:补齐小幅微调(quick refine)分支,完善预算/并发/状态字段流转
-  预览链路增强:完善排程预览服务读写与桥接逻辑,新增本地预览页 `infra/schedule_preview_viewer.html`
- ♻️ 缓存治理统一:将相关缓存处理收口到 DAO + `cache_deleter` 联动清理,移除旧散落逻辑
- 🐛 修复粗排核心 bug:禁止单节降级,强制双节并按 `1-2/3-4/...` 对齐;修复结束日扫描边界问题
-  新增粗排回归测试:覆盖孤立单节、偶数起点双节、Filler 对齐等关键场景
2026-03-22 13:50:10 +08:00

769 lines
23 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);
}
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;
}
.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;
}
.legend {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
font-size: 12px;
color: var(--sub);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.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">
<div class="panel left">
<div class="panel-header">
<h1>排程预览 JSON 输入</h1>
<p>粘贴 <code>/api/v1/agent/schedule-preview</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>
<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>
<div class="summary" id="summaryText">这里会显示排程摘要。</div>
<div class="legend">
<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>
</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)])
);
const state = {
raw: null,
weekMap: new Map()
};
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");
function setError(message) {
errorText.textContent = message || "";
}
function isObject(v) {
return v && typeof v === "object" && !Array.isArray(v);
}
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: []
};
}
function parseInput(text) {
const root = JSON.parse(text);
if (!isObject(root)) {
throw new Error("根对象必须是 JSON Object。");
}
// 1. 兼容两种结构:
// 1.1 新结构:{ status, info, data: { ...真正字段... } }
// 1.2 旧结构:{ conversation_id, summary, candidate_plans, ... }
// 2. 若 data 存在则优先使用 data避免误读外层字段。
const payload = isObject(root.data) ? root.data : root;
const candidatePlans = Array.isArray(payload.candidate_plans) ? payload.candidate_plans : [];
if (!Array.isArray(payload.candidate_plans)) {
throw new Error("缺少 data.candidate_plans 数组。");
}
return {
conversationId: String(payload.conversation_id || ""),
traceId: String(payload.trace_id || ""),
generatedAt: String(payload.generated_at || ""),
summary: String(payload.summary || ""),
candidatePlans
};
}
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 mergeEmbeddedEvents(events) {
// 1. 先按“同一天 + 同时间段”聚合,识别课程与任务重叠场景。
// 2. 如果同槽位同时出现 existing 课程 + 任务,则把任务并入课程卡片中显示。
// 3. 兜底:不满足合并条件的事件保持原样,避免误吞数据。
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;
}
function assignLanesByDay(events) {
// 1. 按“每天”独立分配 lane避免跨天互相影响宽度。
// 2. 用贪心法给重叠区间分轨:能复用轨道就复用,否则开新轨道。
// 3. 最后给每个事件附上 dayLaneCount渲染时按轨道等分宽度。
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;
}
}
function buildWeekGrid(events) {
const weekGrid = document.createElement("div");
weekGrid.className = "week-grid";
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);
}
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);
}
}
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;
const header = document.createElement("div");
header.className = "title";
header.textContent = e.name;
card.appendChild(header);
const detail = document.createElement("div");
detail.className = "meta-text";
detail.textContent = `${e.startTime || "-"} - ${e.endTime || "-"} ${e.type || "-"} ${e.status || "-"}`;
card.appendChild(detail);
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";
item.textContent = `嵌入:${task.name}${task.status ? `${task.status}` : ""}`;
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";
box.innerHTML = `<strong>未定位到节次的事件(无法映射 start_time</strong><br>${unplaced.map((e) => `- D${e.dayOfWeek} ${e.name} (${e.startTime || "?"} - ${e.endTime || "?"})`).join("<br>")}`;
container.appendChild(box);
}
return container;
}
function renderWeek(weekNo) {
const weekEvents = state.weekMap.get(weekNo) || [];
const merged = mergeEmbeddedEvents(weekEvents.map((e) => ({ ...e })));
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));
}
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 || "(无摘要)";
}
function fillWeekSelector() {
const weeks = Array.from(state.weekMap.keys()).sort((a, b) => a - b);
weekSelect.innerHTML = "";
for (const week of weeks) {
const op = document.createElement("option");
op.value = String(week);
op.textContent = `${week}`;
weekSelect.appendChild(op);
}
if (weeks.length > 0) renderWeek(weeks[0]);
}
function renderFromInput() {
setError("");
try {
const parsed = parseInput(jsonInput.value.trim());
const map = buildWeekMap(parsed.candidatePlans);
state.raw = parsed;
state.weekMap = map;
fillMeta(parsed);
if (map.size === 0) {
weekSelect.innerHTML = "";
gridWrap.innerHTML = `<div class="empty">candidate_plans 为空,当前没有可渲染的周课表。</div>`;
} else {
fillWeekSelector();
}
} catch (err) {
setError(`解析失败:${err.message || err}`);
}
}
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-03-22T15: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" }
}
]
}
]
}
}, null, 2);
}
renderBtn.addEventListener("click", renderFromInput);
weekSelect.addEventListener("change", () => renderWeek(Number(weekSelect.value)));
exampleBtn.addEventListener("click", () => {
setExample();
renderFromInput();
});
clearBtn.addEventListener("click", () => {
jsonInput.value = "";
setError("");
state.raw = null;
state.weekMap = new Map();
summaryText.textContent = "这里会显示排程摘要。";
conversationMeta.textContent = "conversation_id: -";
traceMeta.textContent = "trace_id: -";
timeMeta.textContent = "generated_at: -";
weekSelect.innerHTML = "";
gridWrap.innerHTML = `<div class="empty">先粘贴 JSON 再渲染课表。</div>`;
});
setExample();
</script>
</body>
</html>