feat: add frontend dashboard, login page and portal entry
Public directory contains the Kanban SPA with dashboard UI, login page, and styles. Portal directory provides a standalone service entry page with CSS styling.
This commit is contained in:
64
portal/index.html
Normal file
64
portal/index.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>gahow-pc services</title>
|
||||||
|
<link rel="stylesheet" href="/portal.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>gahow-pc</h1>
|
||||||
|
<p>Local services</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="service-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Services</h2>
|
||||||
|
</div>
|
||||||
|
<div class="service-grid">
|
||||||
|
<a class="service-card primary" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:3443/">
|
||||||
|
<span class="service-icon">Gi</span>
|
||||||
|
<span class="service-topline">Repository</span>
|
||||||
|
<span class="service-name">Gitea</span>
|
||||||
|
<span class="service-desc">本地 Git 仓库与项目管理</span>
|
||||||
|
<span class="service-port">https :3443</span>
|
||||||
|
</a>
|
||||||
|
<a class="service-card primary" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:8443/">
|
||||||
|
<span class="service-icon">Kb</span>
|
||||||
|
<span class="service-topline">Ops</span>
|
||||||
|
<span class="service-name">Kanban</span>
|
||||||
|
<span class="service-desc">GPU、额度、项目与 agent 任务</span>
|
||||||
|
<span class="service-port">https :8443</span>
|
||||||
|
</a>
|
||||||
|
<a class="service-card primary" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:5443/">
|
||||||
|
<span class="service-icon">St</span>
|
||||||
|
<span class="service-topline">Finance</span>
|
||||||
|
<span class="service-name">Stock Agent</span>
|
||||||
|
<span class="service-desc">个人股票分析与日报</span>
|
||||||
|
<span class="service-port">https :5443</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="api-section">
|
||||||
|
<summary>后端 API</summary>
|
||||||
|
<div class="service-grid">
|
||||||
|
<a class="service-card" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:8443/api/health">
|
||||||
|
<span class="service-name">Kanban API</span>
|
||||||
|
<span class="service-port">https :8443/api</span>
|
||||||
|
<span class="service-desc">Kanban 前后端同一个 Node 服务</span>
|
||||||
|
</a>
|
||||||
|
<a class="service-card" href="https://gahow-pc.ipads-lab.se.sjtu.edu.cn:8788/api/health">
|
||||||
|
<span class="service-name">Stock Agent API</span>
|
||||||
|
<span class="service-port">https :8788/api</span>
|
||||||
|
<span class="service-desc">Express API,位于 /home/gahow/projects/stock-agent</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
166
portal/portal.css
Normal file
166
portal/portal.css
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f4f6f8;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--ink: #17202c;
|
||||||
|
--muted: #697586;
|
||||||
|
--line: #d7dee8;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--soft: #eef4ff;
|
||||||
|
--shadow: 0 16px 40px rgba(23, 32, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 10%, rgba(37, 99, 235, 0.12), transparent 28%),
|
||||||
|
radial-gradient(circle at 90% 18%, rgba(18, 128, 92, 0.11), transparent 26%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1180px, calc(100vw - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 42px 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.08;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p,
|
||||||
|
.service-desc {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading span {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card,
|
||||||
|
.api-section {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 8px 14px;
|
||||||
|
min-height: 168px;
|
||||||
|
padding: 18px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card.primary {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 32%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-name {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-topline {
|
||||||
|
align-self: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-desc {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-port {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--soft);
|
||||||
|
color: #1849a9;
|
||||||
|
padding: 3px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-section summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-section .service-grid {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
628
public/app.js
Normal file
628
public/app.js
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
let state = {
|
||||||
|
gpus: [],
|
||||||
|
quotas: [],
|
||||||
|
repos: [],
|
||||||
|
branches: [],
|
||||||
|
agentProfiles: [],
|
||||||
|
tasks: [],
|
||||||
|
selectedRepoId: null,
|
||||||
|
selectedBranch: null,
|
||||||
|
expandedGpuHosts: new Set(),
|
||||||
|
settings: { gpuHosts: [] },
|
||||||
|
errors: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function percent(value, total) {
|
||||||
|
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
|
||||||
|
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
}[char]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return 'N/A';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return 'N/A';
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(start, end) {
|
||||||
|
const from = new Date(start).getTime();
|
||||||
|
const to = new Date(end).getTime();
|
||||||
|
if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return '';
|
||||||
|
const seconds = Math.max(1, Math.round((to - from) / 1000));
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const rest = minutes % 60;
|
||||||
|
return rest ? `${hours}h ${rest}m` : `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
return {
|
||||||
|
running: '运行中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败'
|
||||||
|
}[status] || status || '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canResumeTask(task) {
|
||||||
|
return Boolean(task.sessionId && task.status !== 'running');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSession(sessionId) {
|
||||||
|
return sessionId ? sessionId.slice(0, 8) : '无 Session';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGpuIdle(gpu) {
|
||||||
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
||||||
|
return memoryPercent < 5 && gpu.gpuUtilizationPercent < 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(message, tone = '') {
|
||||||
|
if (!message) return;
|
||||||
|
const host = $('toastHost');
|
||||||
|
if (!host) return;
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.className = `toast ${tone}`.trim();
|
||||||
|
node.textContent = message;
|
||||||
|
host.appendChild(node);
|
||||||
|
setTimeout(() => {
|
||||||
|
node.style.transition = 'opacity 200ms ease';
|
||||||
|
node.style.opacity = '0';
|
||||||
|
setTimeout(() => node.remove(), 220);
|
||||||
|
}, 4500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options) {
|
||||||
|
const response = await fetch(path, options);
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
throw new Error('未登录');
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(text || `${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_LABELS = {
|
||||||
|
settings: '设置',
|
||||||
|
gpus: 'GPU',
|
||||||
|
quotas: '额度',
|
||||||
|
repos: '项目列表',
|
||||||
|
agentProfiles: 'Agent 配置',
|
||||||
|
tasks: '任务历史'
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderErrorBanner() {
|
||||||
|
const banner = $('errorBanner');
|
||||||
|
if (!banner) return;
|
||||||
|
const entries = Object.entries(state.errors || {});
|
||||||
|
if (!entries.length) {
|
||||||
|
banner.hidden = true;
|
||||||
|
banner.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
banner.hidden = false;
|
||||||
|
banner.innerHTML = `
|
||||||
|
<div><strong>部分数据加载失败</strong></div>
|
||||||
|
<ul>${entries.map(([key, message]) => (
|
||||||
|
`<li><strong>${escapeHtml(ERROR_LABELS[key] || key)}</strong> · ${escapeHtml(message)}</li>`
|
||||||
|
)).join('')}</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGpuHostsChips() {
|
||||||
|
const chips = $('gpuHostsChips');
|
||||||
|
if (!chips) return;
|
||||||
|
const hosts = state.settings?.gpuHosts || [];
|
||||||
|
if (!hosts.length) {
|
||||||
|
chips.innerHTML = '<span class="host-chip-empty">未配置任何机器</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusByHost = new Map(state.gpus.map((host) => [host.host, host.ok]));
|
||||||
|
chips.innerHTML = hosts.map((host) => {
|
||||||
|
const ok = statusByHost.get(host);
|
||||||
|
const cls = ok === true ? 'ok' : ok === false ? 'bad' : '';
|
||||||
|
const safe = escapeHtml(host);
|
||||||
|
return `<span class="host-chip ${cls}">
|
||||||
|
<span class="host-chip-dot" aria-hidden="true"></span>
|
||||||
|
<span class="host-chip-label">${safe}</span>
|
||||||
|
<button type="button" class="host-chip-remove" data-remove-host="${safe}" aria-label="删除 ${safe}" title="删除">
|
||||||
|
<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 2.5l5 5M7.5 2.5l-5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGpus() {
|
||||||
|
const okHosts = state.gpus.filter((host) => host.ok).length;
|
||||||
|
$('gpuSummary').textContent = state.gpus.length ? `${okHosts}/${state.gpus.length} 在线` : '尚未配置';
|
||||||
|
renderGpuHostsChips();
|
||||||
|
$('gpuBoard').innerHTML = state.gpus.length ? state.gpus.map((host) => {
|
||||||
|
if (!host.ok) {
|
||||||
|
return `<article class="host-card">
|
||||||
|
<div class="host-title"><span>${escapeHtml(host.host)}</span><span class="status-bad">离线</span></div>
|
||||||
|
<div class="repo-meta">${escapeHtml(host.error || '无法获取 GPU 状态')}</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
const idleCount = host.gpus.filter(isGpuIdle).length;
|
||||||
|
const totalCount = host.gpus.length;
|
||||||
|
const expanded = state.expandedGpuHosts.has(host.host);
|
||||||
|
const thumbRows = host.gpus.map((gpu) => {
|
||||||
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
||||||
|
const busy = !isGpuIdle(gpu);
|
||||||
|
return `<span class="gpu-thumb ${busy ? 'busy' : 'idle'}" title="#${gpu.index} GPU ${gpu.gpuUtilizationPercent}% · MEM ${memoryPercent}%"></span>`;
|
||||||
|
}).join('');
|
||||||
|
const gpuRows = host.gpus.map((gpu) => {
|
||||||
|
const memoryPercent = percent(gpu.memoryUsedMiB, gpu.memoryTotalMiB);
|
||||||
|
return `<article class="gpu-detail-card">
|
||||||
|
<div class="metric-row">
|
||||||
|
<strong>#${gpu.index} ${escapeHtml(gpu.name)}</strong>
|
||||||
|
<span>${gpu.gpuUtilizationPercent}% GPU</span>
|
||||||
|
</div>
|
||||||
|
<meter class="meter" min="0" max="100" low="50" high="85" optimum="10" value="${gpu.gpuUtilizationPercent}" title="GPU Util"></meter>
|
||||||
|
<div class="metric-row muted">
|
||||||
|
<span>${gpu.memoryUsedMiB} / ${gpu.memoryTotalMiB} MiB</span>
|
||||||
|
<span>${memoryPercent}% 显存</span>
|
||||||
|
</div>
|
||||||
|
<meter class="meter" min="0" max="100" low="50" high="85" optimum="10" value="${memoryPercent}" title="Memory"></meter>
|
||||||
|
</article>`;
|
||||||
|
}).join('');
|
||||||
|
return `<article class="host-card ${expanded ? 'expanded' : ''}">
|
||||||
|
<button class="host-summary" type="button" data-host="${escapeHtml(host.host)}" aria-expanded="${expanded}">
|
||||||
|
<span>
|
||||||
|
<strong>${escapeHtml(host.host)}</strong>
|
||||||
|
<span class="muted">${idleCount}/${totalCount} 空闲</span>
|
||||||
|
</span>
|
||||||
|
<span class="gpu-thumbs">${thumbRows}</span>
|
||||||
|
<span class="chip">${expanded ? '收起' : '展开'}</span>
|
||||||
|
</button>
|
||||||
|
${expanded ? `<div class="gpu-list">${gpuRows || '<span class="muted">未发现 GPU</span>'}</div>` : ''}
|
||||||
|
</article>`;
|
||||||
|
}).join('') : '<div class="empty-state">还没有配置 GPU 机器</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuotas() {
|
||||||
|
const okSources = state.quotas.filter((quota) => quota.ok).length;
|
||||||
|
$('quotaSummary').textContent = state.quotas.length ? `${okSources}/${state.quotas.length} 可用` : '未配置';
|
||||||
|
$('quotaBoard').innerHTML = state.quotas.length ? state.quotas.map((quota) => {
|
||||||
|
const summary = quota.summary || {};
|
||||||
|
const limit = summary.limit ?? null;
|
||||||
|
const used = summary.used ?? null;
|
||||||
|
const remaining = summary.remaining ?? null;
|
||||||
|
const usedPercent = limit && used !== null ? percent(used, limit) : null;
|
||||||
|
return `<article class="quota-card">
|
||||||
|
<div class="host-title">
|
||||||
|
<span>${escapeHtml(quota.label)}</span>
|
||||||
|
<span class="${quota.ok ? 'status-ok' : 'status-warn'}">${quota.ok ? '已更新' : '未配置'}</span>
|
||||||
|
</div>
|
||||||
|
${quota.ok ? `
|
||||||
|
<div class="metric-row"><span class="muted">剩余</span><strong>${remaining ?? 'N/A'}</strong></div>
|
||||||
|
<div class="metric-row"><span class="muted">已用</span><strong>${used ?? 'N/A'}</strong></div>
|
||||||
|
${usedPercent === null ? '' : `<meter class="meter" min="0" max="100" low="50" high="85" optimum="10" value="${usedPercent}"></meter>`}
|
||||||
|
` : `<div class="repo-meta">${escapeHtml(quota.error || '尚未配置')}</div>`}
|
||||||
|
</article>`;
|
||||||
|
}).join('') : '<div class="empty-state">还没有配置额度数据源</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRepos() {
|
||||||
|
const selectedRepo = state.repos.find((repo) => repo.id === state.selectedRepoId);
|
||||||
|
$('repoSummary').textContent = state.repos.length
|
||||||
|
? (selectedRepo ? `${state.repos.length} 个 · 当前 ${selectedRepo.fullName}` : `${state.repos.length} 个`)
|
||||||
|
: '暂无';
|
||||||
|
$('repoBoard').innerHTML = state.repos.length ? state.repos.map((repo) => {
|
||||||
|
const selected = repo.id === state.selectedRepoId ? ' selected' : '';
|
||||||
|
return `<article class="repo-card${selected}" data-id="${escapeHtml(repo.id)}">
|
||||||
|
<div class="repo-title">
|
||||||
|
<span>${escapeHtml(repo.fullName)}</span>
|
||||||
|
<span class="chip">${escapeHtml(repo.defaultBranch)}</span>
|
||||||
|
</div>
|
||||||
|
${repo.description ? `<div class="repo-meta">${escapeHtml(repo.description)}</div>` : ''}
|
||||||
|
<div class="repo-meta">${escapeHtml(repo.cloneUrl)}</div>
|
||||||
|
</article>`;
|
||||||
|
}).join('') : '<div class="empty-state">无法读取项目,请检查 Gitea 配置</div>';
|
||||||
|
renderAgentContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentContext() {
|
||||||
|
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
|
||||||
|
const repoEl = $('agentContextRepo');
|
||||||
|
const branchEl = $('agentContextBranch');
|
||||||
|
if (!repoEl || !branchEl) return;
|
||||||
|
if (repo) {
|
||||||
|
repoEl.textContent = repo.fullName;
|
||||||
|
repoEl.classList.remove('empty');
|
||||||
|
} else {
|
||||||
|
repoEl.textContent = '从左侧选择一个项目';
|
||||||
|
repoEl.classList.add('empty');
|
||||||
|
}
|
||||||
|
if (state.selectedBranch) {
|
||||||
|
branchEl.textContent = state.selectedBranch;
|
||||||
|
branchEl.hidden = false;
|
||||||
|
} else {
|
||||||
|
branchEl.hidden = true;
|
||||||
|
branchEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBranches() {
|
||||||
|
$('branchSelect').innerHTML = state.branches.length ? state.branches.map((branch) => {
|
||||||
|
const selected = branch.name === state.selectedBranch ? 'selected' : '';
|
||||||
|
return `<option value="${escapeHtml(branch.name)}" ${selected}>${escapeHtml(branch.name)}</option>`;
|
||||||
|
}).join('') : '<option value="">无可用 Branch</option>';
|
||||||
|
renderAgentContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfiles() {
|
||||||
|
$('profileSelect').innerHTML = state.agentProfiles.map((profile) => (
|
||||||
|
`<option value="${escapeHtml(profile.id)}">${escapeHtml(profile.label || profile.id)}</option>`
|
||||||
|
)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskMatchesSelection(task) {
|
||||||
|
return task.repoFullName === state.selectedRepoId && task.branch === state.selectedBranch && canResumeTask(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResumeTasks() {
|
||||||
|
const options = state.tasks.filter(taskMatchesSelection);
|
||||||
|
const prev = $('resumeTaskSelect').value;
|
||||||
|
$('resumeTaskSelect').innerHTML = [
|
||||||
|
'<option value="">新 Session</option>',
|
||||||
|
...options.map((task) => {
|
||||||
|
const prompt = task.prompt ? ` · ${task.prompt.slice(0, 40)}` : '';
|
||||||
|
const label = `${formatDate(task.finishedAt || task.createdAt)} · ${statusLabel(task.status)} · ${shortSession(task.sessionId)}${prompt}`;
|
||||||
|
return `<option value="${escapeHtml(task.id)}">${escapeHtml(label)}</option>`;
|
||||||
|
})
|
||||||
|
].join('');
|
||||||
|
if (prev && options.some((task) => task.id === prev)) {
|
||||||
|
$('resumeTaskSelect').value = prev;
|
||||||
|
}
|
||||||
|
$('resumeTaskButton').disabled = options.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTerminal(label, content, tone = '', expanded = true) {
|
||||||
|
if (!content) return '';
|
||||||
|
const lineCount = content.split('\n').length;
|
||||||
|
const body = `<pre class="terminal-body" tabindex="0">${escapeHtml(content)}</pre>`;
|
||||||
|
if (!expanded) {
|
||||||
|
return `<details class="terminal ${tone}">
|
||||||
|
<summary class="terminal-head">
|
||||||
|
<span>${label}</span>
|
||||||
|
<span>${lineCount} lines</span>
|
||||||
|
</summary>
|
||||||
|
${body}
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
return `<section class="terminal ${tone}">
|
||||||
|
<div class="terminal-head">
|
||||||
|
<span>${label}</span>
|
||||||
|
<span>${lineCount} lines</span>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskFullBody(task, { expandedTerminals }) {
|
||||||
|
const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : '';
|
||||||
|
return `
|
||||||
|
<div class="task-head">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(task.profileLabel)}</strong>
|
||||||
|
<div class="task-meta">${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-actions">
|
||||||
|
<span class="chip status-chip">${escapeHtml(statusLabel(task.status))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-facts">
|
||||||
|
<span>${formatDate(task.createdAt)}</span>
|
||||||
|
${task.finishedAt ? `<span>完成 ${formatDate(task.finishedAt)}</span>` : ''}
|
||||||
|
${duration ? `<span>耗时 ${escapeHtml(duration)}</span>` : ''}
|
||||||
|
${Number.isInteger(task.exitCode) ? `<span>Exit ${task.exitCode}</span>` : ''}
|
||||||
|
${task.sessionId ? `<span class="session-id">Session ${escapeHtml(shortSession(task.sessionId))}</span>` : ''}
|
||||||
|
${task.parentTaskId ? `<span>续自 ${escapeHtml(task.parentTaskId.slice(0, 8))}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="task-meta workspace-path">${escapeHtml(task.workspace)}</div>
|
||||||
|
<div class="task-prompt">${escapeHtml(task.prompt)}</div>
|
||||||
|
${renderTerminal('stderr', task.stderr, 'stderr', expandedTerminals)}
|
||||||
|
${renderTerminal('stdout', task.stdout, '', expandedTerminals)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveTaskCard(task) {
|
||||||
|
return `<article class="task-card active-task status-${escapeHtml(task.status)}">
|
||||||
|
${taskFullBody(task, { expandedTerminals: true })}
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistoryTaskCard(task) {
|
||||||
|
const duration = task.finishedAt ? formatDuration(task.createdAt, task.finishedAt) : '';
|
||||||
|
const promptPreview = (task.prompt || '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
||||||
|
const resumeButton = canResumeTask(task)
|
||||||
|
? `<button class="secondary-button small" type="button" data-resume-task="${escapeHtml(task.id)}">选择继续</button>`
|
||||||
|
: '';
|
||||||
|
return `<details class="task-card history-task status-${escapeHtml(task.status)}">
|
||||||
|
<summary class="task-summary">
|
||||||
|
<span class="chip status-chip">${escapeHtml(statusLabel(task.status))}</span>
|
||||||
|
<span class="task-summary-main">
|
||||||
|
<span class="task-summary-title">${escapeHtml(task.repoFullName)} · ${escapeHtml(task.branch)}</span>
|
||||||
|
${promptPreview ? `<span class="task-summary-prompt muted">${escapeHtml(promptPreview)}</span>` : ''}
|
||||||
|
</span>
|
||||||
|
<span class="task-summary-resume">${resumeButton}</span>
|
||||||
|
<span class="task-summary-meta">
|
||||||
|
<span class="muted task-summary-date">${formatDate(task.finishedAt || task.createdAt)}</span>
|
||||||
|
<span class="muted task-summary-duration">${duration ? escapeHtml(duration) : ''}</span>
|
||||||
|
</span>
|
||||||
|
<span class="task-summary-chevron" aria-hidden="true">›</span>
|
||||||
|
</summary>
|
||||||
|
<div class="task-body">
|
||||||
|
${taskFullBody(task, { expandedTerminals: false })}
|
||||||
|
</div>
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskCard(task) {
|
||||||
|
return task.status === 'running' ? renderActiveTaskCard(task) : renderHistoryTaskCard(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTasks() {
|
||||||
|
const running = state.tasks.filter((task) => task.status === 'running').length;
|
||||||
|
const completed = state.tasks.filter((task) => task.status === 'completed').length;
|
||||||
|
const failed = state.tasks.filter((task) => task.status === 'failed').length;
|
||||||
|
$('taskSummary').textContent = `${running} 运行中 · ${completed} 已完成 · ${failed} 失败`;
|
||||||
|
|
||||||
|
const activeTasks = state.tasks.filter((task) => task.status === 'running');
|
||||||
|
const historyTasks = state.tasks.filter((task) => task.status !== 'running');
|
||||||
|
const sections = [];
|
||||||
|
if (activeTasks.length) {
|
||||||
|
sections.push(`<section class="task-section">
|
||||||
|
<div class="task-section-heading"><h3>运行中</h3><span class="muted">${activeTasks.length} 个</span></div>
|
||||||
|
<div class="task-list">${activeTasks.map(renderTaskCard).join('')}</div>
|
||||||
|
</section>`);
|
||||||
|
}
|
||||||
|
if (historyTasks.length) {
|
||||||
|
sections.push(`<section class="task-section">
|
||||||
|
<div class="task-section-heading"><h3>历史 Session</h3><span class="muted">${historyTasks.length} 个</span></div>
|
||||||
|
<div class="task-list history-list">${historyTasks.map(renderTaskCard).join('')}</div>
|
||||||
|
</section>`);
|
||||||
|
}
|
||||||
|
$('taskBoard').innerHTML = sections.join('') || '<div class="empty-state">暂无 Session</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!state.selectedRepoId && state.repos[0]) state.selectedRepoId = state.repos[0].id;
|
||||||
|
renderErrorBanner();
|
||||||
|
renderGpus();
|
||||||
|
renderQuotas();
|
||||||
|
renderRepos();
|
||||||
|
renderBranches();
|
||||||
|
renderProfiles();
|
||||||
|
renderResumeTasks();
|
||||||
|
renderTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBranches() {
|
||||||
|
if (!state.selectedRepoId) {
|
||||||
|
state.branches = [];
|
||||||
|
state.selectedBranch = null;
|
||||||
|
renderBranches();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.branches = await request(`/api/branches?repo=${encodeURIComponent(state.selectedRepoId)}`);
|
||||||
|
} catch (error) {
|
||||||
|
state.branches = [];
|
||||||
|
toast(`读取 branches 失败: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
|
||||||
|
if (!state.branches.some((branch) => branch.name === state.selectedBranch)) {
|
||||||
|
state.selectedBranch = state.branches.find((branch) => branch.name === repo?.defaultBranch)?.name || state.branches[0]?.name || null;
|
||||||
|
}
|
||||||
|
renderBranches();
|
||||||
|
renderResumeTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const button = $('refreshButton');
|
||||||
|
button.disabled = true;
|
||||||
|
button.classList.add('loading');
|
||||||
|
try {
|
||||||
|
const data = await request('/api/dashboard');
|
||||||
|
state = { ...state, ...data, errors: data.errors || {} };
|
||||||
|
render();
|
||||||
|
await loadBranches();
|
||||||
|
} catch (error) {
|
||||||
|
toast(`刷新失败: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('refreshButton').addEventListener('click', refresh);
|
||||||
|
|
||||||
|
$('logoutButton').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
} finally {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('gpuBoard').addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('.host-summary');
|
||||||
|
if (!button) return;
|
||||||
|
const host = button.dataset.host;
|
||||||
|
if (state.expandedGpuHosts.has(host)) state.expandedGpuHosts.delete(host);
|
||||||
|
else state.expandedGpuHosts.add(host);
|
||||||
|
renderGpus();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveGpuHosts(gpuHosts, successMessage) {
|
||||||
|
try {
|
||||||
|
const settings = await request('/api/settings/gpu-hosts', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ gpuHosts })
|
||||||
|
});
|
||||||
|
state.settings = settings;
|
||||||
|
renderGpuHostsChips();
|
||||||
|
if (successMessage) toast(successMessage, 'success');
|
||||||
|
await refresh();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast(`保存失败: ${error.message}`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHostAdding(adding) {
|
||||||
|
const trigger = $('hostAddTrigger');
|
||||||
|
const form = $('hostAddForm');
|
||||||
|
const input = $('hostAddInput');
|
||||||
|
if (!trigger || !form) return;
|
||||||
|
form.hidden = !adding;
|
||||||
|
trigger.hidden = adding;
|
||||||
|
if (adding && input) {
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('hostAddTrigger').addEventListener('click', () => setHostAdding(true));
|
||||||
|
$('hostAddCancel').addEventListener('click', () => setHostAdding(false));
|
||||||
|
$('hostAddInput').addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHostAdding(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('hostAddForm').addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = $('hostAddInput').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
toast('请输入主机名', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = state.settings?.gpuHosts || [];
|
||||||
|
if (current.includes(name)) {
|
||||||
|
toast(`${name} 已存在`, 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await saveGpuHosts([...current, name], `已添加 ${name}`);
|
||||||
|
if (ok) setHostAdding(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('gpuHostsChips').addEventListener('click', async (event) => {
|
||||||
|
const button = event.target.closest('[data-remove-host]');
|
||||||
|
if (!button) return;
|
||||||
|
const host = button.dataset.removeHost;
|
||||||
|
const current = state.settings?.gpuHosts || [];
|
||||||
|
if (!current.includes(host)) return;
|
||||||
|
await saveGpuHosts(current.filter((item) => item !== host), `已移除 ${host}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('repoBoard').addEventListener('click', (event) => {
|
||||||
|
const card = event.target.closest('.repo-card');
|
||||||
|
if (!card) return;
|
||||||
|
state.selectedRepoId = card.dataset.id;
|
||||||
|
state.selectedBranch = null;
|
||||||
|
renderRepos();
|
||||||
|
loadBranches();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('branchSelect').addEventListener('change', (event) => {
|
||||||
|
state.selectedBranch = event.target.value;
|
||||||
|
renderResumeTasks();
|
||||||
|
renderAgentContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectResumeTask(taskId) {
|
||||||
|
const task = state.tasks.find((item) => item.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
state.selectedRepoId = task.repoFullName;
|
||||||
|
state.selectedBranch = task.branch;
|
||||||
|
renderRepos();
|
||||||
|
await loadBranches();
|
||||||
|
$('resumeTaskSelect').value = task.id;
|
||||||
|
$('taskForm').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
$('promptInput').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('taskBoard').addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('[data-resume-task]');
|
||||||
|
if (!button) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
selectResumeTask(button.dataset.resumeTask);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitTask(resume) {
|
||||||
|
const repo = state.repos.find((item) => item.id === state.selectedRepoId);
|
||||||
|
const prompt = $('promptInput').value.trim();
|
||||||
|
if (!repo) {
|
||||||
|
toast('请先从左侧选择一个项目', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!prompt) {
|
||||||
|
toast('请输入命令', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
repo,
|
||||||
|
branch: $('branchSelect').value,
|
||||||
|
profileId: $('profileSelect').value,
|
||||||
|
prompt,
|
||||||
|
resumeTaskId: resume ? $('resumeTaskSelect').value : ''
|
||||||
|
};
|
||||||
|
if (resume && !payload.resumeTaskId) {
|
||||||
|
toast('没有可继续的 Session', 'warn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const submitButtons = [$('startTaskButton'), $('resumeTaskButton')];
|
||||||
|
submitButtons.forEach((btn) => { if (btn) btn.disabled = true; });
|
||||||
|
try {
|
||||||
|
await request('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
$('promptInput').value = '';
|
||||||
|
toast(resume ? 'Session 已继续' : '任务已启动', 'success');
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast(`启动任务失败: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
submitButtons.forEach((btn) => { if (btn) btn.disabled = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('taskForm').addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await submitTask(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('resumeTaskButton').addEventListener('click', () => submitTask(true));
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, 30000);
|
||||||
118
public/index.html
Normal file
118
public/index.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Local Kanban</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css?v=20260514">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-glow" aria-hidden="true"></div>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark" aria-hidden="true">LK</div>
|
||||||
|
<div>
|
||||||
|
<h1>Local Kanban</h1>
|
||||||
|
<p id="subtitle" class="muted">GPU · 额度 · 项目 · Agent</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<button id="refreshButton" type="button" class="icon-button" title="刷新" aria-label="刷新">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.36-6.36L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.36 6.36L3 16"/><path d="M3 21v-5h5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="logoutButton" type="button" class="ghost-button">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="errorBanner" class="error-banner" hidden></div>
|
||||||
|
|
||||||
|
<section class="grid dashboard-grid">
|
||||||
|
<section class="panel span-2">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>GPU</h2>
|
||||||
|
<span id="gpuSummary" class="badge"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gpu-hosts-row">
|
||||||
|
<div id="gpuHostsChips" class="gpu-hosts-chips"></div>
|
||||||
|
<button id="hostAddTrigger" type="button" class="host-add-button" title="添加机器">
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
<span>添加</span>
|
||||||
|
</button>
|
||||||
|
<form id="hostAddForm" class="host-add-form" hidden>
|
||||||
|
<input id="hostAddInput" type="text" placeholder="主机名" autocomplete="off" spellcheck="false">
|
||||||
|
<button type="submit" class="primary-button small">添加</button>
|
||||||
|
<button id="hostAddCancel" type="button" class="link-button">取消</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="gpuBoard" class="gpu-board"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>AI 额度</h2>
|
||||||
|
<span id="quotaSummary" class="badge"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="quotaBoard" class="quota-board"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid work-grid">
|
||||||
|
<section class="panel project-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>项目</h2>
|
||||||
|
<span id="repoSummary" class="badge"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="repoBoard" class="repo-board"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel agent-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>Agent</h2>
|
||||||
|
<span id="taskSummary" class="badge"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="agentContext" class="agent-context">
|
||||||
|
<span class="agent-context-label">当前项目</span>
|
||||||
|
<span id="agentContextRepo" class="agent-context-repo">未选择</span>
|
||||||
|
<span id="agentContextBranch" class="chip" hidden></span>
|
||||||
|
</div>
|
||||||
|
<form id="taskForm" class="task-form">
|
||||||
|
<div class="task-controls">
|
||||||
|
<label class="select-field">
|
||||||
|
<span class="field-label">Branch</span>
|
||||||
|
<select id="branchSelect" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="select-field">
|
||||||
|
<span class="field-label">AI 配置</span>
|
||||||
|
<select id="profileSelect" required></select>
|
||||||
|
</label>
|
||||||
|
<label class="select-field">
|
||||||
|
<span class="field-label">历史 Session</span>
|
||||||
|
<select id="resumeTaskSelect"></select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="prompt-field">
|
||||||
|
<span class="field-label">命令</span>
|
||||||
|
<textarea id="promptInput" rows="5" required placeholder="输入要交给 agent 的任务"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="startTaskButton" type="submit" class="primary-button">启动任务</button>
|
||||||
|
<button id="resumeTaskButton" type="button" class="secondary-button">继续 Session</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="taskBoard" class="task-board"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<div id="toastHost" class="toast-host" aria-live="polite"></div>
|
||||||
|
<script type="module" src="/app.js?v=20260514"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
public/login.html
Normal file
23
public/login.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Login · Local Kanban</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="login-shell">
|
||||||
|
<form id="loginForm" class="login-panel">
|
||||||
|
<h1>Local Kanban</h1>
|
||||||
|
<label>
|
||||||
|
Access Token
|
||||||
|
<input id="tokenInput" type="password" autocomplete="current-password" required autofocus>
|
||||||
|
</label>
|
||||||
|
<button type="submit">登录</button>
|
||||||
|
<p id="loginError" class="status-bad"></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
public/login.js
Normal file
18
public/login.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const tokenInput = document.getElementById('tokenInput');
|
||||||
|
const loginError = document.getElementById('loginError');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
loginError.textContent = '';
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: tokenInput.value })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loginError.textContent = response.status === 429 ? '尝试次数过多' : 'Token 无效';
|
||||||
|
});
|
||||||
1213
public/styles.css
Normal file
1213
public/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user