fix(web): visual QA front-end items U4–U6, U13–U16 and copy list

- U4: form save failures toast the first problem and scroll/focus the first
  invalid field (policy draft, settings cards, resource drawers, subscription
  form) via lib/form-errors.
- U5: FormField keeps 8px between a hint and the next label.
- U6: file manager status bar keeps free space left-aligned, clear of the
  Agent button.
- U13: move/copy dialog stops "target is the source folder / inside the
  source" before requesting a preview (transferTargetProblem + tests).
- U14: account popover uses the solid float material.
- U15/U16: inbox tile hint shortened; TMDB assign dialog uses the shared
  Select and labelled fields (type, TMDB ID, note).
- Copy: "TMDB 已保存" spacing, API Key block no longer repeats the section
  title, whole-day intervals read "每 7 天", "最高级别:提示",
  "延后:已有计划在途", failed records show "未生成目标路径", upstream model
  column "默认模型".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:58:38 +08:00
1 parent 2ae780d2a1
commit 082c556a67
18 files changed
+130 -38

No files matched your search

@@ -277,7 +277,7 @@ export function AccountMenu({ session }: { session: SessionInfo }) {
<span className="hidden truncate sm:inline">{actor?.name ?? "账户"}</span> <span className="hidden truncate sm:inline">{actor?.name ?? "账户"}</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-64 p-2"> <PopoverContent align="end" className="pg-float-solid w-64 p-2">
<div className="px-2 pt-1 pb-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="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="mt-1 truncate text-sm font-medium text-foreground">{actor?.name ?? "—"}</p>
@@ -27,7 +27,8 @@ export function FormField({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("grid min-w-0 gap-1.5", className)}> // pb-1:说明文字与下一个字段标签至少隔 8px(外层 gap-1 + 这里 4px;QA U5)
<div className={cn("grid min-w-0 gap-1.5 pb-1", className)}>
<Label htmlFor={htmlFor} className="text-caption font-medium text-foreground"> <Label htmlFor={htmlFor} className="text-caption font-medium text-foreground">
{label} {label}
{required ? <span className="text-destructive">*</span> : null} {required ? <span className="text-destructive">*</span> : null}
@@ -55,6 +55,7 @@ import {
newIdempotencyKey, newIdempotencyKey,
splitCommon, splitCommon,
stemRange, stemRange,
transferTargetProblem,
} from "./model.ts"; } from "./model.ts";
// ─── 共用 ─────────────────────────────────────────────────────── // ─── 共用 ───────────────────────────────────────────────────────
@@ -485,6 +486,11 @@ export function TransferDialog({
const runPreview = (t: FileRef, m: "move" | "copy") => { const runPreview = (t: FileRef, m: "move" | "copy") => {
setPlan(null); setPlan(null);
setErr(null); setErr(null);
const problem = transferTargetProblem(items, t);
if (problem) {
setErr(problem);
return;
}
dry.mutate( dry.mutate(
{ idempotencyKey: `${key}-dry-${m}-${t.root}-${t.path}`, items, target: t, mode: m, dryRun: true }, { idempotencyKey: `${key}-dry-${m}-${t.root}-${t.path}`, items, target: t, mode: m, dryRun: true },
{ onSuccess: setPlan, onError: (e) => setErr(filesErrorText(e, "预览失败")) }, { onSuccess: setPlan, onError: (e) => setErr(filesErrorText(e, "预览失败")) },
@@ -23,6 +23,7 @@ import {
shiftEpisodes, shiftEpisodes,
splitCommon, splitCommon,
stemRange, stemRange,
transferTargetProblem,
} from "./model.ts"; } from "./model.ts";
const order = ["a", "b", "c", "d", "e"]; const order = ["a", "b", "c", "d", "e"];
@@ -257,3 +258,20 @@ describe("演示开关", () => {
expect(resolveFilesMock({ dev: true, search: "?mock=0", storage })).toBe(false); expect(resolveFilesMock({ dev: true, search: "?mock=0", storage })).toBe(false);
}); });
}); });
describe("transferTargetProblem(U13)", () => {
const items = [
{ root: "dl", path: "a/b.mkv" },
{ root: "dl", path: "pack" },
];
it("目标就是源所在目录 / 在源目录之内时拦下", () => {
expect(transferTargetProblem(items, { root: "dl", path: "a" })).toMatch(/已在这个目录里/);
expect(transferTargetProblem(items, { root: "dl", path: "pack/sub" })).toMatch(/目标在源目录之内/);
expect(transferTargetProblem([{ root: "dl", path: "x.mkv" }], { root: "dl", path: "" })).toMatch(/已在/);
});
it("其他目录、其他根放行", () => {
expect(transferTargetProblem(items, { root: "dl", path: "c" })).toBeNull();
expect(transferTargetProblem(items, { root: "lib", path: "a" })).toBeNull();
expect(transferTargetProblem(items, { root: "dl", path: "packs" })).toBeNull();
});
});
+17
View File
@@ -178,6 +178,23 @@ export function parentRel(path: string): string {
return i < 0 ? "" : path.slice(0, i); return i < 0 ? "" : path.slice(0, i);
} }
/**
* 移动 / 复制目标的前端预检(与服务端 transfer 校验同口径,QA U13):目标就是源所在目录,
* 或目标在源目录之内时直接提示,不发预览请求。
*/
export function transferTargetProblem(
items: { root: string; path: string }[],
target: { root: string; path: string },
): string | null {
for (const it of items) {
if (it.root !== target.root) continue;
if (target.path === it.path || target.path.startsWith(`${it.path}/`))
return `目标在源目录之内(${baseName(it.path)}),换一个目录`;
if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`;
}
return null;
}
export function baseName(path: string): string { export function baseName(path: string): string {
const i = path.lastIndexOf("/"); const i = path.lastIndexOf("/");
return i < 0 ? path : path.slice(i + 1); return i < 0 ? path : path.slice(i + 1);
+1 -1
View File
@@ -128,7 +128,7 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
reject: { label: "拒绝", tone: "destructive" }, reject: { label: "拒绝", tone: "destructive" },
unknown_field: { label: "字段未知", tone: "warning" }, unknown_field: { label: "字段未知", tone: "warning" },
skipped: { label: "跳过", tone: "neutral" }, skipped: { label: "跳过", tone: "neutral" },
deferred: { label: "延后(单元在途)", tone: "warning" }, deferred: { label: "延后:已有计划在途", tone: "warning" },
}; };
export const DECISION_KIND: Record<DecisionKind, string> = { export const DECISION_KIND: Record<DecisionKind, string> = {
@@ -30,6 +30,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
import { Switch } from "~/components/ui/switch.tsx"; import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx"; import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { formErrorSummary, revealFirstError } from "~/lib/form-errors.ts";
import { actorText } from "~/lib/labels.ts"; import { actorText } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts"; import { cn, formatDateTime } from "~/lib/utils.ts";
import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts"; import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts";
@@ -633,7 +634,8 @@ function PolicyEditorSheet({
open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)), open: t.open || Object.keys(r.errors).some((k) => k.startsWith(`${t.key}.`)),
})), })),
})); }));
toast.error("表单有错误"); toast.error(formErrorSummary(r.errors));
revealFirstError();
return; return;
} }
setErrors({}); setErrors({});
@@ -55,6 +55,8 @@ export function describeSchedule(s: ScheduleSpec): string {
if (s.type === "cron") return `cron ${s.cron}`; if (s.type === "cron") return `cron ${s.cron}`;
if (s.everyMs < 1000) return `每 ${s.everyMs} 毫秒`; if (s.everyMs < 1000) return `每 ${s.everyMs} 毫秒`;
const { value, unit } = splitInterval(s.everyMs); const { value, unit } = splitInterval(s.everyMs);
// 整天的间隔按天说(每 168 小时 → 每 7 天)
if (unit === "h" && value >= 24 && value % 24 === 0) return `每 ${value / 24} 天`;
return `每 ${value} ${UNIT_LABEL[unit]}`; return `每 ${value} ${UNIT_LABEL[unit]}`;
} }
@@ -104,8 +104,8 @@ export function ApiKeysSection() {
<SettingsBlock <SettingsBlock
section="api-keys" section="api-keys"
anchor="keys" anchor="keys"
title="API Key" title="已签发的 Key"
description="签发给外部程序(媒体服务器插件、脚本)的 Key,按权限范围授权。本机 Token 在「账户与安全」。" description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
plain plain
actions={ actions={
<Button size="sm" onClick={() => setOpen(true)}> <Button size="sm" onClick={() => setOpen(true)}>
@@ -84,7 +84,7 @@ export function PiUpstreamsBlock() {
key: "model", key: "model",
header: "模型", header: "模型",
hideBelow: "sm", hideBelow: "sm",
cell: (r) => <code className="text-meta break-all">{r.defaultModel ?? "provider 默认"}</code>, cell: (r) => <code className="text-meta break-all">{r.defaultModel ?? "默认模型"}</code>,
}, },
{ key: "status", header: "状态", cell: (r) => <UpstreamStatusBadge u={r} /> }, { key: "status", header: "状态", cell: (r) => <UpstreamStatusBadge u={r} /> },
]} ]}
@@ -159,7 +159,7 @@ function UpstreamDrawer({
try { try {
await update.mutateAsync({ id: upstream.id, body: parsed.data }); await update.mutateAsync({ id: upstream.id, body: parsed.data });
setV((s) => ({ ...s, apiKey: "" })); setV((s) => ({ ...s, apiKey: "" }));
toast.success(`${v.name.trim()}已保存`); toast.success(`${v.name.trim()} 已保存`);
} catch (err) { } catch (err) {
toast.error(errorMessage(err, "保存失败")); toast.error(errorMessage(err, "保存失败"));
} }
@@ -14,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
import { Switch } from "~/components/ui/switch.tsx"; import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx"; import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。 // 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。 // 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
@@ -193,6 +194,7 @@ function ResourceFormDrawer<Row extends { id: string }>({
fe[config.fields[0]?.name ?? ""] ??= fe[""]; fe[config.fields[0]?.name ?? ""] ??= fe[""];
} }
setErrors(fe); setErrors(fe);
revealFirstError();
return; return;
} }
setErrors({}); setErrors({});
@@ -12,6 +12,7 @@ import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { zodFieldErrors } from "~/components/console/form-field.tsx"; import { zodFieldErrors } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { FieldControl, type FieldSpec, type FormValues } from "./resource-section.tsx"; import { FieldControl, type FieldSpec, type FormValues } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx"; import { SettingsBlock } from "./settings-block.tsx";
@@ -56,13 +57,14 @@ export function SettingsCard({
fe[spec.fields.find((f) => k.startsWith(f.name.split(".")[0] ?? ""))?.name ?? k] ??= m; fe[spec.fields.find((f) => k.startsWith(f.name.split(".")[0] ?? ""))?.name ?? k] ??= m;
} }
setErrors(fe); setErrors(fe);
revealFirstError();
return; return;
} }
setErrors({}); setErrors({});
try { try {
const next = await update.mutateAsync(patch); const next = await update.mutateAsync(patch);
setValues(spec.fromSettings(next)); setValues(spec.fromSettings(next));
toast.success(`${spec.title}已保存`); toast.success(`${spec.title} 已保存`);
} catch (err) { } catch (err) {
toast.error(errorMessage(err, "保存失败")); toast.error(errorMessage(err, "保存失败"));
} }
@@ -44,6 +44,7 @@ import { Switch } from "~/components/ui/switch.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { Textarea } from "~/components/ui/textarea.tsx"; import { Textarea } from "~/components/ui/textarea.tsx";
import { ApiRequestError, errorMessage } from "~/lib/api.ts"; import { ApiRequestError, errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
import { import {
AUTOFILL_LABEL, AUTOFILL_LABEL,
@@ -340,6 +341,7 @@ export function SubscriptionFormDialog({
if (Object.keys(all).length) { if (Object.keys(all).length) {
const basicKeys = ["target", "episodeStart", "episodeEnd", "total"]; const basicKeys = ["target", "episodeStart", "episodeEnd", "total"];
setTab(Object.keys(all).some((k) => basicKeys.includes(k)) ? "basic" : "advanced"); setTab(Object.keys(all).some((k) => basicKeys.includes(k)) ? "basic" : "advanced");
revealFirstError();
return; return;
} }
setDup(null); setDup(null);
@@ -24,7 +24,7 @@ const DECISION_LABEL = {
reject: { label: "不收录", tone: "destructive" }, reject: { label: "不收录", tone: "destructive" },
unknown_field: { label: "缺字段,待复核", tone: "warning" }, unknown_field: { label: "缺字段,待复核", tone: "warning" },
skipped: { label: "被跳过", tone: "neutral" }, skipped: { label: "被跳过", tone: "neutral" },
deferred: { label: "延后(单元在途)", tone: "warning" }, deferred: { label: "延后:已有计划在途", tone: "warning" },
} as const; } as const;
/** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */ /** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */
+23
View File
@@ -0,0 +1,23 @@
// 表单级错误定位(QA U4):保存失败时滚到第一个出错的字段并聚焦,长表单里不用自己找。
/** 下一帧(错误已渲染)后,在 root 里找第一个 aria-invalid 的控件或 role=alert 的提示,滚到视口中间并聚焦 */
export function revealFirstError(root?: HTMLElement | null) {
if (typeof window === "undefined") return;
requestAnimationFrame(() =>
requestAnimationFrame(() => {
const scope: ParentNode = root ?? document;
const el =
scope.querySelector<HTMLElement>('[aria-invalid="true"]') ?? scope.querySelector<HTMLElement>('[role="alert"]');
if (!el) return;
el.scrollIntoView({ block: "center", behavior: "smooth" });
if (el.matches("input, textarea, select, button, [tabindex]")) el.focus({ preventScroll: true });
}),
);
}
/** toast 用的一句话:"有 3 处需要修改:填写模板名称" */
export function formErrorSummary(errors: Record<string, string>): string {
const msgs = Object.values(errors).filter(Boolean);
if (msgs.length === 0) return "表单有错误";
return msgs.length === 1 ? `需要修改:${msgs[0]}` : `有 ${msgs.length} 处需要修改:${msgs[0]}`;
}
+2 -1
View File
@@ -1088,7 +1088,8 @@ export default function FilesPage() {
{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""} {totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
</span> </span>
) : null} ) : null}
{root.usage ? <span className="ml-auto">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null} {/* 靠左排:右下角是 Agent 悬浮按钮(QA U6) */}
{root.usage ? <span>剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable && root.kind === "library" ? <StatusBadge>只读</StatusBadge> : null} {!root.writable && root.kind === "library" ? <StatusBadge>只读</StatusBadge> : null}
</div> </div>
) : null} ) : null}
+33 -22
View File
@@ -1,13 +1,14 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts"; import type { Decision, DecisionKind, DecisionOption, DecisionStatus } from "@umt/contracts";
import { BellOff, Bot, CheckCheck, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react"; import { BellOff, Bot, CheckCheck, ChevronRight, Inbox, Radio, RotateCw } from "lucide-react";
import { type ReactNode, useMemo, useRef, useState } from "react"; import { type ReactNode, useId, useMemo, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router"; import { Link, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { decisionKeys, useDecisionSummary, useDecisions, useResolveDecision } from "~/api/decisions.ts"; import { decisionKeys, useDecisionSummary, useDecisions, useResolveDecision } from "~/api/decisions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { JsonView } from "~/components/console/json-view.tsx"; import { JsonView } from "~/components/console/json-view.tsx";
import { consolePath } from "~/components/console/nav.ts"; import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
@@ -18,6 +19,7 @@ import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/lib
import { Chip, relTime, Tone } from "~/components/library/parts.tsx"; import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx"; import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx"; import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { actorText } from "~/lib/labels.ts"; import { actorText } from "~/lib/labels.ts";
@@ -111,6 +113,7 @@ function requiresOf(option: DecisionOption): Requires {
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) { function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
const resolve = useResolveDecision(); const resolve = useResolveDecision();
const uid = useId();
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const requires = requiresOf(option); const requires = requiresOf(option);
const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie"); const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie");
@@ -161,28 +164,36 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
} }
> >
{requires === "tmdb" ? ( {requires === "tmdb" ? (
<div className="flex gap-2"> <div className="grid gap-x-2 sm:grid-cols-[8rem_minmax(0,1fr)]">
<select <FormField label="类型" htmlFor={`${uid}-type`}>
aria-label="TMDB 类型" <Select value={tmdbType} onValueChange={(v) => setTmdbType(v as "movie" | "tv")}>
value={tmdbType} <SelectTrigger id={`${uid}-type`} className="w-full">
onChange={(e) => setTmdbType(e.target.value as "movie" | "tv")} <SelectValue />
className="h-9 shrink-0 rounded-lg border border-input bg-transparent px-2 text-caption" </SelectTrigger>
> <SelectContent>
<option value="movie">电影</option> <SelectItem value="movie">电影</SelectItem>
<option value="tv">剧集</option> <SelectItem value="tv">剧集</SelectItem>
</select> </SelectContent>
<Input </Select>
aria-label="TMDB ID" </FormField>
inputMode="numeric" <FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
value={tmdbId} <Input
onChange={(e) => setTmdbId(e.target.value)} id={`${uid}-tmdb`}
placeholder="TMDB ID,如 872585" inputMode="numeric"
/> value={tmdbId}
onChange={(e) => setTmdbId(e.target.value)}
aria-invalid={tmdbId.trim() !== "" && !tmdbOk}
/>
</FormField>
</div> </div>
) : requires === "mediaEntityId" ? ( ) : requires === "mediaEntityId" ? (
<EntityPicker value={entity} onChange={setEntity} /> <FormField label="本地作品" required>
<EntityPicker value={entity} onChange={setEntity} />
</FormField>
) : null} ) : null}
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="备注(可选)" rows={2} /> <FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
</FormField>
</ConfirmDialog> </ConfirmDialog>
); );
} }
@@ -358,7 +369,7 @@ export default function InboxPage() {
<StatTile <StatTile
label={isOpenView ? "未关闭" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")} label={isOpenView ? "未关闭" : (DECISION_STATUS[status as DecisionStatus]?.label ?? "全部")}
value={summary.isPending ? "…" : (summary.data?.total ?? 0)} value={summary.isPending ? "…" : (summary.data?.total ?? 0)}
hint={isOpenView ? "待处理 + 调查中 + 等你决定" : undefined} hint={isOpenView ? "含调查中、等你决定" : undefined}
icon={Inbox} icon={Inbox}
active={!kind} active={!kind}
onClick={() => set("kind", "")} onClick={() => set("kind", "")}
@@ -368,7 +379,7 @@ export default function InboxPage() {
key={k} key={k}
label={DECISION_KIND[k]} label={DECISION_KIND[k]}
value={v.count} value={v.count}
hint={`最高 ${SEVERITY[v.sev].label}`} hint={`最高级别:${SEVERITY[v.sev].label}`}
tone={SEVERITY[v.sev].tone} tone={SEVERITY[v.sev].tone}
active={kind === k} active={kind === k}
onClick={() => set("kind", kind === k ? "" : k)} onClick={() => set("kind", kind === k ? "" : k)}
+10 -5
View File
@@ -47,13 +47,18 @@ function reasonText(r: { code: string; message: string }) {
return r.code && r.code !== r.message && /^[a-z0-9_.-]+$/i.test(r.code) ? `${r.message}(${r.code})` : r.message; return r.code && r.code !== r.message && /^[a-z0-9_.-]+$/i.test(r.code) ? `${r.message}(${r.code})` : r.message;
} }
function PathBlock({ label, p }: { label: string; p: PublicPathRef }) { /** failed:失败且没有目标路径时直接说"未生成目标路径",不再显示视角与"未知或未授权" */
function PathBlock({ label, p, failed = false }: { label: string; p: PublicPathRef; failed?: boolean }) {
const noTarget = failed && p.path === null;
return ( return (
<div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2"> <div className="min-w-0 flex-1 rounded-lg border border-border/70 px-3 py-2">
<p className="text-meta text-muted-foreground"> <p className="text-meta text-muted-foreground">
{label} · {VIEW_LABEL[p.view]} {label}
{noTarget ? "" : ` · ${VIEW_LABEL[p.view]}`}
</p>
<p className="mt-0.5 font-mono text-caption break-all text-foreground">
{p.path ?? (noTarget ? "未生成目标路径" : "未知或未授权")}
</p> </p>
<p className="mt-0.5 font-mono text-caption break-all text-foreground">{p.path ?? "未知或未授权"}</p>
</div> </div>
); );
} }
@@ -98,7 +103,7 @@ function FileRow({ f }: { f: PublicOrganizationFile }) {
<div className="flex flex-col gap-2 md:flex-row md:items-center"> <div className="flex flex-col gap-2 md:flex-row md:items-center">
<PathBlock label="整理前" p={f.source} /> <PathBlock label="整理前" p={f.source} />
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" /> <ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
<PathBlock label="整理后" p={f.target} /> <PathBlock label="整理后" p={f.target} failed={f.status === "failed"} />
</div> </div>
{reason ? ( {reason ? (
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}> <p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
@@ -196,7 +201,7 @@ export default function OrganizeRecordPage() {
<div className="flex flex-col gap-2 md:flex-row md:items-center"> <div className="flex flex-col gap-2 md:flex-row md:items-center">
<PathBlock label="整理前" p={r.source} /> <PathBlock label="整理前" p={r.source} />
<ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" /> <ArrowRight className="hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
<PathBlock label="整理后" p={r.target} /> <PathBlock label="整理后" p={r.target} failed={r.status === "failed"} />
</div> </div>
<KvList <KvList
items={[ items={[