(tasks.values());
+ }
+
+ private void runTask(TaskInfo taskInfo, TaskRunner runner) {
+ taskInfo.setStatus(TaskStatus.RUNNING);
+ taskInfo.setMessage("任务执行中");
+ taskInfo.setUpdatedAt(Instant.now());
+
+ final TaskContext context = new TaskContext(taskInfo);
+ try {
+ final TaskResult result = runner.run(context);
+ taskInfo.setStatus(TaskStatus.SUCCESS);
+ taskInfo.setProgress(100);
+ taskInfo.setMessage(result != null ? result.getMessage() : "执行完成");
+ taskInfo.getFiles().clear();
+ if (result != null && result.getFiles() != null) {
+ taskInfo.getFiles().addAll(result.getFiles());
+ }
+ } catch (Exception e) {
+ taskInfo.setStatus(TaskStatus.FAILED);
+ taskInfo.setError(e.getMessage());
+ taskInfo.setMessage("执行失败");
+ }
+ taskInfo.setUpdatedAt(Instant.now());
+ }
+
+ @PreDestroy
+ public void destroy() {
+ executor.shutdownNow();
+ }
+}
diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js
new file mode 100644
index 0000000..cbd86e3
--- /dev/null
+++ b/src/main/resources/static/app.js
@@ -0,0 +1,488 @@
+const state = {
+ tasks: [],
+ files: [],
+ presets: [],
+ defaultPresetId: "",
+ activeView: "dashboard",
+ polling: null,
+};
+
+const CUSTOM_PRESET_ID = "custom";
+
+const viewMeta = {
+ dashboard: { title: "工作台", desc: "查看系统状态与最近产物" },
+ svn: { title: "SVN 日志抓取", desc: "配置 SVN 参数并生成 Markdown" },
+ ai: { title: "AI 工作量分析", desc: "选择 Markdown 后生成工作量 Excel" },
+ history: { title: "任务历史", desc: "查看任务执行状态、日志与产物" },
+ settings: { title: "系统设置", desc: "配置 API Key 与输出目录" },
+};
+
+document.addEventListener("DOMContentLoaded", async () => {
+ bindNav();
+ bindForms();
+ await loadPresets();
+ await refreshAll();
+ await loadSettings();
+
+ state.polling = setInterval(refreshAll, 5000);
+});
+
+function bindNav() {
+ document.querySelectorAll(".nav-item").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const view = btn.dataset.view;
+ switchView(view);
+ });
+ });
+}
+
+function bindForms() {
+ const testBtn = document.querySelector("#btn-test-connection");
+ testBtn.addEventListener("click", onTestConnection);
+
+ const svnForm = document.querySelector("#svn-form");
+ svnForm.addEventListener("submit", onRunSvn);
+
+ const aiForm = document.querySelector("#ai-form");
+ aiForm.addEventListener("submit", onRunAi);
+
+ const settingsForm = document.querySelector("#settings-form");
+ settingsForm.addEventListener("submit", onSaveSettings);
+
+ const svnPresetSelect = document.querySelector("#svn-preset-select");
+ svnPresetSelect.addEventListener("change", onSvnPresetChange);
+}
+
+function switchView(view) {
+ state.activeView = view;
+ document.querySelectorAll(".nav-item").forEach((btn) => {
+ btn.classList.toggle("active", btn.dataset.view === view);
+ });
+ document.querySelectorAll(".view").forEach((v) => {
+ v.classList.toggle("active", v.id === `view-${view}`);
+ });
+ document.querySelector("#view-title").textContent = viewMeta[view].title;
+ document.querySelector("#view-desc").textContent = viewMeta[view].desc;
+
+ if (view === "history") {
+ renderTaskTable();
+ renderFileTable();
+ }
+ if (view === "ai") {
+ renderMdFilePicker();
+ }
+}
+
+async function apiFetch(url, options = {}) {
+ const response = await fetch(url, {
+ headers: { "Content-Type": "application/json" },
+ ...options,
+ });
+ if (!response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.error || `请求失败: ${response.status}`);
+ }
+ const text = await response.text();
+ return text ? JSON.parse(text) : {};
+}
+
+async function refreshAll() {
+ try {
+ const [tasksResp, filesResp] = await Promise.all([
+ apiFetch("/api/tasks"),
+ apiFetch("/api/files"),
+ ]);
+ state.tasks = (tasksResp || []).slice().sort((a, b) => sortByTimeDesc(a.createdAt, b.createdAt));
+ state.files = (filesResp.files || []).slice().sort((a, b) => sortByTimeDesc(a.modifiedAt, b.modifiedAt));
+ renderDashboard();
+ if (state.activeView === "history") {
+ renderTaskTable();
+ renderFileTable();
+ }
+ if (state.activeView === "ai") {
+ renderMdFilePicker();
+ }
+ } catch (err) {
+ toast(err.message, true);
+ }
+}
+
+async function loadPresets() {
+ try {
+ const data = await apiFetch("/api/svn/presets");
+ state.presets = data.presets || [];
+ state.defaultPresetId = data.defaultPresetId || "";
+ renderPresetSelects();
+ applyPresetToSvnForm(state.defaultPresetId);
+ } catch (err) {
+ toast(err.message, true);
+ }
+}
+
+function renderPresetSelects() {
+ const svnSelect = document.querySelector("#svn-preset-select");
+ const settingsSelect = document.querySelector("#settings-default-preset");
+ svnSelect.innerHTML = "";
+ settingsSelect.innerHTML = "";
+
+ state.presets.forEach((preset) => {
+ const option1 = document.createElement("option");
+ option1.value = preset.id;
+ option1.textContent = `${preset.name}`;
+ svnSelect.appendChild(option1);
+
+ const option2 = document.createElement("option");
+ option2.value = preset.id;
+ option2.textContent = `${preset.name}`;
+ settingsSelect.appendChild(option2);
+ });
+
+ const customOption = document.createElement("option");
+ customOption.value = CUSTOM_PRESET_ID;
+ customOption.textContent = "自定义 SVN 地址";
+ svnSelect.appendChild(customOption);
+
+ const selected = state.defaultPresetId || (state.presets[0] ? state.presets[0].id : CUSTOM_PRESET_ID);
+ svnSelect.value = selected;
+ settingsSelect.value = selected;
+}
+
+function onSvnPresetChange(event) {
+ applyPresetToSvnForm(event.target.value);
+}
+
+function applyPresetToSvnForm(presetId) {
+ const form = document.querySelector("#svn-form");
+ const select = document.querySelector("#svn-preset-select");
+ const projectInput = form.querySelector("[name='projectName']");
+ const urlInput = form.querySelector("[name='url']");
+
+ if (presetId === CUSTOM_PRESET_ID) {
+ select.value = CUSTOM_PRESET_ID;
+ projectInput.readOnly = false;
+ urlInput.readOnly = false;
+ return;
+ }
+
+ const preset = state.presets.find((item) => item.id === presetId);
+ if (!preset) {
+ return;
+ }
+
+ select.value = preset.id;
+ projectInput.value = preset.name;
+ urlInput.value = preset.url;
+ projectInput.readOnly = true;
+ urlInput.readOnly = true;
+}
+
+function renderDashboard() {
+ const total = state.tasks.length;
+ const running = state.tasks.filter((t) => t.status === "RUNNING" || t.status === "PENDING").length;
+ const failed = state.tasks.filter((t) => t.status === "FAILED").length;
+
+ document.querySelector("#stat-total").textContent = `${total}`;
+ document.querySelector("#stat-running").textContent = `${running}`;
+ document.querySelector("#stat-failed").textContent = `${failed}`;
+
+ const taskList = document.querySelector("#recent-tasks");
+ taskList.innerHTML = "";
+ state.tasks.slice(0, 6).forEach((task) => {
+ const li = document.createElement("li");
+ li.innerHTML = `${task.type} · ${task.status}
${task.message || ""}`;
+ taskList.appendChild(li);
+ });
+ if (taskList.children.length === 0) {
+ taskList.innerHTML = "暂无任务记录";
+ }
+
+ const fileList = document.querySelector("#recent-files");
+ fileList.innerHTML = "";
+ state.files.slice(0, 6).forEach((file) => {
+ const path = file.path;
+ const li = document.createElement("li");
+ li.innerHTML = `${escapeHtml(path)}
${formatBytes(file.size)}`;
+ fileList.appendChild(li);
+ });
+ if (fileList.children.length === 0) {
+ fileList.innerHTML = "暂无输出文件";
+ }
+}
+
+async function onTestConnection() {
+ const form = document.querySelector("#svn-form");
+ const payload = readForm(form);
+ if (!payload.url || !payload.username || !payload.password) {
+ toast("请先填写 SVN 地址、账号和密码", true);
+ return;
+ }
+
+ const btn = document.querySelector("#btn-test-connection");
+ setLoading(btn, true);
+ try {
+ await apiFetch("/api/svn/test-connection", {
+ method: "POST",
+ body: JSON.stringify({
+ url: payload.url,
+ username: payload.username,
+ password: payload.password,
+ }),
+ });
+ toast("SVN 连接成功");
+ } catch (err) {
+ toast(err.message, true);
+ } finally {
+ setLoading(btn, false);
+ }
+}
+
+async function onRunSvn(event) {
+ event.preventDefault();
+ const form = event.target;
+ const payload = readForm(form);
+ const btn = document.querySelector("#btn-svn-run");
+ setLoading(btn, true);
+
+ try {
+ const data = await apiFetch("/api/svn/fetch", {
+ method: "POST",
+ body: JSON.stringify({
+ projectName: payload.projectName || "",
+ url: payload.url,
+ username: payload.username,
+ password: payload.password,
+ startRevision: toNumberOrNull(payload.startRevision),
+ endRevision: toNumberOrNull(payload.endRevision),
+ filterUser: payload.filterUser || "",
+ }),
+ });
+ toast(`SVN 抓取任务已创建:${data.taskId}`);
+ switchView("history");
+ refreshAll();
+ } catch (err) {
+ toast(err.message, true);
+ } finally {
+ setLoading(btn, false);
+ }
+}
+
+function renderMdFilePicker() {
+ const box = document.querySelector("#md-file-picker");
+ const mdFiles = state.files.filter((f) => f.path.toLowerCase().endsWith(".md"));
+ box.innerHTML = "";
+
+ if (mdFiles.length === 0) {
+ box.innerHTML = "暂无 Markdown 文件,请先执行 SVN 抓取。
";
+ return;
+ }
+
+ mdFiles.forEach((file, idx) => {
+ const path = file.path;
+ const id = `md-file-${idx}`;
+ const label = document.createElement("label");
+ const input = document.createElement("input");
+ input.type = "checkbox";
+ input.id = id;
+ input.value = path;
+ label.setAttribute("for", id);
+ const span = document.createElement("span");
+ span.textContent = `${path} (${formatBytes(file.size)})`;
+ label.appendChild(input);
+ label.appendChild(span);
+ box.appendChild(label);
+ });
+}
+
+async function onRunAi(event) {
+ event.preventDefault();
+ const form = event.target;
+ const payload = readForm(form);
+ const checked = [...document.querySelectorAll("#md-file-picker input[type='checkbox']:checked")]
+ .map((input) => input.value);
+ if (!checked.length) {
+ toast("请至少选择一个 Markdown 文件", true);
+ return;
+ }
+
+ const btn = document.querySelector("#btn-ai-run");
+ setLoading(btn, true);
+ try {
+ const data = await apiFetch("/api/ai/analyze", {
+ method: "POST",
+ body: JSON.stringify({
+ filePaths: checked,
+ period: payload.period || "",
+ apiKey: payload.apiKey || "",
+ outputFileName: payload.outputFileName || "",
+ }),
+ });
+ toast(`AI 分析任务已创建:${data.taskId}`);
+ switchView("history");
+ refreshAll();
+ } catch (err) {
+ toast(err.message, true);
+ } finally {
+ setLoading(btn, false);
+ }
+}
+
+function renderTaskTable() {
+ const container = document.querySelector("#task-table");
+ if (!state.tasks.length) {
+ container.innerHTML = "暂无任务记录
";
+ return;
+ }
+
+ const rows = state.tasks.map((task) => {
+ const files = (task.files || []).map((f) => `${escapeHtml(f)}`).join("
");
+ return `
+ | ${escapeHtml(task.taskId.slice(0, 8))} |
+ ${escapeHtml(task.type)} |
+ ${task.status} |
+ ${task.progress || 0}% |
+ ${escapeHtml(task.message || "")}${task.error ? ` ${escapeHtml(task.error)}` : ""} |
+ ${files || "-"} |
+
`;
+ }).join("");
+
+ container.innerHTML = `
+ | 任务ID | 类型 | 状态 | 进度 | 说明 | 产物 |
+ ${rows}
+
`;
+}
+
+function renderFileTable() {
+ const container = document.querySelector("#file-table");
+ if (!state.files.length) {
+ container.innerHTML = "暂无输出文件
";
+ return;
+ }
+ const rows = state.files.map((file) => {
+ const path = file.path;
+ return `
+
+ | ${escapeHtml(path)} |
+ ${formatBytes(file.size)} |
+ ${formatTime(file.modifiedAt)} |
+ 下载 |
+
+ `;
+ }).join("");
+ container.innerHTML = `
+ | 文件路径 | 大小 | 更新时间 | 操作 |
+ ${rows}
+
`;
+}
+
+async function loadSettings() {
+ try {
+ const data = await apiFetch("/api/settings");
+ document.querySelector("#settings-form [name='outputDir']").value = data.outputDir || "";
+ state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
+ const settingsPreset = document.querySelector("#settings-default-preset");
+ if (settingsPreset && state.defaultPresetId) {
+ settingsPreset.value = state.defaultPresetId;
+ }
+ applyPresetToSvnForm(state.defaultPresetId);
+ document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`;
+ } catch (err) {
+ toast(err.message, true);
+ }
+}
+
+async function onSaveSettings(event) {
+ event.preventDefault();
+ const payload = readForm(event.target);
+ const btn = event.target.querySelector("button[type='submit']");
+ setLoading(btn, true);
+ try {
+ const data = await apiFetch("/api/settings", {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ });
+ state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
+ applyPresetToSvnForm(state.defaultPresetId);
+ document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`;
+ toast("设置保存成功");
+ } catch (err) {
+ toast(err.message, true);
+ } finally {
+ setLoading(btn, false);
+ }
+}
+
+function readForm(form) {
+ const data = new FormData(form);
+ return Object.fromEntries(data.entries());
+}
+
+function setLoading(button, loading) {
+ if (!button) {
+ return;
+ }
+ button.disabled = loading;
+ if (loading) {
+ button.dataset.originalText = button.textContent;
+ button.textContent = "处理中...";
+ } else {
+ button.textContent = button.dataset.originalText || button.textContent;
+ }
+}
+
+function toNumberOrNull(value) {
+ if (value === null || value === undefined || String(value).trim() === "") {
+ return null;
+ }
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function toast(message, isError = false) {
+ const el = document.querySelector("#toast");
+ el.textContent = message;
+ el.classList.add("show");
+ el.style.background = isError ? "#7a271a" : "#11343b";
+ setTimeout(() => {
+ el.classList.remove("show");
+ }, 2800);
+}
+
+function escapeHtml(text) {
+ return String(text)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/\"/g, """)
+ .replace(/'/g, "'");
+}
+
+function formatBytes(bytes) {
+ if (bytes === null || bytes === undefined) {
+ return "-";
+ }
+ const units = ["B", "KB", "MB", "GB"];
+ let value = Number(bytes);
+ let idx = 0;
+ while (value >= 1024 && idx < units.length - 1) {
+ value = value / 1024;
+ idx += 1;
+ }
+ return `${value.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
+}
+
+function formatTime(value) {
+ if (!value) {
+ return "-";
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "-";
+ }
+ return date.toLocaleString("zh-CN", { hour12: false });
+}
+
+function sortByTimeDesc(left, right) {
+ const l = left ? new Date(left).getTime() : 0;
+ const r = right ? new Date(right).getTime() : 0;
+ return r - l;
+}
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html
new file mode 100644
index 0000000..a80f3f5
--- /dev/null
+++ b/src/main/resources/static/index.html
@@ -0,0 +1,132 @@
+
+
+
+
+
+ SVN 日志工作台
+
+
+
+
+
+
+
+
+
+
+
+
+ 任务总数
+ 0
+
+
+ 执行中
+ 0
+
+
+ 失败任务
+ 0
+
+
+
+
+
+ 最近任务
+
+
+
+ 最近文件
+
+
+
+
+
+
+
+
+
+
+
+ 任务列表
+
+
+
+ 输出文件
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css
new file mode 100644
index 0000000..cc646d8
--- /dev/null
+++ b/src/main/resources/static/styles.css
@@ -0,0 +1,372 @@
+:root {
+ --bg: #eef2f5;
+ --panel: #ffffff;
+ --text: #122126;
+ --muted: #4b5f66;
+ --primary: #0f766e;
+ --primary-soft: #d1f0eb;
+ --danger: #b42318;
+ --warning: #b54708;
+ --success: #067647;
+ --border: #d6e0e4;
+ --shadow: 0 10px 24px rgba(12, 41, 49, 0.08);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
+ color: var(--text);
+ background: radial-gradient(circle at top right, #dff4ef 0%, var(--bg) 42%, #edf1f7 100%);
+}
+
+.app-shell {
+ min-height: 100vh;
+ display: grid;
+ grid-template-columns: 260px 1fr;
+}
+
+.sidebar {
+ border-right: 1px solid var(--border);
+ background: linear-gradient(180deg, #0d645d 0%, #13454f 100%);
+ color: #f8fffd;
+ padding: 24px 18px;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+}
+
+.sidebar h1 {
+ font-size: 22px;
+ margin: 0 0 20px;
+ letter-spacing: 0.4px;
+}
+
+.sidebar nav {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.nav-item {
+ border: 1px solid transparent;
+ background: transparent;
+ color: #ecf8f5;
+ text-align: left;
+ font-size: 16px;
+ line-height: 1.5;
+ padding: 10px 12px;
+ border-radius: 10px;
+ cursor: pointer;
+ transition: background-color 0.2s ease, border-color 0.2s ease;
+}
+
+.nav-item:hover,
+.nav-item:focus-visible {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: rgba(255, 255, 255, 0.25);
+ outline: 2px solid rgba(255, 255, 255, 0.4);
+ outline-offset: 2px;
+}
+
+.nav-item.active {
+ background: #effaf7;
+ border-color: #bbebe2;
+ color: #114549;
+ font-weight: 700;
+}
+
+.main {
+ padding: 24px;
+}
+
+.main-header {
+ margin-bottom: 18px;
+}
+
+.main-header h2 {
+ margin: 0;
+ font-size: 28px;
+}
+
+.main-header p {
+ margin: 6px 0 0;
+ color: var(--muted);
+}
+
+.view {
+ display: none;
+}
+
+.view.active {
+ display: block;
+}
+
+.grid {
+ display: grid;
+ gap: 16px;
+}
+
+.grid.cols-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ margin-bottom: 16px;
+}
+
+.grid.cols-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.card {
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ box-shadow: var(--shadow);
+ padding: 16px;
+}
+
+.card h3 {
+ margin-top: 0;
+ margin-bottom: 12px;
+}
+
+.stat p {
+ font-size: 40px;
+ margin: 0;
+ font-weight: 700;
+ color: var(--primary);
+}
+
+.list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.list li {
+ border-bottom: 1px solid #edf2f4;
+ padding: 10px 0;
+ font-size: 15px;
+ line-height: 1.5;
+}
+
+.list li:last-child {
+ border-bottom: none;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+
+label {
+ display: block;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.6;
+}
+
+input,
+select,
+button {
+ font: inherit;
+}
+
+input {
+ width: 100%;
+ margin-top: 6px;
+ border: 1px solid #b6c5ca;
+ border-radius: 10px;
+ padding: 10px 12px;
+ min-height: 44px;
+ background: #fff;
+}
+
+select {
+ width: 100%;
+ margin-top: 6px;
+ border: 1px solid #b6c5ca;
+ border-radius: 10px;
+ padding: 10px 12px;
+ min-height: 44px;
+ background: #fff;
+}
+
+input:focus-visible {
+ outline: 2px solid #76b8ad;
+ outline-offset: 1px;
+ border-color: #4fa494;
+}
+
+select:focus-visible {
+ outline: 2px solid #76b8ad;
+ outline-offset: 1px;
+ border-color: #4fa494;
+}
+
+.span-2 {
+ grid-column: span 2;
+}
+
+.actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+button {
+ min-height: 44px;
+ border-radius: 10px;
+ border: 1px solid #a9bbc1;
+ background: #f4f8fa;
+ padding: 0 16px;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+}
+
+button:hover,
+button:focus-visible {
+ background: #e4edf1;
+ outline: 2px solid #b7cad2;
+ outline-offset: 2px;
+}
+
+button.primary {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: #fff;
+}
+
+button.primary:hover,
+button.primary:focus-visible {
+ background: #0c5f59;
+}
+
+button:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.file-picker {
+ border: 1px solid #c7d6db;
+ border-radius: 10px;
+ background: #f9fbfc;
+ padding: 8px;
+ max-height: 220px;
+ overflow: auto;
+}
+
+.file-picker label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px;
+ border-radius: 8px;
+ font-weight: 500;
+}
+
+.file-picker label:hover {
+ background: #ecf4f7;
+}
+
+.table-wrap {
+ overflow-x: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 720px;
+}
+
+th,
+td {
+ padding: 10px 8px;
+ border-bottom: 1px solid #e8eef0;
+ text-align: left;
+ font-size: 14px;
+ vertical-align: top;
+}
+
+.tag {
+ display: inline-block;
+ border-radius: 999px;
+ padding: 2px 10px;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.tag.SUCCESS {
+ background: #d1fadf;
+ color: var(--success);
+}
+
+.tag.RUNNING,
+.tag.PENDING {
+ background: #fef0c7;
+ color: var(--warning);
+}
+
+.tag.FAILED {
+ background: #fee4e2;
+ color: var(--danger);
+}
+
+.muted {
+ color: var(--muted);
+}
+
+.toast {
+ position: fixed;
+ right: 20px;
+ bottom: 20px;
+ border-radius: 10px;
+ padding: 12px 14px;
+ background: #11343b;
+ color: #fff;
+ min-width: 240px;
+ max-width: 380px;
+ display: none;
+ box-shadow: var(--shadow);
+}
+
+.toast.show {
+ display: block;
+}
+
+@media (max-width: 1024px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ position: static;
+ height: auto;
+ }
+
+ .sidebar nav {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .grid.cols-3,
+ .grid.cols-2,
+ .form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .span-2 {
+ grid-column: span 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+ }
+}