feat(web): W3-3 site workbench, sources/feeds/snapshots, downloads with business timeline, organization records and agent sessions
- sites + /sites/:siteId: organized view with snapshot-first release list, inventory overlay, candidate reasons, volatile-field recheck, stale marking that keeps existing data, live search, unified download entry; origin view placeholder (M2) - sources: feed CRUD/enable/fetch/reset, cursor/health/gap hints; per-site snapshot history - tasks: download tasks with pause/resume (threshold pause cannot be bypassed), plans, cache volume thresholds and cleanup, business timeline (observation → plan → download → organization → agent/engine) - organize: record list/detail with file-level results, manual organize with dryRun preview - agent: session list/page and layout drawer with context prefix; streaming events, tool cards, rejection alerts, compaction, budget meters, model text vs server report; mock switch (?mock=1) until W2-D endpoints land Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6280d52ec3
commit
7103bc8f7c
35 files changed
+6134
-45
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
|
||||
import { Bot, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { isMissingEndpoint } from "~/api/parse.ts";
|
||||
import { useSubmitRelease } from "~/api/releases.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const STEPS = [
|
||||
"生成下载计划并预留 Slot 与预算(同一 Slot 同时只有一个在途计划)",
|
||||
"去重:已有相同资源或同 hash 任务时走接管,不重复提交",
|
||||
"缓存卷硬阈值检查:超过阈值或读不到磁盘状态时阻止新提交,不能手动绕过",
|
||||
"选择下载器实例并打管理标签提交;下单前复核免费/做种等易变字段",
|
||||
];
|
||||
|
||||
/**
|
||||
* 统一下载入口:与自动收录走同一提交流程,不在前端直接调下载器。
|
||||
*/
|
||||
export function DownloadDialog({
|
||||
release,
|
||||
slots,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
release: WorkbenchRelease;
|
||||
slots: AcquisitionSlot[];
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const submit = useSubmitRelease();
|
||||
const [slotId, setSlotId] = useState("");
|
||||
const [missing, setMissing] = useState(false);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
const go = () =>
|
||||
submit.mutate(
|
||||
{ sourceReleaseId: release.id, ...(slotId ? { slotId } : {}) },
|
||||
{
|
||||
onSuccess: (plan) => {
|
||||
setDone(plan.id);
|
||||
toast.success(`已生成下载计划(${plan.status}),进度见“下载与任务”`);
|
||||
},
|
||||
onError: (e) => {
|
||||
if (isMissingEndpoint(e)) setMissing(true);
|
||||
else toast.error(errorMessage(e));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setMissing(false);
|
||||
setDone(null);
|
||||
}
|
||||
onOpenChange(o);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>下载这个资源</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{release.title}
|
||||
{release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-caption text-muted-foreground">
|
||||
{STEPS.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
{slots.length ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
label="版本目标"
|
||||
value={slotId}
|
||||
onChange={setSlotId}
|
||||
allLabel="自动匹配"
|
||||
options={slots.map((s) => ({ value: s.id, label: `${s.name}(${s.status})` }))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{release.entity
|
||||
? "该作品还没有版本目标,提交时按默认规则建临时目标。"
|
||||
: "资源尚未识别到本地作品,提交后先进入识别。"}
|
||||
</p>
|
||||
)}
|
||||
{missing ? (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
||||
后端还没有开放手动提交端点(POST /acquisition-plans)。可以委托 Agent 用 download.plan / download.submit
|
||||
能力走同一流程提交。
|
||||
</div>
|
||||
) : null}
|
||||
{done ? (
|
||||
<p className="text-caption text-success">
|
||||
计划已生成。
|
||||
<Link to="/console/tasks" className="underline underline-offset-4">
|
||||
去“下载与任务”查看
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
openAgentDrawer({
|
||||
label: `资源:${release.title}`,
|
||||
releaseId: release.id,
|
||||
entityId: release.entity?.id,
|
||||
siteId: release.siteId,
|
||||
goal: "判断这个资源是否值得下载;合适就按正常流程提交",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Bot />
|
||||
委托 Agent
|
||||
</Button>
|
||||
<Button onClick={go} disabled={submit.isPending || !!done}>
|
||||
<Download />
|
||||
{submit.isPending ? "提交中…" : "提交下载"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
type FeedPurpose,
|
||||
type FeedSource,
|
||||
FeedSourceCreate,
|
||||
FeedSourceUpdate,
|
||||
type Site,
|
||||
type SiteAccount,
|
||||
} from "@umt/contracts";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateFeed, useUpdateFeed } from "~/api/feeds.ts";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
export const PURPOSE_LABEL: Record<FeedPurpose, string> = {
|
||||
movie: "电影",
|
||||
series: "剧集",
|
||||
variety: "综艺",
|
||||
promotion: "促销",
|
||||
search: "搜索",
|
||||
generic: "通用",
|
||||
};
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
type Draft = {
|
||||
name: string;
|
||||
url: string;
|
||||
purpose: FeedPurpose;
|
||||
siteId: string;
|
||||
siteAccountId: string;
|
||||
pollMin: string;
|
||||
filter: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
function initial(feed: FeedSource | null): Draft {
|
||||
return {
|
||||
name: feed?.name ?? "",
|
||||
url: "",
|
||||
purpose: feed?.purpose ?? "generic",
|
||||
siteId: feed?.siteId ?? "",
|
||||
siteAccountId: feed?.siteAccountId ?? "",
|
||||
pollMin: String(Math.round((feed?.pollIntervalSec ?? 900) / 60)),
|
||||
filter: feed && Object.keys(feed.filter).length ? JSON.stringify(feed.filter, null, 2) : "",
|
||||
enabled: feed?.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 新建 / 编辑 Feed:URL 只写(含 passkey),编辑时留空表示不变 */
|
||||
export function FeedFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
feed,
|
||||
sites,
|
||||
accounts,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
feed: FeedSource | null;
|
||||
sites: Site[];
|
||||
accounts: SiteAccount[];
|
||||
}) {
|
||||
const [d, setD] = useState<Draft>(() => initial(feed));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const create = useCreateFeed();
|
||||
const update = useUpdateFeed();
|
||||
const pending = create.isPending || update.isPending;
|
||||
const siteAccounts = accounts.filter((a) => a.siteId === d.siteId);
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v }));
|
||||
|
||||
const submit = () => {
|
||||
let filter: Record<string, unknown> | undefined;
|
||||
if (d.filter.trim()) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(d.filter);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not object");
|
||||
filter = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
setErrors({ filter: "过滤条件必须是 JSON 对象" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const raw = {
|
||||
name: d.name.trim(),
|
||||
purpose: d.purpose,
|
||||
siteId: d.siteId || null,
|
||||
siteAccountId: d.siteAccountId || null,
|
||||
pollIntervalSec: Math.round(Number(d.pollMin) * 60),
|
||||
enabled: d.enabled,
|
||||
...(filter !== undefined ? { filter } : feed ? { filter: {} } : {}),
|
||||
...(d.url.trim() ? { url: d.url.trim() } : {}),
|
||||
};
|
||||
const parsed = feed ? FeedSourceUpdate.safeParse(raw) : FeedSourceCreate.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const e = zodFieldErrors(parsed.error.issues);
|
||||
if (e.pollIntervalSec) e.pollMin = "轮询间隔至少 1 分钟";
|
||||
if (e.url) e.url = feed ? "URL 格式不对" : "需要完整的 Feed URL";
|
||||
if (e.name) e.name = "名称不能为空";
|
||||
setErrors(e);
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
const done = {
|
||||
onSuccess: () => {
|
||||
toast.success(feed ? "已保存" : "已新建 Feed,会按轮询策略抓取");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (e: unknown) => toast.error(errorMessage(e)),
|
||||
};
|
||||
if (feed) update.mutate({ id: feed.id, body: parsed.data as FeedSourceUpdate }, done);
|
||||
else create.mutate(parsed.data as FeedSourceCreate, done);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{feed ? "编辑 Feed" : "新建 Feed"}</DialogTitle>
|
||||
<DialogDescription>URL 可能含 passkey,只写入加密凭证,之后只显示脱敏地址。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<FormField label="名称" htmlFor="feed-name" required error={errors.name}>
|
||||
<Input
|
||||
id="feed-name"
|
||||
value={d.name}
|
||||
aria-invalid={!!errors.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="URL"
|
||||
htmlFor="feed-url"
|
||||
required={!feed}
|
||||
error={errors.url}
|
||||
hint={feed ? `当前:${feed.urlDisplay};留空表示不变` : "RSS / Atom 地址"}
|
||||
>
|
||||
<Input
|
||||
id="feed-url"
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
value={d.url}
|
||||
aria-invalid={!!errors.url}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => set("url", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid gap-x-3 sm:grid-cols-2">
|
||||
<FormField label="用途" htmlFor="feed-purpose">
|
||||
<Select value={d.purpose} onValueChange={(v) => set("purpose", v as FeedPurpose)}>
|
||||
<SelectTrigger id="feed-purpose" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{Object.entries(PURPOSE_LABEL).map(([v, l]) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{l}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField label="轮询间隔(分钟)" htmlFor="feed-poll" error={errors.pollMin}>
|
||||
<Input
|
||||
id="feed-poll"
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={d.pollMin}
|
||||
aria-invalid={!!errors.pollMin}
|
||||
onChange={(e) => set("pollMin", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="站点" htmlFor="feed-site">
|
||||
<Select
|
||||
value={d.siteId || NONE}
|
||||
onValueChange={(v) => setD((x) => ({ ...x, siteId: v === NONE ? "" : v, siteAccountId: "" }))}
|
||||
>
|
||||
<SelectTrigger id="feed-site" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={NONE}>不关联站点</SelectItem>
|
||||
{sites.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="站点账号"
|
||||
htmlFor="feed-account"
|
||||
hint={d.siteId && !siteAccounts.length ? "该站点没有账号" : undefined}
|
||||
>
|
||||
<Select
|
||||
value={d.siteAccountId || NONE}
|
||||
disabled={!d.siteId}
|
||||
onValueChange={(v) => set("siteAccountId", v === NONE ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="feed-account" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={NONE}>不指定</SelectItem>
|
||||
{siteAccounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="过滤条件(JSON,可选)"
|
||||
htmlFor="feed-filter"
|
||||
error={errors.filter}
|
||||
hint='如 {"include":["2160p"],"exclude":["CAM"]}'
|
||||
>
|
||||
<Textarea
|
||||
id="feed-filter"
|
||||
rows={3}
|
||||
className="font-mono text-caption"
|
||||
value={d.filter}
|
||||
aria-invalid={!!errors.filter}
|
||||
onChange={(e) => set("filter", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between gap-3 py-1 text-caption text-foreground">
|
||||
<label htmlFor="feed-enabled">启用(关闭后停止轮询,历史观察保留)</label>
|
||||
<Switch id="feed-enabled" checked={d.enabled} onCheckedChange={(v) => set("enabled", v)} />
|
||||
</div>
|
||||
<DialogFooter className="mt-2 gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { Bot, Download, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEntityDetail } from "~/api/inventory.ts";
|
||||
import { useRefreshReleaseDetail, useReleaseDetail } from "~/api/releases.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
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 { InventoryOverlay, ReleaseBadges } from "./release-list.tsx";
|
||||
|
||||
const DECISION_LABEL = {
|
||||
accept: { label: "符合,可收录", tone: "success" },
|
||||
upgrade: { label: "可升级", tone: "success" },
|
||||
reject: { label: "不收录", tone: "destructive" },
|
||||
unknown_field: { label: "缺字段,待复核", tone: "warning" },
|
||||
skipped: { label: "被跳过", tone: "neutral" },
|
||||
} as const;
|
||||
|
||||
/** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */
|
||||
function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry: InventoryEntry | undefined }) {
|
||||
const detail = useEntityDetail(release.entity?.id);
|
||||
if (!release.entity) {
|
||||
return (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
尚未识别到本地作品({release.resolutionStatus}),不显示库存推断。
|
||||
<Link to="/console/identity" className="ml-1 underline underline-offset-4">
|
||||
去身份与元数据处理
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const candidates = detail.data?.candidates.filter((c) => c.sourceReleaseId === release.id) ?? [];
|
||||
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) => (
|
||||
<li key={v.assetId} className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge tone={v.available ? "success" : "warning"}>{v.available ? "可用" : "不可用"}</StatusBadge>
|
||||
<span className="text-foreground">
|
||||
{[v.resolution, v.sourceType, v.releaseGroup, v.contentEdition].filter(Boolean).join(" · ") ||
|
||||
"版本特征未知"}
|
||||
</span>
|
||||
{v.sizeBytes ? <span className="text-muted-foreground">{formatBytes(v.sizeBytes, 1)}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{detail.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : detail.error ? (
|
||||
<p className="text-meta text-destructive">作品详情读取失败:{errorMessage(detail.error)}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{slots.length ? `${slots.length} 个版本目标(已订阅)` : "没有版本目标(未订阅)"}
|
||||
</p>
|
||||
{candidates.length ? (
|
||||
<ul className="space-y-2">
|
||||
{candidates.map((c) => {
|
||||
const d = DECISION_LABEL[c.decision];
|
||||
const slot = slots.find((s) => s.id === c.slotId);
|
||||
return (
|
||||
<li key={`${c.slotId}-${c.evaluatedAt}`} className="rounded-lg border border-border/70 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-caption">
|
||||
<StatusBadge tone={d.tone}>{d.label}</StatusBadge>
|
||||
<span className="text-foreground">{slot ? `目标 ${slot.name}` : "版本目标"}</span>
|
||||
<span className="text-muted-foreground">{relTime(c.evaluatedAt)}评估</span>
|
||||
</div>
|
||||
{c.reasons.length ? (
|
||||
<ul className="mt-1 list-disc pl-5 text-meta text-muted-foreground">
|
||||
{c.reasons.map((r) => (
|
||||
<li key={r}>{r}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{c.rejected.length ? (
|
||||
<ul className="mt-1 list-disc pl-5 text-meta text-destructive">
|
||||
{c.rejected.map((r) => (
|
||||
<li key={r.code}>{r.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">规则引擎还没评估过这条资源。</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseDetailSheet({
|
||||
siteId,
|
||||
release,
|
||||
entry,
|
||||
onClose,
|
||||
}: {
|
||||
siteId: string;
|
||||
release: WorkbenchRelease | null;
|
||||
entry: InventoryEntry | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const detail = useReleaseDetail(siteId, release?.id);
|
||||
const refresh = useRefreshReleaseDetail();
|
||||
const entity = useEntityDetail(release?.entity?.id);
|
||||
const [dl, setDl] = useState(false);
|
||||
const r = detail.data?.release ?? release;
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open={!!release}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
width="lg"
|
||||
title={r?.title ?? ""}
|
||||
description={r?.subtitle ?? undefined}
|
||||
footer={
|
||||
r ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
openAgentDrawer({
|
||||
label: `资源:${r.title}`,
|
||||
releaseId: r.id,
|
||||
entityId: r.entity?.id,
|
||||
siteId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Bot />问 Agent
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setDl(true)}>
|
||||
<Download />
|
||||
下载
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!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)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="本地库存与候选理由">
|
||||
<LocalInventory release={r} entry={entry} />
|
||||
</DetailSection>
|
||||
|
||||
<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: "Feed 观察次数", value: r.observedByFeeds },
|
||||
{ label: "站点资源 ID", value: r.siteReleaseId, mono: true },
|
||||
{ label: "infohash", value: r.infohashV1 ?? r.infohashV2, mono: true, wide: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
{detail.data?.observations.length ? (
|
||||
<DetailSection title="Feed 观察">
|
||||
<ul className="space-y-1 text-caption">
|
||||
{detail.data.observations.map((o) => (
|
||||
<li key={o.observationId} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="text-foreground">{o.feedName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDateTime(o.firstSeenAt)} → {relTime(o.lastSeenAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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} />
|
||||
</>
|
||||
)}
|
||||
{r ? <DownloadDialog release={r} slots={entity.data?.slots ?? []} open={dl} onOpenChange={setDl} /> : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Site, SiteAccount, SiteHealth, SourceRelease, WorkbenchRelease } from "@umt/contracts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// 站点资源字段的展示口径:缺失字段按"未知"显示,不当成 0 / 否。
|
||||
|
||||
type SiteExtra = { hr?: unknown; categoryName?: unknown; snatched?: unknown; detailUrl?: unknown };
|
||||
|
||||
export function siteExtra(r: Pick<SourceRelease, "raw">): SiteExtra {
|
||||
const s = (r.raw as { _site?: SiteExtra })._site;
|
||||
return s && typeof s === "object" ? s : {};
|
||||
}
|
||||
|
||||
/** H&R 三态 */
|
||||
export function hrOf(r: Pick<SourceRelease, "raw">): "yes" | "no" | "unknown" {
|
||||
const hr = siteExtra(r).hr;
|
||||
return hr === true ? "yes" : hr === false ? "no" : "unknown";
|
||||
}
|
||||
|
||||
export function categoryLabel(r: Pick<SourceRelease, "raw" | "categoryRaw">): string | null {
|
||||
const name = siteExtra(r).categoryName;
|
||||
return typeof name === "string" && name ? name : r.categoryRaw;
|
||||
}
|
||||
|
||||
const PROMO_LABEL: Record<string, string> = {
|
||||
free: "免费",
|
||||
"2xfree": "2x 免费",
|
||||
"50%": "50%",
|
||||
"2x50%": "2x 50%",
|
||||
"30%": "30%",
|
||||
"2x": "2x 上传",
|
||||
};
|
||||
|
||||
/** 促销标签;none / unknown 返回 null */
|
||||
export function promotionLabel(r: Pick<SourceRelease, "promotion" | "promotionExpiresAt">, now = Date.now()) {
|
||||
const kind = r.promotion.kind;
|
||||
if (!kind || kind === "none" || kind === "unknown") return null;
|
||||
const expired = r.promotionExpiresAt ? new Date(r.promotionExpiresAt).getTime() < now : false;
|
||||
return { label: PROMO_LABEL[kind] ?? kind, free: kind === "free" || kind === "2xfree", expired };
|
||||
}
|
||||
|
||||
export const isFree = (r: Pick<SourceRelease, "promotion">) =>
|
||||
r.promotion.kind === "free" || r.promotion.kind === "2xfree";
|
||||
|
||||
/** 相对时间(中文);未来时间显示"x 后" */
|
||||
export function relTime(iso: string | null | undefined, now = Date.now()): string {
|
||||
if (!iso) return "—";
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return iso;
|
||||
const diff = now - t;
|
||||
const abs = Math.abs(diff);
|
||||
const suffix = diff >= 0 ? "前" : "后";
|
||||
if (abs < 60_000) return diff >= 0 ? "刚刚" : "即将";
|
||||
if (abs < 3_600_000) return `${Math.round(abs / 60_000)} 分钟${suffix}`;
|
||||
if (abs < 86_400_000) return `${Math.round(abs / 3_600_000)} 小时${suffix}`;
|
||||
if (abs < 30 * 86_400_000) return `${Math.round(abs / 86_400_000)} 天${suffix}`;
|
||||
return new Date(iso).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
export const ORIGIN_LABEL: Record<WorkbenchRelease["origin"], string> = {
|
||||
snapshot: "快照",
|
||||
feed: "Feed",
|
||||
live: "实时补查",
|
||||
unknown: "来源未知",
|
||||
};
|
||||
|
||||
export const SITE_HEALTH: Record<SiteHealth, { label: string; tone: StatusTone }> = {
|
||||
unknown: { label: "健康未知", tone: "neutral" },
|
||||
healthy: { label: "健康", tone: "success" },
|
||||
degraded: { label: "降级", tone: "warning" },
|
||||
failing: { label: "故障", tone: "destructive" },
|
||||
disabled: { label: "已停用", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const ACCOUNT_STATUS: Record<SiteAccount["status"], { label: string; tone: StatusTone }> = {
|
||||
unknown: { label: "未验证", tone: "neutral" },
|
||||
active: { label: "有效", tone: "success" },
|
||||
invalid: { label: "凭证无效", tone: "destructive" },
|
||||
expired: { label: "已过期", tone: "warning" },
|
||||
};
|
||||
|
||||
/** 站点下的账号总体状态:有效优先,其次最差的那个 */
|
||||
export function accountSummary(accounts: SiteAccount[]): { label: string; tone: StatusTone } {
|
||||
const enabled = accounts.filter((a) => a.enabled);
|
||||
if (!enabled.length) return { label: accounts.length ? "账号已停用" : "未配置账号", tone: "neutral" };
|
||||
if (enabled.some((a) => a.status === "active")) return ACCOUNT_STATUS.active;
|
||||
for (const s of ["invalid", "expired", "unknown"] as const)
|
||||
if (enabled.some((a) => a.status === s)) return ACCOUNT_STATUS[s];
|
||||
return ACCOUNT_STATUS.unknown;
|
||||
}
|
||||
|
||||
export function circuitLabel(site: Pick<Site, "circuitState" | "circuitOpenUntil">): string | null {
|
||||
if (site.circuitState === "closed") return null;
|
||||
if (site.circuitState === "half_open") return "熔断半开(试探中)";
|
||||
return `熔断中${site.circuitOpenUntil ? `,${relTime(site.circuitOpenUntil)}恢复` : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, Clock } from "lucide-react";
|
||||
import { inventoryFlags } from "~/api/inventory.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { categoryLabel, hrOf, ORIGIN_LABEL, promotionLabel, relTime } from "./release-fields.ts";
|
||||
|
||||
/** 促销 / H&R 徽章(列表与详情共用) */
|
||||
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
const promo = promotionLabel(r);
|
||||
const hr = hrOf(r);
|
||||
return (
|
||||
<>
|
||||
{promo ? (
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}>
|
||||
{promo.label}
|
||||
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||||
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */
|
||||
export function InventoryOverlay({
|
||||
release,
|
||||
entry,
|
||||
loading,
|
||||
}: {
|
||||
release: WorkbenchRelease;
|
||||
entry: InventoryEntry | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (!release.entity) {
|
||||
const label =
|
||||
release.resolutionStatus === "ambiguous"
|
||||
? "识别有歧义"
|
||||
: release.resolutionStatus === "unidentifiable"
|
||||
? "无法识别"
|
||||
: release.resolutionStatus === "rejected"
|
||||
? "已排除"
|
||||
: "未识别到本地作品";
|
||||
return <span className="text-meta text-muted-foreground">{label}</span>;
|
||||
}
|
||||
const flags = inventoryFlags(entry, release.resolutionStatus);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-meta text-foreground">
|
||||
{release.entity.title}
|
||||
{release.entity.year ? ` (${release.entity.year})` : ""}
|
||||
</span>
|
||||
{loading ? <span className="text-meta text-muted-foreground">库存加载中…</span> : null}
|
||||
{!loading && entry && flags.length === 0 ? <StatusBadge>本地无版本</StatusBadge> : null}
|
||||
{flags.map((f) => (
|
||||
<StatusBadge key={f.key} tone={f.tone}>
|
||||
{f.label}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReleaseRow({
|
||||
r,
|
||||
entry,
|
||||
inventoryLoading,
|
||||
staleBefore,
|
||||
selected,
|
||||
onOpen,
|
||||
}: {
|
||||
r: WorkbenchRelease;
|
||||
entry: InventoryEntry | undefined;
|
||||
inventoryLoading: boolean;
|
||||
/** 早于此时间确认的数据标"过期" */
|
||||
staleBefore: number | null;
|
||||
selected: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore;
|
||||
const cat = categoryLabel(r);
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"block w-full min-w-0 cursor-pointer px-4 py-3 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
selected && "bg-foreground/[0.05]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<ReleaseBadges r={r} />
|
||||
</span>
|
||||
</div>
|
||||
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
||||
{cat ? <span>{cat}</span> : null}
|
||||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{r.seeders ?? "?"}
|
||||
<ArrowDown className="ml-1 h-3 w-3" />
|
||||
{r.leechers ?? "?"}
|
||||
</span>
|
||||
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
|
||||
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
|
||||
</span>
|
||||
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<InventoryOverlay release={r} entry={entry} loading={inventoryLoading} />
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Site, SiteSnapshot } from "@umt/contracts";
|
||||
import { Camera } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { snapshotStale, useSiteSnapshots, useSnapshotSite } from "~/api/sites.ts";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { relTime, SITE_HEALTH } from "./release-fields.ts";
|
||||
|
||||
const KIND: Record<SiteSnapshot["kind"], string> = {
|
||||
periodic: "定期快照",
|
||||
backfill: "缺口补漏",
|
||||
live_search: "实时补查",
|
||||
manual: "手动快照",
|
||||
};
|
||||
const STATUS: Record<SiteSnapshot["status"], { label: string; tone: StatusTone }> = {
|
||||
running: { label: "进行中", tone: "neutral" },
|
||||
ok: { label: "完成", tone: "success" },
|
||||
partial: { label: "部分完成", tone: "warning" },
|
||||
failed: { label: "失败", tone: "destructive" },
|
||||
};
|
||||
|
||||
function duration(s: SiteSnapshot) {
|
||||
if (!s.finishedAt) return null;
|
||||
const ms = new Date(s.finishedAt).getTime() - new Date(s.startedAt).getTime();
|
||||
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
/** 单个站点的快照状态 + 最近记录 + 手动快照 */
|
||||
export function SnapshotPanel({ site }: { site: Site }) {
|
||||
const snaps = useSiteSnapshots(site.id, 8);
|
||||
const snap = useSnapshotSite();
|
||||
const health = SITE_HEALTH[site.healthStatus];
|
||||
const stale = site.snapshotEnabled && snapshotStale(site);
|
||||
return (
|
||||
<div className="pg-slab min-w-0 rounded-xl p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lead font-semibold text-foreground">{site.name}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
|
||||
{!site.enabled ? <StatusBadge tone="warning">站点已停用</StatusBadge> : null}
|
||||
{site.snapshotEnabled ? (
|
||||
<StatusBadge tone={stale ? "warning" : "success"}>
|
||||
{stale ? "快照过期" : "快照新鲜"} · {relTime(site.lastSnapshotAt)}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge>未开启定期快照</StatusBadge>
|
||||
)}
|
||||
{site.snapshotIntervalSec ? (
|
||||
<span className="text-meta text-muted-foreground">
|
||||
每 {Math.round(site.snapshotIntervalSec / 60)} 分钟
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!site.enabled || snap.isPending}
|
||||
onClick={() =>
|
||||
snap.mutate(
|
||||
{ id: site.id, maxPages: 2 },
|
||||
{
|
||||
onSuccess: () => toast.success(`${site.name}:已排队手动快照`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Camera />
|
||||
手动快照
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{snaps.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : snaps.error ? (
|
||||
<ErrorState plain error={snaps.error} onRetry={() => void snaps.refetch()} />
|
||||
) : !snaps.data.items.length ? (
|
||||
<p className="text-caption text-muted-foreground">还没有快照记录。</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60 text-caption">
|
||||
{snaps.data.items.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
|
||||
<StatusBadge tone={STATUS[s.status].tone}>{STATUS[s.status].label}</StatusBadge>
|
||||
<span className="text-foreground">{KIND[s.kind]}</span>
|
||||
{s.query ? <span className="min-w-0 truncate text-muted-foreground">“{s.query}”</span> : null}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{s.pages.length ? `第 ${s.pages.join(",")} 页 · ` : ""}
|
||||
{s.itemCount} 条(新 {s.newCount})
|
||||
</span>
|
||||
<span className="ml-auto text-meta text-muted-foreground" title={formatDateTime(s.startedAt)}>
|
||||
{relTime(s.startedAt)}
|
||||
{duration(s) ? ` · ${duration(s)}` : ""}
|
||||
</span>
|
||||
{s.error ? (
|
||||
<span className="w-full text-meta break-words text-destructive">
|
||||
{String((s.error as { message?: unknown }).message ?? JSON.stringify(s.error))}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user