Merge branch 'detail1'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
3067b668d8
16 files changed
+1992
-175
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type { SiteDetailPage } from "@umt/adapters";
|
||||
import { formatDetailPage, type SiteDetailPage } from "@umt/adapters";
|
||||
import type { SiteReleasePage } from "@umt/contracts";
|
||||
import { sql } from "@umt/db";
|
||||
import { sharedCache, storageSettings } from "../../../shared-cache.ts";
|
||||
@@ -99,6 +99,12 @@ export async function detailScreenshotRequest(
|
||||
export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise<SiteReleasePage | null> {
|
||||
const p = await loadStored(rt, srId);
|
||||
if (!p) return null;
|
||||
// 展示结构每次现算:旧缓存也能用,解析规则改了不必清缓存
|
||||
const formatted = formatDetailPage({
|
||||
descriptionText: p.descriptionText ?? null,
|
||||
mediaInfo: p.mediaInfo ?? null,
|
||||
files: p.files ?? [],
|
||||
});
|
||||
return {
|
||||
fetchedAt: p.fetchedAt,
|
||||
descriptionText: p.descriptionText,
|
||||
@@ -118,6 +124,7 @@ export async function loadDetailPage(rt: SourcesRuntime, srId: string): Promise<
|
||||
files: p.files,
|
||||
tags: p.tags,
|
||||
uploader: p.uploader,
|
||||
...formatted,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,14 @@ describe.skipIf(!up)("站点详情页扩展内容:按需抓 + 24 小时缓存 +
|
||||
expect(page.mediaInfo).toMatch(/^General\nComplete name : Dune.mkv/);
|
||||
expect(page.descriptionText).toContain("◎片名 沙丘2");
|
||||
expect(page.descriptionText).not.toContain("Complete name");
|
||||
// 展示结构:读缓存时现算
|
||||
expect(page.infoBlock).toContainEqual({ key: "片名", value: "沙丘2" });
|
||||
expect(page.media).toMatchObject({
|
||||
kind: "mediainfo",
|
||||
general: { fileName: "Dune.mkv", container: "Matroska", size: "78 GiB", duration: "2 h 46 min" },
|
||||
video: [{ codec: "HEVC" }],
|
||||
});
|
||||
expect(Array.isArray(page.fileTree)).toBe(true);
|
||||
const row = await h.db
|
||||
.selectFrom("source_release")
|
||||
.select(["raw"])
|
||||
|
||||
@@ -35,7 +35,7 @@ export function DetailSheet({
|
||||
className={cn("w-full gap-0 p-0", width === "lg" ? "sm:max-w-2xl" : "sm:max-w-lg", className)}
|
||||
>
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
|
||||
<SheetTitle className="truncate text-lead">{title}</SheetTitle>
|
||||
<SheetTitle className="line-clamp-2 text-lead break-words">{title}</SheetTitle>
|
||||
{description ? <SheetDescription className="break-words">{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-4">{children}</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { Bot, Download, RefreshCw } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Bot, Check, Download, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
@@ -15,9 +15,9 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { DownloadDialog } from "./download-dialog.tsx";
|
||||
import { categoryLabel, hrOf, ORIGIN_LABEL, relTime } from "./release-fields.ts";
|
||||
import { absTime, categoryLabel, countText, ORIGIN_LABEL, relTime, snatchedOf } from "./release-fields.ts";
|
||||
import { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
|
||||
import { ReleasePageContent } from "./release-page-section.tsx";
|
||||
import { ExternalIdTags, externalLinks, ReleasePageSections } from "./release-page-section.tsx";
|
||||
|
||||
const DECISION_LABEL = {
|
||||
accept: { label: "符合,可收录", tone: "success" },
|
||||
@@ -45,7 +45,6 @@ function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry:
|
||||
const slots = detail.data?.slots ?? [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<InventoryOverlay release={release} entry={entry} loading={false} />
|
||||
{entry?.versions.length ? (
|
||||
<ul className="space-y-1 text-caption">
|
||||
{entry.versions.map((v) => (
|
||||
@@ -136,6 +135,19 @@ export function ReleaseDetailSheet({
|
||||
refresh.mutate({ siteId, rid });
|
||||
}, [needPage, rid, siteId, refresh]);
|
||||
|
||||
const page = detail.data?.page ?? null;
|
||||
const snatched = r ? snatchedOf(r) : null;
|
||||
const doRefresh = () =>
|
||||
r &&
|
||||
refresh.mutate(
|
||||
{ siteId, rid: r.id },
|
||||
{
|
||||
onSuccess: (d) =>
|
||||
d.refreshError ? toast.warning(`复核失败,显示上次数据:${d.refreshError}`) : toast.success("已复核"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!release}
|
||||
@@ -146,6 +158,9 @@ export function ReleaseDetailSheet({
|
||||
footer={
|
||||
r ? (
|
||||
<>
|
||||
<div className="mr-auto min-w-0 flex-1 basis-60">
|
||||
<InventoryOverlay release={r} entry={entry} loading={false} />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -170,91 +185,94 @@ export function ReleaseDetailSheet({
|
||||
>
|
||||
{!r ? null : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<ReleaseBadges r={r} />
|
||||
<StatusBadge>{ORIGIN_LABEL[r.origin]}</StatusBadge>
|
||||
</div>
|
||||
|
||||
<DetailSection
|
||||
title="易变字段复核"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={refresh.isPending}
|
||||
onClick={() =>
|
||||
refresh.mutate(
|
||||
{ siteId, rid: r.id },
|
||||
{
|
||||
onSuccess: (d) =>
|
||||
d.refreshError
|
||||
? toast.warning(`复核失败,显示上次数据:${d.refreshError}`)
|
||||
: toast.success("已复核"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-muted-foreground">
|
||||
<ReleaseBadges r={r} showUnknownHr />
|
||||
<span className="text-foreground">{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 2)}</span>
|
||||
<span title="做种 / 下载 / 完成">
|
||||
<ArrowUp className="inline size-3" />
|
||||
{countText(r.seeders)}
|
||||
<ArrowDown className="ml-1.5 inline size-3" />
|
||||
{countText(r.leechers)}
|
||||
<Check className="ml-1.5 inline size-3" />
|
||||
{countText(snatched)}
|
||||
</span>
|
||||
<span title={r.sourcePublishedAt ? absTime(r.sourcePublishedAt) : undefined}>
|
||||
{r.sourcePublishedAt ? `${relTime(r.sourcePublishedAt)}上传` : "上传时间未知"}
|
||||
</span>
|
||||
<Button variant="outline" size="xs" className="ml-auto" disabled={refresh.isPending} onClick={doRefresh}>
|
||||
<RefreshCw className={refresh.isPending ? "animate-spin" : undefined} />
|
||||
复核详情
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{detail.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : detail.error ? (
|
||||
<ErrorState plain error={detail.error} onRetry={() => void detail.refetch()} />
|
||||
) : detail.data ? (
|
||||
<>
|
||||
{detail.data.refreshError ? (
|
||||
<p className="mb-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
最近一次复核失败,以下是过期数据:{detail.data.refreshError}
|
||||
</p>
|
||||
) : null}
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "促销观察于", value: formatDateTime(detail.data.volatile.promotionObservedAt) },
|
||||
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
|
||||
{
|
||||
label: "做种 / 下载",
|
||||
value: `${r.seeders ?? "—"} / ${r.leechers ?? "—"}(${relTime(detail.data.volatile.peersObservedAt)}观察)`,
|
||||
},
|
||||
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-meta text-muted-foreground">提交下载时会再次核对免费和做种状态。</p>
|
||||
</>
|
||||
</div>
|
||||
{page && (externalLinks(page).length || page.tags.length || page.uploader) ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<ExternalIdTags page={page} />
|
||||
{page.tags.map((t) => (
|
||||
<span key={t} className="rounded-md border border-border/70 px-1.5 leading-5">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{page.uploader ? <span className="ml-1">发布者 {page.uploader}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="站点详情页">
|
||||
{detail.data?.page ? (
|
||||
<ReleasePageContent page={detail.data.page} />
|
||||
) : refresh.isPending || detail.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : detail.data && !detail.data.detailSupported ? (
|
||||
<p className="text-caption text-muted-foreground">这个站点不支持抓详情页。</p>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
还没有详情页内容{detail.data?.refreshError ? `(${detail.data.refreshError})` : ""}
|
||||
。点「复核详情」抓取。
|
||||
{detail.data?.refreshError ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
|
||||
最近一次复核失败,下面是之前的数据:{detail.data.refreshError}
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{page ? (
|
||||
<ReleasePageSections page={page} />
|
||||
) : refresh.isPending || detail.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : detail.error ? (
|
||||
<ErrorState plain error={detail.error} onRetry={() => void detail.refetch()} />
|
||||
) : detail.data && !detail.data.detailSupported ? (
|
||||
<p className="text-caption text-muted-foreground">这个站点不支持抓详情页。</p>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">还没有详情页内容。点「复核详情」抓取。</p>
|
||||
)}
|
||||
|
||||
{!page?.fileTree.length && r.fileList?.length ? (
|
||||
<DetailSection title={`文件(${r.fileList.length})`}>
|
||||
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
|
||||
{r.fileList.map((f) => (
|
||||
<li key={f.path} className="flex justify-between gap-3">
|
||||
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{formatBytes(f.size, 1)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
<DetailSection title="本地库存与候选理由">
|
||||
<LocalInventory release={r} entry={entry} />
|
||||
</DetailSection>
|
||||
|
||||
{detail.data ? (
|
||||
<DetailSection title="数据时效">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "促销观察于", value: formatDateTime(detail.data.volatile.promotionObservedAt) },
|
||||
{ label: "促销截止", value: formatDateTime(detail.data.volatile.promotionExpiresAt) },
|
||||
{ label: "做种人数观察于", value: formatDateTime(detail.data.volatile.peersObservedAt) },
|
||||
{ label: "详情抓取于", value: formatDateTime(detail.data.volatile.detailFetchedAt) },
|
||||
]}
|
||||
/>
|
||||
<p className="mt-2 text-meta text-muted-foreground">提交下载时会再次核对免费和做种状态。</p>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
<DetailSection title="资源信息">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "分类", value: categoryLabel(r) },
|
||||
{ label: "大小", value: r.sizeBytes === null ? "未知" : formatBytes(r.sizeBytes, 2) },
|
||||
{ label: "H&R", value: { yes: "有", no: "无", unknown: "未知(按有义务处理)" }[hrOf(r)] },
|
||||
{ label: "站点上传", value: formatDateTime(r.sourcePublishedAt) },
|
||||
{ label: "首次发现", value: formatDateTime(r.firstSeenAt) },
|
||||
{ label: "最近确认", value: `${formatDateTime(r.dataUpdatedAt)}(${ORIGIN_LABEL[r.origin]})` },
|
||||
{ label: "站点上传", value: formatDateTime(r.sourcePublishedAt) },
|
||||
{ label: "最近确认", value: `${formatDateTime(r.dataUpdatedAt)}(${ORIGIN_LABEL[r.origin]})` },
|
||||
{ label: "Feed 观察次数", value: r.observedByFeeds },
|
||||
{ label: "站点 ID", value: r.siteReleaseId, mono: true },
|
||||
{ label: "infohash", value: r.infohashV1 ?? r.infohashV2, mono: true, wide: true },
|
||||
@@ -277,19 +295,6 @@ export function ReleaseDetailSheet({
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{r.fileList?.length ? (
|
||||
<DetailSection title={`文件(${r.fileList.length})`}>
|
||||
<ul className="max-h-60 space-y-1 overflow-auto text-meta">
|
||||
{r.fileList.map((f) => (
|
||||
<li key={f.path} className="flex justify-between gap-3">
|
||||
<span className="min-w-0 font-mono break-all text-foreground">{f.path}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{formatBytes(f.size, 1)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
<JsonView label="解析结果" value={r.parsed} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,99 +1,381 @@
|
||||
import type { SiteReleasePage } from "@umt/contracts";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import type { SiteFileTreeRow, SiteMediaSummary, SiteReleasePage } from "@umt/contracts";
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, ExternalLink, FileVideo, Folder, FolderOpen } from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
/** 详情页扩展内容里的外部站点链接 */
|
||||
export function externalLinks(p: Pick<SiteReleasePage, "imdbId" | "doubanId" | "tmdb">) {
|
||||
const out: { label: string; href: string }[] = [];
|
||||
if (p.tmdb) out.push({ label: `TMDB ${p.tmdb.id}`, href: `https://www.themoviedb.org/${p.tmdb.type}/${p.tmdb.id}` });
|
||||
if (p.imdbId) out.push({ label: `IMDb ${p.imdbId}`, href: `https://www.imdb.com/title/${p.imdbId}/` });
|
||||
if (p.doubanId) out.push({ label: `豆瓣 ${p.doubanId}`, href: `https://movie.douban.com/subject/${p.doubanId}/` });
|
||||
if (p.imdbId) out.push({ label: "IMDb", href: `https://www.imdb.com/title/${p.imdbId}/` });
|
||||
if (p.tmdb) out.push({ label: "TMDB", href: `https://www.themoviedb.org/${p.tmdb.type}/${p.tmdb.id}` });
|
||||
if (p.doubanId) out.push({ label: "豆瓣", href: `https://movie.douban.com/subject/${p.doubanId}/` });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ReleasePageContent({ page }: { page: SiteReleasePage }) {
|
||||
/** IMDb / TMDB / 豆瓣小标签(新窗口打开) */
|
||||
export function ExternalIdTags({ page }: { page: Pick<SiteReleasePage, "imdbId" | "doubanId" | "tmdb"> }) {
|
||||
const links = externalLinks(page);
|
||||
const empty =
|
||||
!links.length &&
|
||||
!page.uploader &&
|
||||
!page.tags.length &&
|
||||
!page.descriptionText &&
|
||||
!page.mediaInfo &&
|
||||
!page.screenshots.length &&
|
||||
!page.files.length;
|
||||
if (!links.length) return null;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{links.length || page.uploader ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-caption">
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 text-foreground underline underline-offset-4"
|
||||
>
|
||||
{l.label}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
))}
|
||||
{page.uploader ? <span className="text-muted-foreground">上传者 {page.uploader}</span> : null}
|
||||
<>
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={l.href}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-1.5 text-meta leading-5 text-foreground hover:bg-muted"
|
||||
>
|
||||
{l.label}
|
||||
<ExternalLink className="size-3 text-muted-foreground" />
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 长文本:超出时折叠,点「展开」看全部 */
|
||||
function Clamp({ children, long, lines = 3 }: { children: ReactNode; long: boolean; lines?: 3 | 6 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className={cn(long && !open && (lines === 3 ? "line-clamp-3" : "line-clamp-6"))}>{children}</div>
|
||||
{long ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? "收起" : "展开"}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTable({ items }: { items: SiteReleasePage["infoBlock"] }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-[minmax(4.5rem,auto)_1fr] gap-x-4 gap-y-1.5 text-caption">
|
||||
{items.map((it) => (
|
||||
<div key={it.key} className="contents">
|
||||
<dt className="text-muted-foreground">{it.key}</dt>
|
||||
<dd className="min-w-0 break-words whitespace-pre-line text-foreground">
|
||||
<Clamp long={it.value.length > 90}>{it.value}</Clamp>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{page.tags.length ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{page.tags.map((t) => (
|
||||
<StatusBadge key={t}>{t}</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{page.descriptionText ? (
|
||||
<p className="max-h-72 overflow-auto text-caption break-words whitespace-pre-wrap text-foreground">
|
||||
{page.descriptionText}
|
||||
</p>
|
||||
) : null}
|
||||
{page.mediaInfo ? (
|
||||
<details className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<summary className="cursor-pointer text-caption text-foreground">MediaInfo</summary>
|
||||
<pre className="mt-2 max-h-80 overflow-auto font-mono text-meta whitespace-pre text-muted-foreground">
|
||||
{page.mediaInfo}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{page.screenshots.length ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{page.screenshots.map((s, i) => (
|
||||
<a key={s.url} href={s.url} target="_blank" rel="noreferrer noopener" title={s.host}>
|
||||
<img
|
||||
src={s.url}
|
||||
alt={`截图 ${i + 1}`}
|
||||
loading="lazy"
|
||||
className="aspect-video w-full rounded-md border border-border/70 bg-muted object-cover"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{page.files.length ? (
|
||||
<details className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<summary className="cursor-pointer text-caption text-foreground">文件清单({page.files.length})</summary>
|
||||
<ul className="mt-2 max-h-60 space-y-1 overflow-auto text-meta">
|
||||
{page.files.map((f, i) => (
|
||||
<li key={`${i}-${f.name}`} className="flex justify-between gap-3">
|
||||
<span className="min-w-0 font-mono break-all text-foreground">{f.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{f.sizeBytes === null ? "" : formatBytes(f.sizeBytes, 1)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{empty ? <p className="text-caption text-muted-foreground">详情页上没有解析到简介、截图或外部链接。</p> : null}
|
||||
<p className="text-meta text-muted-foreground">
|
||||
抓取于 {formatDateTime(page.fetchedAt)},缓存 24 小时;截图经服务端代理加载。
|
||||
</p>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function Synopsis({ paragraphs }: { paragraphs: string[] }) {
|
||||
const total = paragraphs.reduce((n, p) => n + p.length, 0);
|
||||
return (
|
||||
<div className="text-caption leading-relaxed text-foreground">
|
||||
<Clamp long={total > 320 || paragraphs.length > 4} lines={6}>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={`${i}-${p.slice(0, 16)}`} className={cn("break-words", i > 0 && "mt-2")}>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</Clamp>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dot = (xs: (string | null | undefined)[]) => xs.filter(Boolean).join(" · ");
|
||||
|
||||
function TrackTable({ head, rows }: { head: string[]; rows: ReactNode[][] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-meta">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
{head.map((h) => (
|
||||
<th key={h} className="py-1 pr-3 font-normal whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground">
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="border-t border-border/60">
|
||||
{r.map((c, j) => (
|
||||
<td key={j} className={cn("py-1 pr-3 align-top", j < r.length - 1 && "whitespace-nowrap")}>
|
||||
{c ?? "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const flag = (on: boolean | null, label: string) =>
|
||||
on ? <span className="rounded-md border border-border px-1 text-micro text-muted-foreground">{label}</span> : null;
|
||||
|
||||
function TechSpecs({ media, raw }: { media: SiteMediaSummary | null; raw: string | null }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{media ? (
|
||||
<>
|
||||
<p className="text-caption text-foreground">
|
||||
{dot([media.general.container, media.general.duration, media.general.overallBitrate, media.general.size]) ||
|
||||
"—"}
|
||||
</p>
|
||||
{media.video.length ? (
|
||||
<TrackTable
|
||||
head={["视频", "分辨率", "帧率", "码率", "位深", "HDR"]}
|
||||
rows={media.video.map((v) => [
|
||||
v.codec,
|
||||
v.resolution,
|
||||
v.frameRate,
|
||||
v.bitrate,
|
||||
v.bitDepth,
|
||||
v.hdr.length ? v.hdr.join(" + ") : "SDR",
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
{media.audio.length ? (
|
||||
<TrackTable
|
||||
head={[`音轨(${media.audio.length})`, "编码", "声道", "码率", "标题"]}
|
||||
rows={media.audio.map((a) => [
|
||||
<span key="l" className="inline-flex items-center gap-1">
|
||||
{a.language ?? "未标语言"}
|
||||
{flag(a.isDefault, "默认")}
|
||||
</span>,
|
||||
a.codec,
|
||||
a.channels,
|
||||
a.bitrate,
|
||||
a.title,
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
{media.subtitles.length ? (
|
||||
<TrackTable
|
||||
head={[`字幕(${media.subtitles.length})`, "格式", "标题"]}
|
||||
rows={media.subtitles.map((s) => [
|
||||
<span key="l" className="inline-flex items-center gap-1">
|
||||
{s.language ?? "未标语言"}
|
||||
{flag(s.isDefault, "默认")}
|
||||
{flag(s.forced, "强制")}
|
||||
</span>,
|
||||
s.format,
|
||||
s.title,
|
||||
])}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{raw ? (
|
||||
<details className="rounded-lg border border-border/70 px-3 py-2" open={!media}>
|
||||
<summary className="cursor-pointer text-caption text-muted-foreground">
|
||||
{media?.kind === "bdinfo" ? "BDInfo 原文" : "MediaInfo 原文"}
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-80 overflow-auto font-mono text-meta whitespace-pre text-muted-foreground">
|
||||
{raw}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 截图网格 + 灯箱(左右方向键切换,Esc 关闭) */
|
||||
function Screenshots({ shots }: { shots: SiteReleasePage["screenshots"] }) {
|
||||
const [open, setOpen] = useState<number | null>(null);
|
||||
const n = shots.length;
|
||||
const go = (d: number) => setOpen((i) => (i === null ? i : (i + d + n) % n));
|
||||
const cur = open === null ? null : shots[open];
|
||||
return (
|
||||
<>
|
||||
<div className={cn("grid gap-2", n === 1 ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3")}>
|
||||
{shots.map((s, i) => (
|
||||
<button
|
||||
key={s.url}
|
||||
type="button"
|
||||
onClick={() => setOpen(i)}
|
||||
title={s.host}
|
||||
aria-label={`查看截图 ${i + 1}`}
|
||||
className="overflow-hidden rounded-md border border-border/70 bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<img
|
||||
src={s.url}
|
||||
alt={`截图 ${i + 1}`}
|
||||
loading="lazy"
|
||||
className={cn("w-full", n === 1 ? "max-h-96 object-contain" : "aspect-video object-cover")}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Dialog open={open !== null} onOpenChange={(o) => !o && setOpen(null)}>
|
||||
<DialogContent
|
||||
className="gap-3 p-3 sm:max-w-5xl"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") go(-1);
|
||||
else if (e.key === "ArrowRight") go(1);
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">截图</DialogTitle>
|
||||
<DialogDescription className="sr-only">左右方向键切换</DialogDescription>
|
||||
{cur ? (
|
||||
<img
|
||||
src={cur.url}
|
||||
alt={`截图 ${(open ?? 0) + 1}`}
|
||||
className="max-h-[78vh] w-full rounded-lg bg-muted object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2 pr-10">
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{(open ?? 0) + 1} / {n}
|
||||
{cur?.host ? ` · ${cur.host}` : ""}
|
||||
</span>
|
||||
{n > 1 ? (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="icon-sm" aria-label="上一张" onClick={() => go(-1)}>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon-sm" aria-label="下一张" onClick={() => go(1)}>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_TAG: Partial<Record<NonNullable<SiteFileTreeRow["role"]>, string>> = {
|
||||
sample: "Sample",
|
||||
subtitle: "字幕",
|
||||
extra: "附件",
|
||||
};
|
||||
|
||||
/** 文件目录树:目录可折叠,文件多时默认只展开第一层 */
|
||||
export function FileTree({ rows }: { rows: SiteFileTreeRow[] }) {
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(
|
||||
() => new Set(rows.length > 40 ? rows.filter((r) => r.dir && r.depth >= 1).map((r) => r.path) : []),
|
||||
);
|
||||
const visible = useMemo(
|
||||
() => rows.filter((r) => ![...collapsed].some((c) => r.path.startsWith(`${c}/`))),
|
||||
[rows, collapsed],
|
||||
);
|
||||
const toggle = (p: string) =>
|
||||
setCollapsed((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(p)) n.delete(p);
|
||||
else n.add(p);
|
||||
return n;
|
||||
});
|
||||
return (
|
||||
<ul className="max-h-80 overflow-auto text-meta">
|
||||
{visible.map((r) => {
|
||||
const pad = { paddingLeft: `${r.depth * 1.25}rem` };
|
||||
if (r.dir) {
|
||||
const shut = collapsed.has(r.path);
|
||||
const Icon = shut ? Folder : FolderOpen;
|
||||
return (
|
||||
<li key={r.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(r.path)}
|
||||
aria-expanded={!shut}
|
||||
className="flex w-full items-center gap-1.5 rounded-md py-1 pr-1 text-left hover:bg-muted"
|
||||
style={pad}
|
||||
>
|
||||
<ChevronDown className={cn("size-3 shrink-0 text-muted-foreground", shut && "-rotate-90")} />
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{r.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{dot([
|
||||
r.fileCount ? `${r.fileCount} 个文件` : null,
|
||||
r.sizeBytes ? formatBytes(r.sizeBytes, 1) : null,
|
||||
])}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const main = r.role === "main";
|
||||
const tag = r.role ? ROLE_TAG[r.role] : undefined;
|
||||
return (
|
||||
<li key={r.path} className="flex items-center gap-1.5 py-1 pr-1" style={pad}>
|
||||
<span className="size-3 shrink-0" />
|
||||
{main ? (
|
||||
<FileVideo className="size-3.5 shrink-0 text-foreground" />
|
||||
) : (
|
||||
<span className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn("min-w-0 flex-1 break-all", main ? "text-foreground" : "text-muted-foreground")}
|
||||
title={r.path}
|
||||
>
|
||||
{r.name}
|
||||
</span>
|
||||
{tag ? (
|
||||
<span className="shrink-0 rounded-md border border-border/70 px-1 text-micro text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="w-16 shrink-0 text-right text-muted-foreground">
|
||||
{r.sizeBytes === null ? "" : formatBytes(r.sizeBytes, 1)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** 资料 → 简介 → 技术规格 → 截图 → 文件;没内容的节不出现 */
|
||||
export function ReleasePageSections({ page }: { page: SiteReleasePage }) {
|
||||
const fileCount = page.fileTree.filter((r) => !r.dir).length;
|
||||
const empty =
|
||||
!page.infoBlock.length &&
|
||||
!page.paragraphs.length &&
|
||||
!page.media &&
|
||||
!page.mediaInfo &&
|
||||
!page.screenshots.length &&
|
||||
!page.fileTree.length;
|
||||
return (
|
||||
<>
|
||||
{page.infoBlock.length ? (
|
||||
<DetailSection title="资料">
|
||||
<InfoTable items={page.infoBlock} />
|
||||
</DetailSection>
|
||||
) : null}
|
||||
{page.paragraphs.length ? (
|
||||
<DetailSection title="简介">
|
||||
<Synopsis paragraphs={page.paragraphs} />
|
||||
</DetailSection>
|
||||
) : null}
|
||||
{page.media || page.mediaInfo ? (
|
||||
<DetailSection title="技术规格">
|
||||
<TechSpecs media={page.media} raw={page.mediaInfo} />
|
||||
</DetailSection>
|
||||
) : null}
|
||||
{page.screenshots.length ? (
|
||||
<DetailSection title={`截图(${page.screenshots.length})`}>
|
||||
<Screenshots shots={page.screenshots} />
|
||||
</DetailSection>
|
||||
) : null}
|
||||
{page.fileTree.length ? (
|
||||
<DetailSection title={`文件(${fileCount})`}>
|
||||
<FileTree rows={page.fileTree} />
|
||||
</DetailSection>
|
||||
) : null}
|
||||
{empty ? (
|
||||
<p className="text-caption text-muted-foreground">详情页上没有简介、技术规格、截图和文件清单。</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user