控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
152 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|