feat(web): subscription calendar view, reset action and quality preset fields

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:14:40 +08:00
1 parent 70d56645fe
commit 05350460f0
7 files changed
+638 -166

No files matched your search

+202 -164
View File
@@ -1,6 +1,7 @@
import type { SubscriptionGroup, SubscriptionOverviewItem, SubscriptionSort } from "@umt/contracts";
import {
CalendarClock,
CalendarDays,
ChevronDown,
LayoutGrid,
List,
@@ -24,6 +25,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { opts, SUB_ORIGIN } from "~/components/library/labels.ts";
import { DeleteSubscriptionsDialog, useSubscriptionOps } from "~/components/subscriptions/actions.tsx";
import { SubscriptionCalendarView } from "~/components/subscriptions/calendar.tsx";
import { GROUP_LABEL, GROUP_ORDER, SUB_KIND_LABEL } from "~/components/subscriptions/model.ts";
import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/overview-item.tsx";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
@@ -96,7 +98,7 @@ function Pills<T extends string>({
}
export default function SubscriptionsPage() {
const f = useQueryState(["kind", "group", "q", "site", "rg", "origin", "up", "sort"] as const);
const f = useQueryState(["kind", "group", "q", "site", "rg", "origin", "up", "sort", "view", "month"] as const);
const v = f.values;
const [q, setQ] = useState(v.q);
const dq = useDebounced(q.trim());
@@ -111,7 +113,9 @@ export default function SubscriptionsPage() {
/* 无存储时用卡片 */
}
}, []);
const calendar = v.view === "calendar";
const setView = (x: "card" | "list") => {
if (calendar) f.set("view", "");
setViewState(x);
try {
localStorage.setItem(VIEW_KEY, x);
@@ -219,21 +223,23 @@ export default function SubscriptionsPage() {
]}
/>
<div className="flex items-center gap-1">
<Button
variant={selecting ? "secondary" : "ghost"}
size="sm"
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
>
{selecting ? <X /> : null}
{selecting ? "退出批量" : "批量"}
</Button>
{calendar ? null : (
<Button
variant={selecting ? "secondary" : "ghost"}
size="sm"
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
>
{selecting ? <X /> : null}
{selecting ? "退出批量" : "批量"}
</Button>
)}
<fieldset className="m-0 flex rounded-full border border-border p-0.5" aria-label="视图">
<Button
variant="ghost"
size="icon-sm"
aria-label="卡片视图"
aria-pressed={view === "card"}
className={cn("rounded-full", view === "card" && "bg-foreground/[0.08]")}
aria-pressed={!calendar && view === "card"}
className={cn("rounded-full", !calendar && view === "card" && "bg-foreground/[0.08]")}
onClick={() => setView("card")}
>
<LayoutGrid />
@@ -242,169 +248,201 @@ export default function SubscriptionsPage() {
variant="ghost"
size="icon-sm"
aria-label="列表视图"
aria-pressed={view === "list"}
className={cn("rounded-full", view === "list" && "bg-foreground/[0.08]")}
aria-pressed={!calendar && view === "list"}
className={cn("rounded-full", !calendar && view === "list" && "bg-foreground/[0.08]")}
onClick={() => setView("list")}
>
<List />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="日历"
aria-pressed={calendar}
className={cn("rounded-full", calendar && "bg-foreground/[0.08]")}
onClick={() => f.set("view", "calendar")}
>
<CalendarDays />
</Button>
</fieldset>
</div>
</div>
<div className="mb-3">
<Pills
label="分组"
value={v.group as SubscriptionGroup | ""}
onChange={(x) => f.set("group", x)}
items={[
{ value: "", label: "全部", count: allCount },
...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })),
]}
{calendar ? (
<SubscriptionCalendarView
month={v.month}
onMonth={(m) => f.set("month", m)}
mediaKind={(v.kind || undefined) as "series" | "movie" | "variety" | undefined}
/>
</div>
<FilterBar
search={{ value: q, onChange: setQ, placeholder: "作品标题 / 别名 / 检索词" }}
onReset={
filtered
? () => {
setQ("");
for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, "");
}
: undefined
}
>
<FilterSelect
label="站点"
value={v.site}
onChange={(x) => f.set("site", x)}
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
/>
<FilterSelect
label="规则组"
value={v.rg}
onChange={(x) => f.set("rg", x)}
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
/>
<FilterSelect label="来源" value={v.origin} onChange={(x) => f.set("origin", x)} options={opts(SUB_ORIGIN)} />
<FilterSelect
label="洗版"
value={v.up}
onChange={(x) => f.set("up", x)}
options={[
{ value: "true", label: "洗版中" },
{ value: "false", label: "未洗版" },
]}
/>
<FilterSelect
label="排序"
value={v.sort}
onChange={(x) => f.set("sort", x)}
allLabel="按分组"
options={opts(SORT_LABEL)}
/>
</FilterBar>
{selecting ? (
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
<span className="text-sm text-foreground tabular-nums">已选 {selIds.length}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSel(new Set(sel.size === items.length ? [] : items.map((x) => x.id)))}
>
{sel.size === items.length && items.length ? "取消全选" : "全选"}
</Button>
<span className="ml-auto flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "search")}
>
<Search />
立即搜索
</Button>
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "pause")}
>
<Pause />
暂停
</Button>
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "resume")}
>
<Play />
恢复
</Button>
<Button size="sm" variant="destructive" disabled={!selIds.length} onClick={() => setConfirmDelete(true)}>
<Trash2 />
删除
</Button>
</span>
</div>
) : null}
{query.isPending ? (
<LoadingState rows={4} />
) : query.isError ? (
<ErrorState error={query.error} onRetry={() => query.refetch()} />
) : items.length === 0 ? (
<EmptyState
icon={CalendarClock}
title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"}
description={filtered || v.group || v.kind ? undefined : "从媒体库、搜索或 Agent 添加,也可以点「新建订阅」。"}
/>
) : grouped ? (
<div className="space-y-6">
{GROUP_ORDER.map((g) => {
const list = items.filter((x) => x.group === g);
if (!list.length) return null;
const closed = collapsed.has(g);
return (
<section key={g} aria-labelledby={`grp-${g}`}>
<button
type="button"
id={`grp-${g}`}
aria-expanded={!closed}
onClick={() =>
setCollapsed((s) => {
const n = new Set(s);
if (closed) n.delete(g);
else n.add(g);
return n;
})
}
className="mb-2 flex cursor-pointer items-center gap-1.5 text-body font-semibold text-foreground"
>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", closed && "-rotate-90")}
/>
{GROUP_LABEL[g]}
<span className="text-meta font-normal text-muted-foreground tabular-nums">
{counts?.[g] ?? list.length}
</span>
</button>
{closed ? null : renderItems(list)}
</section>
);
})}
</div>
) : (
renderItems(items)
<>
<div className="mb-3">
<Pills
label="分组"
value={v.group as SubscriptionGroup | ""}
onChange={(x) => f.set("group", x)}
items={[
{ value: "", label: "全部", count: allCount },
...GROUP_ORDER.map((g) => ({ value: g, label: GROUP_LABEL[g], count: counts?.[g] })),
]}
/>
</div>
<FilterBar
search={{ value: q, onChange: setQ, placeholder: "作品标题 / 别名 / 检索词" }}
onReset={
filtered
? () => {
setQ("");
for (const k of ["q", "site", "rg", "origin", "up"] as const) f.set(k, "");
}
: undefined
}
>
<FilterSelect
label="站点"
value={v.site}
onChange={(x) => f.set("site", x)}
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
/>
<FilterSelect
label="规则组"
value={v.rg}
onChange={(x) => f.set("rg", x)}
options={(groups.data?.items ?? []).map((g) => ({ value: g.id, label: g.name }))}
/>
<FilterSelect
label="来源"
value={v.origin}
onChange={(x) => f.set("origin", x)}
options={opts(SUB_ORIGIN)}
/>
<FilterSelect
label="洗版"
value={v.up}
onChange={(x) => f.set("up", x)}
options={[
{ value: "true", label: "洗版中" },
{ value: "false", label: "未洗版" },
]}
/>
<FilterSelect
label="排序"
value={v.sort}
onChange={(x) => f.set("sort", x)}
allLabel="按分组"
options={opts(SORT_LABEL)}
/>
</FilterBar>
{selecting ? (
<div className="pg-slab sticky top-2 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-xl px-3 py-2">
<span className="text-sm text-foreground tabular-nums">已选 {selIds.length}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSel(new Set(sel.size === items.length ? [] : items.map((x) => x.id)))}
>
{sel.size === items.length && items.length ? "取消全选" : "全选"}
</Button>
<span className="ml-auto flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "search")}
>
<Search />
立即搜索
</Button>
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "pause")}
>
<Pause />
暂停
</Button>
<Button
size="sm"
variant="outline"
disabled={!selIds.length || ops.pending}
onClick={() => ops.batch(selIds, "resume")}
>
<Play />
恢复
</Button>
<Button
size="sm"
variant="destructive"
disabled={!selIds.length}
onClick={() => setConfirmDelete(true)}
>
<Trash2 />
删除
</Button>
</span>
</div>
) : null}
{query.isPending ? (
<LoadingState rows={4} />
) : query.isError ? (
<ErrorState error={query.error} onRetry={() => query.refetch()} />
) : items.length === 0 ? (
<EmptyState
icon={CalendarClock}
title={filtered || v.group || v.kind ? "没有符合条件的订阅" : "还没有订阅"}
description={
filtered || v.group || v.kind ? undefined : "从媒体库、搜索或 Agent 添加,也可以点「新建订阅」。"
}
/>
) : grouped ? (
<div className="space-y-6">
{GROUP_ORDER.map((g) => {
const list = items.filter((x) => x.group === g);
if (!list.length) return null;
const closed = collapsed.has(g);
return (
<section key={g} aria-labelledby={`grp-${g}`}>
<button
type="button"
id={`grp-${g}`}
aria-expanded={!closed}
onClick={() =>
setCollapsed((s) => {
const n = new Set(s);
if (closed) n.delete(g);
else n.add(g);
return n;
})
}
className="mb-2 flex cursor-pointer items-center gap-1.5 text-body font-semibold text-foreground"
>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", closed && "-rotate-90")}
/>
{GROUP_LABEL[g]}
<span className="text-meta font-normal text-muted-foreground tabular-nums">
{counts?.[g] ?? list.length}
</span>
</button>
{closed ? null : renderItems(list)}
</section>
);
})}
</div>
) : (
renderItems(items)
)}
{query.hasNextPage ? (
<div className="mt-4 flex justify-center">
<Button variant="outline" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>
{query.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
</>
)}
{query.hasNextPage ? (
<div className="mt-4 flex justify-center">
<Button variant="outline" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>
{query.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
{creating ? <SubscriptionFormDialog onClose={() => setCreating(false)} /> : null}
{editing ? (