feat(web): W3-1 login, session shell, home dashboard, settings, engine panel and audit pages
- /login: setup/login/TOTP states driven by server error codes, fixed-height error slot - console layout: GET /auth/session gate, redirect to /login, account popover (change password, logout) - home: six dashboard sections with SSE-driven refresh, automation switch and read-only reconcile toggle - settings tabs: downloaders, media servers, library roots, path mappings, site credentials, API keys (one-time plaintext), Pi upstream, cache/organize/retention/budget, automation - engine: runs list (status/name filters, cursor paging, stats tiles), run detail (step timeline, json payloads, signals, cancel) - audit: filterable list with detail sheet - vite: allow repo root in server.fs so deno-installed deps under node_modules/.deno load in dev Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ef73abe3df
commit
0dec0854df
21 files changed
+3905
-57
No files matched your search
@@ -0,0 +1,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") }),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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") });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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") }),
|
||||
});
|
||||
}
|
||||
@@ -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") });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user