merge: fe3-web (FE-3 rules page, naming form, global search) into main

Conflicts resolved: routes.ts keeps both /console/status (FE-1) and
/console/search (FE-3); smoke list keeps FE-1/FE-4/FE-5 routes plus the
rules tabs, search results and preferences→rules redirects. Top bar mounts
SearchTrigger and CommandPalette next to the FE-1 inbox bell.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:39:13 +08:00
commit 04401309fb
31 files changed
+5049 -429

No files matched your search

+4
View File
@@ -10,6 +10,8 @@ import { ErrorState } from "~/components/console/empty-state.tsx";
import { InboxBell } from "~/components/console/inbox-bell.tsx";
import { findNavItem, NAV_GROUPS, type NavGroup, pageTitle } from "~/components/console/nav.ts";
import PrismLeak from "~/components/prism-leak.tsx";
import { CommandPalette } from "~/components/search/command-palette.tsx";
import { SearchTrigger } from "~/components/search/search-trigger.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";
@@ -233,6 +235,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
</div>
<div className="ml-auto flex items-center gap-2">
<SearchTrigger />
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */}
<InboxBell />
<AgentDrawerTrigger className="xl:hidden" />
@@ -249,6 +252,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
</main>
<AgentDrawer />
<AgentLauncher />
<CommandPalette />
</div>
</div>
);
@@ -53,6 +53,7 @@ import {
} from "~/components/library/labels.ts";
import { Chip, ChipList, CoverPlaceholder, Panel, ProgressLine, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx";
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -644,6 +645,7 @@ export default function LibraryDetailPage() {
合并作品
</Button>
) : null}
<EntityNamingButton entityId={e.id} kind={e.kind} />
</div>
</header>
+12 -408
View File
@@ -1,413 +1,17 @@
import type { PreferenceKind, PreferenceSuppression, PreferenceTag } from "@umt/contracts";
import { Ban, History, ListChecks, Plus, ShieldOff, Tags } from "lucide-react";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
useDeleteTag,
useLiftSuppression,
usePreferenceTags,
useSuppressions,
useTagRevisions,
useUpdateTag,
} from "~/api/preferences.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.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 { 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 { MEDIA_KIND, opts, PREF_KIND, PREF_ORIGIN, PREF_STATUS } from "~/components/library/labels.ts";
import { Chip, Panel, relTime, Tone } from "~/components/library/parts.tsx";
import { PreferenceFormDialog } from "~/components/library/preferences-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { redirect } from "react-router";
// 偏好标签(REQ §6.7):一个偏好一个标签;停用/删除/改语义留抑制记录,Agent 不据同一旧证据自动加回。
// 偏好标签已并入规则页(docs/design/subscriptions.md §6):单条规则以标签形态显示在“规则组”Tab。
// 旧链接带 origin 筛选时透传,规则页按来源筛选。
const SUP_REASON: Record<string, string> = {
deleted: "删除",
"status:disabled": "停用",
"status:deleted": "删除",
edited: "修改语义",
};
const supReason = (r: string | null) => (r ? (SUP_REASON[r] ?? r) : "未注明");
const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—");
/** scope 摘要:{} = 全局;mediaKinds 翻中文;其余键原样 */
function scopeSummary(scope: Record<string, unknown>): string {
const keys = Object.keys(scope);
if (keys.length === 0) return "全局";
const parts: string[] = [];
for (const k of keys) {
const v = scope[k];
if (k === "mediaKinds" && Array.isArray(v))
parts.push(v.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? String(x)).join("、"));
else parts.push(`${k}: ${Array.isArray(v) ? v.join("、") : typeof v === "object" ? "…" : String(v)}`);
}
return parts.join(" · ");
export function clientLoader({ request }: { request: Request }) {
const from = new URL(request.url).searchParams;
const to = new URLSearchParams();
const origin = from.get("origin");
if (origin) to.set("origin", origin);
const qs = to.toString();
return redirect(`/console/rules${qs ? `?${qs}` : ""}`);
}
function TagCard({ tag, onOpen }: { tag: PreferenceTag; onOpen: () => void }) {
const inactive = tag.status !== "active";
return (
<button
type="button"
onClick={onOpen}
className={cn(
"min-w-0 cursor-pointer rounded-xl border border-border/80 bg-foreground/[0.02] p-3 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
inactive && "opacity-70",
)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("min-w-0 font-medium break-words text-foreground", inactive && "line-through")}>
{tag.label}
</span>
<Tone v={PREF_STATUS[tag.status]} className="shrink-0" />
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip>{PREF_KIND[tag.kind].label}</Chip>
<Chip>{scopeSummary(tag.scope)}</Chip>
<Chip>{PREF_ORIGIN[tag.origin]}</Chip>
</div>
{tag.description ? (
<p className="mt-2 line-clamp-2 text-meta break-words text-muted-foreground">{tag.description}</p>
) : null}
<p className="mt-2 text-micro text-muted-foreground tabular-nums">
r{tag.revision} · 更新于 {relTime(tag.updatedAt)}
</p>
</button>
);
}
export default function PreferencesPage() {
const [status, setStatus] = useState("");
const [kind, setKind] = useState("");
const tags = usePreferenceTags({
status: (status || undefined) as "active" | "disabled" | undefined,
kind: (kind || undefined) as PreferenceKind | undefined,
});
const deleted = usePreferenceTags({ status: "deleted" });
const allTags = usePreferenceTags({});
const sups = useSuppressions();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [form, setForm] = useState<{ open: boolean; tag: PreferenceTag | null }>({ open: false, tag: null });
const update = useUpdateTag();
const del = useDeleteTag();
const lift = useLiftSuppression();
const tagById = useMemo(() => {
const m = new Map<string, PreferenceTag>();
for (const t of [...(allTags.data?.items ?? []), ...(deleted.data?.items ?? [])]) m.set(t.id, t);
return m;
}, [allTags.data, deleted.data]);
const selected = selectedId ? (tagById.get(selectedId) ?? tags.data?.items.find((t) => t.id === selectedId)) : null;
const toggle = async (t: PreferenceTag) => {
const next = t.status === "active" ? "disabled" : "active";
try {
await update.mutateAsync({ id: t.id, status: next });
toast.success(next === "active" ? "已启用,抑制已解除" : "已停用");
} catch (err) {
toast.error(errorMessage(err));
}
};
const history = useMemo<TimelineItem[]>(() => {
const items: (TimelineItem & { ts: number })[] = [];
for (const s of sups.data?.items ?? []) {
const label = s.preferenceTagId ? (tagById.get(s.preferenceTagId)?.label ?? "未知标签") : "未关联标签";
items.push({
id: `sup-${s.id}`,
at: s.createdAt,
ts: Date.parse(s.createdAt),
tone: "warning",
title: `「${label}」加入抑制(${supReason(s.reason)})`,
meta: actorName(s.suppressedBy),
});
if (s.liftedAt)
items.push({
id: `lift-${s.id}`,
at: s.liftedAt,
ts: Date.parse(s.liftedAt),
tone: "success",
title: `「${label}」的抑制已解除`,
meta: actorName(s.liftedBy),
});
}
for (const t of deleted.data?.items ?? []) {
if ((sups.data?.items ?? []).some((s) => s.preferenceTagId === t.id && s.reason === "deleted")) continue;
items.push({
id: `del-${t.id}`,
at: t.updatedAt,
ts: Date.parse(t.updatedAt),
tone: "neutral",
title: `「${t.label}」已删除`,
});
}
return items.sort((a, b) => b.ts - a.ts).slice(0, 30);
}, [sups.data, deleted.data, tagById]);
const activeSups = (sups.data?.items ?? []).filter((s) => s.active);
const filtered = !!status || !!kind;
return (
<>
<PageHeader
title="偏好标签"
description="软偏好影响排序,硬规则直接排除。手动取消的偏好 Agent 不会再加回。"
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("rules")}>
<ListChecks />
收录策略
</Link>
</Button>
<Button onClick={() => setForm({ open: true, tag: null })}>
<Plus />
添加偏好
</Button>
</>
}
/>
<FilterBar
onReset={
filtered
? () => {
setStatus("");
setKind("");
}
: undefined
}
>
<FilterSelect
label="状态"
value={status}
onChange={setStatus}
allLabel="未删除"
options={[
{ value: "active", label: "生效中" },
{ value: "disabled", label: "已停用" },
]}
/>
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(PREF_KIND)} />
</FilterBar>
<div className="space-y-6">
{tags.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={3} />
</div>
) : tags.isError ? (
<ErrorState error={tags.error} onRetry={() => tags.refetch()} />
) : tags.data.items.length === 0 ? (
<EmptyState
icon={Tags}
title={filtered ? "没有符合条件的偏好" : "还没有偏好标签"}
description="点「添加偏好」,或在 Agent 会话里说明长期偏好。"
/>
) : (
<Panel title="偏好" description={`${tags.data.items.length} 个标签`}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tags.data.items.map((t) => (
<TagCard key={t.id} tag={t} onOpen={() => setSelectedId(t.id)} />
))}
</div>
</Panel>
)}
<Panel title="抑制记录" description={`${activeSups.length} 条生效中`} bodyClassName="space-y-3">
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-foreground">
<ShieldOff className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p>停用、删除或修改语义的偏好会留下抑制记录,Agent 不会按旧证据加回。重新启用、重新添加或解除抑制后恢复。</p>
</div>
{sups.isPending ? (
<LoadingState rows={2} />
) : sups.isError ? (
<ErrorState plain error={sups.error} onRetry={() => sups.refetch()} />
) : sups.data.items.length === 0 ? (
<EmptyState plain icon={Ban} title="没有抑制记录" className="py-8" />
) : (
<ul className="divide-y divide-border/60">
{sups.data.items.map((s) => (
<SuppressionRow
key={s.id}
s={s}
label={s.preferenceTagId ? tagById.get(s.preferenceTagId)?.label : undefined}
onLift={async () => {
try {
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
toast.success("已解除抑制");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
))}
</ul>
)}
</Panel>
<Panel title="历史" description="停用、删除、解除抑制(新 → 旧)">
{sups.isPending || deleted.isPending ? (
<LoadingState rows={2} />
) : history.length === 0 ? (
<EmptyState plain icon={History} title="还没有变更历史" className="py-8" />
) : (
<Timeline items={history} />
)}
</Panel>
</div>
<DetailSheet
open={!!selected}
onOpenChange={(o) => !o && setSelectedId(null)}
title={selected?.label ?? ""}
description={selected ? `${PREF_KIND[selected.kind].label} · ${PREF_KIND[selected.kind].hint}` : undefined}
footer={
selected && selected.status !== "deleted" ? (
<>
<ConfirmDialog
trigger={<Button variant="destructive">删除</Button>}
title={`删除「${selected.label}」?`}
description="删除后留下抑制记录,Agent 不会按旧证据加回。"
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(selected.id);
toast.success("已删除");
setSelectedId(null);
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
<Button variant="outline" onClick={() => toggle(selected)} disabled={update.isPending}>
{selected.status === "active" ? "停用" : "重新启用"}
</Button>
<Button onClick={() => setForm({ open: true, tag: selected })}>编辑</Button>
</>
) : undefined
}
>
{selected ? (
<>
<KvList
items={[
{ label: "状态", value: <Tone v={PREF_STATUS[selected.status]} /> },
{ label: "来源", value: PREF_ORIGIN[selected.origin] },
{ label: "作用范围", value: scopeSummary(selected.scope) },
{ label: "修订", value: `r${selected.revision}` },
{ label: "创建", value: formatDateTime(selected.createdAt) },
{ label: "更新", value: formatDateTime(selected.updatedAt) },
{ label: "说明", value: selected.description, wide: true },
{ label: "语义指纹", value: selected.fingerprint, mono: true, wide: true },
]}
/>
<DetailSection title="规则">
<JsonView value={selected.rule} />
</DetailSection>
<DetailSection title="作用范围">
<JsonView value={selected.scope} />
</DetailSection>
<DetailSection title="证据">
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
</DetailSection>
<DetailSection title="修订历史">
<TagRevisions id={selected.id} />
</DetailSection>
</>
) : null}
</DetailSheet>
<PreferenceFormDialog open={form.open} tag={form.tag} onOpenChange={(o) => setForm((f) => ({ ...f, open: o }))} />
</>
);
}
const REV_ACTION = { created: "创建", updated: "修改", deleted: "删除" } as const;
const FIELD_LABEL: Record<string, string> = {
label: "名称",
kind: "类型",
scope: "作用范围",
rule: "规则",
description: "说明",
evidence: "证据",
status: "状态",
};
function TagRevisions({ id }: { id: string }) {
const q = useTagRevisions(id);
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.data.length === 0)
return <p className="text-caption text-muted-foreground">没有修订记录。W4 之前创建的标签没有历史。</p>;
return (
<ul className="space-y-2">
{q.data.map((r) => (
<li key={r.id} className="rounded-lg border border-border/70 px-3 py-2 text-caption">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{REV_ACTION[r.action]}</span>
{r.revision ? <span className="text-muted-foreground tabular-nums">r{r.revision}</span> : null}
<span className="text-muted-foreground">{r.actorType === "api_key" ? "API Key" : "用户"}</span>
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{formatDateTime(r.at)}</span>
</div>
<p className="mt-1 text-meta text-muted-foreground">
{r.changedFields.length
? `改动:${r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}`
: `名称:${r.snapshot.label} · 状态:${r.snapshot.status}`}
</p>
</li>
))}
</ul>
);
}
function SuppressionRow({
s,
label,
onLift,
}: {
s: PreferenceSuppression;
label: string | undefined;
onLift: () => Promise<void>;
}) {
return (
<li className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words text-foreground">{label ?? "未关联标签"}</span>
<Chip>{supReason(s.reason)}</Chip>
{s.active ? <StatusBadge tone="warning">抑制中</StatusBadge> : <StatusBadge>已解除</StatusBadge>}
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)}
{s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}
</p>
</div>
{s.active ? (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
解除抑制
</Button>
}
title="解除这条抑制?"
description="解除后,Agent 可按证据再次提出这个偏好,仍需授权。标签不会恢复。"
confirmLabel="解除"
onConfirm={onLift}
/>
) : null}
</li>
);
export default function PreferencesRedirect() {
return null;
}
+180 -19
View File
@@ -1,38 +1,199 @@
import { FilePlus2, Tags } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { consolePath } from "~/components/console/nav.ts";
import type { RuleGroup, RuleGroupKind, RuleGroupSource } from "@umt/contracts";
import { FilePlus2, Plus } from "lucide-react";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useRuleGroups, useRuleRefs } from "~/api/rules.ts";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { PolicyWorkspace } from "~/components/library/subscriptions-policy.tsx";
import { RuleGroupEditor } from "~/components/rules/group-editor.tsx";
import { RuleGroupTable, RuleTags } from "~/components/rules/group-list.tsx";
import { isTagLike, RULE_KIND, RULE_SOURCE } from "~/components/rules/labels.ts";
import { AtomsTab, DefaultsTab, SuppressionsTab } from "~/components/rules/tabs.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
// 收录策略(REQ §6.5):草案 → 预览 → 发布;偏好标签在 /console/preferences,排序时叠加生效。
// 规则(docs/design/subscriptions.md §6):收录策略 + 偏好标签合一。
// Tab:规则组(含单条规则的标签形态)/ 自定义条件 / 默认值 / 收录策略 / 抑制记录。
export const meta = () => [{ title: "收录策略 · UHD 媒体自动化控制台" }];
export const meta = () => [{ title: "规则 · UHD 媒体自动化控制台" }];
const TABS = ["groups", "atoms", "defaults", "policies", "suppressions"] as const;
type Tab = (typeof TABS)[number];
/** 旧 /preferences?origin= 透传过来的来源 */
const ORIGIN_TO_SOURCE: Record<string, RuleGroupSource> = {
manual: "manual",
conversation: "chat",
agent_inferred: "agent",
};
export default function RulesPage() {
const [creating, setCreating] = useState(false);
const [params, setParams] = useSearchParams();
const tabParam = params.get("tab") ?? "groups";
const tab: Tab = (TABS as readonly string[]).includes(tabParam) ? (tabParam as Tab) : "groups";
const setParam = (k: string, v: string) =>
setParams(
(p) => {
if (v) p.set(k, v);
else p.delete(k);
return p;
},
{ replace: true },
);
const kind = params.get("kind") ?? "";
const source = params.get("source") ?? ORIGIN_TO_SOURCE[params.get("origin") ?? ""] ?? "";
const enabled = params.get("enabled") ?? "";
const [q, setQ] = useState(params.get("q") ?? "");
const filtered = !!(kind || source || enabled || q.trim());
const groups = useRuleGroups({
kind: (kind || undefined) as RuleGroupKind | undefined,
source: (source || undefined) as RuleGroupSource | undefined,
enabled: (enabled || undefined) as "true" | "false" | undefined,
q: q.trim() || undefined,
});
const all = useRuleGroups({});
const refs = useRuleRefs();
const [editor, setEditor] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const [creatingPolicy, setCreatingPolicy] = useState(false);
const items = groups.data?.items;
const tags = useMemo(() => (items ?? []).filter(isTagLike), [items]);
const rest = useMemo(() => items?.filter((g) => !isTagLike(g)), [items]);
const editing: RuleGroup | null = editor.id
? ((items ?? []).find((g) => g.id === editor.id) ?? all.data?.items.find((g) => g.id === editor.id) ?? null)
: null;
const open = (g: RuleGroup) => setEditor({ open: true, id: g.id });
const create = () => setEditor({ open: true, id: null });
return (
<>
<PageHeader
title="收录策略"
description="草案发布后才生效。回滚会用旧版本内容发布一个新版本。"
title="规则"
description="规则组决定哪些资源能下载、同一作品优先挑哪个。收录策略决定哪些新作品自动收录。"
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("preferences")}>
<Tags />
偏好标签
</Link>
</Button>
<Button onClick={() => setCreating(true)}>
tab === "policies" ? (
<Button onClick={() => setCreatingPolicy(true)}>
<FilePlus2 />
新建策略草案
</Button>
</>
) : (
<Button onClick={create}>
<Plus />
新建规则组
</Button>
)
}
/>
<PolicyWorkspace creating={creating} onCreatingChange={setCreating} />
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
if (v === "groups") p.delete("tab");
else p.set("tab", v);
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="规则分节">
<TabsTrigger value="groups">规则组</TabsTrigger>
<TabsTrigger value="atoms">自定义条件</TabsTrigger>
<TabsTrigger value="defaults">默认值</TabsTrigger>
<TabsTrigger value="policies">收录策略</TabsTrigger>
<TabsTrigger value="suppressions">抑制记录</TabsTrigger>
</TabsList>
<TabsContent value="groups">
<FilterBar
search={{
value: q,
onChange: (v) => {
setQ(v);
setParam("q", v);
},
placeholder: "搜索名称",
}}
onReset={
filtered
? () => {
setQ("");
setParams(new URLSearchParams(), { replace: true });
}
: undefined
}
>
<FilterSelect
label="类型"
value={kind}
onChange={(v) => setParam("kind", v)}
options={(Object.keys(RULE_KIND) as RuleGroupKind[]).map((k) => ({
value: k,
label: RULE_KIND[k].label,
}))}
/>
<FilterSelect
label="来源"
value={source}
onChange={(v) => {
setParam("origin", "");
setParam("source", v);
}}
options={(Object.keys(RULE_SOURCE) as RuleGroupSource[]).map((k) => ({
value: k,
label: RULE_SOURCE[k],
}))}
/>
<FilterSelect
label="状态"
value={enabled}
onChange={(v) => setParam("enabled", v)}
options={[
{ value: "true", label: "启用" },
{ value: "false", label: "停用" },
]}
/>
</FilterBar>
<div className="space-y-6">
{groups.isSuccess && (tags.length > 0 || !filtered) ? <RuleTags rows={tags} onOpen={open} /> : null}
<RuleGroupTable
rows={rest}
loading={groups.isPending}
error={groups.error}
onRetry={() => groups.refetch()}
refs={refs.data}
filtered={filtered}
onOpen={open}
onCreate={create}
/>
</div>
</TabsContent>
<TabsContent value="atoms">
<AtomsTab />
</TabsContent>
<TabsContent value="defaults">
<DefaultsTab groups={all.data?.items} />
</TabsContent>
<TabsContent value="policies">
<PolicyWorkspace creating={creatingPolicy} onCreatingChange={setCreatingPolicy} />
</TabsContent>
<TabsContent value="suppressions">
<SuppressionsTab />
</TabsContent>
</Tabs>
<RuleGroupEditor
open={editor.open}
group={editing}
defaultKind={(kind || "acquisition") as RuleGroupKind}
onOpenChange={(o) => setEditor((e) => ({ ...e, open: o }))}
/>
</>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { Search } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { EmptyState } from "~/components/console/empty-state.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { RELEASE_FILTER_KEYS, type ReleaseFilterState, ReleaseResults } from "~/components/search/release-results.tsx";
import { ActionsSection, MediaSection } from "~/components/search/search-sections.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
// 全局搜索结果页(W9-G):/console/search?q= 三段 —— 作品 / 资源 / 操作;q 与资源筛选都写在 URL 里。
export const meta = () => [{ title: "搜索 · UHD 媒体自动化控制台" }];
export default function SearchPage() {
const [params, setParams] = useSearchParams();
const q = (params.get("q") ?? "").trim();
const [input, setInput] = useState(q);
useEffect(() => setInput(q), [q]);
const f = useQueryState(RELEASE_FILTER_KEYS);
const submit = (e: FormEvent) => {
e.preventDefault();
const next = input.trim();
if (!next || next === q) return;
// 换关键词时筛选随之清空(站点/分辨率分面随结果变化)
setParams(new URLSearchParams({ q: next }));
};
return (
<>
<PageHeader title="搜索" description="本地作品、TMDB、各站点资源和控制台页面。" />
<form onSubmit={submit} className="mb-6 flex gap-2">
<div className="relative min-w-0 flex-1 sm:max-w-xl">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="作品名、别名或资源标题"
aria-label="搜索关键词"
className="pl-9"
/>
</div>
<Button type="submit" disabled={!input.trim()}>
搜索
</Button>
</form>
{!q ? (
<EmptyState icon={Search} title="输入关键词开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" />
) : (
<div key={q} className="space-y-8">
<MediaSection q={q} />
<ReleaseResults q={q} filters={f.values as ReleaseFilterState} setFilter={f.set} resetFilters={f.reset} />
<ActionsSection q={q} />
</div>
)}
</>
);
}