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:
commit
04401309fb
31 files changed
+5049
-429
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user