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
@@ -7,6 +7,7 @@ import {
|
|||||||
Subscription,
|
Subscription,
|
||||||
type SubscriptionBatch,
|
type SubscriptionBatch,
|
||||||
SubscriptionBatchResult,
|
SubscriptionBatchResult,
|
||||||
|
SubscriptionCalendar,
|
||||||
SubscriptionCoverage,
|
SubscriptionCoverage,
|
||||||
type SubscriptionCreate,
|
type SubscriptionCreate,
|
||||||
type SubscriptionGroup,
|
type SubscriptionGroup,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
type SubscriptionOrigin,
|
type SubscriptionOrigin,
|
||||||
SubscriptionOverviewPage,
|
SubscriptionOverviewPage,
|
||||||
SubscriptionProgress,
|
SubscriptionProgress,
|
||||||
|
SubscriptionResetResult,
|
||||||
type SubscriptionSort,
|
type SubscriptionSort,
|
||||||
type SubscriptionStatus,
|
type SubscriptionStatus,
|
||||||
type SubscriptionUpdate,
|
type SubscriptionUpdate,
|
||||||
@@ -30,6 +32,8 @@ export const subscriptionKeys = {
|
|||||||
overview: (f: OverviewFilters) => ["subscriptions", "overview", f] as const,
|
overview: (f: OverviewFilters) => ["subscriptions", "overview", f] as const,
|
||||||
detail: (id: string) => ["subscriptions", "detail", id] as const,
|
detail: (id: string) => ["subscriptions", "detail", id] as const,
|
||||||
coverage: (id: string) => ["subscriptions", "coverage", id] as const,
|
coverage: (id: string) => ["subscriptions", "coverage", id] as const,
|
||||||
|
calendar: (from: string, to: string, kind?: SubscriptionMediaKind) =>
|
||||||
|
["subscriptions", "calendar", from, to, kind ?? ""] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SubscriptionFilters = {
|
export type SubscriptionFilters = {
|
||||||
@@ -254,6 +258,28 @@ export function useSetSlotOrder() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 重置进度:取消未提交/下载已不在的计划,清延后,重评;仍在下载的保留,不删资产 */
|
||||||
|
export function useResetSubscription() {
|
||||||
|
const done = useInvalidateSubs();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
api
|
||||||
|
.post<unknown>(`/subscriptions/${id}/reset`)
|
||||||
|
.then((d) => parseOrThrow(SubscriptionResetResult, d, "/subscriptions/reset")),
|
||||||
|
onSuccess: done,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日历:from / to 为 YYYY-MM-DD,跨度不超过 62 天 */
|
||||||
|
export function useSubscriptionCalendar(from: string, to: string, mediaKind?: SubscriptionMediaKind) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: subscriptionKeys.calendar(from, to, mediaKind),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
getParsed(SubscriptionCalendar, "/subscriptions/calendar", { from, to, mediaKind }, signal),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 409 重复订阅:消息里带已有订阅 id */
|
/** 409 重复订阅:消息里带已有订阅 id */
|
||||||
export function duplicateSubscriptionId(message: string): string | null {
|
export function duplicateSubscriptionId(message: string): string | null {
|
||||||
const m = /已有订阅[::]\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(message);
|
const m = /已有订阅[::]\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(message);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { Subscription } from "@umt/contracts";
|
import type { Subscription } from "@umt/contracts";
|
||||||
import { Grid3x3, MoreHorizontal, Pause, Pencil, Play, RotateCcw, Search, Trash2 } from "lucide-react";
|
import { Eraser, Grid3x3, MoreHorizontal, Pause, Pencil, Play, RotateCcw, Search, Trash2 } from "lucide-react";
|
||||||
import { type ReactNode, useState } from "react";
|
import { type ReactNode, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
type SubscriptionAction,
|
type SubscriptionAction,
|
||||||
|
useResetSubscription,
|
||||||
useSearchSubscription,
|
useSearchSubscription,
|
||||||
useSubscriptionAction,
|
useSubscriptionAction,
|
||||||
useSubscriptionBatch,
|
useSubscriptionBatch,
|
||||||
@@ -26,7 +27,8 @@ export function useSubscriptionOps() {
|
|||||||
const search = useSearchSubscription();
|
const search = useSearchSubscription();
|
||||||
const action = useSubscriptionAction();
|
const action = useSubscriptionAction();
|
||||||
const batch = useSubscriptionBatch();
|
const batch = useSubscriptionBatch();
|
||||||
const pending = search.isPending || action.isPending || batch.isPending;
|
const reset = useResetSubscription();
|
||||||
|
const pending = search.isPending || action.isPending || batch.isPending || reset.isPending;
|
||||||
return {
|
return {
|
||||||
pending,
|
pending,
|
||||||
search: (id: string) =>
|
search: (id: string) =>
|
||||||
@@ -42,6 +44,11 @@ export function useSubscriptionOps() {
|
|||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
reset: (id: string) =>
|
||||||
|
reset.mutateAsync(id).then((r) => {
|
||||||
|
const kept = r.keptPlans.length ? `,${r.keptPlans.length} 个仍在下载的保留` : "";
|
||||||
|
toast.success(`已重置:释放 ${r.releasedPlans.length} 个计划${kept}`);
|
||||||
|
}),
|
||||||
remove: (ids: string[]) =>
|
remove: (ids: string[]) =>
|
||||||
batch.mutateAsync({ ids, action: "delete" }).then((r) => {
|
batch.mutateAsync({ ids, action: "delete" }).then((r) => {
|
||||||
const bad = r.results.filter((x) => !x.ok);
|
const bad = r.results.filter((x) => !x.ok);
|
||||||
@@ -110,6 +117,7 @@ export function SubscriptionMenu({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [confirm, setConfirm] = useState(false);
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
const run = (fn: () => void) => () => {
|
const run = (fn: () => void) => () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
fn();
|
fn();
|
||||||
@@ -156,6 +164,11 @@ export function SubscriptionMenu({
|
|||||||
恢复
|
恢复
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!ended ? (
|
||||||
|
<MenuItem icon={<Eraser />} disabled={ops.pending} onClick={run(() => setConfirmReset(true))}>
|
||||||
|
重置
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
{ended ? (
|
{ended ? (
|
||||||
<MenuItem icon={<RotateCcw />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resubscribe"))}>
|
<MenuItem icon={<RotateCcw />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resubscribe"))}>
|
||||||
重新订阅
|
重新订阅
|
||||||
@@ -169,6 +182,19 @@ export function SubscriptionMenu({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<DeleteSubscriptionsDialog open={confirm} onOpenChange={setConfirm} ids={[sub.id]} />
|
<DeleteSubscriptionsDialog open={confirm} onOpenChange={setConfirm} ids={[sub.id]} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmReset}
|
||||||
|
onOpenChange={setConfirmReset}
|
||||||
|
title="重置这个订阅的进度?"
|
||||||
|
description="取消还没提交、或下载任务已不在的计划,清掉延后记录,然后重新评估。仍在下载的任务、已入库文件和总集数保留。"
|
||||||
|
confirmLabel="重置"
|
||||||
|
onConfirm={() =>
|
||||||
|
ops.reset(sub.id).catch((e) => {
|
||||||
|
toast.error(errorMessage(e));
|
||||||
|
throw e;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import type { CalendarEntryState, SubscriptionCalendarEntry, SubscriptionMediaKind } from "@umt/contracts";
|
||||||
|
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { useSubscriptionCalendar } from "~/api/subscriptions.ts";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
|
import { consolePath } from "~/components/console/nav.ts";
|
||||||
|
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 (
|
||||||
|
<Link
|
||||||
|
to={consolePath("subscriptions", 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>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ?? ""}`}>
|
||||||
|
<Link
|
||||||
|
to={consolePath("subscriptions", 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>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,15 @@ import {
|
|||||||
draftFromSubscription,
|
draftFromSubscription,
|
||||||
draftToParams,
|
draftToParams,
|
||||||
emptyDraft,
|
emptyDraft,
|
||||||
|
entriesByDate,
|
||||||
foldNames,
|
foldNames,
|
||||||
|
monthGrid,
|
||||||
|
monthKey,
|
||||||
|
monthStart,
|
||||||
moveItem,
|
moveItem,
|
||||||
progressText,
|
progressText,
|
||||||
seasonLabel,
|
seasonLabel,
|
||||||
|
shiftMonth,
|
||||||
slotBadge,
|
slotBadge,
|
||||||
upgradeOf,
|
upgradeOf,
|
||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
@@ -153,4 +158,51 @@ describe("表单 ↔ params", () => {
|
|||||||
expect(d.keywords).toBe("a\nb");
|
expect(d.keywords).toBe("a\nb");
|
||||||
expect(d.upgrade).toBe("per_unit");
|
expect(d.upgrade).toBe("per_unit");
|
||||||
});
|
});
|
||||||
|
it("质量预设:草稿 ↔ params;未知值回填为空", () => {
|
||||||
|
const d = { ...emptyDraft("movie"), resolution: "4k" as const, source: "remux" as const, effect: "dv" as const };
|
||||||
|
const { params } = draftToParams(
|
||||||
|
d,
|
||||||
|
{ resolution: "720p", effect: "sdr" },
|
||||||
|
{ upgradeChanged: false, totalChanged: false },
|
||||||
|
);
|
||||||
|
expect(params).toEqual({ resolution: "4k", source: "remux", effect: "dv" });
|
||||||
|
const cleared = draftToParams(
|
||||||
|
emptyDraft("movie"),
|
||||||
|
{ resolution: "720p" },
|
||||||
|
{ upgradeChanged: false, totalChanged: false },
|
||||||
|
);
|
||||||
|
expect(cleared.params.resolution).toBeUndefined();
|
||||||
|
const back = draftFromSubscription(
|
||||||
|
{
|
||||||
|
mediaKind: "movie",
|
||||||
|
seasonScope: "current_season",
|
||||||
|
mode: "once",
|
||||||
|
priority: "tracking",
|
||||||
|
seasonUnitId: null,
|
||||||
|
policyRevisionId: null,
|
||||||
|
params: { resolution: "1080p", source: "bogus", effect: "hdr" },
|
||||||
|
} as unknown as Subscription,
|
||||||
|
"off",
|
||||||
|
);
|
||||||
|
expect([back.resolution, back.source, back.effect]).toEqual(["1080p", "", "hdr"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("日历", () => {
|
||||||
|
it("月网格从周一开始,整周铺满;跨年切月", () => {
|
||||||
|
const first = monthStart("2026-09");
|
||||||
|
const g = monthGrid(first);
|
||||||
|
expect(g[0]).toEqual({ date: "2026-08-31", inMonth: false });
|
||||||
|
expect(g.length % 7).toBe(0);
|
||||||
|
expect(g.at(-1)).toEqual({ date: "2026-10-04", inMonth: false });
|
||||||
|
expect(g.filter((d) => d.inMonth)).toHaveLength(30);
|
||||||
|
expect(monthKey(shiftMonth(monthStart("2026-12"), 1))).toBe("2027-01");
|
||||||
|
expect(monthKey(monthStart("bad", new Date(2026, 4, 9)))).toBe("2026-05");
|
||||||
|
});
|
||||||
|
it("按日期分桶", () => {
|
||||||
|
const e = (date: string) => ({ date, subscriptionId: date }) as never;
|
||||||
|
const m = entriesByDate([e("2026-09-01"), e("2026-09-01"), e("2026-09-02")]);
|
||||||
|
expect(m.get("2026-09-01")).toHaveLength(2);
|
||||||
|
expect(m.get("2026-09-03")).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import type {
|
import type {
|
||||||
AcquisitionSlot,
|
AcquisitionSlot,
|
||||||
AutoFillMode,
|
AutoFillMode,
|
||||||
|
CalendarEntryState,
|
||||||
CoverageCell,
|
CoverageCell,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
SubscriptionCalendarEntry,
|
||||||
SubscriptionCoverage,
|
SubscriptionCoverage,
|
||||||
|
SubscriptionEffect,
|
||||||
SubscriptionGroup,
|
SubscriptionGroup,
|
||||||
SubscriptionMediaKind,
|
SubscriptionMediaKind,
|
||||||
SubscriptionOverviewItem,
|
SubscriptionOverviewItem,
|
||||||
|
SubscriptionResolution,
|
||||||
SubscriptionSlotSummary,
|
SubscriptionSlotSummary,
|
||||||
|
SubscriptionSourcePreset,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||||
|
|
||||||
@@ -182,6 +187,30 @@ export function moveItem<T>(arr: readonly T[], from: number, to: number): T[] {
|
|||||||
|
|
||||||
// ── 表单 ──
|
// ── 表单 ──
|
||||||
|
|
||||||
|
export const RESOLUTION_LABEL: Record<SubscriptionResolution, string> = {
|
||||||
|
"4k": "4K",
|
||||||
|
"1080p": "1080p",
|
||||||
|
"720p": "720p",
|
||||||
|
};
|
||||||
|
export const SOURCE_PRESET_LABEL: Record<SubscriptionSourcePreset, string> = {
|
||||||
|
bluray_disc: "蓝光原盘",
|
||||||
|
remux: "Remux",
|
||||||
|
bluray: "BluRay",
|
||||||
|
uhd: "UHD",
|
||||||
|
web: "WEB-DL",
|
||||||
|
hdtv: "HDTV",
|
||||||
|
h265: "H.265",
|
||||||
|
h264: "H.264",
|
||||||
|
};
|
||||||
|
export const EFFECT_LABEL: Record<SubscriptionEffect, string> = {
|
||||||
|
dv: "杜比视界",
|
||||||
|
hdr: "HDR",
|
||||||
|
sdr: "SDR",
|
||||||
|
atmos: "杜比全景声",
|
||||||
|
};
|
||||||
|
const oneOf = <K extends string>(v: unknown, labels: Record<K, string>): K | "" =>
|
||||||
|
typeof v === "string" && v in labels ? (v as K) : "";
|
||||||
|
|
||||||
export type SubscriptionDraft = {
|
export type SubscriptionDraft = {
|
||||||
mediaKind: SubscriptionMediaKind;
|
mediaKind: SubscriptionMediaKind;
|
||||||
seasonUnitId: string;
|
seasonUnitId: string;
|
||||||
@@ -202,6 +231,10 @@ export type SubscriptionDraft = {
|
|||||||
include: string;
|
include: string;
|
||||||
exclude: string;
|
exclude: string;
|
||||||
keywords: string;
|
keywords: string;
|
||||||
|
/** MP 分辨率 / 资源质量 / 特效预设(写到该订阅自己的版本目标) */
|
||||||
|
resolution: SubscriptionResolution | "";
|
||||||
|
source: SubscriptionSourcePreset | "";
|
||||||
|
effect: SubscriptionEffect | "";
|
||||||
searchIntervalHours: string;
|
searchIntervalHours: string;
|
||||||
stopAfter: string;
|
stopAfter: string;
|
||||||
autoFill: AutoFillMode;
|
autoFill: AutoFillMode;
|
||||||
@@ -239,6 +272,9 @@ export function emptyDraft(kind: SubscriptionMediaKind = "series"): Subscription
|
|||||||
include: "",
|
include: "",
|
||||||
exclude: "",
|
exclude: "",
|
||||||
keywords: "",
|
keywords: "",
|
||||||
|
resolution: "",
|
||||||
|
source: "",
|
||||||
|
effect: "",
|
||||||
searchIntervalHours: "",
|
searchIntervalHours: "",
|
||||||
stopAfter: "",
|
stopAfter: "",
|
||||||
autoFill: "inherit",
|
autoFill: "inherit",
|
||||||
@@ -281,6 +317,9 @@ export function draftFromSubscription(sub: Subscription, upgrade: UpgradeStrateg
|
|||||||
include: strArr(p.include).join("\n"),
|
include: strArr(p.include).join("\n"),
|
||||||
exclude: strArr(p.exclude).join("\n"),
|
exclude: strArr(p.exclude).join("\n"),
|
||||||
keywords: strArr(p.keywords).join("\n"),
|
keywords: strArr(p.keywords).join("\n"),
|
||||||
|
resolution: oneOf(p.resolution, RESOLUTION_LABEL),
|
||||||
|
source: oneOf(p.source, SOURCE_PRESET_LABEL),
|
||||||
|
effect: oneOf(p.effect, EFFECT_LABEL),
|
||||||
searchIntervalHours: numStr(p.searchIntervalHours),
|
searchIntervalHours: numStr(p.searchIntervalHours),
|
||||||
stopAfter: typeof p.stopAfter === "string" ? toLocalInput(p.stopAfter) : "",
|
stopAfter: typeof p.stopAfter === "string" ? toLocalInput(p.stopAfter) : "",
|
||||||
autoFill: p.autoFill === "on" || p.autoFill === "off" ? p.autoFill : "inherit",
|
autoFill: p.autoFill === "on" || p.autoFill === "off" ? p.autoFill : "inherit",
|
||||||
@@ -318,6 +357,9 @@ export function draftToParams(
|
|||||||
"downloadSavePath",
|
"downloadSavePath",
|
||||||
"autoFill",
|
"autoFill",
|
||||||
"fillBudget",
|
"fillBudget",
|
||||||
|
"resolution",
|
||||||
|
"source",
|
||||||
|
"effect",
|
||||||
];
|
];
|
||||||
for (const k of known) delete out[k];
|
for (const k of known) delete out[k];
|
||||||
const set = (k: string, v: unknown) => {
|
const set = (k: string, v: unknown) => {
|
||||||
@@ -334,6 +376,9 @@ export function draftToParams(
|
|||||||
const kw = lines(d.keywords);
|
const kw = lines(d.keywords);
|
||||||
if (kw.length > 10) errors.keywords = "最多 10 个";
|
if (kw.length > 10) errors.keywords = "最多 10 个";
|
||||||
set("keywords", kw);
|
set("keywords", kw);
|
||||||
|
set("resolution", d.resolution);
|
||||||
|
set("source", d.source);
|
||||||
|
set("effect", d.effect);
|
||||||
if (d.mediaKind !== "movie") {
|
if (d.mediaKind !== "movie") {
|
||||||
const s = d.episodeStart.trim();
|
const s = d.episodeStart.trim();
|
||||||
const e = d.episodeEnd.trim();
|
const e = d.episodeEnd.trim();
|
||||||
@@ -380,3 +425,53 @@ export function draftToParams(
|
|||||||
}
|
}
|
||||||
return { params: out as JsonObject, errors };
|
return { params: out as JsonObject, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 日历(设计 §4)──
|
||||||
|
|
||||||
|
export const CALENDAR_STATE_LABEL: Record<CalendarEntryState, string> = {
|
||||||
|
have: "已入库",
|
||||||
|
partial: "部分入库",
|
||||||
|
missing: "未入库",
|
||||||
|
upcoming: "未播出",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ymd = (d: Date) =>
|
||||||
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
/** "YYYY-MM" → 该月第一天;无效时取今天所在月 */
|
||||||
|
export function monthStart(month: string | undefined, today = new Date()): Date {
|
||||||
|
const m = /^(\d{4})-(\d{2})$/.exec(month ?? "");
|
||||||
|
if (m) {
|
||||||
|
const d = new Date(Number(m[1]), Number(m[2]) - 1, 1);
|
||||||
|
if (!Number.isNaN(d.getTime())) return d;
|
||||||
|
}
|
||||||
|
return new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const monthKey = (d: Date) => ymd(d).slice(0, 7);
|
||||||
|
|
||||||
|
export function shiftMonth(d: Date, n: number): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth() + n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 月视图网格:从该月 1 日所在周的周一起,整周铺满(5 或 6 周) */
|
||||||
|
export function monthGrid(first: Date): { date: string; inMonth: boolean }[] {
|
||||||
|
const start = new Date(first);
|
||||||
|
start.setDate(1 - ((first.getDay() + 6) % 7));
|
||||||
|
const last = new Date(first.getFullYear(), first.getMonth() + 1, 0);
|
||||||
|
const days: { date: string; inMonth: boolean }[] = [];
|
||||||
|
for (let d = new Date(start); days.length < 42; d.setDate(d.getDate() + 1)) {
|
||||||
|
if (d > last && days.length % 7 === 0) break;
|
||||||
|
days.push({ date: ymd(d), inMonth: d.getMonth() === first.getMonth() });
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按日期分桶 */
|
||||||
|
export function entriesByDate(entries: readonly SubscriptionCalendarEntry[]): Map<string, SubscriptionCalendarEntry[]> {
|
||||||
|
const m = new Map<string, SubscriptionCalendarEntry[]>();
|
||||||
|
for (const e of entries) m.set(e.date, [...(m.get(e.date) ?? []), e]);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const todayKey = (now = new Date()) => ymd(now);
|
||||||
@@ -51,7 +51,10 @@ import {
|
|||||||
type DraftErrors,
|
type DraftErrors,
|
||||||
draftFromSubscription,
|
draftFromSubscription,
|
||||||
draftToParams,
|
draftToParams,
|
||||||
|
EFFECT_LABEL,
|
||||||
emptyDraft,
|
emptyDraft,
|
||||||
|
RESOLUTION_LABEL,
|
||||||
|
SOURCE_PRESET_LABEL,
|
||||||
SUB_KIND_LABEL,
|
SUB_KIND_LABEL,
|
||||||
type SubscriptionDraft,
|
type SubscriptionDraft,
|
||||||
UPGRADE_LABEL,
|
UPGRADE_LABEL,
|
||||||
@@ -713,6 +716,47 @@ export function SubscriptionFormDialog({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<FormField label="分辨率" htmlFor={f("res")}>
|
||||||
|
<Pick
|
||||||
|
id={f("res")}
|
||||||
|
value={d.resolution}
|
||||||
|
onChange={(v) => patch({ resolution: v })}
|
||||||
|
placeholder="不限"
|
||||||
|
options={Object.entries(RESOLUTION_LABEL).map(([value, label]) => ({
|
||||||
|
value: value as keyof typeof RESOLUTION_LABEL,
|
||||||
|
label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="资源质量" htmlFor={f("src")}>
|
||||||
|
<Pick
|
||||||
|
id={f("src")}
|
||||||
|
value={d.source}
|
||||||
|
onChange={(v) => patch({ source: v })}
|
||||||
|
placeholder="不限"
|
||||||
|
options={Object.entries(SOURCE_PRESET_LABEL).map(([value, label]) => ({
|
||||||
|
value: value as keyof typeof SOURCE_PRESET_LABEL,
|
||||||
|
label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="特效" htmlFor={f("fx")}>
|
||||||
|
<Pick
|
||||||
|
id={f("fx")}
|
||||||
|
value={d.effect}
|
||||||
|
onChange={(v) => patch({ effect: v })}
|
||||||
|
placeholder="不限"
|
||||||
|
options={Object.entries(EFFECT_LABEL).map(([value, label]) => ({
|
||||||
|
value: value as keyof typeof EFFECT_LABEL,
|
||||||
|
label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<p className="text-meta text-muted-foreground">
|
||||||
|
分辨率、资源质量、特效写进这个订阅自己的版本目标;UHD 和杜比全景声按标题匹配,与「包含」同时生效。
|
||||||
|
</p>
|
||||||
<FormField
|
<FormField
|
||||||
label="搜索关键词"
|
label="搜索关键词"
|
||||||
htmlFor={f("kw")}
|
htmlFor={f("kw")}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { SubscriptionGroup, SubscriptionOverviewItem, SubscriptionSort } from "@umt/contracts";
|
import type { SubscriptionGroup, SubscriptionOverviewItem, SubscriptionSort } from "@umt/contracts";
|
||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
|
CalendarDays,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
@@ -24,6 +25,7 @@ import { PageHeader } from "~/components/console/page-header.tsx";
|
|||||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||||
import { opts, SUB_ORIGIN } from "~/components/library/labels.ts";
|
import { opts, SUB_ORIGIN } from "~/components/library/labels.ts";
|
||||||
import { DeleteSubscriptionsDialog, useSubscriptionOps } from "~/components/subscriptions/actions.tsx";
|
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 { GROUP_LABEL, GROUP_ORDER, SUB_KIND_LABEL } from "~/components/subscriptions/model.ts";
|
||||||
import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/overview-item.tsx";
|
import { SubscriptionCard, SubscriptionRow } from "~/components/subscriptions/overview-item.tsx";
|
||||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||||
@@ -96,7 +98,7 @@ function Pills<T extends string>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SubscriptionsPage() {
|
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 v = f.values;
|
||||||
const [q, setQ] = useState(v.q);
|
const [q, setQ] = useState(v.q);
|
||||||
const dq = useDebounced(q.trim());
|
const dq = useDebounced(q.trim());
|
||||||
@@ -111,7 +113,9 @@ export default function SubscriptionsPage() {
|
|||||||
/* 无存储时用卡片 */
|
/* 无存储时用卡片 */
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
const calendar = v.view === "calendar";
|
||||||
const setView = (x: "card" | "list") => {
|
const setView = (x: "card" | "list") => {
|
||||||
|
if (calendar) f.set("view", "");
|
||||||
setViewState(x);
|
setViewState(x);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(VIEW_KEY, x);
|
localStorage.setItem(VIEW_KEY, x);
|
||||||
@@ -219,21 +223,23 @@ export default function SubscriptionsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
{calendar ? null : (
|
||||||
variant={selecting ? "secondary" : "ghost"}
|
<Button
|
||||||
size="sm"
|
variant={selecting ? "secondary" : "ghost"}
|
||||||
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
size="sm"
|
||||||
>
|
onClick={() => (selecting ? exitSelect() : setSelecting(true))}
|
||||||
{selecting ? <X /> : null}
|
>
|
||||||
{selecting ? "退出批量" : "批量"}
|
{selecting ? <X /> : null}
|
||||||
</Button>
|
{selecting ? "退出批量" : "批量"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<fieldset className="m-0 flex rounded-full border border-border p-0.5" aria-label="视图">
|
<fieldset className="m-0 flex rounded-full border border-border p-0.5" aria-label="视图">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label="卡片视图"
|
aria-label="卡片视图"
|
||||||
aria-pressed={view === "card"}
|
aria-pressed={!calendar && view === "card"}
|
||||||
className={cn("rounded-full", view === "card" && "bg-foreground/[0.08]")}
|
className={cn("rounded-full", !calendar && view === "card" && "bg-foreground/[0.08]")}
|
||||||
onClick={() => setView("card")}
|
onClick={() => setView("card")}
|
||||||
>
|
>
|
||||||
<LayoutGrid />
|
<LayoutGrid />
|
||||||
@@ -242,169 +248,201 @@ export default function SubscriptionsPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
aria-label="列表视图"
|
aria-label="列表视图"
|
||||||
aria-pressed={view === "list"}
|
aria-pressed={!calendar && view === "list"}
|
||||||
className={cn("rounded-full", view === "list" && "bg-foreground/[0.08]")}
|
className={cn("rounded-full", !calendar && view === "list" && "bg-foreground/[0.08]")}
|
||||||
onClick={() => setView("list")}
|
onClick={() => setView("list")}
|
||||||
>
|
>
|
||||||
<List />
|
<List />
|
||||||
</Button>
|
</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>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
{calendar ? (
|
||||||
<Pills
|
<SubscriptionCalendarView
|
||||||
label="分组"
|
month={v.month}
|
||||||
value={v.group as SubscriptionGroup | ""}
|
onMonth={(m) => f.set("month", m)}
|
||||||
onChange={(x) => f.set("group", x)}
|
mediaKind={(v.kind || undefined) as "series" | "movie" | "variety" | undefined}
|
||||||
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)
|
<>
|
||||||
|
<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}
|
{creating ? <SubscriptionFormDialog onClose={() => setCreating(false)} /> : null}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|||||||
Reference in new issue
Block a user