控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?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
193 lines
7.6 KiB
TypeScript
193 lines
7.6 KiB
TypeScript
import type { CalendarEntryState, SubscriptionCalendarEntry, SubscriptionMediaKind } from "@umt/contracts";
|
|
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { useSubscriptionCalendar } from "~/api/subscriptions.ts";
|
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { RecordLink } from "~/components/console/record-sheet.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { cn } from "~/lib/utils.ts";
|
|
import {
|
|
CALENDAR_STATE_LABEL,
|
|
entriesByDate,
|
|
monthGrid,
|
|
monthKey,
|
|
monthStart,
|
|
SUB_KIND_LABEL,
|
|
shiftMonth,
|
|
todayKey,
|
|
} from "./model.ts";
|
|
|
|
// 订阅日历(设计 §4):月视图,事件 = 追踪中订阅范围内集的播出日期(同日同季聚合),电影取上映日期。
|
|
// 状态色只表示入库状态:已入库 success、部分 warning、未入库 destructive、未播出无彩。条目链到订阅详情。
|
|
|
|
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
|
|
|
|
const STATE_CLS: Record<CalendarEntryState, string> = {
|
|
have: "border-success/40 bg-success/10",
|
|
partial: "border-warning/40 bg-warning/10",
|
|
missing: "border-destructive/40 bg-destructive/10",
|
|
upcoming: "border-border bg-foreground/[0.04]",
|
|
};
|
|
const DOT_CLS: Record<CalendarEntryState, string> = {
|
|
have: "bg-success",
|
|
partial: "bg-warning",
|
|
missing: "bg-destructive",
|
|
upcoming: "bg-muted-foreground/50",
|
|
};
|
|
|
|
function Entry({ e }: { e: SubscriptionCalendarEntry }) {
|
|
return (
|
|
<RecordLink
|
|
page="subscriptions"
|
|
id={e.subscriptionId}
|
|
title={`${e.title} ${e.label} · ${CALENDAR_STATE_LABEL[e.state]}${e.total > 1 ? ` ${e.have}/${e.total}` : ""}`}
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-1 rounded-tight border px-1.5 py-0.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.06]",
|
|
STATE_CLS[e.state],
|
|
)}
|
|
>
|
|
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT_CLS[e.state])} />
|
|
<span className="truncate">{e.title}</span>
|
|
<span className="shrink-0 text-muted-foreground tabular-nums">{e.label}</span>
|
|
<span className="sr-only">{CALENDAR_STATE_LABEL[e.state]}</span>
|
|
</RecordLink>
|
|
);
|
|
}
|
|
|
|
export function SubscriptionCalendarView({
|
|
month,
|
|
onMonth,
|
|
mediaKind,
|
|
}: {
|
|
/** YYYY-MM;空 = 本月 */
|
|
month: string;
|
|
onMonth: (m: string) => void;
|
|
mediaKind?: SubscriptionMediaKind | undefined;
|
|
}) {
|
|
const first = monthStart(month);
|
|
const grid = monthGrid(first);
|
|
const from = grid[0]!.date;
|
|
const to = grid.at(-1)!.date;
|
|
const q = useSubscriptionCalendar(from, to, mediaKind);
|
|
const byDate = entriesByDate(q.data?.entries ?? []);
|
|
const today = todayKey();
|
|
const title = `${first.getFullYear()} 年 ${first.getMonth() + 1} 月`;
|
|
const unknown = q.data?.unknown ?? [];
|
|
|
|
return (
|
|
<section aria-label="订阅日历" className="space-y-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="上个月"
|
|
onClick={() => onMonth(monthKey(shiftMonth(first, -1)))}
|
|
>
|
|
<ChevronLeft />
|
|
</Button>
|
|
<h2 className="min-w-28 text-center text-body font-semibold text-foreground tabular-nums">{title}</h2>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="下个月"
|
|
onClick={() => onMonth(monthKey(shiftMonth(first, 1)))}
|
|
>
|
|
<ChevronRight />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => onMonth("")}>
|
|
本月
|
|
</Button>
|
|
<ul className="ml-auto flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
|
|
{(Object.keys(CALENDAR_STATE_LABEL) as CalendarEntryState[]).map((k) => (
|
|
<li key={k} className="flex items-center gap-1.5">
|
|
<span aria-hidden className={cn("h-2 w-2 rounded-full", DOT_CLS[k])} />
|
|
{CALENDAR_STATE_LABEL[k]}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{q.isPending ? (
|
|
<LoadingState rows={5} />
|
|
) : q.isError ? (
|
|
<ErrorState error={q.error} onRetry={() => q.refetch()} />
|
|
) : (
|
|
<>
|
|
<div className="overflow-x-auto scroll-bleed">
|
|
<div className="pg-slab grid min-w-[640px] grid-cols-7 overflow-hidden rounded-xl">
|
|
{WEEKDAYS.map((w) => (
|
|
<div
|
|
key={w}
|
|
className="border-b border-border/60 px-2 py-1.5 text-meta font-medium text-muted-foreground"
|
|
>
|
|
周{w}
|
|
</div>
|
|
))}
|
|
{grid.map((d, i) => {
|
|
const list = byDate.get(d.date) ?? [];
|
|
return (
|
|
<div
|
|
key={d.date}
|
|
className={cn(
|
|
"min-h-24 min-w-0 space-y-1 border-border/60 p-1.5",
|
|
i % 7 !== 6 && "border-r",
|
|
i < grid.length - 7 && "border-b",
|
|
!d.inMonth && "bg-foreground/[0.02]",
|
|
)}
|
|
>
|
|
<time
|
|
dateTime={d.date}
|
|
className={cn(
|
|
"block text-meta tabular-nums",
|
|
d.inMonth ? "text-muted-foreground" : "text-muted-foreground/50",
|
|
d.date === today && "font-semibold text-foreground",
|
|
)}
|
|
>
|
|
{Number(d.date.slice(8, 10))}
|
|
{d.date === today ? <span className="ml-1">今天</span> : null}
|
|
</time>
|
|
{list.map((e) => (
|
|
<Entry key={`${e.subscriptionId}-${e.seasonNumber ?? ""}-${e.date}`} e={e} />
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{!q.data?.entries.length && !unknown.length ? (
|
|
<EmptyState icon={CalendarDays} title="这个月没有播出" description="追踪中的订阅有播出日期后出现在这里。" />
|
|
) : null}
|
|
{unknown.length ? (
|
|
<section aria-labelledby="cal-unknown" className="space-y-2">
|
|
<h3 id="cal-unknown" className="text-body font-semibold text-foreground">
|
|
播出日期未知
|
|
<span className="ml-1.5 text-meta font-normal text-muted-foreground tabular-nums">
|
|
{unknown.length}
|
|
</span>
|
|
</h3>
|
|
<p className="text-meta text-muted-foreground">TMDB 还没有给出这些集的播出日期,元数据刷新后更新。</p>
|
|
<ul className="pg-slab divide-y divide-border/60 overflow-hidden rounded-xl">
|
|
{unknown.map((u) => (
|
|
<li key={`${u.subscriptionId}-${u.seasonNumber ?? ""}`}>
|
|
<RecordLink
|
|
page="subscriptions"
|
|
id={u.subscriptionId}
|
|
className="flex min-w-0 items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-foreground/[0.04]"
|
|
>
|
|
<span className="truncate">{u.title}</span>
|
|
<span className="shrink-0 text-meta text-muted-foreground">
|
|
{SUB_KIND_LABEL[u.mediaKind]}
|
|
{u.seasonNumber !== null ? ` · S${String(u.seasonNumber).padStart(2, "0")}` : ""}
|
|
{u.episodes.length ? ` · ${u.episodes.length} 集` : ""}
|
|
</span>
|
|
</RecordLink>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|