feat(web): 新增可视化工作台并支持预置SVN项目
新增 Spring Boot Web 后端与前端页面,打通 SVN 抓取、AI 分析、任务管理、文件下载与系统设置全流程。增加 3 个默认 SVN 预置项目下拉与默认项配置,提升日常使用效率与可维护性。
This commit is contained in:
@@ -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 = `<strong>${task.type}</strong> · <span class="tag ${task.status}">${task.status}</span><br><span class="muted">${task.message || ""}</span>`;
|
||||
taskList.appendChild(li);
|
||||
});
|
||||
if (taskList.children.length === 0) {
|
||||
taskList.innerHTML = "<li class='muted'>暂无任务记录</li>";
|
||||
}
|
||||
|
||||
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 = `<a href="/api/files/download?path=${encodeURIComponent(path)}">${escapeHtml(path)}</a><br><span class='muted'>${formatBytes(file.size)}</span>`;
|
||||
fileList.appendChild(li);
|
||||
});
|
||||
if (fileList.children.length === 0) {
|
||||
fileList.innerHTML = "<li class='muted'>暂无输出文件</li>";
|
||||
}
|
||||
}
|
||||
|
||||
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 = "<p class='muted'>暂无 Markdown 文件,请先执行 SVN 抓取。</p>";
|
||||
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 = "<p class='muted'>暂无任务记录</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = state.tasks.map((task) => {
|
||||
const files = (task.files || []).map((f) => `<a href="/api/files/download?path=${encodeURIComponent(f)}">${escapeHtml(f)}</a>`).join("<br>");
|
||||
return `<tr>
|
||||
<td>${escapeHtml(task.taskId.slice(0, 8))}</td>
|
||||
<td>${escapeHtml(task.type)}</td>
|
||||
<td><span class="tag ${task.status}">${task.status}</span></td>
|
||||
<td>${task.progress || 0}%</td>
|
||||
<td>${escapeHtml(task.message || "")}${task.error ? `<br><span class='muted'>${escapeHtml(task.error)}</span>` : ""}</td>
|
||||
<td>${files || "-"}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
container.innerHTML = `<table>
|
||||
<thead><tr><th>任务ID</th><th>类型</th><th>状态</th><th>进度</th><th>说明</th><th>产物</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function renderFileTable() {
|
||||
const container = document.querySelector("#file-table");
|
||||
if (!state.files.length) {
|
||||
container.innerHTML = "<p class='muted'>暂无输出文件</p>";
|
||||
return;
|
||||
}
|
||||
const rows = state.files.map((file) => {
|
||||
const path = file.path;
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(path)}</td>
|
||||
<td>${formatBytes(file.size)}</td>
|
||||
<td>${formatTime(file.modifiedAt)}</td>
|
||||
<td><a href="/api/files/download?path=${encodeURIComponent(path)}">下载</a></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
container.innerHTML = `<table>
|
||||
<thead><tr><th>文件路径</th><th>大小</th><th>更新时间</th><th>操作</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
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, """)
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SVN 日志工作台</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="主导航">
|
||||
<h1>SVN 工作台</h1>
|
||||
<nav>
|
||||
<button class="nav-item active" data-view="dashboard">工作台</button>
|
||||
<button class="nav-item" data-view="svn">SVN 日志抓取</button>
|
||||
<button class="nav-item" data-view="ai">AI 工作量分析</button>
|
||||
<button class="nav-item" data-view="history">任务历史</button>
|
||||
<button class="nav-item" data-view="settings">系统设置</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main" id="main">
|
||||
<header class="main-header">
|
||||
<h2 id="view-title">工作台</h2>
|
||||
<p id="view-desc">查看系统状态与最近产物</p>
|
||||
</header>
|
||||
|
||||
<section class="view active" id="view-dashboard" aria-live="polite">
|
||||
<div class="grid cols-3" id="stats-cards">
|
||||
<article class="card stat">
|
||||
<h3>任务总数</h3>
|
||||
<p id="stat-total">0</p>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<h3>执行中</h3>
|
||||
<p id="stat-running">0</p>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<h3>失败任务</h3>
|
||||
<p id="stat-failed">0</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="grid cols-2">
|
||||
<article class="card">
|
||||
<h3>最近任务</h3>
|
||||
<ul id="recent-tasks" class="list"></ul>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>最近文件</h3>
|
||||
<ul id="recent-files" class="list"></ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-svn">
|
||||
<article class="card form-card">
|
||||
<h3>SVN 抓取参数</h3>
|
||||
<form id="svn-form" class="form-grid">
|
||||
<label>预置项目
|
||||
<select name="presetId" id="svn-preset-select" aria-label="预置 SVN 项目"></select>
|
||||
</label>
|
||||
<label>项目名<input name="projectName" placeholder="如:PRS-7050"></label>
|
||||
<label>SVN 地址<input required name="url" placeholder="https://..." aria-label="SVN 地址"></label>
|
||||
<label>账号<input required name="username" placeholder="请输入账号"></label>
|
||||
<label>密码<input required type="password" name="password" placeholder="请输入密码"></label>
|
||||
<label>开始版本号<input name="startRevision" inputmode="numeric" placeholder="默认最新"></label>
|
||||
<label>结束版本号<input name="endRevision" inputmode="numeric" placeholder="默认最新"></label>
|
||||
<label class="span-2">过滤用户名<input name="filterUser" placeholder="包含匹配,留空不过滤"></label>
|
||||
<div class="actions span-2">
|
||||
<button type="button" id="btn-test-connection">测试连接</button>
|
||||
<button type="submit" id="btn-svn-run" class="primary">开始抓取并导出</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-ai">
|
||||
<article class="card form-card">
|
||||
<h3>AI 分析参数</h3>
|
||||
<form id="ai-form" class="form-grid">
|
||||
<label class="span-2">选择 Markdown 输入文件</label>
|
||||
<div class="span-2 file-picker" id="md-file-picker" role="group" aria-label="Markdown 文件选择"></div>
|
||||
<label>工作周期<input name="period" placeholder="例如 2026年03月"></label>
|
||||
<label>输出文件名<input name="outputFileName" placeholder="例如 202603工作量统计.xlsx"></label>
|
||||
<label class="span-2">临时 API Key(可选)<input type="password" name="apiKey" placeholder="优先使用设置页或环境变量"></label>
|
||||
<div class="actions span-2">
|
||||
<button type="submit" id="btn-ai-run" class="primary">开始 AI 分析并导出 Excel</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-history">
|
||||
<article class="card">
|
||||
<h3>任务列表</h3>
|
||||
<div id="task-table" class="table-wrap"></div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>输出文件</h3>
|
||||
<div id="file-table" class="table-wrap"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-settings">
|
||||
<article class="card form-card">
|
||||
<h3>系统设置</h3>
|
||||
<form id="settings-form" class="form-grid">
|
||||
<label class="span-2">DeepSeek API Key
|
||||
<input type="password" name="apiKey" placeholder="设置后将保存在当前进程内存">
|
||||
</label>
|
||||
<label class="span-2">默认 SVN 项目
|
||||
<select name="defaultSvnPresetId" id="settings-default-preset"></select>
|
||||
</label>
|
||||
<label class="span-2">输出目录
|
||||
<input name="outputDir" placeholder="默认 outputs">
|
||||
</label>
|
||||
<div class="actions span-2">
|
||||
<button type="submit" class="primary">保存设置</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="settings-state" class="muted"></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="toast" id="toast" aria-live="assertive"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user