控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
251 lines
9.1 KiB
TypeScript
251 lines
9.1 KiB
TypeScript
import type { SubscriptionOverviewItem } from "@umt/contracts";
|
|
import { RecordLink } from "~/components/console/record-sheet.tsx";
|
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|
import { CoverPlaceholder, relTime } from "~/components/library/parts.tsx";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import { SubscriptionMenu } from "./actions.tsx";
|
|
import {
|
|
autoFillText,
|
|
foldNames,
|
|
progressParts,
|
|
progressText,
|
|
SUB_KIND_LABEL,
|
|
seasonLabel,
|
|
slotBadge,
|
|
} from "./model.ts";
|
|
|
|
// 总览卡片 / 列表行(设计 §1.2)。点卡片 = 查看对比。
|
|
|
|
type Item = SubscriptionOverviewItem;
|
|
|
|
export const itemTitle = (x: Item) => x.mediaEntityTitle ?? x.titleQuery ?? "未命名";
|
|
|
|
/** 状态角标:只标 暂停 / 待定 / 洗版中 / 等待新季 */
|
|
function Flags({ x }: { x: Item }) {
|
|
return (
|
|
<>
|
|
{x.group === "paused" ? <StatusBadge tone="neutral">已暂停</StatusBadge> : null}
|
|
{x.group === "pending" ? (
|
|
<StatusBadge tone="warning">{x.mediaEntityId ? "待定" : "不在媒体库"}</StatusBadge>
|
|
) : null}
|
|
{x.upgrading ? <StatusBadge tone="warning">洗版中</StatusBadge> : null}
|
|
{x.awaitingNewSeason ? <StatusBadge tone="success">等待新季</StatusBadge> : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 进度:数字 + 已入库/在途两段细条;总未知写"持续追踪" */
|
|
export function ProgressBlock({ x, className }: { x: Pick<Item, "progress" | "mode">; className?: string }) {
|
|
const parts = progressParts(x.progress);
|
|
const unknown = x.progress.total === null;
|
|
return (
|
|
<div className={cn("min-w-0", className)}>
|
|
<div className="flex items-baseline justify-between gap-2 text-meta">
|
|
<span className="font-medium text-foreground tabular-nums">
|
|
{progressText(x.progress)}
|
|
{x.progress.inflight > 0 ? (
|
|
<span className="font-normal text-muted-foreground"> · 下载中 {x.progress.inflight}</span>
|
|
) : null}
|
|
</span>
|
|
{unknown ? <span className="text-muted-foreground">持续追踪</span> : null}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"mt-1 flex h-1 w-full gap-px overflow-hidden rounded-full bg-foreground/[0.08]",
|
|
unknown &&
|
|
"bg-[repeating-linear-gradient(90deg,var(--color-foreground)_0_4px,transparent_4px_8px)] opacity-20",
|
|
)}
|
|
role="img"
|
|
aria-label={`已整理 ${x.progress.have},下载中 ${x.progress.inflight},总 ${x.progress.total ?? "未知"}`}
|
|
>
|
|
{unknown ? null : (
|
|
<>
|
|
<div className="h-full bg-foreground/70" style={{ width: `${parts.have * 100}%` }} />
|
|
<div
|
|
className="h-full bg-[repeating-linear-gradient(135deg,var(--color-foreground)_0_2px,transparent_2px_4px)] opacity-50"
|
|
style={{ width: `${parts.inflight * 100}%` }}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 只按标题订阅、没关联媒体库作品时的提示:这类订阅不搜索也不下载(L5) */
|
|
export const UNBOUND_HINT = "未关联作品,不会自动搜索和下载";
|
|
|
|
export function SlotBadges({ slots, max = 4, unbound }: { slots: Item["slots"]; max?: number; unbound?: boolean }) {
|
|
if (unbound) return <span className="text-meta text-warning">{UNBOUND_HINT}</span>;
|
|
if (slots.length === 0) return <span className="text-meta text-muted-foreground">没有收录版本</span>;
|
|
return (
|
|
<div className="flex min-w-0 flex-wrap gap-1">
|
|
{slots.slice(0, max).map((s) => {
|
|
const b = slotBadge(s);
|
|
return (
|
|
<StatusBadge key={s.slotId} tone={b.tone} className="max-w-full">
|
|
<span className="max-w-24 truncate text-foreground" title={s.name}>
|
|
{s.name}
|
|
</span>
|
|
<span className="tabular-nums">{b.label}</span>
|
|
</StatusBadge>
|
|
);
|
|
})}
|
|
{slots.length > max ? <span className="text-meta text-muted-foreground">+{slots.length - max}</span> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function nextSearchText(x: Item) {
|
|
if (!x.searchEnabled) return "搜索已关";
|
|
if (x.status === "paused" || x.status === "completed") return "—";
|
|
return x.nextSearchAt ? relTime(x.nextSearchAt) : "待排期";
|
|
}
|
|
|
|
function Meta({ x }: { x: Item }) {
|
|
return (
|
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-meta">
|
|
<dt className="text-muted-foreground">上次更新</dt>
|
|
<dd className="truncate text-foreground tabular-nums">{relTime(x.lastUpdatedAt)}</dd>
|
|
<dt className="text-muted-foreground">下次搜索</dt>
|
|
<dd className="truncate text-foreground tabular-nums">{nextSearchText(x)}</dd>
|
|
<dt className="text-muted-foreground">站点</dt>
|
|
<dd className="truncate text-foreground" title={x.sites.map((s) => s.name).join("、")}>
|
|
{foldNames(x.sites.map((s) => s.name))}
|
|
</dd>
|
|
<dt className="text-muted-foreground">规则组</dt>
|
|
<dd className="truncate text-foreground" title={x.ruleGroups.map((s) => s.name).join("、")}>
|
|
{foldNames(
|
|
x.ruleGroups.map((s) => s.name),
|
|
2,
|
|
"未设置",
|
|
)}
|
|
</dd>
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
function TitleLine({ x }: { x: Item }) {
|
|
const season = seasonLabel(x);
|
|
return (
|
|
<div className="min-w-0">
|
|
<p className="truncate text-body font-semibold text-foreground" title={itemTitle(x)}>
|
|
{itemTitle(x)}
|
|
</p>
|
|
<p className="truncate text-meta text-muted-foreground">
|
|
{[x.year, SUB_KIND_LABEL[x.mediaKind], season].filter(Boolean).join(" · ")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SubscriptionCard({
|
|
x,
|
|
onEdit,
|
|
selected,
|
|
onSelect,
|
|
}: {
|
|
x: Item;
|
|
onEdit: () => void;
|
|
selected?: boolean;
|
|
onSelect?: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<article
|
|
className={cn(
|
|
"pg-slab group relative flex min-w-0 flex-col gap-3 rounded-xl p-3.5 transition-shadow duration-micro hover:shadow-md",
|
|
selected && "ring-2 ring-foreground/40",
|
|
)}
|
|
>
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<CoverPlaceholder title={itemTitle(x)} kind={x.mediaKind} className="h-20 w-14" />
|
|
<div className="min-w-0 flex-1">
|
|
<RecordLink
|
|
page="subscriptions"
|
|
id={x.id}
|
|
className="block min-w-0 rounded-md outline-none after:absolute after:inset-0 after:content-[''] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<TitleLine x={x} />
|
|
</RecordLink>
|
|
<div className="relative mt-1.5 flex flex-wrap gap-1">
|
|
<Flags x={x} />
|
|
</div>
|
|
</div>
|
|
<div className="relative z-10 flex items-center gap-1">
|
|
{onSelect ? (
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`选择 ${itemTitle(x)}`}
|
|
checked={!!selected}
|
|
onChange={(e) => onSelect(e.target.checked)}
|
|
className="h-4 w-4 cursor-pointer accent-foreground"
|
|
/>
|
|
) : null}
|
|
<SubscriptionMenu sub={x} onEdit={onEdit} />
|
|
</div>
|
|
</div>
|
|
<ProgressBlock x={x} />
|
|
<SlotBadges slots={x.slots} unbound={!x.mediaEntityId} />
|
|
<Meta x={x} />
|
|
<p className="text-meta text-muted-foreground">{autoFillText(x.autoFill)}</p>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
/** 列表视图的一行(桌面表格化,手机折成两行) */
|
|
export function SubscriptionRow({
|
|
x,
|
|
onEdit,
|
|
selected,
|
|
onSelect,
|
|
}: {
|
|
x: Item;
|
|
onEdit: () => void;
|
|
selected?: boolean;
|
|
onSelect?: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<li
|
|
className={cn(
|
|
"relative grid min-w-0 grid-cols-[auto_1fr_auto] items-center gap-3 px-3 py-2.5 hover:bg-foreground/[0.03] md:grid-cols-[auto_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,2fr)_7rem_7rem_auto]",
|
|
selected && "bg-foreground/[0.05]",
|
|
)}
|
|
>
|
|
<div className="relative z-10 flex items-center gap-2">
|
|
{onSelect ? (
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`选择 ${itemTitle(x)}`}
|
|
checked={!!selected}
|
|
onChange={(e) => onSelect(e.target.checked)}
|
|
className="h-4 w-4 cursor-pointer accent-foreground"
|
|
/>
|
|
) : null}
|
|
<CoverPlaceholder title={itemTitle(x)} kind={x.mediaKind} small className="h-10 w-7" />
|
|
</div>
|
|
<RecordLink
|
|
page="subscriptions"
|
|
id={x.id}
|
|
className="min-w-0 outline-none after:absolute after:inset-0 after:content-[''] focus-visible:underline"
|
|
>
|
|
<TitleLine x={x} />
|
|
</RecordLink>
|
|
<ProgressBlock x={x} className="hidden md:block" />
|
|
<div className="relative hidden min-w-0 md:block">
|
|
<SlotBadges slots={x.slots} max={3} unbound={!x.mediaEntityId} />
|
|
</div>
|
|
<span className="hidden text-meta text-muted-foreground tabular-nums md:block">{relTime(x.lastUpdatedAt)}</span>
|
|
<span className="hidden text-meta text-muted-foreground tabular-nums md:block">{nextSearchText(x)}</span>
|
|
<div className="relative z-10 flex items-center gap-1">
|
|
<span className="hidden flex-wrap gap-1 lg:flex">
|
|
<Flags x={x} />
|
|
</span>
|
|
<SubscriptionMenu sub={x} onEdit={onEdit} />
|
|
</div>
|
|
<div className="col-span-3 -mt-1 md:hidden">
|
|
<ProgressBlock x={x} />
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|