Files
UHD_Media_Tool/apps/web/app/components/library/slot-card.tsx
T
rootandClaude Opus 5.5 d3ab54f2bd feat(server): W4-A integration endpoints and contract gaps
- POST /acquisition-plans: manual submit via download.plan -> download.submit
  (same authorization/audit/idempotency path as the agent), slotId or entityId
- read-only /media-assets, /asset-locations, /asset-unit-coverage, /path-bindings,
  /reconcile-runs, /server-mappings, /artifacts, GET /title-aliases/{id};
  unimplemented write endpoints removed from api.ts; registry test now checks both directions
- candidate evaluation upsert per (slot, source_release) + migration 0022 dedupe/unique index
- organization records expose downloadTaskId (+ list filter); inventory adds
  subscribed/candidateReasons; feed sources expose derived possibleGap
- GET /events?after=&limit= JSON replay page for API key callers (same sse_event table)
- preference tag revision history from audit_event; subscriptions/identities/aliases
  lists carry mediaEntityTitle; subscription q/mediaKind search; slot subscriptionId
  filter + currentAsset summary; policy preview samples carry title/reasons
- DecisionSummary, IdentityRevisionList, ReleaseIdentityResult, PolicyPreviewSummary exported from contracts

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:10:02 +08:00

112 lines
4.9 KiB
TypeScript

import type { AcquisitionSlot, CandidateEvaluation } from "@umt/contracts";
import { Link2 } from "lucide-react";
import type { ReactNode } from "react";
import { formatDateTime } from "~/lib/utils.ts";
import { CANDIDATE_DECISION, SLOT_STATUS } from "./labels.ts";
import { Chip, ChipList, Tone } from "./parts.tsx";
import { summarizeCompletion, summarizeHard, summarizeSoft, summarizeUpgrade } from "./slot-rules.tsx";
/** 一个 Slot 的卡片(卡内子块,不叠玻璃):状态、规则摘要、当前资产、候选拒绝理由 */
export function SlotCard({
slot,
currentAsset,
unitLabel,
candidates = [],
actions,
}: {
slot: AcquisitionSlot;
/** 当前资产的一行说明(交付特征) */
currentAsset?: ReactNode;
/** 绑定到某个内容单元时的说明 */
unitLabel?: string;
candidates?: CandidateEvaluation[];
actions?: ReactNode;
}) {
const st = SLOT_STATUS[slot.status];
const hard = summarizeHard(slot.hardConditions, slot.fallback);
const soft = summarizeSoft(slot.softPreferences);
const upgrade = summarizeUpgrade(slot.upgradeThreshold);
// 服务端按 (Slot, 来源资源) 只保留最新一次评估;这里再按资源去重兜底(列表已按评估时间新→旧)
const latest = candidates.filter(
(c, i) => candidates.findIndex((x) => x.sourceReleaseId === c.sourceReleaseId) === i,
);
return (
<article className="min-w-0 rounded-xl border border-border/80 bg-foreground/[0.02] p-4">
<header className="flex flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-mono text-body font-semibold text-foreground">{slot.name}</h3>
<Tone v={st} />
{unitLabel ? <Chip>{unitLabel}</Chip> : null}
{slot.requireDistinctAsset ? (
<Chip>
<Link2 className="h-3 w-3" />
需不同物理资产{slot.distinctGroup ? ` · ${slot.distinctGroup}` : ""}
</Chip>
) : null}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">r{slot.revision}</span>
{actions}
</header>
{slot.statusReason ? <p className="mt-2 text-caption text-muted-foreground">{slot.statusReason}</p> : null}
<dl className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<div className="min-w-0">
<dt className="mb-1 text-meta text-muted-foreground">硬条件</dt>
<dd>
<ChipList items={hard} empty="不限" />
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-meta text-muted-foreground">软偏好</dt>
<dd>
<ChipList items={soft} empty="无" />
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-meta text-muted-foreground">升级</dt>
<dd>
<ChipList items={upgrade} empty="未设置" />
</dd>
</div>
<div className="min-w-0">
<dt className="mb-1 text-meta text-muted-foreground">完成条件</dt>
<dd className="text-caption text-foreground">{summarizeCompletion(slot.completion)}</dd>
</div>
<div className="min-w-0 sm:col-span-2">
<dt className="mb-1 text-meta text-muted-foreground">当前资产</dt>
<dd className="text-caption break-words text-foreground">{currentAsset ?? "无"}</dd>
</div>
</dl>
{latest.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-2 text-meta text-muted-foreground">最近候选 · 为什么选 / 不选(每个资源显示最近一次评估)</p>
<ul className="space-y-2">
{latest.map((c) => (
<li key={c.sourceReleaseId} className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Tone v={CANDIDATE_DECISION[c.decision]} />
<span className="min-w-0 font-mono text-meta break-all text-foreground">{c.title}</span>
</div>
{c.rejected.length ? (
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-meta text-muted-foreground">
{c.rejected.map((r) => (
<li key={r.code} className="break-words">
{r.message}
</li>
))}
</ul>
) : null}
{c.reasons.length ? (
<p className="mt-1 text-meta break-words text-muted-foreground">{c.reasons.join(";")}</p>
) : null}
<p className="text-micro text-muted-foreground tabular-nums">评估于 {formatDateTime(c.evaluatedAt)}</p>
</li>
))}
</ul>
</div>
) : null}
<p className="mt-3 text-micro text-muted-foreground tabular-nums">
最近评估 {formatDateTime(slot.lastEvaluatedAt)}
{slot.nextEvaluateAt ? ` · 下次 ${formatDateTime(slot.nextEvaluateAt)}` : ""}
</p>
</article>
);
}