feat: 新增日程面板、复盘页面与 Agent 读写接口
- 日程:单次/每日/每周/每月重复,JSON 存储,月历按优先级着色圆点 + 悬停提示 - 复盘页:可自定义折线图卡片(SVG) + 每日心得总览(按日期/任务展开) - Agent 接口:GET /api/agent/overview 读日程+进度,POST /api/agent/schedules 建日程(PPM_API_TOKEN Bearer 鉴权) - 导出/导入/统计纳入日程与图表;47 项测试全绿
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
const state = {
|
||||
view: "home",
|
||||
tasks: [], version: 1, selectedId: null, selectedTask: null,
|
||||
daily: null, history: null, summary: null, csrf: "", authEnabled: false,
|
||||
charts: [], notes: null, editingChartId: null,
|
||||
filter: "", editingId: null, dragId: null, selectedDate: null, ganttScale: "month",
|
||||
schedules: { calendar: null, today: null }, scheduleMonth: new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" }).format(new Date()).slice(0, 7), editingScheduleId: null,
|
||||
};
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
@@ -47,7 +50,7 @@ async function bootstrap() {
|
||||
} catch (error) { toast(error.message); }
|
||||
}
|
||||
|
||||
const layoutCache = { home: null, "task-detail": null };
|
||||
const layoutCache = { home: null, "task-detail": null, review: null };
|
||||
async function ensureLayout(page) { if (!layoutCache[page]) layoutCache[page] = await api(`/api/layouts/${page}`); return layoutCache[page]; }
|
||||
async function loadTasks() {
|
||||
const result = await api("/api/tasks"); state.tasks = result.items; state.version = result.version;
|
||||
@@ -55,6 +58,15 @@ async function loadTasks() {
|
||||
await render();
|
||||
}
|
||||
|
||||
async function loadSchedules() {
|
||||
const day = state.selectedDate || today();
|
||||
const [calendar, todayResult] = await Promise.all([
|
||||
api(`/api/schedules/calendar?month=${encodeURIComponent(state.scheduleMonth)}`),
|
||||
api(`/api/schedules/today?date=${encodeURIComponent(day)}`),
|
||||
]);
|
||||
state.schedules = { calendar, today: todayResult };
|
||||
}
|
||||
|
||||
function renderTaskList() {
|
||||
const filtered = state.tasks.filter((task) => !state.filter || task.name.toLowerCase().includes(state.filter.toLowerCase()));
|
||||
const items = filtered.map((task) => `<div class="task-item ${task.taskId === state.selectedId ? "selected" : ""}" draggable="true" data-task-id="${task.taskId}">
|
||||
@@ -78,7 +90,7 @@ function rerenderSidebarList() {
|
||||
|
||||
function bindSidebarItems() {
|
||||
$$(".task-item").forEach((node) => {
|
||||
node.addEventListener("click", () => { state.selectedId = node.dataset.taskId; render(); });
|
||||
node.addEventListener("click", () => { state.view = "home"; state.selectedId = node.dataset.taskId; render(); });
|
||||
node.addEventListener("dragstart", () => { state.dragId = node.dataset.taskId; node.classList.add("dragging"); });
|
||||
node.addEventListener("dragend", () => { node.classList.remove("dragging"); state.dragId = null; });
|
||||
node.addEventListener("dragover", (event) => event.preventDefault());
|
||||
@@ -97,6 +109,53 @@ function periodText(task) { const metrics = task.metrics || []; return [...new S
|
||||
function summaryFor(taskId) { return state.summary?.tasks?.[taskId] || null; }
|
||||
function metricSummary(taskId, metricId) { return summaryFor(taskId)?.metrics?.find((metric) => metric.metricId === metricId) || null; }
|
||||
|
||||
function renderChartSvg(chart) {
|
||||
const points = chart.data?.points || [];
|
||||
if (!points.length || points.every((point) => Number(point.value) === 0)) return `<div class="chart-empty">暂无数据</div>`;
|
||||
const width = 600, height = 200, pad = { left: 34, right: 12, top: 14, bottom: 30 };
|
||||
const max = Math.max(...points.map((point) => Number(point.value) || 0), 1);
|
||||
const x = (index) => pad.left + (points.length === 1 ? 0 : index * (width - pad.left - pad.right) / (points.length - 1));
|
||||
const y = (value) => pad.top + (height - pad.top - pad.bottom) * (1 - value / max);
|
||||
const coords = points.map((point, index) => `${x(index).toFixed(1)},${y(Number(point.value) || 0).toFixed(1)}`).join(" ");
|
||||
const areaPath = `M ${pad.left.toFixed(1)} ${y(0).toFixed(1)} L ${coords.replace(/ /g, " L ")} L ${x(points.length - 1).toFixed(1)} ${y(0).toFixed(1)} Z`;
|
||||
const labels = [points[0], points[Math.floor((points.length - 1) / 2)], points[points.length - 1]].filter((point, index, list) => point && list.findIndex((item) => item.date === point.date) === index).map((point) => {
|
||||
const index = points.indexOf(point); return `<text x="${x(index)}" y="${height - 7}" text-anchor="middle">${escapeHtml(point.date.slice(5))}</text>`;
|
||||
}).join("");
|
||||
const circles = points.map((point, index) => `<circle cx="${x(index).toFixed(1)}" cy="${y(Number(point.value) || 0).toFixed(1)}" r="2.2"/>`).join("");
|
||||
return `<svg class="chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(chart.title)}"><line class="chart-guide" x1="${pad.left}" y1="${y(0)}" x2="${width - pad.right}" y2="${y(0)}"/><line class="chart-guide" x1="${pad.left}" y1="${y(max)}" x2="${width - pad.right}" y2="${y(max)}"/><text class="chart-max" x="4" y="${y(max) + 4}">${max}</text><path class="chart-area" d="${areaPath}"/><polyline points="${coords}"/><g class="chart-points">${circles}</g><g class="chart-labels">${labels}</g></svg>`;
|
||||
}
|
||||
|
||||
function renderReviewCharts() {
|
||||
const cards = state.charts.map((chart) => `<article class="review-chart-card"><div class="review-chart-head"><h3>${escapeHtml(chart.title)}</h3><span><button class="icon-button chart-edit" data-chart-id="${chart.chartId}" title="编辑图表" aria-label="编辑图表">✎</button><button class="icon-button chart-delete" data-chart-id="${chart.chartId}" title="删除图表" aria-label="删除图表">×</button></span></div>${renderChartSvg(chart)}</article>`).join("");
|
||||
return `<section class="section review-charts"><div class="page-heading review-heading"><div><p class="eyebrow">REVIEW</p><h1>复盘</h1><p>观察完成趋势,回看每日心得</p></div><button class="button primary" id="addChartButton">+ 添加图表</button></div><div class="review-chart-grid">${cards || `<p class="empty-list">还没有图表,添加一张开始复盘</p>`}</div></section>`;
|
||||
}
|
||||
|
||||
function renderReviewNotes() {
|
||||
const days = state.notes?.days || [];
|
||||
const content = days.map((day) => `<details class="review-day" open><summary><span class="review-date-badge">${escapeHtml(day.date.slice(5))}</span><strong>${escapeHtml(day.date)}</strong><span class="muted">${day.records.length} 条记录</span></summary><div class="review-day-records">${day.records.map((record) => `<div class="review-note-record"><strong>${escapeHtml(record.taskName)}</strong>${record.completedAt ? `<time>${escapeHtml(record.completedAt.replace("T", " "))}</time>` : ""}${record.note ? `<p>${escapeHtml(record.note)}</p>` : ""}</div>`).join("")}</div></details>`).join("");
|
||||
return `<section class="section review-notes"><div class="section-heading"><div><p class="eyebrow">REVIEW</p><h2>每日心得</h2></div><span>最近 30 天</span></div>${content || `<p class="empty-list">最近没有心得记录</p>`}</section>`;
|
||||
}
|
||||
|
||||
function fillChartMetrics(taskId, metricId = "") {
|
||||
const select = $("#chartMetricSelect");
|
||||
const task = state.tasks.find((item) => item.taskId === taskId);
|
||||
const options = [`<option value="">完成数</option>`].concat(task ? task.metrics.map((metric) => `<option value="${escapeHtml(metric.metricId)}" ${metric.metricId === metricId ? "selected" : ""}>${escapeHtml(metric.name)}</option>`) : []);
|
||||
select.innerHTML = options.join("");
|
||||
}
|
||||
|
||||
function openChartDialog(chart = null) {
|
||||
state.editingChartId = chart?.chartId || null;
|
||||
const form = $("#chartForm"); form.reset(); $("#chartDialogTitle").textContent = chart ? "编辑图表" : "添加图表";
|
||||
$("#chartTaskSelect").innerHTML = `<option value="">全部任务</option>${state.tasks.map((task) => `<option value="${escapeHtml(task.taskId)}">${escapeHtml(task.name)}</option>`).join("")}`;
|
||||
form.title.value = chart?.title || ""; form.days.value = chart?.days || 30; $("#chartTaskSelect").value = chart?.taskId || ""; fillChartMetrics($("#chartTaskSelect").value, chart?.metricId || ""); $("#chartDialog").showModal();
|
||||
}
|
||||
|
||||
function bindReviewCharts() {
|
||||
$("#addChartButton")?.addEventListener("click", () => openChartDialog());
|
||||
$(".chart-edit") && $$(".chart-edit").forEach((button) => button.addEventListener("click", () => openChartDialog(state.charts.find((chart) => chart.chartId === button.dataset.chartId))));
|
||||
$$(".chart-delete").forEach((button) => button.addEventListener("click", async () => { if (!confirm("确认删除这张图表?")) return; try { await api(`/api/charts/${button.dataset.chartId}`, { method: "DELETE" }); toast("图表已删除"); await render(); } catch (error) { toast(error.message); } }));
|
||||
}
|
||||
|
||||
// ---- 组件注册表:布局白名单对应的渲染器与事件绑定 ----
|
||||
// 页面/区域约束与单例约束由服务端 validate_layout 保证(非法布局回退到内置默认),
|
||||
// 前端只负责渲染已校验的布局,不再二次过滤以免静默丢组件。
|
||||
@@ -110,6 +169,9 @@ const components = {
|
||||
"metric-progress": { render: renderMetricProgress },
|
||||
"activity-heatmap": { render: renderActivityHeatmap },
|
||||
"task-settings": { render: renderTaskSettings, bind: bindSettings },
|
||||
"schedule-board": { render: renderScheduleBoard, bind: bindScheduleBoard },
|
||||
"review-charts": { render: renderReviewCharts, bind: bindReviewCharts },
|
||||
"review-notes": { render: renderReviewNotes },
|
||||
};
|
||||
|
||||
function componentHtml(item) { const entry = components[item.component]; return entry ? entry.render(item.options || {}) : ""; }
|
||||
@@ -132,8 +194,17 @@ async function renderLayout(page) {
|
||||
}
|
||||
|
||||
async function render() {
|
||||
if (state.view === "review") {
|
||||
try {
|
||||
const [charts, notes] = await Promise.all([api("/api/charts"), api(`/api/review/notes?start=${encodeURIComponent(shanghaiDate(-29))}&end=${encodeURIComponent(today())}`)]);
|
||||
state.notes = notes;
|
||||
state.charts = await Promise.all((charts.items || []).map(async (chart) => ({ ...chart, data: await api(`/api/charts/${chart.chartId}/data`) })));
|
||||
} catch (error) { toast(error.message); return; }
|
||||
await renderLayout("review");
|
||||
return;
|
||||
}
|
||||
if (!state.selectedId) {
|
||||
try { state.summary = await api("/api/summary"); } catch (error) { toast(error.message); }
|
||||
try { state.summary = await api("/api/summary"); await loadSchedules(); } catch (error) { toast(error.message); }
|
||||
await renderLayout("home");
|
||||
return;
|
||||
}
|
||||
@@ -153,6 +224,70 @@ async function render() {
|
||||
await renderLayout("task-detail");
|
||||
}
|
||||
|
||||
function scheduleMonthOffset(offset) {
|
||||
const [year, month] = state.scheduleMonth.split("-").map(Number);
|
||||
const value = new Date(Date.UTC(year, month - 1 + offset, 1));
|
||||
state.scheduleMonth = `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function scheduleForSelectedDate() {
|
||||
const selected = state.selectedDate || today();
|
||||
if (state.schedules.today?.date === selected) return state.schedules.today.items || [];
|
||||
return state.schedules.calendar?.days?.find((item) => item.date === selected)?.schedules || [];
|
||||
}
|
||||
|
||||
function renderScheduleBoard() {
|
||||
const calendar = state.schedules.calendar;
|
||||
if (!calendar) return `<section class="section"><div class="section-heading"><h2>日程</h2></div><p class="muted">正在加载日程…</p></section>`;
|
||||
const selected = state.selectedDate || today();
|
||||
const days = calendar.days || [];
|
||||
const offset = days.length ? (new Date(`${days[0].date}T00:00:00Z`).getUTCDay() + 6) % 7 : 0;
|
||||
const cells = Array.from({ length: offset }, () => `<span class="schedule-day blank"></span>`).join("") + days.map((item) => {
|
||||
const schedules = item.schedules || [];
|
||||
const priorities = [...new Set(schedules.map((schedule) => Number(schedule.priority)).filter((priority) => priority >= 1 && priority <= 5))].sort((a, b) => a - b);
|
||||
const visiblePriorities = priorities.slice(0, 3);
|
||||
const remainingPriorities = Math.max(0, priorities.length - visiblePriorities.length);
|
||||
const dots = visiblePriorities.map((priority) => `<i class="dot priority-${priority}" aria-hidden="true"></i>`).join("");
|
||||
const more = remainingPriorities ? `<span class="schedule-dots-more">+${remainingPriorities}</span>` : "";
|
||||
const tooltip = schedules.map((schedule) => `${schedule.time ? escapeHtml(schedule.time) : "全天"} ${escapeHtml(schedule.title)} · 优先级${Number(schedule.priority) || 3}`).join("\n").replace(/\n/g, " ");
|
||||
const tooltipAttribute = tooltip ? ` data-tooltip="${tooltip}"` : "";
|
||||
return `<button type="button" class="schedule-day ${item.date === selected ? "selected" : ""} ${item.date === today() ? "today" : ""}" data-schedule-date="${item.date}"${tooltipAttribute}><span class="schedule-date">${Number(item.date.slice(-2))}</span><span class="schedule-dots" aria-label="${priorities.length} 种优先级">${dots}${more}</span></button>`;
|
||||
}).join("");
|
||||
const items = scheduleForSelectedDate().map((schedule) => `<div class="schedule-item ${schedule.completed ? "completed" : ""}"><span class="priority-bar priority-${schedule.priority}"></span><div class="schedule-copy"><strong>${escapeHtml(schedule.title)}</strong><small>${schedule.time ? escapeHtml(schedule.time) : "全天"}${schedule.recurrence !== "none" ? ` · ${escapeHtml(schedule.recurrence)}` : ""}</small></div><button type="button" class="button subtle schedule-complete" data-schedule-id="${schedule.scheduleId}" data-schedule-version="${schedule.version}" data-completed="${schedule.completed ? "false" : "true"}">${schedule.completed ? "取消完成" : "完成"}</button><button type="button" class="icon-button schedule-edit" data-schedule-id="${schedule.scheduleId}" title="编辑" aria-label="编辑">✎</button></div>`).join("");
|
||||
const legend = [[1, "紧急"], [2, "高"], [3, "正常"], [4, "低"], [5, "挂起候选"]].map(([priority, label]) => `<span class="schedule-legend-item"><i class="dot priority-${priority}" aria-hidden="true"></i><span>${label}</span></span>`).join("");
|
||||
return `<section class="section schedule-board"><div class="section-heading"><div><p class="eyebrow">SCHEDULE</p><h2>日程安排</h2><span>${selected} · ${scheduleForSelectedDate().length} 项</span></div><div class="schedule-actions"><button type="button" class="icon-button" id="schedulePrev" title="上月" aria-label="上月">‹</button><strong>${calendar.year}-${String(calendar.month).padStart(2, "0")}</strong><button type="button" class="icon-button" id="scheduleNext" title="下月" aria-label="下月">›</button><button type="button" class="button primary" id="addScheduleButton">+ 新建日程</button></div></div><div class="schedule-weekdays"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div><div class="schedule-calendar">${cells}</div><div class="schedule-legend" aria-label="优先级图例"><span class="schedule-legend-label">优先级图例</span>${legend}</div><div class="schedule-list">${items || `<p class="muted">这一天没有日程</p>`}</div></section>`;
|
||||
}
|
||||
|
||||
async function refreshSchedules() { try { await loadSchedules(); await renderLayout("home"); } catch (error) { toast(error.message); } }
|
||||
function bindScheduleBoard() {
|
||||
$("#schedulePrev")?.addEventListener("click", async () => { scheduleMonthOffset(-1); await refreshSchedules(); });
|
||||
$("#scheduleNext")?.addEventListener("click", async () => { scheduleMonthOffset(1); await refreshSchedules(); });
|
||||
$("#addScheduleButton")?.addEventListener("click", () => openScheduleDialog());
|
||||
$$('[data-schedule-date]').forEach((node) => node.addEventListener("click", async () => { state.selectedDate = node.dataset.scheduleDate; await refreshSchedules(); }));
|
||||
$$(".schedule-edit").forEach((node) => node.addEventListener("click", () => {
|
||||
const schedule = scheduleForSelectedDate().find((item) => item.scheduleId === node.dataset.scheduleId); if (schedule) openScheduleDialog(schedule);
|
||||
}));
|
||||
$$(".schedule-complete").forEach((node) => node.addEventListener("click", async () => {
|
||||
try { await api(`/api/schedules/${node.dataset.scheduleId}/complete`, { method: "POST", body: JSON.stringify({ date: state.selectedDate || today(), completed: node.dataset.completed === "true", expectedVersion: Number(node.dataset.scheduleVersion) }) }); toast("日程状态已更新"); await refreshSchedules(); } catch (error) { toast(error.message); }
|
||||
}));
|
||||
}
|
||||
|
||||
function openScheduleDialog(schedule = null) {
|
||||
state.editingScheduleId = schedule?.scheduleId || null;
|
||||
const form = $("#scheduleForm"); form.reset(); $("#scheduleDialogTitle").textContent = schedule ? "编辑日程" : "新建日程";
|
||||
form.title.value = schedule?.title || ""; form.description.value = schedule?.description || ""; form.date.value = schedule?.date || state.selectedDate || today(); form.time.value = schedule?.time || ""; form.recurrence.value = schedule?.recurrence || "none"; form.priority.value = schedule?.priority || 3; form.state.value = schedule?.state || "active";
|
||||
$("#scheduleDialog").showModal();
|
||||
}
|
||||
|
||||
$("#scheduleForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault(); const form = event.currentTarget;
|
||||
const payload = { title: form.title.value, description: form.description.value, date: form.date.value || null, time: form.time.value || null, recurrence: form.recurrence.value, priority: Number(form.priority.value), state: form.state.value };
|
||||
try { const editing = state.editingScheduleId; if (editing) { payload.expectedVersion = scheduleForSelectedDate().find((item) => item.scheduleId === editing)?.version || 1; }
|
||||
const schedule = await api(editing ? `/api/schedules/${editing}` : "/api/schedules", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#scheduleDialog").close(); state.selectedDate = schedule.date; state.scheduleMonth = schedule.date.slice(0, 7); toast(editing ? "日程已更新" : "日程已创建"); await refreshSchedules();
|
||||
} catch (error) { toast(error.message); }
|
||||
});
|
||||
$("#cancelScheduleButton")?.addEventListener("click", () => $("#scheduleDialog").close()); $("#closeScheduleButton")?.addEventListener("click", () => $("#scheduleDialog").close());
|
||||
|
||||
function renderOverviewSummary() {
|
||||
const active = state.tasks.filter((task) => !task.deletedAt); const activePlans = active.filter((task) => task.state === "active").length;
|
||||
return `<div class="page-heading"><div><p class="eyebrow">OVERVIEW · ${today()}</p><h1>把注意力放回正在发生的事</h1><p>所有计划、记录和节奏,都在这里保持可见。</p></div></div><div class="summary-strip"><div class="summary-cell"><strong>${active.length}</strong><span>进行中的任务</span></div><div class="summary-cell"><strong>${activePlans}</strong><span>活跃计划</span></div><div class="summary-cell"><strong>${active.filter((task) => task.priority <= 2).length}</strong><span>高优先级</span></div></div>`;
|
||||
@@ -419,9 +554,25 @@ function renderMetricEditor(metrics) {
|
||||
$("#addMetricButton").addEventListener("click", () => { const metrics = $$(".metric-row").map((row) => Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value]))); metrics.push({ name: "新指标", kind: "quantity", period: "day", target: "", unit: "次" }); renderMetricEditor(metrics); });
|
||||
$("#taskForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const metrics = $$(".metric-row").map((row) => { const result = Object.fromEntries($$("[data-field]", row).map((input) => [input.dataset.field, input.value])); result.metricId = row.dataset.metricId || null; result.target = result.target ? Number(result.target) : null; if (result.kind === "duration") { result.unit = ""; result.durationUnit = result.durationUnit || "minute"; if (result.target != null) result.target = Math.round(result.target * (DURATION_SECONDS[result.durationUnit] || 60)); } else { delete result.durationUnit; } return result; }); const payload = { name: form.name.value, description: form.description.value, startDate: form.startDate.value || null, endDate: form.endDate.value || null, priority: Number(form.priority.value), state: form.state.value, metrics }; try { const endpoint = state.editingId ? `/api/tasks/${state.editingId}` : "/api/tasks"; const method = state.editingId ? "PUT" : "POST"; if (state.editingId) payload.expectedVersion = state.selectedTask.version; const task = await api(endpoint, { method, body: JSON.stringify(payload) }); $("#taskDialog").close(); state.selectedId = task.taskId; toast(state.editingId ? "任务属性已更新" : "任务已创建"); await loadTasks(); } catch (error) { toast(error.message); } });
|
||||
|
||||
$("#addTaskButton").addEventListener("click", () => openTaskDialog()); $("#homeButton").addEventListener("click", () => { state.selectedId = null; render(); });
|
||||
$("#addTaskButton").addEventListener("click", () => openTaskDialog());
|
||||
$("#reviewButton").addEventListener("click", () => { state.view = "review"; render(); });
|
||||
$("#homeButton").addEventListener("click", () => { state.view = "home"; state.selectedId = null; render(); });
|
||||
$("#cancelTaskButton").addEventListener("click", () => $("#taskDialog").close());
|
||||
$("#closeTaskButton").addEventListener("click", () => $("#taskDialog").close());
|
||||
$("#cancelChartButton").addEventListener("click", () => $("#chartDialog").close());
|
||||
$("#closeChartButton").addEventListener("click", () => $("#chartDialog").close());
|
||||
$("#chartTaskSelect").addEventListener("change", (event) => fillChartMetrics(event.target.value));
|
||||
$("#chartForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const payload = { title: form.title.value, taskId: form.taskId.value || null, metricId: form.metricId.value || null, days: Number(form.days.value) };
|
||||
try {
|
||||
const editing = state.editingChartId ? state.charts.find((chart) => chart.chartId === state.editingChartId) : null;
|
||||
if (editing) payload.expectedVersion = editing.version;
|
||||
await api(editing ? `/api/charts/${editing.chartId}` : "/api/charts", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||||
$("#chartDialog").close(); toast(editing ? "图表已更新" : "图表已创建"); await render();
|
||||
} catch (error) { toast(error.message); }
|
||||
});
|
||||
|
||||
function renderAdminStats(stats) {
|
||||
const cells = [
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<span><strong>进度台</strong><small id="logicalDate"></small></span>
|
||||
</button>
|
||||
<div class="top-actions">
|
||||
<button class="button" id="reviewButton" type="button">复盘</button>
|
||||
<button class="button primary" id="addTaskButton" type="button">+ 新建任务</button>
|
||||
<button class="icon-button" id="adminButton" type="button" title="数据管理" aria-label="数据管理">⚙</button>
|
||||
</div>
|
||||
@@ -45,6 +46,35 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="scheduleDialog" class="dialog schedule-dialog">
|
||||
<form id="scheduleForm">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">日程定义</p><h2 id="scheduleDialogTitle">新建日程</h2></div><button class="icon-button" id="closeScheduleButton" type="button" title="关闭" aria-label="关闭">×</button></div>
|
||||
<div class="form-grid">
|
||||
<label class="wide">标题<input name="title" required maxlength="120"></label>
|
||||
<label>日期<input name="date" type="date"></label>
|
||||
<label>时间<input name="time" type="time" maxlength="5"></label>
|
||||
<label>重复<select name="recurrence"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option></select></label>
|
||||
<label>优先级<select name="priority"><option value="1">1 · 紧急</option><option value="2">2 · 高</option><option value="3" selected>3 · 正常</option><option value="4">4 · 低</option><option value="5">5 · 低优先</option></select></label>
|
||||
<label>状态<select name="state"><option value="active">进行中</option><option value="archived">已归档</option></select></label>
|
||||
<label class="wide">说明<textarea name="description" maxlength="2000" rows="3"></textarea></label>
|
||||
</div>
|
||||
<div class="dialog-actions"><button class="button" id="cancelScheduleButton" type="button">取消</button><button class="button primary" type="submit">保存日程</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="chartDialog" class="dialog chart-dialog">
|
||||
<form id="chartForm">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">REVIEW</p><h2 id="chartDialogTitle">添加图表</h2></div><button class="icon-button" id="closeChartButton" type="button" title="关闭" aria-label="关闭">×</button></div>
|
||||
<div class="form-grid">
|
||||
<label class="wide">标题<input name="title" required maxlength="80"></label>
|
||||
<label>任务<select name="taskId" id="chartTaskSelect"><option value="">全部任务</option></select></label>
|
||||
<label>指标<select name="metricId" id="chartMetricSelect"><option value="">完成数</option></select></label>
|
||||
<label>天数<input name="days" type="number" min="7" max="365" value="30" required></label>
|
||||
</div>
|
||||
<div class="dialog-actions"><button class="button" id="cancelChartButton" type="button">取消</button><button class="button primary" type="submit">保存图表</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="adminDialog" class="dialog admin-dialog">
|
||||
<div class="dialog-heading"><div><p class="eyebrow">后台</p><h2>数据管理</h2></div><button class="icon-button" id="closeAdminButton" title="关闭" aria-label="关闭">×</button></div>
|
||||
<div class="admin-actions"><button class="button" id="exportButton">导出 JSON</button><button class="button" id="importButton">导入还原</button><input type="file" id="importFile" accept="application/json,.json" hidden><button class="button" id="archiveButton">执行补归档</button><button class="button" id="logoutButton">退出登录</button></div>
|
||||
|
||||
@@ -194,12 +194,70 @@ label { display: grid; gap: 6px; font-size: 12px; font-weight: 600; color: #4551
|
||||
.login-overlay { position: fixed; inset: 0; z-index: 100; background: #eef2ee; display: grid; place-items: center; padding: 20px; }
|
||||
.login-overlay[hidden] { display: none; }.login-panel { width: min(380px, 100%); background: white; border: 1px solid var(--line); padding: 38px; border-radius: 8px; }.login-panel h1 { margin: 6px 0 28px; font-size: 25px; }.wide-button { width: 100%; margin-top: 16px; }.form-error { color: var(--red); min-height: 18px; font-size: 12px; }
|
||||
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 200; max-width: min(360px, calc(100vw - 44px)); background: var(--ink); color: white; border-radius: 6px; padding: 11px 15px; transform: translateY(100px); opacity: 0; transition: .2s ease; }.toast.show { transform: translateY(0); opacity: 1; }
|
||||
@media (max-width: 820px) {
|
||||
.review-heading { margin-bottom: 20px; }
|
||||
.review-chart-grid { display: grid; gap: 14px; }
|
||||
.review-chart-card { border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 16px; }
|
||||
.review-chart-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.review-chart-head h3 { margin: 0; font-size: 15px; }
|
||||
.review-chart-head > span { display: flex; gap: 5px; }
|
||||
.review-chart-head .icon-button { width: 30px; min-height: 30px; font-size: 16px; }
|
||||
.chart-svg { display: block; width: 100%; height: auto; min-height: 180px; overflow: visible; }
|
||||
.chart-svg polyline { fill: none; stroke: var(--green); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.chart-svg circle { fill: var(--surface); stroke: var(--green); stroke-width: 1.5; }
|
||||
.chart-area { fill: rgba(23,107,75,.10); }
|
||||
.chart-guide { stroke: #e6ebe7; stroke-width: 1; }
|
||||
.chart-svg text { fill: var(--muted); font-size: 11px; }
|
||||
.chart-empty { min-height: 180px; display: grid; place-items: center; color: var(--muted); font-size: 13px; }
|
||||
.review-day { border: 1px solid var(--line); border-radius: 6px; background: var(--surface); margin-bottom: 9px; overflow: hidden; }
|
||||
.review-day summary { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; }
|
||||
.review-day summary::-webkit-details-marker { display: none; }
|
||||
.review-date-badge { display: grid; place-items: center; min-width: 48px; min-height: 30px; border-radius: 4px; background: var(--green-soft); color: var(--green); font-size: 12px; font-weight: 700; }
|
||||
.review-day-records { border-top: 1px solid var(--line); padding: 2px 14px 8px 72px; }
|
||||
.review-note-record { padding: 10px 0; border-bottom: 1px solid #eef2ee; }
|
||||
.review-note-record:last-child { border-bottom: 0; }
|
||||
.review-note-record strong { display: block; font-size: 13px; }.review-note-record time { display: block; color: var(--muted); font-size: 11px; margin-top: 3px; }.review-note-record p { margin: 7px 0 0; white-space: pre-wrap; font-size: 13px; line-height: 1.6; }
|
||||
.chart-dialog { width: min(560px, calc(100vw - 28px)); }
|
||||
.schedule-actions { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
.schedule-actions strong { min-width: 76px; text-align: center; font-size: 14px; }
|
||||
.schedule-weekdays, .schedule-calendar { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 5px; }
|
||||
.schedule-weekdays { color: var(--muted); font-size: 11px; text-align: center; margin-bottom: 5px; }
|
||||
.schedule-date { font-size: 13px; font-weight: 600; }
|
||||
.schedule-day { min-height: 52px; position: relative; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); color: var(--ink); display: flex; align-items: flex-start; justify-content: flex-start; padding: 7px; transition: .15s ease; }
|
||||
.schedule-day:hover, .schedule-day.selected { border-color: var(--green); background: var(--green-soft); }
|
||||
.schedule-day:hover, .schedule-day:focus-visible { transform: translateY(-1px); box-shadow: 0 3px 8px rgba(23,33,26,.08); z-index: 4; }
|
||||
.schedule-day.today { border-color: var(--green); box-shadow: 0 0 0 1px var(--green); }
|
||||
.schedule-day.today .schedule-date { font-weight: 800; color: var(--green); }
|
||||
.schedule-dots { position: absolute; right: 7px; bottom: 7px; display: flex; align-items: flex-end; justify-content: flex-end; gap: 4px; min-height: 9px; max-width: calc(100% - 14px); }
|
||||
.schedule-dots .dot, .schedule-legend .dot { width: 7px; height: 7px; flex: 0 0 7px; display: block; border-radius: 50%; box-shadow: 0 0 0 1.5px #fff; }
|
||||
.schedule-dots .dot.priority-1, .schedule-legend .dot.priority-1 { background: #e5484d; }
|
||||
.schedule-dots .dot.priority-2, .schedule-legend .dot.priority-2 { background: #f76b15; }
|
||||
.schedule-dots .dot.priority-3, .schedule-legend .dot.priority-3 { background: var(--green); }
|
||||
.schedule-dots .dot.priority-4, .schedule-legend .dot.priority-4 { background: #8a93a6; }
|
||||
.schedule-dots .dot.priority-5, .schedule-legend .dot.priority-5 { background: #c6ccd6; }
|
||||
.schedule-dots-more { color: var(--muted); font-size: 10px; line-height: 9px; white-space: nowrap; }
|
||||
.schedule-day[data-tooltip]::after { content: attr(data-tooltip); position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; display: none; width: max-content; max-width: min(260px, calc(100vw - 28px)); padding: 8px 10px; border-radius: 5px; background: var(--ink); color: #fff; box-shadow: 0 5px 16px rgba(23,33,26,.2); font-size: 11px; font-weight: 400; line-height: 1.5; text-align: left; white-space: pre-line; pointer-events: none; }
|
||||
.schedule-day[data-tooltip]:hover::after, .schedule-day[data-tooltip]:focus-visible::after { display: block; }
|
||||
.schedule-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 13px; margin-top: 12px; color: var(--muted); font-size: 11px; }
|
||||
.schedule-legend-label { margin-right: 2px; font-weight: 600; }
|
||||
.schedule-legend-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.schedule-list { display: grid; gap: 7px; margin-top: 15px; }
|
||||
.schedule-item { display: grid; grid-template-columns: 4px 1fr auto auto; gap: 10px; align-items: center; border-top: 1px solid var(--line); padding: 11px 0; }
|
||||
.schedule-item .priority-bar { height: 36px; }
|
||||
.schedule-copy { min-width: 0; }
|
||||
.schedule-copy strong, .schedule-copy small { display: block; }
|
||||
.schedule-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.schedule-copy small { margin-top: 4px; color: var(--muted); font-size: 11px; }
|
||||
.schedule-item.completed .schedule-copy strong { text-decoration: line-through; color: var(--muted); }
|
||||
.schedule-item .icon-button { width: 32px; min-height: 32px; font-size: 16px; }
|
||||
.schedule-dialog { width: min(620px, calc(100vw - 28px)); }
|
||||
.schedule-day.blank { border-color: transparent; background: transparent; pointer-events: none; }
|
||||
@media (max-width: 820px) {
|
||||
.topbar { padding: 0 12px; }.button.primary { padding-inline: 10px; }
|
||||
.workspace { display: block; }.sidebar { border-right: 0; border-bottom: 1px solid var(--line); max-height: 41vh; }.main-content { padding: 22px 16px 50px; }
|
||||
.summary-strip { grid-template-columns: repeat(3, 1fr); }.summary-cell { padding: 15px 10px; }.summary-cell strong { font-size: 20px; }
|
||||
.overview-row { grid-template-columns: 1fr auto; }.overview-row .progress-track { grid-column: 1 / -1; grid-row: 2; }
|
||||
.metric-row { grid-template-columns: repeat(2, 1fr); }.remove-metric { grid-column: 2; justify-self: end; }
|
||||
.review-day-records { padding-left: 14px; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.brand-button small { display: none; }.top-actions .primary { font-size: 0; width: 42px; }.top-actions .primary::first-letter { font-size: 18px; }
|
||||
|
||||
Reference in New Issue
Block a user