feat(web): W3-1 login, session shell, home dashboard, settings, engine panel and audit pages
- /login: setup/login/TOTP states driven by server error codes, fixed-height error slot - console layout: GET /auth/session gate, redirect to /login, account popover (change password, logout) - home: six dashboard sections with SSE-driven refresh, automation switch and read-only reconcile toggle - settings tabs: downloaders, media servers, library roots, path mappings, site credentials, API keys (one-time plaintext), Pi upstream, cache/organize/retention/budget, automation - engine: runs list (status/name filters, cursor paging, stats tiles), run detail (step timeline, json payloads, signals, cancel) - audit: filterable list with detail sheet - vite: allow repo root in server.fs so deno-installed deps under node_modules/.deno load in dev Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ef73abe3df
commit
0dec0854df
21 files changed
+3905
-57
No files matched your search
@@ -0,0 +1,122 @@
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// 状态值 → 中文文案 + 语气。状态徽章必须带文字,这里集中维护常用的几组。
|
||||
|
||||
export type Labeled = { label: string; tone: StatusTone };
|
||||
|
||||
const pick = (map: Record<string, Labeled>, v: string | null | undefined): Labeled =>
|
||||
(v && map[v]) || { label: v || "未知", tone: "neutral" };
|
||||
|
||||
const HEALTH: Record<string, Labeled> = {
|
||||
healthy: { label: "正常", tone: "success" },
|
||||
ok: { label: "正常", tone: "success" },
|
||||
degraded: { label: "降级", tone: "warning" },
|
||||
failing: { label: "失败中", tone: "destructive" },
|
||||
offline: { label: "离线", tone: "destructive" },
|
||||
circuit_open: { label: "已熔断", tone: "destructive" },
|
||||
disabled: { label: "已停用", tone: "neutral" },
|
||||
unknown: { label: "未检测", tone: "neutral" },
|
||||
unverified: { label: "未校验", tone: "neutral" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
};
|
||||
export const healthLabel = (v: string | null | undefined) => pick(HEALTH, v);
|
||||
|
||||
const DOWNLOAD: Record<string, Labeled> = {
|
||||
queued: { label: "排队", tone: "neutral" },
|
||||
submitting: { label: "提交中", tone: "neutral" },
|
||||
downloading: { label: "下载中", tone: "neutral" },
|
||||
paused: { label: "已暂停", tone: "warning" },
|
||||
stalled: { label: "无进展", tone: "warning" },
|
||||
completed: { label: "下载完成", tone: "success" },
|
||||
importing: { label: "入库中", tone: "neutral" },
|
||||
imported: { label: "已入库", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
removed: { label: "已移除", tone: "neutral" },
|
||||
missing: { label: "丢失", tone: "destructive" },
|
||||
};
|
||||
export const downloadLabel = (v: string | null | undefined) => pick(DOWNLOAD, v);
|
||||
|
||||
const DECISION_KIND: Record<string, string> = {
|
||||
identity_ambiguous: "识别有歧义",
|
||||
download_stalled: "下载无进展",
|
||||
download_failed: "下载失败",
|
||||
path_conflict: "路径冲突",
|
||||
space_insufficient: "空间不足",
|
||||
approval_required: "等待批准",
|
||||
metadata_conflict: "元数据冲突",
|
||||
source_unreachable: "来源不可达",
|
||||
other: "其它",
|
||||
};
|
||||
export const decisionKindLabel = (v: string) => DECISION_KIND[v] ?? v;
|
||||
|
||||
const ORG: Record<string, Labeled> = {
|
||||
queued: { label: "排队", tone: "neutral" },
|
||||
running: { label: "进行中", tone: "neutral" },
|
||||
succeeded: { label: "成功", tone: "success" },
|
||||
partial: { label: "部分成功", tone: "warning" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
};
|
||||
export const organizationLabel = (v: string | null | undefined) => pick(ORG, v);
|
||||
|
||||
const ORG_ACTION: Record<string, string> = {
|
||||
copy: "Copy",
|
||||
move: "Move",
|
||||
rename: "重命名",
|
||||
relocate: "目录调整",
|
||||
hardlink: "硬链接",
|
||||
};
|
||||
export const organizationActionLabel = (v: string) => ORG_ACTION[v] ?? v;
|
||||
|
||||
const AGENT_RUN: Record<string, Labeled> = {
|
||||
queued: { label: "排队", tone: "neutral" },
|
||||
running: { label: "执行中", tone: "neutral" },
|
||||
waiting: { label: "等待", tone: "warning" },
|
||||
succeeded: { label: "完成", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
cancelled: { label: "已取消", tone: "neutral" },
|
||||
budget_exceeded: { label: "超预算", tone: "warning" },
|
||||
};
|
||||
export const agentRunLabel = (v: string | null | undefined) => pick(AGENT_RUN, v);
|
||||
|
||||
const ENGINE_RUN: Record<string, Labeled> = {
|
||||
pending: { label: "待执行", tone: "neutral" },
|
||||
running: { label: "运行中", tone: "neutral" },
|
||||
sleeping: { label: "休眠(旧)", tone: "neutral" },
|
||||
succeeded: { label: "完成(旧)", tone: "success" },
|
||||
completed: { label: "完成", tone: "success" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
canceled: { label: "已取消", tone: "warning" },
|
||||
};
|
||||
export const engineRunLabel = (v: string | null | undefined) => pick(ENGINE_RUN, v);
|
||||
|
||||
/** 相对时长:秒 → "3 分钟" */
|
||||
export function formatDuration(sec: number | null | undefined): string {
|
||||
if (sec === null || sec === undefined || Number.isNaN(sec)) return "—";
|
||||
if (sec < 1) return `${Math.round(sec * 1000)} 毫秒`;
|
||||
if (sec < 60) return `${Math.round(sec)} 秒`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)} 分 ${Math.round(sec % 60)} 秒`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)} 小时 ${Math.floor((sec % 3600) / 60)} 分`;
|
||||
return `${Math.floor(sec / 86400)} 天 ${Math.floor((sec % 86400) / 3600)} 小时`;
|
||||
}
|
||||
|
||||
/** 相对现在:"3 分钟前" */
|
||||
export function formatRelative(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return iso;
|
||||
const diff = (Date.now() - t) / 1000;
|
||||
const abs = Math.abs(diff);
|
||||
const suffix = diff >= 0 ? "前" : "后";
|
||||
if (abs < 45) return diff >= 0 ? "刚刚" : "马上";
|
||||
if (abs < 3600) return `${Math.round(abs / 60)} 分钟${suffix}`;
|
||||
if (abs < 86400) return `${Math.round(abs / 3600)} 小时${suffix}`;
|
||||
return `${Math.round(abs / 86400)} 天${suffix}`;
|
||||
}
|
||||
|
||||
/** startedAt → finishedAt(未结束取现在)的秒数;未开始为 null */
|
||||
export function spanSec(r: { startedAt: string | null; finishedAt: string | null }): number | null {
|
||||
if (!r.startedAt) return null;
|
||||
const end = r.finishedAt ? new Date(r.finishedAt).getTime() : Date.now();
|
||||
return (end - new Date(r.startedAt).getTime()) / 1000;
|
||||
}
|
||||
Reference in new issue
Block a user