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:
1 parent
70d56645fe
commit
05350460f0
7 files changed
+638
-166
No files matched your search
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user