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:
rootandClaude Opus 5.5 committed 2026-09-27 20:40:41 +08:00
1 parent ef73abe3df
commit 0dec0854df
21 files changed
+3905 -57

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyUpdate, CursorPage } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
export function useApiKeys() {
return useQuery({
queryKey: qk.list("api-keys"),
queryFn: ({ signal }) => apiData<CursorPage<ApiKey>>("/api-keys", { params: { limit: 200 }, signal }),
});
}
/** 创建:明文只在返回值里出现一次 */
export function useCreateApiKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: ApiKeyCreate) => apiData<ApiKeyCreated>("/api-keys", { method: "POST", body }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.all("api-keys") }),
});
}
export function useUpdateApiKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: ApiKeyUpdate }) =>
apiData<ApiKey>("/api-keys/{id}", { method: "PATCH", pathParams: { id }, body }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.all("api-keys") }),
});
}
export function useRevokeApiKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiData<unknown>("/api-keys/{id}", { method: "DELETE", pathParams: { id } }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.all("api-keys") }),
});
}
+25
View File
@@ -0,0 +1,25 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import type { AuditEvent, CursorPage } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
export type AuditFilters = {
actorType?: string;
action?: string;
targetType?: string;
targetId?: string;
};
/** 审计事件(新→旧),游标"加载更多" */
export function useAuditEvents(filters: AuditFilters) {
return useInfiniteQuery({
queryKey: qk.list("audit", filters),
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) =>
apiData<CursorPage<AuditEvent>>("/audit-events", {
params: { ...filters, cursor: pageParam, limit: 50 },
signal,
}),
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
}
+69
View File
@@ -0,0 +1,69 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
AdminSetupRequest,
LoginRequest,
LoginResult,
PasswordChangeRequest,
PasswordChangeResult,
SessionInfo,
TotpAction,
TotpActionResult,
TotpStatus,
} from "@umt/contracts";
import { ApiRequestError, apiData, apiFetch } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
/** 登录态探测(免鉴权,不触发 401 跳转) */
export function useSession() {
return useQuery({
queryKey: qk.session,
queryFn: ({ signal }) => apiData<SessionInfo>("/auth/session", { signal, noAuthRedirect: true }),
staleTime: 60_000,
retry: 1,
});
}
/** 登录/初始化类请求:返回错误码(由调用方按码分流文案),不跳转 */
async function authPost<T>(path: string, body: unknown): Promise<T> {
const r = await apiFetch<T>(path, { method: "POST", body, noAuthRedirect: true });
if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message);
return r.data;
}
export const login = (body: LoginRequest) => authPost<LoginResult>("/auth/login", body);
export const setupAdmin = (body: AdminSetupRequest) => authPost<SessionInfo>("/auth/setup", body);
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => authPost<unknown>("/auth/logout", {}),
onSettled: () => {
qc.clear();
},
});
}
export function useChangePassword() {
return useMutation({
mutationFn: (body: PasswordChangeRequest) =>
apiData<PasswordChangeResult>("/auth/password", { method: "POST", body }),
});
}
export function useTotpStatus(enabled = true) {
return useQuery({
queryKey: qk.sub("auth", "totp"),
queryFn: ({ signal }) => apiData<TotpStatus>("/auth/totp", { signal }),
enabled,
});
}
export function useTotpAction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: TotpAction) => apiData<TotpActionResult>("/auth/totp", { method: "POST", body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.all("auth") });
},
});
}
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import type { Dashboard } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
/** 首页聚合;SSE 事件触发 invalidate,另有 60 秒兜底轮询 */
export function useDashboard() {
return useQuery({
queryKey: qk.dashboard,
queryFn: ({ signal }) => apiData<Dashboard>("/dashboard", { signal }),
refetchInterval: 60_000,
});
}
+50
View File
@@ -0,0 +1,50 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CursorPage, EngineRun, EngineRunDetail, EngineStats } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
export type EngineRunFilters = { status?: string; workflowName?: string };
/** 终态:不能再 cancel */
export const ENGINE_TERMINAL = new Set(["completed", "succeeded", "failed", "canceled"]);
export function useEngineRuns(filters: EngineRunFilters) {
return useInfiniteQuery({
queryKey: qk.list("engine", filters),
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) =>
apiData<CursorPage<EngineRun>>("/engine/runs", { params: { ...filters, cursor: pageParam, limit: 50 }, signal }),
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
}
export function useEngineRun(id: string | undefined) {
return useQuery({
queryKey: qk.detail("engine", id ?? ""),
queryFn: ({ signal }) => apiData<EngineRunDetail>("/engine/runs/{id}", { pathParams: { id: id ?? "" }, signal }),
enabled: !!id,
// 非终态每 5 秒兜底刷新(SSE 事件驱动是主路径)
refetchInterval: (q) => (q.state.data && !ENGINE_TERMINAL.has(q.state.data.status) ? 5_000 : false),
});
}
export function useEngineStats() {
return useQuery({
queryKey: qk.engineStats,
queryFn: ({ signal }) => apiData<EngineStats>("/engine/stats", { signal }),
refetchInterval: 30_000,
});
}
export function useCancelEngineRun() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) =>
apiData<{ canceled: boolean }>("/engine/runs/{id}/cancel", {
method: "POST",
pathParams: { id },
body: { reason },
}),
onSettled: () => qc.invalidateQueries({ queryKey: qk.all("engine") }),
});
}
+120
View File
@@ -0,0 +1,120 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CursorPage,
DownloaderInstance,
DownloaderInstanceCreate,
DownloaderInstanceUpdate,
InstanceTestResult,
LibraryRoot,
LibraryRootCreate,
LibraryRootUpdate,
MediaServerInstance,
MediaServerInstanceCreate,
MediaServerInstanceUpdate,
PathMapping,
PathMappingCreate,
PathMappingUpdate,
Site,
SiteAccount,
SiteCredentialsPut,
SiteCredentialsResult,
} from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { type QueryArea, qk } from "~/lib/query-keys.ts";
/** 实例类资源的标准 CRUD 钩子(列表取前 200 条,首版实例数量很少) */
function crudHooks<Row extends { id: string }, Create, Update>(area: QueryArea, base: string) {
return {
useList: () =>
useQuery({
queryKey: qk.list(area),
queryFn: ({ signal }) => apiData<CursorPage<Row>>(base, { params: { limit: 200 }, signal }),
}),
useCreate: () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Create) => apiData<Row>(base, { method: "POST", body }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.all(area) }),
});
},
useUpdate: () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: Update }) =>
apiData<Row>(`${base}/{id}`, { method: "PATCH", pathParams: { id }, body }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.all(area) }),
});
},
useRemove: () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiData<unknown>(`${base}/{id}`, { method: "DELETE", pathParams: { id } }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.all(area) }),
});
},
};
}
/** 连接测试 / 校验类动作(结果写回 health,完成后刷新列表) */
function actionHook<R>(area: QueryArea, path: string) {
return () => {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => apiData<R>(path, { method: "POST", pathParams: { id }, body: {} }),
onSettled: () => qc.invalidateQueries({ queryKey: qk.all(area) }),
});
};
}
export const downloaders = {
...crudHooks<DownloaderInstance, DownloaderInstanceCreate, DownloaderInstanceUpdate>(
"downloader-instances",
"/downloader-instances",
),
useTest: actionHook<InstanceTestResult>("downloader-instances", "/downloader-instances/{id}/test"),
};
export const mediaServers = {
...crudHooks<MediaServerInstance, MediaServerInstanceCreate, MediaServerInstanceUpdate>(
"media-server-instances",
"/media-server-instances",
),
useTest: actionHook<InstanceTestResult>("media-server-instances", "/media-server-instances/{id}/test"),
};
export const libraryRoots = {
...crudHooks<LibraryRoot, LibraryRootCreate, LibraryRootUpdate>("library-roots", "/library-roots"),
useTest: actionHook<InstanceTestResult>("library-roots", "/library-roots/{id}/test"),
};
export const pathMappings = {
...crudHooks<PathMapping, PathMappingCreate, PathMappingUpdate>("path-mappings", "/path-mappings"),
useVerify: actionHook<PathMapping>("path-mappings", "/path-mappings/{id}/verify"),
};
// ─── 站点账号凭证(设置页录入;站点本身由站点工作台管理) ───
export function useSiteOptions() {
return useQuery({
queryKey: qk.list("sites", { limit: 200 }),
queryFn: ({ signal }) => apiData<CursorPage<Site>>("/sites", { params: { limit: 200 }, signal }),
});
}
export function useSiteAccounts() {
return useQuery({
queryKey: qk.list("site-accounts"),
queryFn: ({ signal }) => apiData<CursorPage<SiteAccount>>("/site-accounts", { params: { limit: 200 }, signal }),
});
}
export function usePutSiteCredentials() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ siteId, body }: { siteId: string; body: SiteCredentialsPut }) =>
apiData<SiteCredentialsResult>("/sites/{id}/credentials", { method: "PUT", pathParams: { id: siteId }, body }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.all("site-accounts") });
},
});
}
+32
View File
@@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type GlobalSettings, type GlobalSettingsUpdate, mergeSettings } from "@umt/contracts";
import { apiData } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
export function useSettings() {
return useQuery({
queryKey: qk.settings,
queryFn: ({ signal }) => apiData<GlobalSettings>("/settings", { signal }),
});
}
/** PATCH /settings,乐观更新(失败回滚) */
export function useUpdateSettings() {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: GlobalSettingsUpdate) => apiData<GlobalSettings>("/settings", { method: "PATCH", body: patch }),
onMutate: async (patch) => {
await qc.cancelQueries({ queryKey: qk.settings });
const prev = qc.getQueryData<GlobalSettings>(qk.settings);
if (prev) qc.setQueryData(qk.settings, mergeSettings(prev, patch));
return { prev };
},
onError: (_e, _p, ctx) => {
if (ctx?.prev) qc.setQueryData(qk.settings, ctx.prev);
},
onSuccess: (data) => {
qc.setQueryData(qk.settings, data);
qc.invalidateQueries({ queryKey: qk.dashboard });
},
});
}
@@ -0,0 +1,172 @@
import { PasswordChangeRequest, type SessionInfo } from "@umt/contracts";
import { KeyRound, LoaderCircle, LogOut, UserRound } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useChangePassword, useLogout } from "~/api/auth.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { errorMessage, LOGIN_PATH } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { FormField, zodFieldErrors } from "./form-field.tsx";
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
message: "两次输入的新密码不一致",
path: ["confirm"],
});
function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
const change = useChangePassword();
const [form, setForm] = useState({ currentPassword: "", newPassword: "", confirm: "" });
const [errors, setErrors] = useState<Record<string, string>>({});
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = PasswordForm.safeParse(form);
if (!parsed.success) {
setErrors(zodFieldErrors(parsed.error.issues));
return;
}
setErrors({});
try {
const r = await change.mutateAsync({ currentPassword: form.currentPassword, newPassword: form.newPassword });
toast.success(r.revokedSessions ? `密码已修改,已注销其它 ${r.revokedSessions} 个会话` : "密码已修改");
setForm({ currentPassword: "", newPassword: "", confirm: "" });
onOpenChange(false);
} catch (err) {
const msg = errorMessage(err);
if (/旧密码|当前密码|password/i.test(msg)) setErrors({ currentPassword: "当前密码不正确" });
else toast.error(msg);
}
};
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
setForm((f) => ({ ...f, [k]: e.target.value }));
return (
<Dialog open={open} onOpenChange={(o) => !change.isPending && onOpenChange(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>修改密码</DialogTitle>
<DialogDescription>修改后其它设备上的会话会被注销,当前会话保留。</DialogDescription>
</DialogHeader>
<form id="pwd-form" onSubmit={submit} className="grid gap-1" noValidate>
<FormField label="当前密码" htmlFor="pwd-cur" error={errors.currentPassword}>
<Input
id="pwd-cur"
type="password"
autoComplete="current-password"
value={form.currentPassword}
onChange={set("currentPassword")}
aria-invalid={!!errors.currentPassword}
/>
</FormField>
<FormField label="新密码" htmlFor="pwd-new" error={errors.newPassword} hint="至少 10 个字符">
<Input
id="pwd-new"
type="password"
autoComplete="new-password"
value={form.newPassword}
onChange={set("newPassword")}
aria-invalid={!!errors.newPassword}
/>
</FormField>
<FormField label="确认新密码" htmlFor="pwd-confirm" error={errors.confirm}>
<Input
id="pwd-confirm"
type="password"
autoComplete="new-password"
value={form.confirm}
onChange={set("confirm")}
aria-invalid={!!errors.confirm}
/>
</FormField>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={change.isPending}>
取消
</Button>
<Button type="submit" form="pwd-form" disabled={change.isPending}>
{change.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** 右上角账户弹层:当前身份、会话到期、改密、登出 */
export function AccountMenu({ session }: { session: SessionInfo }) {
const [open, setOpen] = useState(false);
const [pwdOpen, setPwdOpen] = useState(false);
const logout = useLogout();
const actor = session.actor;
const isCookie = !!session.expiresAt;
const doLogout = async () => {
try {
await logout.mutateAsync();
} catch {
// 会话已失效也照样回登录页
}
window.location.assign(LOGIN_PATH);
};
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="max-w-40" aria-label="账户">
<UserRound />
<span className="hidden truncate sm:inline">{actor?.name ?? "账户"}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-2">
<div className="px-2 pt-1 pb-2">
<p className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">当前账户</p>
<p className="mt-1 truncate text-sm font-medium text-foreground">{actor?.name ?? "—"}</p>
<p className="text-meta text-muted-foreground">
{actor?.type === "api_key" ? "API Key 调用" : "管理员"}
{isCookie ? ` · 会话到 ${formatDateTime(session.expiresAt)}` : ""}
</p>
</div>
<div className="border-t border-border pt-1">
{isCookie ? (
<button
type="button"
onClick={() => {
setOpen(false);
setPwdOpen(true);
}}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left text-body text-foreground hover:bg-foreground/[0.06]"
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
修改密码
</button>
) : null}
<button
type="button"
onClick={doLogout}
disabled={logout.isPending}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left text-body text-foreground hover:bg-foreground/[0.06] disabled:opacity-50"
>
<LogOut className="h-4 w-4 text-muted-foreground" />
退出登录
</button>
</div>
</PopoverContent>
</Popover>
<ChangePasswordDialog open={pwdOpen} onOpenChange={setPwdOpen} />
</>
);
}
+122
View File
@@ -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;
}
+217 -4
View File
@@ -1,6 +1,219 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import { AuditAction, type AuditEvent } from "@umt/contracts";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { useAuditEvents } from "~/api/audit.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
export default function Page() {
return <WorkspacePlaceholder />;
export const meta = () => [{ title: "审计日志 · UHD 媒体自动化控制台" }];
const ACTOR_TYPES = [
{ value: "user", label: "管理员" },
{ value: "api_key", label: "API Key" },
{ value: "agent", label: "Agent" },
{ value: "system", label: "系统" },
];
const actorLabel = (t: string) => ACTOR_TYPES.find((a) => a.value === t)?.label ?? t;
const ACTION_LABEL: Record<string, string> = {
"admin.setup": "初始化管理员",
"admin.login_success": "登录",
"admin.logout": "登出",
"admin.password_change": "修改密码",
"admin.totp_enable": "启用二次验证",
"admin.totp_disable": "停用二次验证",
"api_key.create": "创建 API Key",
"api_key.update": "修改 API Key",
"api_key.revoke": "撤销 API Key",
"settings.update": "修改设置",
"secret.create": "新建凭证",
"secret.rotate": "轮换凭证",
"secret.delete": "删除凭证",
"site_account.credentials_set": "录入站点凭证",
"engine.run_cancel": "取消引擎 run",
};
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
const NOUN: Record<string, string> = {
library_root: "库根",
downloader_instance: "下载器",
media_server_instance: "媒体服务器",
path_mapping: "路径映射",
};
function actionLabel(a: string): string {
if (ACTION_LABEL[a]) return ACTION_LABEL[a];
const [noun, verb] = a.split(".");
if (noun && verb && NOUN[noun] && VERB[verb]) return `${VERB[verb]}${NOUN[noun]}`;
return a;
}
// 前缀组 + 具体动作
const ACTION_OPTIONS = [
...[
"admin",
"api_key",
"settings",
"secret",
"library_root",
"downloader_instance",
"media_server_instance",
"path_mapping",
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
...AuditAction.options.map((a) => ({ value: a, label: `${actionLabel(a)} · ${a}` })),
];
export default function AuditPage() {
const [params, setParams] = useSearchParams();
const actorType = params.get("actorType") ?? "";
const action = params.get("action") ?? "";
const [target, setTarget] = useState(params.get("targetType") ?? "");
const [targetType, setTargetType] = useState(target);
const [selected, setSelected] = useState<AuditEvent | null>(null);
useEffect(() => {
const t = setTimeout(() => {
const v = target.trim();
setTargetType(v);
setParams(
(p) => {
if (v) p.set("targetType", v);
else p.delete("targetType");
return p;
},
{ replace: true },
);
}, 350);
return () => clearTimeout(t);
}, [target, setParams]);
const setParam = (k: string, v: string) =>
setParams(
(p) => {
if (v) p.set(k, v);
else p.delete(k);
return p;
},
{ replace: true },
);
const q = useAuditEvents({
actorType: actorType || undefined,
action: action || undefined,
targetType: targetType || undefined,
});
const rows = q.data?.pages.flatMap((p) => p.items);
const filtered = !!(actorType || action || targetType);
return (
<>
<PageHeader
title="审计日志"
description="登录、设置、密钥与实例变更的操作留痕,新的在前。凭证内容不会写进审计。"
/>
<FilterBar
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
onReset={
filtered
? () => {
setTarget("");
setParams({}, { replace: true });
}
: undefined
}
>
<FilterSelect
label="操作者"
value={actorType}
onChange={(v) => setParam("actorType", v)}
options={ACTOR_TYPES}
/>
<FilterSelect label="动作" value={action} onChange={(v) => setParam("action", v)} options={ACTION_OPTIONS} />
</FilterBar>
<DataTable<AuditEvent>
rows={rows}
rowKey={(r) => r.id}
loading={q.isPending}
error={q.error}
onRetry={() => q.refetch()}
empty={{
title: filtered ? "没有符合条件的审计事件" : "还没有审计事件",
description: filtered ? "换个筛选条件试试。" : undefined,
}}
onRowClick={setSelected}
selectedKey={selected?.id ?? null}
hasMore={!!q.hasNextPage}
onLoadMore={() => q.fetchNextPage()}
loadingMore={q.isFetchingNextPage}
columns={[
{
key: "at",
header: "时间",
cell: (r) => (
<span className="whitespace-nowrap tabular-nums" title={formatDateTime(r.at)}>
{formatRelative(r.at)}
</span>
),
},
{
key: "action",
header: "动作",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{actionLabel(r.action)}</p>
<p className="font-mono text-meta text-muted-foreground">{r.action}</p>
</div>
),
},
{ key: "actor", header: "操作者", hideBelow: "sm", cell: (r) => actorLabel(r.actorType) },
{
key: "target",
header: "对象",
hideBelow: "md",
cell: (r) =>
r.targetType ? (
<div className="min-w-0 max-w-[18rem]">
<p className="truncate">{r.targetType}</p>
<p className="truncate font-mono text-meta text-muted-foreground">{r.targetId}</p>
</div>
) : (
"—"
),
},
]}
/>
<DetailSheet
open={!!selected}
onOpenChange={(o) => !o && setSelected(null)}
title={selected ? actionLabel(selected.action) : ""}
description={selected ? formatDateTime(selected.at) : undefined}
>
{selected ? (
<>
<DetailSection title="事件">
<KvList
items={[
{ label: "动作", value: selected.action, mono: true },
{
label: "操作者",
value: `${actorLabel(selected.actorType)}${selected.actorId ? ` · ${selected.actorId}` : ""}`,
wide: true,
},
{ label: "对象类型", value: selected.targetType },
{ label: "对象 ID", value: selected.targetId, mono: true },
{ label: "Trace", value: selected.traceId, mono: true },
{ label: "事件 ID", value: selected.id, mono: true },
]}
/>
</DetailSection>
<JsonView label="负载" value={selected.payload} />
</>
) : null}
</DetailSheet>
</>
);
}
+231 -4
View File
@@ -1,6 +1,233 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import type { EngineStepAttempt } from "@umt/contracts";
import { ArrowLeft, Ban, RotateCw } from "lucide-react";
import { Link, useParams } from "react-router";
import { toast } from "sonner";
import { ENGINE_TERMINAL, useCancelEngineRun, useEngineRun } from "~/api/engine.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
export default function Page() {
return <WorkspacePlaceholder />;
export const meta = () => [{ title: "Run 详情 · 工作流引擎" }];
const KIND_LABEL: Record<string, string> = {
function: "函数",
sleep: "休眠",
workflow: "子工作流",
"signal-send": "发送信号",
"signal-wait": "等待信号",
};
function stepTone(s: string) {
if (s === "completed" || s === "succeeded") return "success" as const;
if (s === "failed") return "destructive" as const;
return "neutral" as const;
}
function stepItems(steps: EngineStepAttempt[]): TimelineItem[] {
const seen = new Map<string, number>();
return steps.map((st) => {
const n = (seen.get(st.stepName) ?? 0) + 1;
seen.set(st.stepName, n);
const status =
st.status === "running"
? "运行中"
: st.status === "failed"
? "失败"
: st.status === "completed" || st.status === "succeeded"
? "完成"
: st.status;
const hasError = st.error !== null && st.error !== undefined;
return {
id: st.id,
at: st.startedAt ?? st.createdAt,
tone: stepTone(st.status),
title: (
<span>
{st.stepName}
<span className="ml-2 text-meta font-normal text-muted-foreground">
{KIND_LABEL[st.kind] ?? st.kind} · {status}
{n > 1 ? ` · 第 ${n} 次` : ""}
</span>
</span>
),
meta: formatDuration(runDurationSec(st)),
description: (
<>
{st.waitSignal ? (
<p>
等待信号 <code className="font-mono">{st.waitSignal}</code>
{st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""}
</p>
) : null}
{st.childWorkflowRunId ? (
<p>
子工作流{" "}
<Link to={consolePath("engine", st.childWorkflowRunId)} className="font-mono underline">
{st.childWorkflowRunId}
</Link>
</p>
) : null}
<p className="font-mono text-meta">{st.id}</p>
</>
),
children: hasError ? (
<JsonView label="错误" value={st.error} tone="destructive" defaultOpen />
) : st.output !== null && st.output !== undefined ? (
<JsonView label="输出" value={st.output} defaultOpen={false} maxHeight="14rem" />
) : undefined,
};
});
}
export default function EngineRunPage() {
const { runId } = useParams();
const q = useEngineRun(runId);
const cancel = useCancelEngineRun();
const back = (
<Link
to={consolePath("engine")}
className={buttonVariants({ variant: "ghost", size: "sm", className: "-ml-2 mb-2" })}
>
<ArrowLeft />
返回 runs
</Link>
);
if (q.isPending) {
return (
<>
{back}
<LoadingState rows={8} />
</>
);
}
if (q.isError) {
return (
<>
{back}
<ErrorState title="加载 run 失败" error={q.error} onRetry={() => q.refetch()} />
</>
);
}
const r = q.data;
const st = engineRunLabel(r.status);
const terminal = ENGINE_TERMINAL.has(r.status);
const hasError = r.error !== null && r.error !== undefined;
return (
<>
{back}
<PageHeader
title={r.workflowName}
description={
<span className="flex flex-wrap items-center gap-2">
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
{r.version ? <span>{r.version}</span> : null}
<span className="font-mono text-meta break-all">{r.id}</span>
</span>
}
actions={
<>
<Button variant="ghost" size="sm" onClick={() => q.refetch()} disabled={q.isFetching}>
<RotateCw className={q.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
{!terminal ? (
<ConfirmDialog
trigger={
<Button variant="destructive" size="sm">
<Ban />
取消 run
</Button>
}
title="取消这个 run?"
description="先标记业务侧取消,再通知引擎停止;正在执行的步骤会在下一个检查点退出。"
confirmLabel="取消 run"
cancelLabel="不取消"
destructive
onConfirm={async () => {
try {
const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" });
if (res.canceled) toast.success("已取消");
else toast.warning("run 已结束,无需取消");
} catch (err) {
toast.error(errorMessage(err, "取消失败"));
throw err;
}
}}
/>
) : null}
</>
}
/>
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<div className="min-w-0 space-y-4">
<section className="pg-slab rounded-xl p-5">
<DetailSection title="概览">
<KvList
items={[
{
label: "开始",
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
},
{ label: "耗时", value: formatDuration(runDurationSec(r)) },
{ label: "结束", value: formatDateTime(r.finishedAt) },
{ label: "尝试次数", value: r.attempts },
{ label: "可执行于", value: formatDateTime(r.availableAt) },
{ label: "截止", value: formatDateTime(r.deadlineAt) },
{ label: "Worker", value: r.workerId, mono: true },
{ label: "父步骤", value: r.parentStepAttemptId, mono: true },
]}
/>
</DetailSection>
</section>
<section className="pg-slab space-y-4 rounded-xl p-5">
{hasError ? <JsonView label="错误" value={r.error} tone="destructive" /> : null}
<JsonView label="输入" value={r.input} defaultOpen={!hasError} />
{!hasError ? <JsonView label="输出" value={r.output} /> : null}
</section>
<section className="pg-slab rounded-xl p-5">
<DetailSection title={`信号(${r.signals.length})`}>
{r.signals.length === 0 ? (
<p className="text-caption text-muted-foreground">没有投递到这个 run 的信号。</p>
) : (
<ul className="space-y-3">
{r.signals.map((sg) => (
<li key={sg.id} className="min-w-0">
<p className="text-sm">
<code className="font-mono">{sg.signal}</code>
<span className="ml-2 text-meta text-muted-foreground">{formatDateTime(sg.createdAt)}</span>
</p>
<JsonView value={sg.data} maxHeight="10rem" className="mt-1" />
</li>
))}
</ul>
)}
</DetailSection>
</section>
</div>
<section className="pg-slab min-w-0 rounded-xl p-5">
<DetailSection title={`步骤尝试(${r.stepAttempts.length})`}>
{r.stepAttempts.length === 0 ? (
<EmptyState plain title="还没有执行任何步骤" className="py-8" />
) : (
<Timeline items={stepItems(r.stepAttempts)} />
)}
</DetailSection>
</section>
</div>
</>
);
}
+216 -4
View File
@@ -1,6 +1,218 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import { useQueryClient } from "@tanstack/react-query";
import type { EngineRun } from "@umt/contracts";
import { Ban, CheckCircle2, Clock, Cpu, LoaderCircle, RotateCw, XCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useEngineRuns, useEngineStats } from "~/api/engine.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
export default function Page() {
return <WorkspacePlaceholder />;
export const meta = () => [{ title: "工作流引擎 · UHD 媒体自动化控制台" }];
const STAT_ORDER = [
{ status: "pending", label: "待执行", icon: Clock, tone: "neutral" },
{ status: "running", label: "运行中", icon: LoaderCircle, tone: "neutral" },
{ status: "completed", label: "完成", icon: CheckCircle2, tone: "success" },
{ status: "failed", label: "失败", icon: XCircle, tone: "destructive" },
{ status: "canceled", label: "已取消", icon: Ban, tone: "warning" },
] as const;
const STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled", "sleeping", "succeeded"].map((s) => ({
value: s,
label: engineRunLabel(s).label,
}));
/** 持续任务事件到达时节流刷新引擎视图(引擎本身不发事件,业务任务事件足以代表它在动) */
const ENGINE_EVENTS = [
"task.created",
"task.status_changed",
"download.status_changed",
"organization.changed",
"agent.run_status",
];
function useDebounced<T>(value: T, ms = 350): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export default function EnginePage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const status = params.get("status") ?? "";
const [name, setName] = useState(params.get("workflow") ?? "");
const workflowName = useDebounced(name.trim());
useEffect(() => {
setParams(
(p) => {
if (workflowName) p.set("workflow", workflowName);
else p.delete("workflow");
return p;
},
{ replace: true },
);
}, [workflowName, setParams]);
const setStatus = (s: string) =>
setParams(
(p) => {
if (s) p.set("status", s);
else p.delete("status");
return p;
},
{ replace: true },
);
const runs = useEngineRuns({ status: status || undefined, workflowName: workflowName || undefined });
const stats = useEngineStats();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(ENGINE_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.all("engine") });
}, 2000);
});
const rows = runs.data?.pages.flatMap((p) => p.items);
const s = stats.data;
return (
<>
<PageHeader
title="工作流引擎"
description="持久任务引擎的运行记录,只读诊断用;业务进度请看「下载与任务」。"
actions={
<Button
variant="outline"
size="sm"
onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}
disabled={runs.isFetching}
>
<RotateCw className={runs.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
/>
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{STAT_ORDER.map((t) => (
<StatTile
key={t.status}
label={t.label}
icon={t.icon}
tone={(s?.byStatus[t.status] ?? 0) > 0 ? t.tone : "neutral"}
value={stats.isPending ? "…" : stats.isError ? "—" : (s?.byStatus[t.status] ?? 0)}
active={status === t.status}
onClick={() => setStatus(status === t.status ? "" : t.status)}
/>
))}
</div>
<div className="mb-5 flex flex-wrap gap-x-5 gap-y-1 text-meta text-muted-foreground">
<span>
最老待执行:
{s?.oldestPending ? (
<button
type="button"
className="cursor-pointer text-foreground underline-offset-2 hover:underline"
onClick={() => navigate(consolePath("engine", s.oldestPending?.id ?? ""))}
>
{s.oldestPending.workflowName} · 已等 {formatDuration(s.oldestPending.ageSec)}
</button>
) : (
"无"
)}
</span>
<span className="inline-flex items-center gap-1.5">
<Cpu className="h-3.5 w-3.5" />
持有租约的 worker:
{s && s.activeWorkers.length > 0
? s.activeWorkers.map((w) => `${w.workerId}(${w.running})`).join("、")
: "无"}
<span className="text-muted-foreground/80">(快照,非权威注册表)</span>
</span>
</div>
<FilterBar
search={{ value: name, onChange: setName, placeholder: "按工作流名称筛选" }}
onReset={
status || name
? () => {
setName("");
setStatus("");
}
: undefined
}
>
<FilterSelect label="状态" value={status} onChange={setStatus} options={STATUS_OPTIONS} />
</FilterBar>
<DataTable<EngineRun>
rows={rows}
rowKey={(r) => r.id}
loading={runs.isPending}
error={runs.error}
onRetry={() => runs.refetch()}
empty={{
title: status || workflowName ? "没有符合条件的 run" : "引擎还没有运行记录",
description: status || workflowName ? "换个筛选条件试试。" : "有任务入队后会出现在这里。",
}}
onRowClick={(r) => navigate(consolePath("engine", r.id))}
hasMore={!!runs.hasNextPage}
onLoadMore={() => runs.fetchNextPage()}
loadingMore={runs.isFetchingNextPage}
columns={[
{
key: "status",
header: "状态",
cell: (r) => {
const l = engineRunLabel(r.status);
return <StatusBadge tone={l.tone}>{l.label}</StatusBadge>;
},
},
{
key: "name",
header: "工作流",
cell: (r) => (
<div className="min-w-0 max-w-[16rem] sm:max-w-none">
<p className="truncate font-medium">
{r.workflowName}
{r.version ? <span className="ml-1.5 text-meta text-muted-foreground">{r.version}</span> : null}
</p>
<p className="truncate font-mono text-meta text-muted-foreground">{r.id}</p>
</div>
),
},
{ key: "attempts", header: "尝试", hideBelow: "md", align: "right", cell: (r) => r.attempts },
{
key: "dur",
header: "耗时",
hideBelow: "sm",
cell: (r) => <span className="tabular-nums">{formatDuration(runDurationSec(r))}</span>,
},
{
key: "created",
header: "创建",
hideBelow: "md",
cell: (r) => <span className="whitespace-nowrap text-muted-foreground">{formatRelative(r.createdAt)}</span>,
},
]}
/>
</>
);
}
+494 -37
View File
@@ -1,58 +1,515 @@
import { Activity, AlertTriangle, CircleCheck, HardDrive, ListTodo, Radio, Sparkles } from "lucide-react";
import { useHealth } from "~/api/health.ts";
import { EmptyState } from "~/components/console/empty-state.tsx";
import { useQueryClient } from "@tanstack/react-query";
import type { Dashboard } from "@umt/contracts";
import {
Activity,
AlertTriangle,
ArrowRight,
CircleCheck,
HardDrive,
ListTodo,
type LucideIcon,
Pause,
Play,
Radio,
ShieldAlert,
Sparkles,
} from "lucide-react";
import type { ReactNode } from "react";
import { useRef } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useDashboard } from "~/api/dashboard.ts";
import { useUpdateSettings } from "~/api/settings.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { formatDateTime } from "~/lib/utils.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import {
agentRunLabel,
decisionKindLabel,
downloadLabel,
formatRelative,
healthLabel,
organizationActionLabel,
organizationLabel,
} from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
// 首页六区块(REQ §13.4.3),W3 接入正式状态投影
const SECTIONS = [
{ title: "自动化概览", icon: Sparkles, hint: "今日新增、升级与已核验入库" },
{ title: "当前工作", icon: Activity, hint: "正在搜索/下载/整理/核验的目标" },
{ title: "需要我处理", icon: AlertTriangle, hint: "根因级事项与剩余决策" },
{ title: "缓存与保留", icon: HardDrive, hint: "使用率、在途预留与可清理量" },
{ title: "来源健康", icon: Radio, hint: "站点、qB、Emby、模型上游" },
{ title: "最近产出", icon: ListTodo, hint: "新增的集与版本、整理前后路径" },
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [
"task.created",
"task.status_changed",
"download.submitted",
"download.status_changed",
"download.threshold",
"organization.changed",
"agent.run_status",
"feed.fetched",
"feed.health_changed",
"decision.created",
"decision.updated",
"settings.changed",
];
function ServiceStatus() {
const { data, isPending, isError, error } = useHealth();
if (isPending) return <StatusBadge>检查中</StatusBadge>;
if (isError) return <StatusBadge tone="destructive">服务不可达:{error.message}</StatusBadge>;
function useLiveDashboard() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(DASHBOARD_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.dashboard });
}, 1500);
});
return useDashboard();
}
function Section({
title,
icon: Icon,
to,
children,
className,
}: {
title: string;
icon: LucideIcon;
to?: string;
children: ReactNode;
className?: string;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<StatusBadge tone={data.status === "ok" ? "success" : "warning"}>
服务{data.status === "ok" ? "正常" : "降级"}
</StatusBadge>
<StatusBadge tone={data.db === "ok" ? "success" : data.db === "down" ? "destructive" : "neutral"}>
数据库 {data.db === "ok" ? "正常" : data.db === "down" ? "不可用" : "未知"}
</StatusBadge>
<span className="text-meta text-muted-foreground">
v{data.version} · {data.role} · {formatDateTime(data.time)}
</span>
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
<div className="mb-3 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-body font-semibold text-foreground">{title}</h2>
{to ? (
<Link
to={to}
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
查看
<ArrowRight className="h-3.5 w-3.5" />
</Link>
) : null}
</div>
{children}
</section>
);
}
function Row({ children }: { children: ReactNode }) {
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
}
// ─── 自动化总开关:显眼状态 + 切换 ───
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
const update = useUpdateSettings();
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
try {
await update.mutateAsync({ automation: patch });
toast.success(ok);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
};
const paused = !a.enabled;
const readOnly = a.readOnlyReconcile;
return (
<div
className={cn(
"pg-slab mb-4 flex flex-col gap-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
(paused || readOnly) && "border border-warning/40",
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<span
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
)}
>
{readOnly ? (
<ShieldAlert className="h-5 w-5" />
) : paused ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-lead font-semibold text-foreground">
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
</p>
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
</div>
<p className="mt-0.5 text-caption text-muted-foreground">
{paused
? "自动收录、下载、整理都已停下;查询与手动操作仍可用。"
: readOnly
? "恢复后的只读对账:只核对不写入,确认无误后再关掉。"
: `${a.activeSubscriptions} 个订阅在追踪,按规则自动收录、下载与入库。`}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<div className="flex items-center gap-2 text-caption text-foreground">
<label htmlFor="home-readonly">只读对账</label>
<Switch
id="home-readonly"
checked={readOnly}
disabled={update.isPending}
onCheckedChange={(v) =>
setAutomation({ readOnlyReconcile: v }, v ? "已进入只读对账模式" : "已退出只读对账模式").catch(() => {})
}
aria-label="只读对账模式"
/>
</div>
{paused ? (
<Button
size="sm"
disabled={update.isPending}
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
>
<Play />
恢复自动化
</Button>
) : (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={update.isPending}>
<Pause />
暂停自动化
</Button>
}
title="暂停全部自动化?"
description="自动收录、下载、整理会停下,已提交的下载任务不受影响。随时可以恢复。"
confirmLabel="暂停"
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
/>
)}
</div>
</div>
);
}
function Overview({ a }: { a: Dashboard["automation"] }) {
const t = a.today;
return (
<Section title="自动化概览" icon={Sparkles} to={consolePath("subscriptions")}>
<div className="grid grid-cols-2 gap-2">
<StatTile plain label="今日新计划" value={t.plansCreated} />
<StatTile plain label="今日提交下载" value={t.downloadsSubmitted} />
<StatTile plain label="今日入库文件" value={t.filesImported} />
<StatTile plain label="今日升级" value={t.upgrades} />
</div>
<p className="mt-3 text-meta text-muted-foreground">
整理成功 {t.recordsSucceeded} · 失败 {t.recordsFailed} · 满足的版本目标 {a.slotCounts.satisfied} · 需关注{" "}
{a.slotCounts.attention}
</p>
</Section>
);
}
function CurrentWork({ w }: { w: Dashboard["currentWork"] }) {
const counts = [
{ label: "下载中", v: w.downloading },
{ label: "已暂停", v: w.paused },
{ label: "无进展", v: w.stalled },
{ label: "入库中", v: w.importing },
];
const idle = counts.every((c) => c.v === 0) && w.items.length === 0 && w.agentRuns.length === 0;
return (
<Section title="当前工作" icon={Activity} to={consolePath("tasks")}>
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
{counts.map((c) => (
<span key={c.label}>
{c.label} <span className="font-medium text-foreground tabular-nums">{c.v}</span>
</span>
))}
</div>
{idle ? (
<EmptyState plain icon={CircleCheck} title="没有进行中的工作" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{w.items.slice(0, 5).map((it) => {
const s = downloadLabel(it.status);
return (
<Row key={it.downloadTaskId}>
<Link to={consolePath("tasks", it.downloadTaskId)} className="min-w-0 flex-1 truncate hover:underline">
{it.name}
</Link>
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
{Math.round(it.progress * 100)}%
</span>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</Row>
);
})}
{w.agentRuns.slice(0, 3).map((r) => {
const s = agentRunLabel(r.status);
return (
<Row key={r.id}>
<Link to={consolePath("agent", r.id)} className="min-w-0 flex-1 truncate hover:underline">
Agent:{r.goal}
</Link>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</Row>
);
})}
</ul>
)}
</Section>
);
}
function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
return (
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有待你决定的事" className="py-6" />
) : (
<>
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
{n.total}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">项待处理</span>
</p>
<ul className="divide-y divide-border/60">
{n.byKind.map((k) => (
<Row key={k.kind}>
<span className="truncate">{decisionKindLabel(k.kind)}</span>
<span className="text-meta text-muted-foreground tabular-nums">{k.count}</span>
</Row>
))}
</ul>
</>
)}
</Section>
);
}
function CacheSection({ c }: { c: Dashboard["cache"] }) {
return (
<Section title="缓存与保留" icon={HardDrive} to={consolePath("settings")}>
{c.volumes.length === 0 ? (
<EmptyState
plain
icon={HardDrive}
title="尚未登记下载器"
description="添加下载器实例后显示缓存卷水位。"
className="py-6"
/>
) : (
<ul className="space-y-4">
{c.volumes.map((v) => {
const pct = v.usedPercent;
const over = pct !== null && pct >= v.hardThresholdPercent;
const near = pct !== null && pct >= v.resumePercent;
return (
<li key={v.volumeKey} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{v.volumeKey}</span>
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
</StatusBadge>
</div>
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
)}
style={{ width: `${pct ?? 0}%` }}
/>
<span
aria-hidden
className="absolute inset-y-0 w-px bg-destructive"
style={{ left: `${v.hardThresholdPercent}%` }}
/>
</div>
<p className="mt-1.5 text-meta text-muted-foreground">
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
</p>
{v.notes.map((n) => (
<p key={n} className="text-meta text-muted-foreground">
{n}
</p>
))}
</li>
);
})}
</ul>
)}
</Section>
);
}
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
const rows = [
...s.sites.map((x) => ({
id: x.id,
kind: "站点",
name: x.name,
enabled: x.enabled,
health: x.health,
at: x.lastSnapshotAt,
err: null as string | null,
})),
...s.feeds.map((x) => ({
id: x.id,
kind: "Feed",
name: x.name,
enabled: x.enabled,
health: x.health,
at: x.lastSuccessAt,
err: x.lastError,
})),
...s.downloaders.map((x) => ({
id: x.id,
kind: "下载器",
name: x.name,
enabled: x.enabled,
health: x.health,
at: x.lastSeenAt,
err: null,
})),
...s.mediaServers.map((x) => ({
id: x.id,
kind: "媒体服务器",
name: x.name,
enabled: x.enabled,
health: x.health,
at: x.lastSeenAt,
err: null,
})),
];
return (
<Section title="来源健康" icon={Radio} to={consolePath("sources")}>
{rows.length === 0 ? (
<EmptyState
plain
icon={Radio}
title="还没有来源"
description="在系统设置或来源页登记站点、Feed 与实例。"
className="py-6"
/>
) : (
<ul className="divide-y divide-border/60">
{rows.slice(0, 8).map((r) => {
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
return (
<Row key={`${r.kind}-${r.id}`}>
<div className="min-w-0 flex-1">
<p className="truncate">
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
{r.name}
</p>
{r.err ? <p className="truncate text-meta text-destructive">{r.err}</p> : null}
</div>
<span className="hidden shrink-0 text-meta text-muted-foreground sm:inline">
{formatRelative(r.at)}
</span>
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
</Row>
);
})}
</ul>
)}
</Section>
);
}
function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) {
return (
<Section title="最近产出" icon={ListTodo} to={consolePath("organize")}>
{items.length === 0 ? (
<EmptyState plain icon={ListTodo} title="还没有入库记录" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{items.slice(0, 6).map((r) => {
const s = organizationLabel(r.status);
return (
<li key={r.recordId} className="min-w-0 py-2">
<div className="flex items-center justify-between gap-3 text-sm">
<Link to={consolePath("organize", r.recordId)} className="min-w-0 truncate hover:underline">
{r.title ?? "未命名"}
</Link>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</div>
<p className="truncate text-meta text-muted-foreground">
{organizationActionLabel(r.action)} · {r.counts.succeeded}/{r.counts.total} 个文件 ·{" "}
{formatRelative(r.finishedAt)}
{r.targetPath ? ` · ${r.targetPath}` : ""}
</p>
</li>
);
})}
</ul>
)}
</Section>
);
}
function LiveIndicator() {
const s = useSseStatus();
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
return <StatusBadge tone={tone}>{label}</StatusBadge>;
}
export default function Home() {
const q = useLiveDashboard();
return (
<>
<PageHeader
title="首页"
description="系统是否在推进目标、完成了什么、还有什么缺口、是否需要你。"
actions={<ServiceStatus />}
actions={
<div className="flex items-center gap-2">
<LiveIndicator />
{q.data ? (
<span className="hidden text-meta text-muted-foreground sm:inline">
更新于 {formatRelative(q.data.generatedAt)}
</span>
) : null}
</div>
}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{SECTIONS.map((s) => (
<section key={s.title} className="pg-slab rounded-xl p-5">
<div className="mb-3 flex items-center gap-2">
<s.icon className="h-4 w-4 text-muted-foreground" />
<h2 className="text-body font-semibold text-foreground">{s.title}</h2>
{q.isPending ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["a", "b", "c", "d", "e", "f"].map((k) => (
<div key={k} className="pg-slab rounded-xl p-5">
<LoadingState rows={3} />
</div>
<EmptyState icon={CircleCheck} title="暂无数据" description={s.hint} plain className="py-8" />
</section>
))}
</div>
))}
</div>
) : q.isError ? (
<ErrorState title="首页数据加载失败" error={q.error} onRetry={() => q.refetch()} />
) : (
<>
<AutomationBanner a={q.data.automation} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Overview a={q.data.automation} />
<CurrentWork w={q.data.currentWork} />
<NeedsAttention n={q.data.needsAttention} />
<CacheSection c={q.data.cache} />
<SourcesSection s={q.data.sources} />
<RecentOutput items={q.data.recentOutput} />
</div>
</>
)}
</>
);
}
+33 -3
View File
@@ -1,12 +1,16 @@
import { ChevronLeft, ChevronRight, Clapperboard, Menu } from "lucide-react";
import type { SessionInfo } from "@umt/contracts";
import { ChevronLeft, ChevronRight, Clapperboard, LoaderCircle, Menu } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link, NavLink, Outlet, useLocation } from "react-router";
import { ApiKeyDialog } from "~/components/console/api-key-dialog.tsx";
import { useSession } from "~/api/auth.ts";
import { AccountMenu } from "~/components/console/account-menu.tsx";
import { ErrorState } from "~/components/console/empty-state.tsx";
import { CONSOLE_BASE, findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
import PrismLeak from "~/components/prism-leak.tsx";
import { ThemeToggle } from "~/components/theme-toggle.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
import { redirectToLogin } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
const PORTAL_NAME = "媒体自动化";
@@ -128,7 +132,33 @@ function Brand({ collapsed = false }: { collapsed?: boolean }) {
);
}
/** 会话探测:未登录跳登录页;探测中/失败给全屏占位 */
export default function ConsoleLayout() {
const session = useSession();
const unauthenticated = session.data && !session.data.authenticated;
useEffect(() => {
if (unauthenticated) redirectToLogin();
}, [unauthenticated]);
if (session.isError) {
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<ErrorState title="无法获取登录状态" error={session.error} onRetry={() => session.refetch()} />
</div>
);
}
if (!session.data || unauthenticated) {
return (
<div className="grid min-h-screen place-items-center bg-background" role="status" aria-label="正在确认登录状态">
<LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return <ConsoleShell session={session.data} />;
}
function ConsoleShell({ session }: { session: SessionInfo }) {
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
@@ -192,8 +222,8 @@ export default function ConsoleLayout() {
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<ApiKeyDialog />
<ThemeToggle />
<AccountMenu session={session} />
</div>
</header>
+61 -3
View File
@@ -1,6 +1,64 @@
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
import { Bot, FolderTree, HardDrive, KeyRound, Link2, Server, Tv, UserCog, Zap } from "lucide-react";
import { useSearchParams } from "react-router";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import {
DownloadersSection,
LibraryRootsSection,
MediaServersSection,
PathMappingsSection,
} from "./settings/instances-tab.tsx";
import { ApiKeysSection, SiteCredentialsSection } from "./settings/keys-tab.tsx";
import { AutomationTab, PiTab, StorageTab } from "./settings/prefs-tab.tsx";
export const meta = () => [{ title: "系统设置 · UHD 媒体自动化控制台" }];
const TABS = [
{ value: "automation", label: "自动化", icon: Zap, render: () => <AutomationTab /> },
{ value: "downloaders", label: "下载器", icon: Server, render: () => <DownloadersSection /> },
{ value: "media-servers", label: "媒体服务器", icon: Tv, render: () => <MediaServersSection /> },
{ value: "library-roots", label: "库根", icon: FolderTree, render: () => <LibraryRootsSection /> },
{ value: "path-mappings", label: "路径映射", icon: Link2, render: () => <PathMappingsSection /> },
{ value: "site-credentials", label: "站点凭证", icon: UserCog, render: () => <SiteCredentialsSection /> },
{ value: "api-keys", label: "API Key", icon: KeyRound, render: () => <ApiKeysSection /> },
{ value: "pi", label: "Pi 上游", icon: Bot, render: () => <PiTab /> },
{ value: "storage", label: "缓存与入库", icon: HardDrive, render: () => <StorageTab /> },
] as const;
// 占位:标题与说明取自 components/console/nav.ts
export default function SettingsPage() {
return <WorkspacePlaceholder />;
const [params, setParams] = useSearchParams();
const raw = params.get("tab");
const tab = TABS.some((t) => t.value === raw) ? (raw as string) : "automation";
return (
<>
<PageHeader title="系统设置" description="实例、路径、凭证、密钥、模型上游与自动化开关。凭证一律只写,不回显。" />
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
p.set("tab", v);
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="设置分节">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
<t.icon />
{t.label}
</TabsTrigger>
))}
</TabsList>
{TABS.map((t) => (
<TabsContent key={t.value} value={t.value}>
{tab === t.value ? t.render() : null}
</TabsContent>
))}
</Tabs>
</>
);
}
@@ -0,0 +1,473 @@
import {
type DownloaderInstance,
DownloaderInstanceCreate,
DownloaderInstanceUpdate,
type LibraryRoot,
LibraryRootCreate,
LibraryRootUpdate,
type MediaServerInstance,
MediaServerInstanceCreate,
MediaServerInstanceUpdate,
type PathMapping,
PathMappingCreate,
PathMappingUpdate,
} from "@umt/contracts";
import { PlugZap } from "lucide-react";
import { toast } from "sonner";
import { downloaders, libraryRoots, mediaServers, pathMappings } from "~/api/instances.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative, healthLabel } from "~/lib/labels.ts";
import { type FormValues, nullStr, num, optStr, ResourceSection, str } from "./resource-section.tsx";
function Health({ status, at }: { status: string; at?: string | null }) {
const h = healthLabel(status);
return (
<div className="flex flex-col items-start gap-0.5">
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
{at ? <span className="text-meta text-muted-foreground">{formatRelative(at)}</span> : null}
</div>
);
}
function Enabled({ on }: { on: boolean }) {
return <StatusBadge tone={on ? "success" : "neutral"}>{on ? "启用" : "停用"}</StatusBadge>;
}
/** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */
function TestButton({
run,
pending,
label = "测试连接",
}: {
run: () => Promise<{ ok: boolean; message: string | null; version?: string | null }>;
pending: boolean;
label?: string;
}) {
return (
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={async () => {
try {
const r = await run();
if (r.ok) toast.success(`${label}成功${r.version ? ` · ${r.version}` : ""}`);
else toast.error(r.message || `${label}失败`);
} catch (err) {
toast.error(errorMessage(err, `${label}失败`));
}
}}
>
<PlugZap />
<span className="hidden md:inline">{label}</span>
</Button>
);
}
const credentialHint = { editHint: "留空表示不修改已保存的凭证" };
export function DownloadersSection() {
const list = downloaders.useList();
const create = downloaders.useCreate();
const update = downloaders.useUpdate();
const remove = downloaders.useRemove();
const test = downloaders.useTest();
return (
<ResourceSection<DownloaderInstance>
hooks={{ list, create, update, remove } as never}
config={{
noun: "下载器实例",
description:
"qBittorrent WebUI。系统只管理带管理标签的任务;共享同一缓存卷的多个实例填相同的缓存卷标识以合并水位。",
emptyHint: "添加 qBittorrent 后才能提交下载。",
columns: [
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.name}</p>
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
</div>
),
},
{
key: "tag",
header: "管理标签",
hideBelow: "md",
cell: (r) => <code className="text-meta">{r.managedTag}</code>,
},
{ key: "vol", header: "缓存卷", hideBelow: "lg", cell: (r) => r.cacheVolumeKey ?? "—" },
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "家里的 qB" },
{
name: "baseUrl",
label: "WebUI 地址",
kind: "url",
required: true,
placeholder: "http://192.168.1.10:8080",
},
{ name: "username", label: "用户名", kind: "text", hint: "留空则把凭证当 WebUI API Key 使用" },
{ name: "credential", label: "密码 / API Key", kind: "password", ...credentialHint },
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: {
name: "",
baseUrl: "",
username: "",
credential: "",
managedTag: "umt",
cacheVolumeKey: "",
enabled: true,
},
fromRow: (r) => ({
name: r.name,
baseUrl: r.baseUrl,
username: r.username ?? "",
credential: "",
managedTag: r.managedTag,
cacheVolumeKey: r.cacheVolumeKey ?? "",
enabled: r.enabled,
}),
toBody: (v: FormValues, mode) => ({
name: str(v.name),
kind: mode === "create" ? "qbittorrent" : undefined,
baseUrl: str(v.baseUrl),
username: nullStr(v.username),
credential: optStr(v.credential),
managedTag: optStr(v.managedTag),
cacheVolumeKey: nullStr(v.cacheVolumeKey),
enabled: v.enabled === true,
}),
createSchema: DownloaderInstanceCreate,
updateSchema: DownloaderInstanceUpdate,
}}
/>
);
}
export function MediaServersSection() {
const list = mediaServers.useList();
const create = mediaServers.useCreate();
const update = mediaServers.useUpdate();
const remove = mediaServers.useRemove();
const test = mediaServers.useTest();
return (
<ResourceSection<MediaServerInstance>
hooks={{ list, create, update, remove } as never}
config={{
noun: "媒体服务器",
description: "Emby / Jellyfin 只用于查询有无、季集与版本,查不到不等于缺失。",
columns: [
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.name}</p>
<p className="truncate text-meta text-muted-foreground">{r.baseUrl}</p>
</div>
),
},
{ key: "kind", header: "类型", hideBelow: "md", cell: (r) => (r.kind === "emby" ? "Emby" : "Jellyfin") },
{ key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => <Enabled on={r.enabled} /> },
{ key: "health", header: "健康", cell: (r) => <Health status={r.healthStatus} at={r.lastSeenAt} /> },
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true },
{
name: "kind",
label: "类型",
kind: "select",
options: [
{ value: "emby", label: "Emby" },
{ value: "jellyfin", label: "Jellyfin" },
],
},
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选;按该用户视角查询" },
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: { name: "", kind: "emby", baseUrl: "", userId: "", credential: "", enabled: true },
fromRow: (r) => ({
name: r.name,
kind: r.kind,
baseUrl: r.baseUrl,
userId: r.userId ?? "",
credential: "",
enabled: r.enabled,
}),
toBody: (v) => ({
name: str(v.name),
kind: str(v.kind),
baseUrl: str(v.baseUrl),
userId: nullStr(v.userId),
credential: optStr(v.credential),
enabled: v.enabled === true,
}),
createSchema: MediaServerInstanceCreate,
updateSchema: MediaServerInstanceUpdate,
}}
/>
);
}
const MEDIA_KIND = [
{ value: "movie", label: "电影" },
{ value: "series", label: "剧集" },
{ value: "variety", label: "综艺" },
{ value: "mixed", label: "混合" },
{ value: "other", label: "其它" },
];
export function LibraryRootsSection() {
const list = libraryRoots.useList();
const create = libraryRoots.useCreate();
const update = libraryRoots.useUpdate();
const remove = libraryRoots.useRemove();
const test = libraryRoots.useTest();
return (
<ResourceSection<LibraryRoot>
hooks={{ list, create, update, remove } as never}
config={{
noun: "库根",
description:
"登记或修改路径时会在目录里写入哨兵文件 .uhd-root,用来判断库根是否在线;目录不存在或不可写会被拒绝。",
deleteHint: "只删除登记,不会删除磁盘上的任何文件。",
columns: [
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className="font-medium">{r.name}</p>
<p className="truncate font-mono text-meta text-muted-foreground">{r.localPath}</p>
</div>
),
},
{
key: "kind",
header: "类型",
hideBelow: "md",
cell: (r) => MEDIA_KIND.find((k) => k.value === r.mediaKind)?.label ?? r.mediaKind,
},
{
key: "mode",
header: "管理方式",
hideBelow: "lg",
cell: (r) => (r.managementMode === "managed" ? "自动维护" : "只登记"),
},
{ key: "health", header: "在线", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
],
rowActions: (r) => <TestButton label="检查哨兵" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "电影库" },
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
{
name: "localPath",
label: "本地路径",
kind: "text",
required: true,
placeholder: "/media/movies",
hint: "执行进程可访问的绝对路径",
wide: true,
},
{
name: "managementMode",
label: "管理方式",
kind: "select",
options: [
{ value: "register_only", label: "只登记(不自动维护)" },
{ value: "managed", label: "自动维护(补齐/升级)" },
],
},
{ name: "enabled", label: "启用", kind: "switch" },
{ name: "notes", label: "备注", kind: "textarea" },
],
initial: {
name: "",
mediaKind: "movie",
localPath: "",
managementMode: "register_only",
enabled: true,
notes: "",
},
fromRow: (r) => ({
name: r.name,
mediaKind: r.mediaKind,
localPath: r.localPath,
managementMode: r.managementMode,
enabled: r.enabled,
notes: r.notes ?? "",
}),
toBody: (v) => ({
name: str(v.name),
mediaKind: str(v.mediaKind),
localPath: str(v.localPath),
managementMode: str(v.managementMode),
enabled: v.enabled === true,
notes: nullStr(v.notes),
}),
createSchema: LibraryRootCreate,
updateSchema: LibraryRootUpdate,
}}
/>
);
}
export function PathMappingsSection() {
const list = pathMappings.useList();
const create = pathMappings.useCreate();
const update = pathMappings.useUpdate();
const remove = pathMappings.useRemove();
const verify = pathMappings.useVerify();
const dls = downloaders.useList();
const mss = mediaServers.useList();
const roots = libraryRoots.useList();
const dlOpts = (dls.data?.items ?? []).map((d) => ({ value: d.id, label: d.name }));
const msOpts = (mss.data?.items ?? []).map((d) => ({ value: d.id, label: d.name }));
const rootOpts = [
{ value: "none", label: "不关联" },
...(roots.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })),
];
const nameOf = (r: PathMapping) =>
r.viewKind === "downloader"
? (dls.data?.items.find((d) => d.id === r.downloaderInstanceId)?.name ?? "下载器")
: (mss.data?.items.find((d) => d.id === r.mediaServerInstanceId)?.name ?? "媒体服务器");
return (
<ResourceSection<PathMapping>
hooks={{ list, create, update, remove } as never}
config={{
noun: "路径映射",
description: "把下载器或媒体服务器看到的路径前缀换成本进程可访问的路径。前缀越长越优先,同长按优先级。",
columns: [
{
key: "map",
header: "映射",
cell: (r) => (
<div className="min-w-0 font-mono text-meta">
<p className="truncate">{r.remotePrefix}</p>
<p className="truncate text-muted-foreground">→ {r.localPrefix}</p>
</div>
),
},
{
key: "view",
header: "视图",
hideBelow: "md",
cell: (r) => (
<span className="text-caption">
{r.viewKind === "downloader" ? "下载器" : "媒体服务器"} · {nameOf(r)}
</span>
),
},
{ key: "prio", header: "优先级", hideBelow: "lg", cell: (r) => r.priority },
{ key: "verify", header: "校验", cell: (r) => <Health status={r.verifyStatus} at={r.verifiedAt} /> },
],
rowActions: (r) => (
<TestButton
label="校验"
pending={verify.isPending}
run={async () => {
const m = await verify.mutateAsync(r.id);
return { ok: m.verifyStatus === "ok", message: String(m.verifyDetail?.message ?? "本地前缀不可达") };
}}
/>
),
fields: [
{
name: "viewKind",
label: "路径视图",
kind: "select",
options: [
{ value: "downloader", label: "下载器看到的路径" },
{ value: "media_server", label: "媒体服务器看到的路径" },
],
},
{
name: "downloaderInstanceId",
label: "下载器实例",
kind: "select",
options: dlOpts,
placeholder: dlOpts.length ? "请选择" : "先添加下载器实例",
visible: (v) => v.viewKind === "downloader",
},
{
name: "mediaServerInstanceId",
label: "媒体服务器",
kind: "select",
options: msOpts,
placeholder: msOpts.length ? "请选择" : "先添加媒体服务器",
visible: (v) => v.viewKind === "media_server",
},
{
name: "remotePrefix",
label: "对方路径前缀",
kind: "text",
required: true,
placeholder: "/downloads",
wide: true,
},
{
name: "localPrefix",
label: "本地路径前缀",
kind: "text",
required: true,
placeholder: "/mnt/cache/downloads",
wide: true,
},
{ name: "libraryRootId", label: "关联库根", kind: "select", options: rootOpts },
{ name: "priority", label: "优先级", kind: "number", hint: "数字越大越优先" },
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: {
viewKind: "downloader",
downloaderInstanceId: "",
mediaServerInstanceId: "",
remotePrefix: "",
localPrefix: "",
libraryRootId: "none",
priority: "0",
enabled: true,
},
fromRow: (r) => ({
viewKind: r.viewKind,
downloaderInstanceId: r.downloaderInstanceId ?? "",
mediaServerInstanceId: r.mediaServerInstanceId ?? "",
remotePrefix: r.remotePrefix,
localPrefix: r.localPrefix,
libraryRootId: r.libraryRootId ?? "none",
priority: String(r.priority),
enabled: r.enabled,
}),
toBody: (v) => {
const dl = v.viewKind === "downloader";
const root = str(v.libraryRootId);
return {
viewKind: str(v.viewKind),
downloaderInstanceId: dl ? nullStr(v.downloaderInstanceId) : null,
mediaServerInstanceId: dl ? null : nullStr(v.mediaServerInstanceId),
remotePrefix: str(v.remotePrefix),
localPrefix: str(v.localPrefix),
libraryRootId: root && root !== "none" ? root : null,
priority: num(v.priority) ?? 0,
enabled: v.enabled === true,
};
},
createSchema: PathMappingCreate,
updateSchema: PathMappingUpdate,
}}
/>
);
}
@@ -0,0 +1,407 @@
import { type ApiKey, ApiKeyCreate, type ApiScope, SiteCredentialsPut } from "@umt/contracts";
import { Check, Copy, KeyRound, LoaderCircle, Plus } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "~/api/api-keys.ts";
import { usePutSiteCredentials, useSiteAccounts, useSiteOptions } from "~/api/instances.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DataTable } from "~/components/console/data-table.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { FieldControl, type FormValues, str } from "./resource-section.tsx";
// ─── 站点账号凭证 ───
const AUTH_KINDS = [
{ value: "cookie", label: "Cookie" },
{ value: "passkey", label: "Passkey" },
{ value: "api_key", label: "API Key" },
{ value: "password", label: "账号密码" },
];
export function SiteCredentialsSection() {
const sites = useSiteOptions();
const accounts = useSiteAccounts();
const put = usePutSiteCredentials();
const [v, setV] = useState<FormValues>({
siteId: "",
label: "default",
authKind: "cookie",
username: "",
credential: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const siteOpts = (sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }));
const siteName = (id: string) => sites.data?.items.find((s) => s.id === id)?.name ?? id.slice(0, 8);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const fe: Record<string, string> = {};
if (!str(v.siteId)) fe.siteId = "请选择站点";
const parsed = SiteCredentialsPut.safeParse({
label: str(v.label) || "default",
authKind: str(v.authKind),
credential: str(v.credential),
username: v.authKind === "password" ? str(v.username) || undefined : undefined,
});
if (!parsed.success) Object.assign(fe, zodFieldErrors(parsed.error.issues));
if (fe.credential) fe.credential = "请填写凭证";
if (Object.keys(fe).length || !parsed.success) {
setErrors(fe);
return;
}
setErrors({});
try {
const r = await put.mutateAsync({ siteId: str(v.siteId), body: parsed.data });
toast.success(r.created ? "已添加站点账号" : "已替换站点凭证");
setV((s) => ({ ...s, credential: "" }));
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<form
onSubmit={submit}
noValidate
className="pg-slab grid content-start gap-x-4 gap-y-1 rounded-xl p-5 sm:grid-cols-2"
>
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
按「站点 + 标签」新建或替换账号凭证。凭证加密保存,提交后不会回显;站点本身在
<Link to={consolePath("sites")} className="mx-1 underline">
PT 站点工作台
</Link>
登记。
</p>
<FieldControl
spec={{
name: "siteId",
label: "站点",
kind: "select",
options: siteOpts,
placeholder: sites.isPending ? "加载中…" : siteOpts.length ? "请选择" : "还没有登记站点",
required: true,
}}
value={v.siteId}
error={errors.siteId}
onChange={(x) => setV((s) => ({ ...s, siteId: x }))}
disabled={put.isPending}
/>
<FieldControl
spec={{ name: "label", label: "标签", kind: "text", hint: "同一站点多个账号时区分" }}
value={v.label}
error={errors.label}
onChange={(x) => setV((s) => ({ ...s, label: x }))}
disabled={put.isPending}
/>
<FieldControl
spec={{ name: "authKind", label: "凭证类型", kind: "select", options: AUTH_KINDS }}
value={v.authKind}
error={errors.authKind}
onChange={(x) => setV((s) => ({ ...s, authKind: x }))}
disabled={put.isPending}
/>
{v.authKind === "password" ? (
<FieldControl
spec={{ name: "username", label: "登录名", kind: "text" }}
value={v.username}
error={errors.username}
onChange={(x) => setV((s) => ({ ...s, username: x }))}
disabled={put.isPending}
/>
) : null}
<FieldControl
spec={{
name: "credential",
label: v.authKind === "password" ? "密码" : "凭证",
kind: "password",
required: true,
wide: true,
placeholder: v.authKind === "cookie" ? "粘贴整段 Cookie" : undefined,
}}
value={v.credential}
error={errors.credential}
onChange={(x) => setV((s) => ({ ...s, credential: x }))}
disabled={put.isPending}
/>
<div className="sm:col-span-2">
<Button type="submit" size="sm" disabled={put.isPending}>
{put.isPending ? <LoaderCircle className="animate-spin" /> : <KeyRound />}
保存凭证
</Button>
</div>
</form>
<DetailSection title="已录入的账号">
<DataTable
rows={accounts.data?.items}
rowKey={(r) => r.id}
loading={accounts.isPending}
error={accounts.error}
onRetry={() => accounts.refetch()}
empty={{ title: "还没有站点账号" }}
columns={[
{
key: "site",
header: "站点 / 标签",
cell: (r) => (
<div className="min-w-0">
<p className="truncate font-medium">{siteName(r.siteId)}</p>
<p className="text-meta text-muted-foreground">{r.label}</p>
</div>
),
},
{
key: "kind",
header: "类型",
cell: (r) => AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind,
},
{
key: "status",
header: "状态",
cell: (r) => (
<StatusBadge tone={!r.enabled ? "neutral" : r.secretRef ? "success" : "warning"}>
{!r.enabled ? "停用" : r.secretRef ? "已录入" : "缺凭证"}
</StatusBadge>
),
},
]}
/>
</DetailSection>
</div>
);
}
// ─── API Key ───
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
{ value: "read", label: "read", hint: "只读查询" },
{ value: "organize.read", label: "organize.read", hint: "读取整理记录(增量同步)" },
{ value: "organize.execute", label: "organize.execute", hint: "触发整理" },
{ value: "admin", label: "admin", hint: "全部管理权限" },
];
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
const [copied, setCopied] = useState(false);
return (
<Dialog open={!!plaintext} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key 已创建</DialogTitle>
<DialogDescription>明文只显示这一次,关闭后无法再查看。请现在复制保存。</DialogDescription>
</DialogHeader>
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3">
<code className="min-w-0 flex-1 font-mono text-caption break-all text-foreground">{plaintext}</code>
<Button
variant="outline"
size="icon-sm"
aria-label="复制"
onClick={async () => {
try {
await navigator.clipboard.writeText(plaintext ?? "");
setCopied(true);
toast.success("已复制");
} catch {
toast.error("复制失败,请手动选中复制");
}
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
<DialogFooter>
<Button onClick={onClose}>我已保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ApiKeysSection() {
const keys = useApiKeys();
const create = useCreateApiKey();
const revoke = useRevokeApiKey();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [scopes, setScopes] = useState<ApiScope[]>(["read"]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [plaintext, setPlaintext] = useState<string | null>(null);
const [revoking, setRevoking] = useState<ApiKey | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes });
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.name) fe.name = "请填写名称(80 字以内)";
if (fe.scopes) fe.scopes = "至少选择一个权限范围";
setErrors(fe);
return;
}
setErrors({});
try {
const r = await create.mutateAsync(parsed.data);
setOpen(false);
setName("");
setScopes(["read"]);
setPlaintext(r.plaintextKey);
} catch (err) {
toast.error(errorMessage(err, "创建失败"));
}
};
return (
<div>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<p className="max-w-2xl text-caption text-muted-foreground">
给外部程序(媒体服务器插件、脚本)用的密钥,按权限范围授权。控制台本身走登录会话,不需要 Key。
</p>
<Button size="sm" onClick={() => setOpen(true)}>
<Plus />
创建 API Key
</Button>
</div>
<DataTable
rows={keys.data?.items}
rowKey={(r) => r.id}
loading={keys.isPending}
error={keys.error}
onRetry={() => keys.refetch()}
empty={{ title: "还没有 API Key" }}
columns={[
{
key: "name",
header: "名称",
cell: (r) => (
<div className="min-w-0">
<p className={cn("font-medium", r.revokedAt && "text-muted-foreground line-through")}>{r.name}</p>
<p className="text-meta text-muted-foreground">创建于 {formatDateTime(r.createdAt)}</p>
</div>
),
},
{
key: "scopes",
header: "权限",
hideBelow: "sm",
cell: (r) => (
<div className="flex flex-wrap gap-1">
{r.scopes.map((s) => (
<code key={s} className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5 text-meta">
{s}
</code>
))}
</div>
),
},
{ key: "used", header: "最近使用", hideBelow: "md", cell: (r) => formatRelative(r.lastUsedAt) },
{
key: "status",
header: "状态",
cell: (r) => (
<StatusBadge tone={r.revokedAt ? "neutral" : "success"}>{r.revokedAt ? "已撤销" : "有效"}</StatusBadge>
),
},
{
key: "act",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (r) =>
r.revokedAt ? null : (
<Button variant="ghost" size="sm" className="hover:text-destructive" onClick={() => setRevoking(r)}>
撤销
</Button>
),
},
]}
/>
<Dialog open={open} onOpenChange={(o) => !create.isPending && setOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>创建 API Key</DialogTitle>
<DialogDescription>创建后明文只显示一次。</DialogDescription>
</DialogHeader>
<form id="apikey-form" onSubmit={submit} noValidate className="grid gap-1">
<FormField label="名称" htmlFor="ak-name" error={errors.name} required>
<Input
id="ak-name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
placeholder="emby-plugin"
/>
</FormField>
<FormField label="权限范围" error={errors.scopes}>
<div className="grid gap-1.5">
{SCOPES.map((s) => {
const on = scopes.includes(s.value);
return (
<label
key={s.value}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-foreground/[0.04]"
>
<input
type="checkbox"
className="h-4 w-4 accent-current"
checked={on}
onChange={() => setScopes((xs) => (on ? xs.filter((x) => x !== s.value) : [...xs, s.value]))}
/>
<code className="text-caption">{s.label}</code>
<span className="text-meta text-muted-foreground">{s.hint}</span>
</label>
);
})}
</div>
</FormField>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={create.isPending}>
取消
</Button>
<Button type="submit" form="apikey-form" disabled={create.isPending}>
{create.isPending ? <LoaderCircle className="animate-spin" /> : null}
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CreatedKeyDialog plaintext={plaintext} onClose={() => setPlaintext(null)} />
<ConfirmDialog
open={!!revoking}
onOpenChange={(o) => !o && setRevoking(null)}
title={`撤销「${revoking?.name ?? ""}」?`}
description="撤销后使用这个 Key 的程序会立即失去访问权限,无法恢复。"
confirmLabel="撤销"
destructive
onConfirm={async () => {
if (!revoking) return;
try {
await revoke.mutateAsync(revoking.id);
toast.success("已撤销");
} catch (err) {
toast.error(errorMessage(err, "撤销失败"));
throw err;
}
}}
/>
</div>
);
}
@@ -0,0 +1,403 @@
import {
type GlobalSettings,
GlobalSettings as GlobalSettingsSchema,
type GlobalSettingsUpdate,
mergeSettings,
} from "@umt/contracts";
import { LoaderCircle, Pause, Play, ShieldAlert } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { useSettings, useUpdateSettings } from "~/api/settings.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { zodFieldErrors } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { FieldControl, type FieldSpec, type FormValues, num, str } from "./resource-section.tsx";
// 全局设置表单:每张卡片一组字段,保存时 PATCH 对应分组;
// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。
type CardSpec = {
title: string;
description?: ReactNode;
fields: FieldSpec[];
fromSettings: (s: GlobalSettings) => FormValues;
toPatch: (v: FormValues, s: GlobalSettings) => GlobalSettingsUpdate;
extra?: (s: GlobalSettings) => ReactNode;
};
function SettingsCard({ spec, settings }: { spec: CardSpec; settings: GlobalSettings }) {
const update = useUpdateSettings();
const [values, setValues] = useState<FormValues>(() => spec.fromSettings(settings));
const [errors, setErrors] = useState<Record<string, string>>({});
const initial = spec.fromSettings(settings);
const dirty = Object.keys(values).some((k) => values[k] !== initial[k]);
const save = async (e: React.FormEvent) => {
e.preventDefault();
const patch = spec.toPatch(values, settings);
const parsed = GlobalSettingsSchema.safeParse(mergeSettings(settings, patch));
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
// 交叉校验(refine)落在分组上:挂到本卡第一个字段
for (const [k, m] of Object.entries(fe)) {
if (!spec.fields.some((f) => f.name === k))
fe[spec.fields.find((f) => k.startsWith(f.name.split(".")[0] ?? ""))?.name ?? k] ??= m;
}
setErrors(fe);
return;
}
setErrors({});
try {
const next = await update.mutateAsync(patch);
setValues(spec.fromSettings(next));
toast.success(`已保存${spec.title}`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<form onSubmit={save} noValidate className="pg-slab min-w-0 rounded-xl p-5">
<h3 className="text-body font-semibold text-foreground">{spec.title}</h3>
{spec.description ? (
<p className="mt-0.5 mb-4 text-caption text-muted-foreground">{spec.description}</p>
) : (
<div className="mb-4" />
)}
<div className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
{spec.fields
.filter((f) => !f.visible || f.visible(values))
.map((f) => (
<FieldControl
key={f.name}
spec={f}
value={values[f.name]}
error={errors[f.name]}
hint={f.hint}
disabled={update.isPending}
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
/>
))}
</div>
{spec.extra?.(settings)}
<div className="mt-2 flex items-center gap-2">
<Button type="submit" size="sm" disabled={update.isPending || !dirty}>
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
{dirty ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setValues(spec.fromSettings(settings));
setErrors({});
}}
>
还原
</Button>
) : null}
</div>
</form>
);
}
function useSettingsGate(render: (s: GlobalSettings) => ReactNode) {
const q = useSettings();
if (q.isPending) return <LoadingState rows={6} />;
if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} />;
return render(q.data);
}
const n = (v: number | null | undefined) => (v === null || v === undefined ? "" : String(v));
// ─── Pi 上游 ───
const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh"].map((v) => ({ value: v, label: v }));
const PI_CARD: CardSpec = {
title: "Pi 上游与模型",
description: "Agent 使用的模型上游。密钥只写入、加密保存,不回显。",
fields: [
{ name: "pi.provider", label: "Provider", kind: "text", required: true, placeholder: "anthropic" },
{ name: "pi.model", label: "模型", kind: "text", placeholder: "留空用 provider 默认" },
{ name: "pi.baseUrl", label: "Base URL", kind: "url", placeholder: "留空用官方地址", wide: true },
{ name: "pi.apiKey", label: "API Key", kind: "password", hint: "留空表示不修改", wide: true },
{ name: "pi.thinkingLevel", label: "思考档位", kind: "select", options: THINKING },
{ name: "pi.contextBudget", label: "上下文预算(token)", kind: "number", hint: "4096 – 2000000" },
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
{
name: "pi.compaction.thresholdRatio",
label: "压缩触发比例",
kind: "number",
hint: "0.3 – 0.95,达到预算的该比例时压缩",
visible: (v) => v["pi.compaction.enabled"] === true,
},
{ name: "pi.subagentsEnabled", label: "允许子代理", kind: "switch" },
],
fromSettings: (s) => ({
"pi.provider": s.pi.provider,
"pi.model": s.pi.model ?? "",
"pi.baseUrl": s.pi.baseUrl ?? "",
"pi.apiKey": "",
"pi.thinkingLevel": s.pi.thinkingLevel,
"pi.contextBudget": n(s.pi.contextBudget),
"pi.compaction.enabled": s.pi.compaction.enabled,
"pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio),
"pi.subagentsEnabled": s.pi.subagentsEnabled,
}),
toPatch: (v) => ({
pi: {
provider: str(v["pi.provider"]),
model: str(v["pi.model"]) || null,
baseUrl: str(v["pi.baseUrl"]) || null,
...(str(v["pi.apiKey"]) ? { apiKey: str(v["pi.apiKey"]) } : {}),
thinkingLevel: str(v["pi.thinkingLevel"]) as GlobalSettings["pi"]["thinkingLevel"],
contextBudget: num(v["pi.contextBudget"]),
compaction: {
enabled: v["pi.compaction.enabled"] === true,
thresholdRatio: num(v["pi.compaction.thresholdRatio"]),
},
subagentsEnabled: v["pi.subagentsEnabled"] === true,
},
}),
extra: (s) => <PiKeyStatus set={!!s.pi.apiKeySecretRef} />,
};
function PiKeyStatus({ set }: { set: boolean }) {
const update = useUpdateSettings();
return (
<div className="mb-2 flex flex-wrap items-center gap-2 text-meta text-muted-foreground">
<StatusBadge tone={set ? "success" : "warning"}>{set ? "上游密钥已保存" : "未设置上游密钥"}</StatusBadge>
{set ? (
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={update.isPending}
onClick={async () => {
try {
await update.mutateAsync({ pi: { apiKey: null } });
toast.success("已清除上游密钥");
} catch (err) {
toast.error(errorMessage(err, "清除失败"));
}
}}
>
清除密钥
</Button>
) : null}
</div>
);
}
const AGENT_BUDGET_CARD: CardSpec = {
title: "Agent 预算",
description: "超过预算的 Agent 运行会停在「超预算」状态,等你放行。",
fields: [
{ name: "budget.agentMaxRunsPerDay", label: "每日最多运行次数", kind: "number" },
{ name: "budget.agentMaxCostUsdPerDay", label: "每日费用上限(USD)", kind: "number" },
],
fromSettings: (s) => ({
"budget.agentMaxRunsPerDay": n(s.budget.agentMaxRunsPerDay),
"budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay),
}),
toPatch: (v) => ({
budget: {
agentMaxRunsPerDay: num(v["budget.agentMaxRunsPerDay"]),
agentMaxCostUsdPerDay: num(v["budget.agentMaxCostUsdPerDay"]),
},
}),
};
export function PiTab() {
return useSettingsGate((s) => (
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<SettingsCard spec={PI_CARD} settings={s} />
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
</div>
));
}
// ─── 缓存 / 入库 / 保留 / 下载预算 ───
const GiB = 1024 ** 3;
const CACHE_CARD: CardSpec = {
title: "缓存阈值与恢复水位",
description: "缓存卷使用率达到硬阈值时停止新增下载并暂停纳管任务;降到恢复水位以下再恢复(只恢复因阈值暂停的)。",
fields: [
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "必须低于硬阈值" },
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不得高于恢复水位" },
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10 – 3600" },
],
fromSettings: (s) => ({
"cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
"cache.resumePercent": n(s.cache.resumePercent),
"cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent),
"cache.checkIntervalSec": n(s.cache.checkIntervalSec),
}),
toPatch: (v) => ({
cache: {
hardThresholdPercent: num(v["cache.hardThresholdPercent"]),
resumePercent: num(v["cache.resumePercent"]),
cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]),
checkIntervalSec: num(v["cache.checkIntervalSec"]),
},
}),
};
const ORGANIZE_CARD: CardSpec = {
title: "入库方式与保留",
description: "默认 Copy 入库,下载源在缓存里保留到做种期满;跨卷 Move 走 Copy→核验→删源。",
fields: [
{
name: "organize.defaultMode",
label: "默认入库方式",
kind: "select",
options: [
{ value: "copy", label: "Copy(保留源文件做种)" },
{ value: "move", label: "Move(移走源文件)" },
],
},
{ name: "organize.allowCrossVolumeMove", label: "允许跨卷 Move", kind: "switch" },
{
name: "retention.seedRetentionDays",
label: "源文件保留天数",
kind: "number",
hint: "Copy 入库后至少保留;0 – 365",
},
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
],
fromSettings: (s) => ({
"organize.defaultMode": s.organize.defaultMode,
"organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove,
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
"retention.logRetentionDays": n(s.retention.logRetentionDays),
}),
toPatch: (v) => ({
organize: {
defaultMode: str(v["organize.defaultMode"]) as "copy" | "move",
allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true,
},
retention: {
seedRetentionDays: num(v["retention.seedRetentionDays"]),
logRetentionDays: num(v["retention.logRetentionDays"]),
},
}),
};
const DOWNLOAD_BUDGET_CARD: CardSpec = {
title: "下载预算",
fields: [
{ name: "budget.maxNewTitlesPerDay", label: "每日最多新作品", kind: "number" },
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量上限(GiB)", kind: "number" },
{ name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1 – 100" },
],
fromSettings: (s) => ({
"budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay),
"budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100),
"budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads),
}),
toPatch: (v) => {
const gib = num(v["budget.maxDownloadGiBPerDay"]);
return {
budget: {
maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]),
maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB),
maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]),
},
};
},
};
export function StorageTab() {
return useSettingsGate((s) => (
<div className="grid gap-4 lg:grid-cols-2">
<SettingsCard spec={CACHE_CARD} settings={s} />
<SettingsCard spec={ORGANIZE_CARD} settings={s} />
<SettingsCard spec={DOWNLOAD_BUDGET_CARD} settings={s} />
</div>
));
}
// ─── 自动化开关 ───
function ToggleRow({
icon,
title,
description,
checked,
onChange,
disabled,
tone,
}: {
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
disabled: boolean;
tone: "success" | "warning";
}) {
return (
<div className="flex items-start gap-4 py-4">
<span
className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-full",
tone === "success" ? "bg-success/10 text-success" : "bg-warning/10 text-warning",
)}
>
{icon}
</span>
<span className="min-w-0 flex-1">
<label htmlFor={`tg-${title}`} className="block cursor-pointer text-sm font-medium text-foreground">
{title}
</label>
<span className="block text-caption text-muted-foreground">{description}</span>
</span>
<Switch id={`tg-${title}`} checked={checked} onCheckedChange={onChange} disabled={disabled} aria-label={title} />
</div>
);
}
export function AutomationTab() {
const update = useUpdateSettings();
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
try {
await update.mutateAsync({ automation: patch });
toast.success(msg);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
}
};
return useSettingsGate((s) => (
<div className="pg-slab divide-y divide-border/60 rounded-xl px-5">
<ToggleRow
icon={s.automation.enabled ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
tone={s.automation.enabled ? "success" : "warning"}
title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`}
description="关闭后所有自动收录、下载、整理停止;只读查询与手动操作仍可用。"
checked={s.automation.enabled}
disabled={update.isPending}
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
/>
<ToggleRow
icon={<ShieldAlert className="h-4 w-4" />}
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
title={`只读对账模式 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
description="从备份恢复后先开启:只核对库存与路径,不做任何自动写入;确认无误后关闭。"
checked={s.automation.readOnlyReconcile}
disabled={update.isPending}
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已进入只读对账模式" : "已退出只读对账模式")}
/>
</div>
));
}
@@ -0,0 +1,343 @@
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { CursorPage } from "@umt/contracts";
import { LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import type { z } from "zod";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑对话框 + 删除 + 行内动作。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
export type FieldValue = string | boolean;
export type FormValues = Record<string, FieldValue>;
export type FieldSpec = {
name: string;
label: string;
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
options?: { value: string; label: string }[];
placeholder?: string;
hint?: string;
/** 编辑时的说明(如"留空表示不修改") */
editHint?: string;
required?: boolean;
/** 编辑时不可改 */
createOnly?: boolean;
/** 条件显示 */
visible?: (v: FormValues) => boolean;
wide?: boolean;
};
export type ResourceConfig<Row extends { id: string }> = {
/** 单数名,用于标题与提示:"下载器实例" */
noun: string;
description?: ReactNode;
columns: DataColumn<Row>[];
fields: FieldSpec[];
/** 新建时的初始值 */
initial: FormValues;
/** 编辑时从行取表单值(凭证字段给空串) */
fromRow: (row: Row) => FormValues;
/** 表单值 → 请求体;mode=edit 时凭证空串应省略 */
toBody: (v: FormValues, mode: "create" | "edit") => unknown;
createSchema: z.ZodType;
updateSchema: z.ZodType;
/** 行内额外动作(测试连接等) */
rowActions?: (row: Row) => ReactNode;
emptyHint?: string;
/** 删除确认的额外说明 */
deleteHint?: string;
};
type Hooks<Row> = {
list: UseQueryResult<CursorPage<Row>>;
create: UseMutationResult<Row, Error, never>;
update: UseMutationResult<Row, Error, { id: string; body: never }>;
remove: UseMutationResult<unknown, Error, string>;
};
export function ResourceSection<Row extends { id: string }>({
config,
hooks,
}: {
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
}) {
const [editing, setEditing] = useState<Row | "new" | null>(null);
const [deleting, setDeleting] = useState<Row | null>(null);
const { list, remove } = hooks;
const columns: DataColumn<Row>[] = [
...config.columns,
{
key: "__actions",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (row) => (
<div className="flex items-center justify-end gap-1">
{config.rowActions?.(row)}
<Button variant="ghost" size="icon-sm" aria-label={`编辑${config.noun}`} onClick={() => setEditing(row)}>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`删除${config.noun}`}
className="hover:text-destructive"
onClick={() => setDeleting(row)}
>
<Trash2 />
</Button>
</div>
),
},
];
return (
<div>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
{config.description ? (
<p className="max-w-2xl text-caption text-muted-foreground">{config.description}</p>
) : (
<span />
)}
<Button size="sm" onClick={() => setEditing("new")}>
<Plus />
添加{config.noun}
</Button>
</div>
<DataTable
columns={columns}
rows={list.data?.items}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
empty={{ title: `还没有${config.noun}`, description: config.emptyHint }}
/>
<ResourceFormDialog
key={editing === "new" ? "new" : (editing?.id ?? "none")}
config={config}
hooks={hooks}
row={editing}
onClose={() => setEditing(null)}
/>
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title={`删除${config.noun}?`}
description={config.deleteHint ?? "删除后无法恢复。"}
confirmLabel="删除"
destructive
onConfirm={async () => {
if (!deleting) return;
try {
await remove.mutateAsync(deleting.id);
toast.success(`已删除${config.noun}`);
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
}
}}
/>
</div>
);
}
function ResourceFormDialog<Row extends { id: string }>({
config,
hooks,
row,
onClose,
}: {
config: ResourceConfig<Row>;
hooks: Hooks<Row>;
row: Row | "new" | null;
onClose: () => void;
}) {
const mode = row === "new" ? "create" : "edit";
const [values, setValues] = useState<FormValues>(() =>
row && row !== "new" ? config.fromRow(row) : { ...config.initial },
);
const [errors, setErrors] = useState<Record<string, string>>({});
const pending = hooks.create.isPending || hooks.update.isPending;
const set = (name: string, v: FieldValue) => setValues((s) => ({ ...s, [name]: v }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const body = config.toBody(values, mode);
const parsed = (mode === "create" ? config.createSchema : config.updateSchema).safeParse(body);
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
// 顶层 refine 错误(path 为空)挂到第一个字段下
if (fe[""]) {
fe[config.fields[0]?.name ?? ""] ??= fe[""];
}
setErrors(fe);
return;
}
setErrors({});
try {
if (mode === "create") await hooks.create.mutateAsync(parsed.data as never);
else if (row && row !== "new") await hooks.update.mutateAsync({ id: row.id, body: parsed.data as never });
toast.success(mode === "create" ? `已添加${config.noun}` : `已保存${config.noun}`);
onClose();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
return (
<Dialog open={!!row} onOpenChange={(o) => !o && !pending && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{mode === "create" ? "添加" : "编辑"}
{config.noun}
</DialogTitle>
<DialogDescription>凭证只写入、加密保存,任何页面都不会回显。</DialogDescription>
</DialogHeader>
<form id="resource-form" onSubmit={submit} noValidate className="grid gap-x-4 gap-y-1 sm:grid-cols-2">
{config.fields
.filter((f) => !f.visible || f.visible(values))
.map((f) => (
<FieldControl
key={f.name}
spec={f}
value={values[f.name]}
error={errors[f.name]}
onChange={(v) => set(f.name, v)}
disabled={pending || (mode === "edit" && !!f.createOnly)}
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
/>
))}
</form>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button type="submit" form="resource-form" disabled={pending}>
{pending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function FieldControl({
spec,
value,
error,
onChange,
disabled,
hint,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
hint?: string;
}) {
const id = `f-${spec.name}`;
const wide = spec.wide || spec.kind === "textarea";
if (spec.kind === "switch") {
return (
<div className={wide ? "sm:col-span-2" : undefined}>
<label htmlFor={id} className="flex min-h-9 items-center justify-between gap-3 text-caption font-medium">
{spec.label}
<Switch id={id} checked={value === true} onCheckedChange={onChange} disabled={disabled} />
</label>
<p className="min-h-4 text-meta text-muted-foreground">{hint ?? ""}</p>
</div>
);
}
let control: ReactNode;
if (spec.kind === "select") {
control = (
<Select value={String(value ?? "")} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className="w-full" aria-invalid={!!error}>
<SelectValue placeholder={spec.placeholder ?? "请选择"} />
</SelectTrigger>
<SelectContent position="popper">
{(spec.options ?? []).map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (spec.kind === "textarea") {
control = (
<Textarea
id={id}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
rows={3}
/>
);
} else {
control = (
<Input
id={id}
type={
spec.kind === "password"
? "password"
: spec.kind === "number"
? "number"
: spec.kind === "url"
? "url"
: "text"
}
autoComplete={spec.kind === "password" ? "new-password" : "off"}
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
/>
);
}
return (
<FormField
label={spec.label}
htmlFor={id}
error={error}
hint={hint}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
{control}
</FormField>
);
}
/** 表单值工具 */
export const str = (v: FieldValue | undefined) => (typeof v === "string" ? v.trim() : "");
export const optStr = (v: FieldValue | undefined) => str(v) || undefined;
export const nullStr = (v: FieldValue | undefined) => str(v) || null;
export const num = (v: FieldValue | undefined) => (str(v) === "" ? undefined : Number(str(v)));
+385 -2
View File
@@ -1,4 +1,387 @@
// 登录页:W3-1 实现中
import { useQueryClient } from "@tanstack/react-query";
import { AdminSetupRequest, LoginRequest, TotpCode } from "@umt/contracts";
import {
AlertCircle,
Clapperboard,
Eye,
EyeOff,
KeyRound,
LoaderCircle,
LockKeyhole,
ShieldCheck,
Terminal,
User,
} from "lucide-react";
import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { z } from "zod";
import { login, setupAdmin, useSession } from "~/api/auth.ts";
import PrismLeak from "~/components/prism-leak.tsx";
import { ThemeToggle } from "~/components/theme-toggle.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Label } from "~/components/ui/label.tsx";
import { ApiRequestError } from "~/lib/api.ts";
import { qk } from "~/lib/query-keys.ts";
import { cn } from "~/lib/utils.ts";
export const meta = () => [{ title: "登录 · UHD 媒体自动化控制台" }];
type Mode = "setup" | "login" | "totp";
/** next 只接受站内控制台路径,防开放重定向 */
function safeNext(raw: string | null): string {
if (raw?.startsWith("/console") && !raw.startsWith("//")) return raw;
return "/console";
}
const SetupForm = AdminSetupRequest.extend({ confirm: z.string() }).refine((v) => v.password === v.confirm, {
message: "两次输入的密码不一致",
path: ["confirm"],
});
/** 后端错误码 → 文案(不直接透传后端 message,setup token 类除外) */
function errorText(err: unknown, mode: Mode): string {
if (!(err instanceof ApiRequestError)) return "发生未知错误,请重试";
switch (err.code) {
case "rate_limited":
return "失败次数过多,请 15 分钟后再试";
case "upstream_unavailable":
return "无法连接服务,请检查服务端是否在运行";
case "unauthorized":
if (mode === "totp") return "验证码错误或已使用,请等待下一个验证码";
if (mode === "setup") return "初始化口令无效,请以服务端日志里最新打印的为准";
return "用户名或密码错误";
case "validation_failed":
case "bad_request":
return "输入格式不正确";
case "conflict":
return "管理员已初始化,请直接登录";
default:
return err.message || "登录失败";
}
}
function IconInput({
icon: Icon,
trailing,
className,
...props
}: React.ComponentProps<typeof Input> & { icon: typeof User; trailing?: ReactNode }) {
return (
<div className="relative">
<Icon className="pointer-events-none absolute top-1/2 left-3.5 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className={cn("h-12 rounded-lg pl-10 text-control md:text-control", trailing && "pr-11", className)}
{...props}
/>
{trailing ? <div className="absolute top-1/2 right-1.5 -translate-y-1/2">{trailing}</div> : null}
</div>
);
}
export default function LoginPage() {
return null;
const session = useSession();
const qc = useQueryClient();
const navigate = useNavigate();
const [params] = useSearchParams();
const next = safeNext(params.get("next"));
const [mode, setMode] = useState<Mode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [setupToken, setSetupToken] = useState("");
const [totp, setTotp] = useState("");
const [showPwd, setShowPwd] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const totpRef = useRef<HTMLInputElement>(null);
// 初始状态:未初始化 → 初始化向导;已登录 → 回跳
useEffect(() => {
if (!session.data) return;
if (session.data.authenticated && session.data.actor?.type === "admin") {
navigate(next, { replace: true });
return;
}
setMode((m) => (session.data.initialized ? (m === "setup" ? "login" : m) : "setup"));
}, [session.data, navigate, next]);
useEffect(() => {
if (mode === "totp") totpRef.current?.focus();
}, [mode]);
const done = async () => {
await qc.invalidateQueries({ queryKey: qk.session });
navigate(next, { replace: true });
};
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
if (loading) return;
setError(null);
setFieldErrors({});
if (mode === "setup") {
const parsed = SetupForm.safeParse({ setupToken: setupToken.trim(), username, password, confirm });
if (!parsed.success) {
const fe: Record<string, string> = {};
for (const i of parsed.error.issues) fe[String(i.path[0])] ??= i.message;
if (fe.setupToken) fe.setupToken = "初始化口令至少 16 个字符";
if (fe.username && !username.trim()) fe.username = "请输入用户名";
setFieldErrors(fe);
return;
}
setLoading(true);
try {
await setupAdmin({ setupToken: parsed.data.setupToken, username, password });
await done();
} catch (err) {
if (err instanceof ApiRequestError && err.code === "conflict") setMode("login");
setError(errorText(err, "setup"));
} finally {
setLoading(false);
}
return;
}
const body = { username, password, totpCode: mode === "totp" ? totp : undefined };
const parsed = (mode === "totp" ? LoginRequest.extend({ totpCode: TotpCode }) : LoginRequest).safeParse(body);
if (!parsed.success) {
const fe: Record<string, string> = {};
for (const i of parsed.error.issues) fe[String(i.path[0])] ??= i.message;
if (fe.username) fe.username = "请输入用户名";
if (fe.password) fe.password = "请输入密码";
setFieldErrors(fe);
return;
}
setLoading(true);
try {
const r = await login(parsed.data);
if (r.status === "totp_required") {
setMode("totp");
setTotp("");
} else {
await done();
}
} catch (err) {
if (err instanceof ApiRequestError && err.code === "precondition_failed") {
setMode("setup");
setError("管理员尚未初始化,请先完成初始化");
} else {
setError(errorText(err, mode));
}
} finally {
setLoading(false);
}
};
const probing = session.isPending;
const disabled = probing || loading;
const title = mode === "setup" ? "初始化管理员" : mode === "totp" ? "二次验证" : "登录控制台";
const subtitle =
mode === "setup"
? "首次启动:设置唯一的管理员账号。"
: mode === "totp"
? "输入身份验证器里的 6 位验证码。"
: "使用管理员账号登录,会话 12 小时有效。";
return (
<div className="relative isolate flex min-h-screen flex-col overflow-x-hidden bg-background">
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
<PrismLeak className="-z-10" />
<header className="pg-chrome sticky top-0 z-20 flex h-14 items-center border-b px-4 md:px-6">
<div className="flex items-center gap-2">
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected">
<Clapperboard className="h-4 w-4" />
</span>
<span className="text-xs font-bold tracking-wide text-foreground">UHD Media Tool</span>
</div>
<div className="ml-auto">
<ThemeToggle />
</div>
</header>
<main className="mx-auto grid w-full max-w-6xl flex-1 items-center gap-10 px-4 py-10 md:px-6 lg:grid-cols-[minmax(0,1fr)_23.5rem] lg:py-16">
{/* 左侧品牌区(纯装饰,仅桌面) */}
<section className="hidden lg:block">
<p className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">媒体自动化</p>
<h1 className="mt-3 max-w-xl font-display text-display-sm leading-tight font-semibold text-foreground">
把想看的片子交代一句,剩下的交给流水线。
</h1>
<p className="mt-4 max-w-lg text-lead text-muted-foreground">
订阅、选片、下载、核验、入库,每一步都有记录可查;拿不准的事项会停下来等你决定。
</p>
<ul className="mt-8 space-y-3 text-body text-muted-foreground">
<li className="flex items-center gap-2.5">
<ShieldCheck className="h-4 w-4 text-foreground/70" />
单管理员登录,外部程序走 API Key
</li>
<li className="flex items-center gap-2.5">
<LockKeyhole className="h-4 w-4 text-foreground/70" />
凭证加密存储,任何页面都不回显
</li>
</ul>
</section>
<section className="pg-slab w-full rounded-2xl p-6 sm:p-7">
<h2 className="text-title font-semibold tracking-tight text-foreground">{title}</h2>
<p className="mt-1 text-caption text-muted-foreground">{subtitle}</p>
{mode === "setup" ? (
<div className="mt-4 flex gap-2.5 rounded-lg border border-border bg-foreground/[0.03] p-3 text-meta text-muted-foreground">
<Terminal className="mt-0.5 h-4 w-4 shrink-0" />
<p>
初始化口令只在服务端首次启动时打印到日志一次,可用
<code className="mx-1 rounded-tight bg-foreground/[0.06] px-1 font-mono">
docker compose logs app | grep 'setup token'
</code>
查看;重启会生成新口令。
</p>
</div>
) : null}
<form className="mt-5 space-y-3" onSubmit={onSubmit} noValidate>
{mode === "setup" ? (
<Field id="setupToken" label="初始化口令" error={fieldErrors.setupToken}>
<IconInput
id="setupToken"
icon={KeyRound}
autoComplete="off"
value={setupToken}
onChange={(e) => setSetupToken(e.target.value)}
disabled={disabled}
aria-invalid={!!fieldErrors.setupToken}
placeholder="从服务端日志复制"
/>
</Field>
) : null}
<Field id="username" label="用户名" error={fieldErrors.username}>
<IconInput
id="username"
icon={User}
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
readOnly={mode === "totp"}
disabled={disabled}
aria-invalid={!!fieldErrors.username}
/>
</Field>
<Field id="password" label="密码" error={fieldErrors.password}>
<IconInput
id="password"
icon={LockKeyhole}
type={showPwd ? "text" : "password"}
autoComplete={mode === "setup" ? "new-password" : "current-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
readOnly={mode === "totp"}
disabled={disabled}
aria-invalid={!!fieldErrors.password}
placeholder={mode === "setup" ? "至少 10 个字符" : undefined}
trailing={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={showPwd ? "隐藏密码" : "显示密码"}
aria-pressed={showPwd}
onClick={() => setShowPwd((v) => !v)}
>
{showPwd ? <EyeOff /> : <Eye />}
</Button>
}
/>
</Field>
{mode === "setup" ? (
<Field id="confirm" label="确认密码" error={fieldErrors.confirm}>
<IconInput
id="confirm"
icon={LockKeyhole}
type={showPwd ? "text" : "password"}
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
disabled={disabled}
aria-invalid={!!fieldErrors.confirm}
/>
</Field>
) : null}
{mode === "totp" ? (
<Field id="totp" label="验证码" error={fieldErrors.totpCode}>
<IconInput
ref={totpRef}
id="totp"
icon={ShieldCheck}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={totp}
onChange={(e) => setTotp(e.target.value.replace(/\D/g, ""))}
disabled={disabled}
aria-invalid={!!fieldErrors.totpCode}
className="font-mono tracking-[0.4em]"
placeholder="000000"
/>
</Field>
) : null}
{/* 固定高度错误占位,报错时不跳 */}
<div className="min-h-5" aria-live="polite">
{error ? (
<p role="alert" className="flex items-center gap-1.5 text-caption text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</p>
) : null}
</div>
<Button type="submit" size="lg" className="w-full" disabled={disabled}>
{loading || probing ? <LoaderCircle className="animate-spin" /> : null}
{mode === "setup" ? "初始化并登录" : mode === "totp" ? "验证" : "登录"}
</Button>
{mode === "totp" ? (
<Button
type="button"
variant="ghost"
className="w-full"
disabled={loading}
onClick={() => {
setMode("login");
setTotp("");
setError(null);
}}
>
返回修改账号
</Button>
) : null}
{session.isError ? (
<p className="text-center text-meta text-muted-foreground">
无法获取登录状态,
<button type="button" className="cursor-pointer underline" onClick={() => session.refetch()}>
重试
</button>
</p>
) : null}
</form>
</section>
</main>
</div>
);
}
function Field({ id, label, error, children }: { id: string; label: string; error?: string; children: ReactNode }) {
return (
<div className="grid gap-1.5">
<Label htmlFor={id} className="text-caption text-muted-foreground">
{label}
</Label>
{children}
{error ? <p className="text-meta text-destructive">{error}</p> : null}
</div>
);
}
+2
View File
@@ -57,6 +57,8 @@ export default defineConfig(({ mode }) => {
server: {
port: 5180,
strictPort: true,
// 依赖装在仓库根 node_modules/.deno(deno workspace),vite 找不到 npm workspace 根,需显式放行
fs: { allow: [path.resolve(process.cwd(), "../..")] },
proxy: {
"/api": { target: apiTarget, changeOrigin: true },
},