Files
UHD_Media_Tool/apps/web/app/components/console/inbox-bell.tsx
T
rootandClaude Opus 5.5 09a2adb637 feat(web): 设计规范与 uhd_web 同步(截至 2026-10-08)
- 材质统一边光:slab / float / pill / pill-selected / seg-thumb / chrome-side 改为左上亮右下暗的 1px 边光 + 外侧暗线;新增 lens / field / chip 参数与 pg-float-modal
- 主按钮:转动光谱环改为静态左上光谱高光,悬停沿边扫过一小段;次按钮同为 lens 玻璃
- Input / Textarea / SelectTrigger 挂 .field-glass,原生下拉与实体选择器同步;对话框按钮行 gap-3
- 提示条改 pg-float(状态色只在图标、展开不叠放);新增 GlassTooltip 接管 title 提示
- 触屏判断 input-modality.ts、touch / hover 自定义变体;animate-pg-pop 改 backwards;scroll-fade-end
- 控制台外壳:侧栏无硬分隔线、折叠开关进标志行;顶栏去磨砂,右侧控件收进玻璃胶囊
- 登录页居中单卡 auth-card;详情头图「订阅」改回普通主按钮,删除 pill-solid;发现页海报角标改 glass-chip-media
- 引入 simple-liquid-glass(liquid-surface.tsx);更新 docs/design 三份规范与 CHANGELOG

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

151 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 { 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 consolePath("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>
);
}