diff --git a/apps/web/app/api/api-keys.ts b/apps/web/app/api/api-keys.ts new file mode 100644 index 00000000..9493740d --- /dev/null +++ b/apps/web/app/api/api-keys.ts @@ -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>("/api-keys", { params: { limit: 200 }, signal }), + }); +} + +/** 创建:明文只在返回值里出现一次 */ +export function useCreateApiKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: ApiKeyCreate) => apiData("/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("/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("/api-keys/{id}", { method: "DELETE", pathParams: { id } }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.all("api-keys") }), + }); +} diff --git a/apps/web/app/api/audit.ts b/apps/web/app/api/audit.ts new file mode 100644 index 00000000..df4f4f62 --- /dev/null +++ b/apps/web/app/api/audit.ts @@ -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>("/audit-events", { + params: { ...filters, cursor: pageParam, limit: 50 }, + signal, + }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + }); +} diff --git a/apps/web/app/api/auth.ts b/apps/web/app/api/auth.ts new file mode 100644 index 00000000..e6576da6 --- /dev/null +++ b/apps/web/app/api/auth.ts @@ -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("/auth/session", { signal, noAuthRedirect: true }), + staleTime: 60_000, + retry: 1, + }); +} + +/** 登录/初始化类请求:返回错误码(由调用方按码分流文案),不跳转 */ +async function authPost(path: string, body: unknown): Promise { + const r = await apiFetch(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("/auth/login", body); +export const setupAdmin = (body: AdminSetupRequest) => authPost("/auth/setup", body); + +export function useLogout() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => authPost("/auth/logout", {}), + onSettled: () => { + qc.clear(); + }, + }); +} + +export function useChangePassword() { + return useMutation({ + mutationFn: (body: PasswordChangeRequest) => + apiData("/auth/password", { method: "POST", body }), + }); +} + +export function useTotpStatus(enabled = true) { + return useQuery({ + queryKey: qk.sub("auth", "totp"), + queryFn: ({ signal }) => apiData("/auth/totp", { signal }), + enabled, + }); +} + +export function useTotpAction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: TotpAction) => apiData("/auth/totp", { method: "POST", body }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.all("auth") }); + }, + }); +} diff --git a/apps/web/app/api/dashboard.ts b/apps/web/app/api/dashboard.ts new file mode 100644 index 00000000..260544e0 --- /dev/null +++ b/apps/web/app/api/dashboard.ts @@ -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", { signal }), + refetchInterval: 60_000, + }); +} diff --git a/apps/web/app/api/engine.ts b/apps/web/app/api/engine.ts new file mode 100644 index 00000000..93f69043 --- /dev/null +++ b/apps/web/app/api/engine.ts @@ -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>("/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("/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("/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") }), + }); +} diff --git a/apps/web/app/api/instances.ts b/apps/web/app/api/instances.ts new file mode 100644 index 00000000..33bf11fd --- /dev/null +++ b/apps/web/app/api/instances.ts @@ -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(area: QueryArea, base: string) { + return { + useList: () => + useQuery({ + queryKey: qk.list(area), + queryFn: ({ signal }) => apiData>(base, { params: { limit: 200 }, signal }), + }), + useCreate: () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: Create) => apiData(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(`${base}/{id}`, { method: "PATCH", pathParams: { id }, body }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.all(area) }), + }); + }, + useRemove: () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiData(`${base}/{id}`, { method: "DELETE", pathParams: { id } }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.all(area) }), + }); + }, + }; +} + +/** 连接测试 / 校验类动作(结果写回 health,完成后刷新列表) */ +function actionHook(area: QueryArea, path: string) { + return () => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => apiData(path, { method: "POST", pathParams: { id }, body: {} }), + onSettled: () => qc.invalidateQueries({ queryKey: qk.all(area) }), + }); + }; +} + +export const downloaders = { + ...crudHooks( + "downloader-instances", + "/downloader-instances", + ), + useTest: actionHook("downloader-instances", "/downloader-instances/{id}/test"), +}; + +export const mediaServers = { + ...crudHooks( + "media-server-instances", + "/media-server-instances", + ), + useTest: actionHook("media-server-instances", "/media-server-instances/{id}/test"), +}; + +export const libraryRoots = { + ...crudHooks("library-roots", "/library-roots"), + useTest: actionHook("library-roots", "/library-roots/{id}/test"), +}; + +export const pathMappings = { + ...crudHooks("path-mappings", "/path-mappings"), + useVerify: actionHook("path-mappings", "/path-mappings/{id}/verify"), +}; + +// ─── 站点账号凭证(设置页录入;站点本身由站点工作台管理) ─── + +export function useSiteOptions() { + return useQuery({ + queryKey: qk.list("sites", { limit: 200 }), + queryFn: ({ signal }) => apiData>("/sites", { params: { limit: 200 }, signal }), + }); +} + +export function useSiteAccounts() { + return useQuery({ + queryKey: qk.list("site-accounts"), + queryFn: ({ signal }) => apiData>("/site-accounts", { params: { limit: 200 }, signal }), + }); +} + +export function usePutSiteCredentials() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ siteId, body }: { siteId: string; body: SiteCredentialsPut }) => + apiData("/sites/{id}/credentials", { method: "PUT", pathParams: { id: siteId }, body }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.all("site-accounts") }); + }, + }); +} diff --git a/apps/web/app/api/settings.ts b/apps/web/app/api/settings.ts new file mode 100644 index 00000000..e536ada9 --- /dev/null +++ b/apps/web/app/api/settings.ts @@ -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("/settings", { signal }), + }); +} + +/** PATCH /settings,乐观更新(失败回滚) */ +export function useUpdateSettings() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (patch: GlobalSettingsUpdate) => apiData("/settings", { method: "PATCH", body: patch }), + onMutate: async (patch) => { + await qc.cancelQueries({ queryKey: qk.settings }); + const prev = qc.getQueryData(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 }); + }, + }); +} diff --git a/apps/web/app/components/console/account-menu.tsx b/apps/web/app/components/console/account-menu.tsx new file mode 100644 index 00000000..2085e31b --- /dev/null +++ b/apps/web/app/components/console/account-menu.tsx @@ -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>({}); + + 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) => + setForm((f) => ({ ...f, [k]: e.target.value })); + + return ( + !change.isPending && onOpenChange(o)}> + + + 修改密码 + 修改后其它设备上的会话会被注销,当前会话保留。 + +
+ + + + + + + + + +
+ + + + +
+
+ ); +} + +/** 右上角账户弹层:当前身份、会话到期、改密、登出 */ +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 ( + <> + + + + + +
+

当前账户

+

{actor?.name ?? "—"}

+

+ {actor?.type === "api_key" ? "API Key 调用" : "管理员"} + {isCookie ? ` · 会话到 ${formatDateTime(session.expiresAt)}` : ""} +

+
+
+ {isCookie ? ( + + ) : null} + +
+
+
+ + + ); +} diff --git a/apps/web/app/lib/labels.ts b/apps/web/app/lib/labels.ts new file mode 100644 index 00000000..7e6239fd --- /dev/null +++ b/apps/web/app/lib/labels.ts @@ -0,0 +1,122 @@ +import type { StatusTone } from "~/components/console/status-badge.tsx"; + +// 状态值 → 中文文案 + 语气。状态徽章必须带文字,这里集中维护常用的几组。 + +export type Labeled = { label: string; tone: StatusTone }; + +const pick = (map: Record, v: string | null | undefined): Labeled => + (v && map[v]) || { label: v || "未知", tone: "neutral" }; + +const HEALTH: Record = { + 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 = { + 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 = { + 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 = { + 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 = { + copy: "Copy", + move: "Move", + rename: "重命名", + relocate: "目录调整", + hardlink: "硬链接", +}; +export const organizationActionLabel = (v: string) => ORG_ACTION[v] ?? v; + +const AGENT_RUN: Record = { + 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 = { + 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; +} diff --git a/apps/web/app/routes/console/audit.tsx b/apps/web/app/routes/console/audit.tsx index dec7d834..f1b23e77 100644 --- a/apps/web/app/routes/console/audit.tsx +++ b/apps/web/app/routes/console/audit.tsx @@ -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 ; +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 = { + "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 = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" }; +const NOUN: Record = { + 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(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 ( + <> + + { + setTarget(""); + setParams({}, { replace: true }); + } + : undefined + } + > + setParam("actorType", v)} + options={ACTOR_TYPES} + /> + setParam("action", v)} options={ACTION_OPTIONS} /> + + + 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) => ( + + {formatRelative(r.at)} + + ), + }, + { + key: "action", + header: "动作", + cell: (r) => ( +
+

{actionLabel(r.action)}

+

{r.action}

+
+ ), + }, + { key: "actor", header: "操作者", hideBelow: "sm", cell: (r) => actorLabel(r.actorType) }, + { + key: "target", + header: "对象", + hideBelow: "md", + cell: (r) => + r.targetType ? ( +
+

{r.targetType}

+

{r.targetId}

+
+ ) : ( + "—" + ), + }, + ]} + /> + !o && setSelected(null)} + title={selected ? actionLabel(selected.action) : ""} + description={selected ? formatDateTime(selected.at) : undefined} + > + {selected ? ( + <> + + + + + + ) : null} + + + ); } diff --git a/apps/web/app/routes/console/engine.$runId.tsx b/apps/web/app/routes/console/engine.$runId.tsx index dec7d834..8db6ebb8 100644 --- a/apps/web/app/routes/console/engine.$runId.tsx +++ b/apps/web/app/routes/console/engine.$runId.tsx @@ -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 ; +export const meta = () => [{ title: "Run 详情 · 工作流引擎" }]; + +const KIND_LABEL: Record = { + 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(); + 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: ( + + {st.stepName} + + {KIND_LABEL[st.kind] ?? st.kind} · {status} + {n > 1 ? ` · 第 ${n} 次` : ""} + + + ), + meta: formatDuration(runDurationSec(st)), + description: ( + <> + {st.waitSignal ? ( +

+ 等待信号 {st.waitSignal} + {st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""} +

+ ) : null} + {st.childWorkflowRunId ? ( +

+ 子工作流{" "} + + {st.childWorkflowRunId} + +

+ ) : null} +

{st.id}

+ + ), + children: hasError ? ( + + ) : st.output !== null && st.output !== undefined ? ( + + ) : undefined, + }; + }); +} + +export default function EngineRunPage() { + const { runId } = useParams(); + const q = useEngineRun(runId); + const cancel = useCancelEngineRun(); + + const back = ( + + + 返回 runs + + ); + + if (q.isPending) { + return ( + <> + {back} + + + ); + } + if (q.isError) { + return ( + <> + {back} + 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} + + {st.label} + {r.version ? {r.version} : null} + {r.id} + + } + actions={ + <> + + {!terminal ? ( + + + 取消 run + + } + 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} + + } + /> + +
+
+
+ + + +
+
+ {hasError ? : null} + + {!hasError ? : null} +
+
+ + {r.signals.length === 0 ? ( +

没有投递到这个 run 的信号。

+ ) : ( +
    + {r.signals.map((sg) => ( +
  • +

    + {sg.signal} + {formatDateTime(sg.createdAt)} +

    + +
  • + ))} +
+ )} +
+
+
+
+ + {r.stepAttempts.length === 0 ? ( + + ) : ( + + )} + +
+
+ + ); } diff --git a/apps/web/app/routes/console/engine.tsx b/apps/web/app/routes/console/engine.tsx index dec7d834..fa9e7ac9 100644 --- a/apps/web/app/routes/console/engine.tsx +++ b/apps/web/app/routes/console/engine.tsx @@ -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 ; +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(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 | 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 ( + <> + qc.invalidateQueries({ queryKey: qk.all("engine") })} + disabled={runs.isFetching} + > + + 刷新 + + } + /> + +
+ {STAT_ORDER.map((t) => ( + 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)} + /> + ))} +
+
+ + 最老待执行: + {s?.oldestPending ? ( + + ) : ( + "无" + )} + + + + 持有租约的 worker: + {s && s.activeWorkers.length > 0 + ? s.activeWorkers.map((w) => `${w.workerId}(${w.running})`).join("、") + : "无"} + (快照,非权威注册表) + +
+ + { + setName(""); + setStatus(""); + } + : undefined + } + > + + + + + 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 {l.label}; + }, + }, + { + key: "name", + header: "工作流", + cell: (r) => ( +
+

+ {r.workflowName} + {r.version ? {r.version} : null} +

+

{r.id}

+
+ ), + }, + { key: "attempts", header: "尝试", hideBelow: "md", align: "right", cell: (r) => r.attempts }, + { + key: "dur", + header: "耗时", + hideBelow: "sm", + cell: (r) => {formatDuration(runDurationSec(r))}, + }, + { + key: "created", + header: "创建", + hideBelow: "md", + cell: (r) => {formatRelative(r.createdAt)}, + }, + ]} + /> + + ); } diff --git a/apps/web/app/routes/console/home.tsx b/apps/web/app/routes/console/home.tsx index 47c4c4b3..9edfcc7e 100644 --- a/apps/web/app/routes/console/home.tsx +++ b/apps/web/app/routes/console/home.tsx @@ -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 检查中; - if (isError) return 服务不可达:{error.message}; +function useLiveDashboard() { + const qc = useQueryClient(); + const timer = useRef | 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 ( -
- - 服务{data.status === "ok" ? "正常" : "降级"} - - - 数据库 {data.db === "ok" ? "正常" : data.db === "down" ? "不可用" : "未知"} - - - v{data.version} · {data.role} · {formatDateTime(data.time)} - +
+
+ +

{title}

+ {to ? ( + + 查看 + + + ) : null} +
+ {children} +
+ ); +} + +function Row({ children }: { children: ReactNode }) { + return
  • {children}
  • ; +} + +// ─── 自动化总开关:显眼状态 + 切换 ─── +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 ( +
    +
    + + {readOnly ? ( + + ) : paused ? ( + + ) : ( + + )} + +
    +
    +

    + {paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"} +

    + 总开关{paused ? "关" : "开"} + {readOnly ? 只读对账 : null} +
    +

    + {paused + ? "自动收录、下载、整理都已停下;查询与手动操作仍可用。" + : readOnly + ? "恢复后的只读对账:只核对不写入,确认无误后再关掉。" + : `${a.activeSubscriptions} 个订阅在追踪,按规则自动收录、下载与入库。`} +

    +
    +
    +
    +
    + + + setAutomation({ readOnlyReconcile: v }, v ? "已进入只读对账模式" : "已退出只读对账模式").catch(() => {}) + } + aria-label="只读对账模式" + /> +
    + {paused ? ( + + ) : ( + + + 暂停自动化 + + } + title="暂停全部自动化?" + description="自动收录、下载、整理会停下,已提交的下载任务不受影响。随时可以恢复。" + confirmLabel="暂停" + onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")} + /> + )} +
    ); } +function Overview({ a }: { a: Dashboard["automation"] }) { + const t = a.today; + return ( +
    +
    + + + + +
    +

    + 整理成功 {t.recordsSucceeded} · 失败 {t.recordsFailed} · 满足的版本目标 {a.slotCounts.satisfied} · 需关注{" "} + {a.slotCounts.attention} +

    +
    + ); +} + +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 ( +
    +
    + {counts.map((c) => ( + + {c.label} {c.v} + + ))} +
    + {idle ? ( + + ) : ( +
      + {w.items.slice(0, 5).map((it) => { + const s = downloadLabel(it.status); + return ( + + + {it.name} + + + {Math.round(it.progress * 100)}% + + {s.label} + + ); + })} + {w.agentRuns.slice(0, 3).map((r) => { + const s = agentRunLabel(r.status); + return ( + + + Agent:{r.goal} + + {s.label} + + ); + })} +
    + )} +
    + ); +} + +function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) { + return ( +
    + {n.total === 0 ? ( + + ) : ( + <> +

    + {n.total} + 项待处理 +

    +
      + {n.byKind.map((k) => ( + + {decisionKindLabel(k.kind)} + {k.count} + + ))} +
    + + )} +
    + ); +} + +function CacheSection({ c }: { c: Dashboard["cache"] }) { + return ( +
    + {c.volumes.length === 0 ? ( + + ) : ( +
      + {c.volumes.map((v) => { + const pct = v.usedPercent; + const over = pct !== null && pct >= v.hardThresholdPercent; + const near = pct !== null && pct >= v.resumePercent; + return ( +
    • +
      + {v.volumeKey} + + {pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`} + +
      +
      +
      + +
      +

      + 阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "} + {formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)} + {v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""} +

      + {v.notes.map((n) => ( +

      + {n} +

      + ))} +
    • + ); + })} +
    + )} +
    + ); +} + +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 ( +
    + {rows.length === 0 ? ( + + ) : ( +
      + {rows.slice(0, 8).map((r) => { + const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled"); + return ( + +
      +

      + {r.kind} + {r.name} +

      + {r.err ?

      {r.err}

      : null} +
      + + {formatRelative(r.at)} + + {h.label} +
      + ); + })} +
    + )} +
    + ); +} + +function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) { + return ( +
    + {items.length === 0 ? ( + + ) : ( +
      + {items.slice(0, 6).map((r) => { + const s = organizationLabel(r.status); + return ( +
    • +
      + + {r.title ?? "未命名"} + + {s.label} +
      +

      + {organizationActionLabel(r.action)} · {r.counts.succeeded}/{r.counts.total} 个文件 ·{" "} + {formatRelative(r.finishedAt)} + {r.targetPath ? ` · ${r.targetPath}` : ""} +

      +
    • + ); + })} +
    + )} +
    + ); +} + +function LiveIndicator() { + const s = useSseStatus(); + const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral"; + const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中"; + return {label}; +} + export default function Home() { + const q = useLiveDashboard(); return ( <> } + actions={ +
    + + {q.data ? ( + + 更新于 {formatRelative(q.data.generatedAt)} + + ) : null} +
    + } /> -
    - {SECTIONS.map((s) => ( -
    -
    - -

    {s.title}

    + {q.isPending ? ( +
    + {["a", "b", "c", "d", "e", "f"].map((k) => ( +
    +
    - -
    - ))} -
    + ))} +
    + ) : q.isError ? ( + q.refetch()} /> + ) : ( + <> + +
    + + + + + + +
    + + )} ); } diff --git a/apps/web/app/routes/console/layout.tsx b/apps/web/app/routes/console/layout.tsx index 54e58f12..7d373315 100644 --- a/apps/web/app/routes/console/layout.tsx +++ b/apps/web/app/routes/console/layout.tsx @@ -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 ( +
    + session.refetch()} /> +
    + ); + } + if (!session.data || unauthenticated) { + return ( +
    + +
    + ); + } + return ; +} + +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() { {activeLabel}
    - +
    diff --git a/apps/web/app/routes/console/settings.tsx b/apps/web/app/routes/console/settings.tsx index 2790885e..0def8c8e 100644 --- a/apps/web/app/routes/console/settings.tsx +++ b/apps/web/app/routes/console/settings.tsx @@ -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: () => }, + { value: "downloaders", label: "下载器", icon: Server, render: () => }, + { value: "media-servers", label: "媒体服务器", icon: Tv, render: () => }, + { value: "library-roots", label: "库根", icon: FolderTree, render: () => }, + { value: "path-mappings", label: "路径映射", icon: Link2, render: () => }, + { value: "site-credentials", label: "站点凭证", icon: UserCog, render: () => }, + { value: "api-keys", label: "API Key", icon: KeyRound, render: () => }, + { value: "pi", label: "Pi 上游", icon: Bot, render: () => }, + { value: "storage", label: "缓存与入库", icon: HardDrive, render: () => }, +] as const; -// 占位:标题与说明取自 components/console/nav.ts export default function SettingsPage() { - return ; + const [params, setParams] = useSearchParams(); + const raw = params.get("tab"); + const tab = TABS.some((t) => t.value === raw) ? (raw as string) : "automation"; + + return ( + <> + + + setParams( + (p) => { + p.set("tab", v); + return p; + }, + { replace: true }, + ) + } + > + + {TABS.map((t) => ( + + + {t.label} + + ))} + + {TABS.map((t) => ( + + {tab === t.value ? t.render() : null} + + ))} + + + ); } diff --git a/apps/web/app/routes/console/settings/instances-tab.tsx b/apps/web/app/routes/console/settings/instances-tab.tsx new file mode 100644 index 00000000..ff2c47ae --- /dev/null +++ b/apps/web/app/routes/console/settings/instances-tab.tsx @@ -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 ( +
    + {h.label} + {at ? {formatRelative(at)} : null} +
    + ); +} + +function Enabled({ on }: { on: boolean }) { + return {on ? "启用" : "停用"}; +} + +/** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */ +function TestButton({ + run, + pending, + label = "测试连接", +}: { + run: () => Promise<{ ok: boolean; message: string | null; version?: string | null }>; + pending: boolean; + label?: string; +}) { + return ( + + ); +} + +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 ( + + hooks={{ list, create, update, remove } as never} + config={{ + noun: "下载器实例", + description: + "qBittorrent WebUI。系统只管理带管理标签的任务;共享同一缓存卷的多个实例填相同的缓存卷标识以合并水位。", + emptyHint: "添加 qBittorrent 后才能提交下载。", + columns: [ + { + key: "name", + header: "名称", + cell: (r) => ( +
    +

    {r.name}

    +

    {r.baseUrl}

    +
    + ), + }, + { + key: "tag", + header: "管理标签", + hideBelow: "md", + cell: (r) => {r.managedTag}, + }, + { key: "vol", header: "缓存卷", hideBelow: "lg", cell: (r) => r.cacheVolumeKey ?? "—" }, + { key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => }, + { key: "health", header: "健康", cell: (r) => }, + ], + rowActions: (r) => 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 ( + + hooks={{ list, create, update, remove } as never} + config={{ + noun: "媒体服务器", + description: "Emby / Jellyfin 只用于查询有无、季集与版本,查不到不等于缺失。", + columns: [ + { + key: "name", + header: "名称", + cell: (r) => ( +
    +

    {r.name}

    +

    {r.baseUrl}

    +
    + ), + }, + { key: "kind", header: "类型", hideBelow: "md", cell: (r) => (r.kind === "emby" ? "Emby" : "Jellyfin") }, + { key: "enabled", header: "状态", hideBelow: "sm", cell: (r) => }, + { key: "health", header: "健康", cell: (r) => }, + ], + rowActions: (r) => 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 ( + + hooks={{ list, create, update, remove } as never} + config={{ + noun: "库根", + description: + "登记或修改路径时会在目录里写入哨兵文件 .uhd-root,用来判断库根是否在线;目录不存在或不可写会被拒绝。", + deleteHint: "只删除登记,不会删除磁盘上的任何文件。", + columns: [ + { + key: "name", + header: "名称", + cell: (r) => ( +
    +

    {r.name}

    +

    {r.localPath}

    +
    + ), + }, + { + 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) => }, + ], + rowActions: (r) => 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 ( + + hooks={{ list, create, update, remove } as never} + config={{ + noun: "路径映射", + description: "把下载器或媒体服务器看到的路径前缀换成本进程可访问的路径。前缀越长越优先,同长按优先级。", + columns: [ + { + key: "map", + header: "映射", + cell: (r) => ( +
    +

    {r.remotePrefix}

    +

    → {r.localPrefix}

    +
    + ), + }, + { + key: "view", + header: "视图", + hideBelow: "md", + cell: (r) => ( + + {r.viewKind === "downloader" ? "下载器" : "媒体服务器"} · {nameOf(r)} + + ), + }, + { key: "prio", header: "优先级", hideBelow: "lg", cell: (r) => r.priority }, + { key: "verify", header: "校验", cell: (r) => }, + ], + rowActions: (r) => ( + { + 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, + }} + /> + ); +} diff --git a/apps/web/app/routes/console/settings/keys-tab.tsx b/apps/web/app/routes/console/settings/keys-tab.tsx new file mode 100644 index 00000000..b8721650 --- /dev/null +++ b/apps/web/app/routes/console/settings/keys-tab.tsx @@ -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({ + siteId: "", + label: "default", + authKind: "cookie", + username: "", + credential: "", + }); + const [errors, setErrors] = useState>({}); + 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 = {}; + 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 ( +
    +
    +

    + 按「站点 + 标签」新建或替换账号凭证。凭证加密保存,提交后不会回显;站点本身在 + + PT 站点工作台 + + 登记。 +

    + setV((s) => ({ ...s, siteId: x }))} + disabled={put.isPending} + /> + setV((s) => ({ ...s, label: x }))} + disabled={put.isPending} + /> + setV((s) => ({ ...s, authKind: x }))} + disabled={put.isPending} + /> + {v.authKind === "password" ? ( + setV((s) => ({ ...s, username: x }))} + disabled={put.isPending} + /> + ) : null} + setV((s) => ({ ...s, credential: x }))} + disabled={put.isPending} + /> +
    + +
    + + + r.id} + loading={accounts.isPending} + error={accounts.error} + onRetry={() => accounts.refetch()} + empty={{ title: "还没有站点账号" }} + columns={[ + { + key: "site", + header: "站点 / 标签", + cell: (r) => ( +
    +

    {siteName(r.siteId)}

    +

    {r.label}

    +
    + ), + }, + { + key: "kind", + header: "类型", + cell: (r) => AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind, + }, + { + key: "status", + header: "状态", + cell: (r) => ( + + {!r.enabled ? "停用" : r.secretRef ? "已录入" : "缺凭证"} + + ), + }, + ]} + /> +
    +
    + ); +} + +// ─── 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 ( + !o && onClose()}> + + + API Key 已创建 + 明文只显示这一次,关闭后无法再查看。请现在复制保存。 + +
    + {plaintext} + +
    + + + +
    +
    + ); +} + +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(["read"]); + const [errors, setErrors] = useState>({}); + const [plaintext, setPlaintext] = useState(null); + const [revoking, setRevoking] = useState(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 ( +
    +
    +

    + 给外部程序(媒体服务器插件、脚本)用的密钥,按权限范围授权。控制台本身走登录会话,不需要 Key。 +

    + +
    + r.id} + loading={keys.isPending} + error={keys.error} + onRetry={() => keys.refetch()} + empty={{ title: "还没有 API Key" }} + columns={[ + { + key: "name", + header: "名称", + cell: (r) => ( +
    +

    {r.name}

    +

    创建于 {formatDateTime(r.createdAt)}

    +
    + ), + }, + { + key: "scopes", + header: "权限", + hideBelow: "sm", + cell: (r) => ( +
    + {r.scopes.map((s) => ( + + {s} + + ))} +
    + ), + }, + { key: "used", header: "最近使用", hideBelow: "md", cell: (r) => formatRelative(r.lastUsedAt) }, + { + key: "status", + header: "状态", + cell: (r) => ( + {r.revokedAt ? "已撤销" : "有效"} + ), + }, + { + key: "act", + header: 操作, + align: "right", + cell: (r) => + r.revokedAt ? null : ( + + ), + }, + ]} + /> + + !create.isPending && setOpen(o)}> + + + 创建 API Key + 创建后明文只显示一次。 + +
    + + setName(e.target.value)} + aria-invalid={!!errors.name} + placeholder="emby-plugin" + /> + + +
    + {SCOPES.map((s) => { + const on = scopes.includes(s.value); + return ( + + ); + })} +
    +
    +
    + + + + +
    +
    + + setPlaintext(null)} /> + + !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; + } + }} + /> +
    + ); +} diff --git a/apps/web/app/routes/console/settings/prefs-tab.tsx b/apps/web/app/routes/console/settings/prefs-tab.tsx new file mode 100644 index 00000000..34a37584 --- /dev/null +++ b/apps/web/app/routes/console/settings/prefs-tab.tsx @@ -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(() => spec.fromSettings(settings)); + const [errors, setErrors] = useState>({}); + 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 ( +
    +

    {spec.title}

    + {spec.description ? ( +

    {spec.description}

    + ) : ( +
    + )} +
    + {spec.fields + .filter((f) => !f.visible || f.visible(values)) + .map((f) => ( + setValues((s) => ({ ...s, [f.name]: v }))} + /> + ))} +
    + {spec.extra?.(settings)} +
    + + {dirty ? ( + + ) : null} +
    + + ); +} + +function useSettingsGate(render: (s: GlobalSettings) => ReactNode) { + const q = useSettings(); + if (q.isPending) return ; + if (q.isError) return 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) => , +}; + +function PiKeyStatus({ set }: { set: boolean }) { + const update = useUpdateSettings(); + return ( +
    + {set ? "上游密钥已保存" : "未设置上游密钥"} + {set ? ( + + ) : null} +
    + ); +} + +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) => ( +
    + + +
    + )); +} + +// ─── 缓存 / 入库 / 保留 / 下载预算 ─── + +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) => ( +
    + + + +
    + )); +} + +// ─── 自动化开关 ─── + +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 ( +
    + + {icon} + + + + {description} + + +
    + ); +} + +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) => ( +
    + : } + tone={s.automation.enabled ? "success" : "warning"} + title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`} + description="关闭后所有自动收录、下载、整理停止;只读查询与手动操作仍可用。" + checked={s.automation.enabled} + disabled={update.isPending} + onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")} + /> + } + tone={s.automation.readOnlyReconcile ? "warning" : "success"} + title={`只读对账模式 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`} + description="从备份恢复后先开启:只核对库存与路径,不做任何自动写入;确认无误后关闭。" + checked={s.automation.readOnlyReconcile} + disabled={update.isPending} + onChange={(v) => set({ readOnlyReconcile: v }, v ? "已进入只读对账模式" : "已退出只读对账模式")} + /> +
    + )); +} diff --git a/apps/web/app/routes/console/settings/resource-section.tsx b/apps/web/app/routes/console/settings/resource-section.tsx new file mode 100644 index 00000000..729a8212 --- /dev/null +++ b/apps/web/app/routes/console/settings/resource-section.tsx @@ -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; + +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 = { + /** 单数名,用于标题与提示:"下载器实例" */ + noun: string; + description?: ReactNode; + columns: DataColumn[]; + 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 = { + list: UseQueryResult>; + create: UseMutationResult; + update: UseMutationResult; + remove: UseMutationResult; +}; + +export function ResourceSection({ + config, + hooks, +}: { + config: ResourceConfig; + hooks: Hooks; +}) { + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + const { list, remove } = hooks; + + const columns: DataColumn[] = [ + ...config.columns, + { + key: "__actions", + header: 操作, + align: "right", + cell: (row) => ( +
    + {config.rowActions?.(row)} + + +
    + ), + }, + ]; + + return ( +
    +
    + {config.description ? ( +

    {config.description}

    + ) : ( + + )} + +
    + r.id} + loading={list.isPending} + error={list.error} + onRetry={() => list.refetch()} + empty={{ title: `还没有${config.noun}`, description: config.emptyHint }} + /> + setEditing(null)} + /> + !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; + } + }} + /> +
    + ); +} + +function ResourceFormDialog({ + config, + hooks, + row, + onClose, +}: { + config: ResourceConfig; + hooks: Hooks; + row: Row | "new" | null; + onClose: () => void; +}) { + const mode = row === "new" ? "create" : "edit"; + const [values, setValues] = useState(() => + row && row !== "new" ? config.fromRow(row) : { ...config.initial }, + ); + const [errors, setErrors] = useState>({}); + 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 ( + !o && !pending && onClose()}> + + + + {mode === "create" ? "添加" : "编辑"} + {config.noun} + + 凭证只写入、加密保存,任何页面都不会回显。 + +
    + {config.fields + .filter((f) => !f.visible || f.visible(values)) + .map((f) => ( + set(f.name, v)} + disabled={pending || (mode === "edit" && !!f.createOnly)} + hint={mode === "edit" && f.editHint ? f.editHint : f.hint} + /> + ))} + + + + + +
    +
    + ); +} + +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 ( +
    + +

    {hint ?? ""}

    +
    + ); + } + let control: ReactNode; + if (spec.kind === "select") { + control = ( + + ); + } else if (spec.kind === "textarea") { + control = ( +