Files
UHD_Media_Tool/apps/web/app/components/console/inbox-bell.tsx
T
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 14:03:47 +08:00

152 lines
6.1 KiB
TypeScript

import type { Decision } from "@umt/contracts";
import { ArrowRight, Bell, CheckCheck } from "lucide-react";
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { useAttention } from "~/api/attention.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { recordPath } from "~/components/console/record-sheet.tsx";
import { SEVERITY } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { cn } from "~/lib/utils.ts";
// 顶栏铃铛(2026-10-06 起只放两类):需要你批准的智能体操作(会话里的审批 / 方案选择、计划审批、原站页面等你接手)
// 与系统健康问题(站点不可达、下载器离线、下载目录读不到容量、磁盘空间不足、媒体库目录离线,恢复后自动消失)。
// 整理问题在整理记录里处理,集数待确认在订阅详情 / 作品页集数映射里看,不进铃铛。
const RECENT = 6;
const DOT = { info: "bg-muted-foreground/60", warning: "bg-warning", critical: "bg-destructive" } as const;
/** 等你批准的事项点开去哪:智能体会话、下载、下载任务;其余去智能体页 */
export function approvalHref(d: Pick<Decision, "subjectType" | "subjectId" | "agentRunId">): string {
if (d.subjectType === "agent_run" && d.subjectId) return consolePath("agent", d.subjectId);
if (d.agentRunId) return consolePath("agent", d.agentRunId);
if (d.subjectType === "acquisition_plan" && d.subjectId)
return `${consolePath("downloads")}?plan=${encodeURIComponent(d.subjectId)}`;
if (d.subjectType === "download_task" && d.subjectId) return recordPath("downloads", d.subjectId);
return consolePath("agent");
}
export const HEALTH_HREF = `${consolePath("status")}#attention`;
function Group({
title,
total,
items,
href,
onGo,
}: {
title: string;
total: number;
items: Decision[];
href: (d: Decision) => string;
onGo: () => void;
}) {
if (!total) return null;
return (
<section className="pt-1">
<p className="flex items-center justify-between gap-2 px-2 py-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
<span>{title}</span>
<span className="tracking-normal tabular-nums">{total}</span>
</p>
<ul>
{items.slice(0, RECENT).map((d) => (
<li key={d.id}>
<Link
to={href(d)}
onClick={onGo}
className="block rounded-lg px-2 py-2 outline-none transition-colors hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring/50"
>
<span className="line-clamp-2 text-body break-words text-foreground">{d.title}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
</span>
</Link>
</li>
))}
</ul>
</section>
);
}
/** bare:放在顶栏的玻璃胶囊里时触发器不自带玻璃 */
export function InboxBell({ bare = false }: { bare?: boolean }) {
const q = useAttention();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const approvals = q.data?.approvals ?? { total: 0, items: [] };
const health = q.data?.health ?? { total: 0, items: [] };
const total = approvals.total + health.total;
const label = total ? `需要你关注 ${total} 项` : "通知";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={bare ? "ghost" : "outline"}
size="icon-sm"
className={cn("relative", bare && "data-[state=open]:bg-foreground/[0.08] data-[state=open]:text-foreground")}
aria-label={label}
title={label}
>
<Bell className="h-4 w-4" />
{total ? (
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-primary px-1 text-center text-micro leading-4 font-semibold text-primary-foreground tabular-nums">
{total > 99 ? "99+" : total}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
collisionPadding={16}
aria-label="通知"
className="pg-float-solid flex max-h-[var(--radix-popover-content-available-height)] w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden p-2"
>
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2 pt-1 pb-2">
<p className="text-sm font-semibold text-foreground">通知</p>
<Button
variant="ghost"
size="xs"
onClick={() => {
setOpen(false);
navigate(consolePath("status"));
}}
>
运行状态
<ArrowRight />
</Button>
</div>
{q.isPending ? (
<LoadingState className="px-4 py-6" />
) : q.isError ? (
<ErrorState plain error="稍后再试" className="py-8" />
) : total === 0 ? (
<EmptyState plain icon={CheckCheck} title="没有等你批准的操作,系统运行正常" className="py-8" />
) : (
<div className="min-h-0 overflow-y-auto overscroll-contain pt-1">
<Group
title="等你批准"
total={approvals.total}
items={approvals.items}
href={approvalHref}
onGo={() => setOpen(false)}
/>
<Group
title="系统健康"
total={health.total}
items={health.items}
href={() => HEALTH_HREF}
onGo={() => setOpen(false)}
/>
</div>
)}
</PopoverContent>
</Popover>
);
}