diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index 1396bfd3..cda25f4e 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -1673,9 +1673,15 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[ .execute(), ]); const iso = (v: Date | null) => (v ? v.toISOString() : null); + // blocked:* 的说明在 error.conflicts[].detail(中文),有就用它,不把内部串直接给人看 + const conflictText = (err: unknown) => + ((err as { conflicts?: { detail?: string }[] } | null)?.conflicts ?? []) + .map((c) => c.detail) + .filter(Boolean) + .join(";"); const reason = (code: string | null, err: unknown) => code - ? { code: code.split(":")[0]!, message: code } + ? { code: code.split(":")[0]!, message: conflictText(err) || code } : err ? { code: (err as { code?: string }).code ?? "error", diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 26e26945..35cfd611 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -523,6 +523,16 @@ function manualIdentityOptions(sourceReleaseId: string) { ]; } +/** 事项摘要里给人看的原因;原始代码仍记在 resolution_evidence */ +const DETAIL_TEXT: Record = { + no_viable_candidate: "没有可信候选", + unparseable_title: "标题无法解析", + weak_evidence: "证据不足", + multiple_candidates_same_level: "多个候选同级", + automation_off: "自动化已关闭", +}; +const detailText = (d: string) => DETAIL_TEXT[d] ?? d; + async function toPool( rt: SourcesRuntime, release: SourceReleaseRow, @@ -583,7 +593,7 @@ async function toPool( kind: "identity_ambiguous", severity: "info", title: `待确认身份:${release.title}`, - summary: `${candidates.length} 个候选证据等级相近(${detail})。选择一个或忽略`, + summary: `${candidates.length} 个候选证据等级相近(${detailText(detail)})。选择一个或忽略`, subjectType: "source_release", subjectId: release.id, dedupeKey: `identity:${release.id}`, @@ -609,7 +619,7 @@ async function toPool( summary: reason === "lookup_failed" ? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略` - : `没有匹配的作品(${detail})。手动指定 TMDB ID、选本地作品或忽略`, + : `没有匹配的作品(${detailText(detail)})。手动指定 TMDB ID、选本地作品或忽略`, subjectType: "source_release", subjectId: release.id, dedupeKey: `identity:${release.id}`, diff --git a/apps/web/app/api/inventory.ts b/apps/web/app/api/inventory.ts index c4fa12e9..6c717c16 100644 --- a/apps/web/app/api/inventory.ts +++ b/apps/web/app/api/inventory.ts @@ -24,6 +24,7 @@ const DECISION_FLAG: Record = { + pending: "排队", + running: "运行中", + waiting: "等待", + succeeded: "成功", + failed: "失败", + cancelled: "已取消", + dead: "放弃", +}; + const TASK_KIND_LABEL: Partial> = { submitDownload: "提交下载", verifyAndImport: "核验入库", @@ -227,7 +238,7 @@ export function buildTimeline(input: { key: `rec-${r.id}`, stage: "organization", at: r.startedAt ?? r.createdAt, - title: `整理 ${r.action === "copy" ? "复制" : r.action === "move" ? "移动" : r.action}:${r.status}`, + title: `整理 ${organizationActionLabel(r.action)}:${organizationLabel(r.status).label}`, detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`, tone: RECORD_TONE[r.status], href: `/console/organize/${r.id}`, @@ -262,7 +273,7 @@ export function buildTimeline(input: { key: `task-${t.id}`, stage: "engine", at: t.startedAt ?? t.createdAt, - title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${t.status}`, + title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${TASK_STATUS_TEXT[t.status] ?? t.status}`, detail: [t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null, tone: TASK_STATUS_TONE[t.status], diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index e3e04083..333573ab 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -15,6 +15,7 @@ import { useQueryState } from "~/components/console/use-query-state.ts"; import { Button } from "~/components/ui/button.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage, TTL_SECOND } from "~/lib/api.ts"; +import { actorText } from "~/lib/labels.ts"; import { formatDateTime } from "~/lib/utils.ts"; // 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。 @@ -201,7 +202,7 @@ export function BusinessTasks() { key: "by", header: "触发", hideBelow: "lg", - cell: (t) => {t.triggeredBy ?? "—"}, + cell: (t) => {actorText(t.triggeredBy)}, }, { key: "at", @@ -294,7 +295,7 @@ export function TaskSummary({ t }: { t: Task }) { `,换成下载器实例名,不露内部 ID */ +export function useVolumeLabel() { + const list = downloaders.useList(); + return (v: Pick) => { + if (!v.volumeKey.startsWith("instance:")) return v.volumeKey; + const names = v.instanceIds.map((id) => list.data?.items.find((i) => i.id === id)?.name).filter(Boolean); + return names.length ? names.join("、") : "下载器缓存"; + }; +} + function VolumeBar({ v }: { v: Volume }) { + const volumeLabel = useVolumeLabel(); const pct = v.usedPercent; const tripped = pct !== null && pct >= v.hardThresholdPercent; const nearing = pct !== null && !tripped && pct >= v.resumePercent; @@ -22,7 +34,7 @@ function VolumeBar({ v }: { v: Volume }) {
- {v.volumeKey} + {volumeLabel(v)} {pct === null ? "使用率未知" : `${pct.toFixed(1)}%`} diff --git a/apps/web/app/components/downloads/download-detail.tsx b/apps/web/app/components/downloads/download-detail.tsx index 4128224b..ea12db62 100644 --- a/apps/web/app/components/downloads/download-detail.tsx +++ b/apps/web/app/components/downloads/download-detail.tsx @@ -20,10 +20,13 @@ import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage } from "~/lib/api.ts"; +import { planReasonText } from "~/lib/labels.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; import { BusinessTimeline } from "./business-timeline.tsx"; import { OrganizeDialog } from "./organize-dialog.tsx"; +const PLAN_PURPOSE_LABEL: Record = { fill: "补缺", upgrade: "升级", replace_source: "换源" }; + /** 进度:细线 + 文字;总量未知时不伪造百分比 */ export function ProgressLine({ t }: { t: DownloadTask }) { const known = t.sizeBytes !== null; @@ -166,9 +169,9 @@ export function DownloadDetail({ task }: { task: DownloadTask }) { diff --git a/apps/web/app/components/library/labels.ts b/apps/web/app/components/library/labels.ts index f155e34b..5850bb0e 100644 --- a/apps/web/app/components/library/labels.ts +++ b/apps/web/app/components/library/labels.ts @@ -264,3 +264,19 @@ export const opts = (rec: Record`)→ 中文;不认识的原样显示 */ +const SLOT_REASON: Record = { + "blocked:no_main_video": "没有可入库的主视频", + "deferred:slot_busy": "已有计划在途,等它结束再评估", + verified: "已核验入库", + verified_partial: "部分核验入库", + hard_conditions_met: "硬条件全部满足", +}; +export function slotReasonText(raw: string): string { + return raw + .split(";") + .filter((p) => !p.startsWith("inflight:")) + .map((p) => SLOT_REASON[p] ?? p) + .join(";"); +} diff --git a/apps/web/app/components/library/slot-card.tsx b/apps/web/app/components/library/slot-card.tsx index f83dca20..c0572f12 100644 --- a/apps/web/app/components/library/slot-card.tsx +++ b/apps/web/app/components/library/slot-card.tsx @@ -2,7 +2,7 @@ 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 { CANDIDATE_DECISION, SLOT_STATUS, slotReasonText } from "./labels.ts"; import { Chip, ChipList, Tone } from "./parts.tsx"; import { summarizeCompletion, summarizeHard, summarizeSoft, summarizeUpgrade } from "./slot-rules.tsx"; @@ -45,7 +45,9 @@ export function SlotCard({ r{slot.revision} {actions} - {slot.statusReason ?

{slot.statusReason}

: null} + {slot.statusReason ? ( +

{slotReasonText(slot.statusReason)}

+ ) : null}
硬条件
@@ -94,7 +96,9 @@ export function SlotCard({ ) : null} {c.reasons.length ? ( -

{c.reasons.join(";")}

+

+ {c.reasons.map(slotReasonText).filter(Boolean).join(";")} +

) : null}

评估于 {formatDateTime(c.evaluatedAt)}

diff --git a/apps/web/app/components/library/subscriptions-policy.tsx b/apps/web/app/components/library/subscriptions-policy.tsx index bac83038..62860138 100644 --- a/apps/web/app/components/library/subscriptions-policy.tsx +++ b/apps/web/app/components/library/subscriptions-policy.tsx @@ -30,6 +30,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~ import { Switch } from "~/components/ui/switch.tsx"; import { Textarea } from "~/components/ui/textarea.tsx"; import { errorMessage } from "~/lib/api.ts"; +import { actorText } from "~/lib/labels.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts"; import { ChipList, ChipToggleGroup, Panel, Tone } from "./parts.tsx"; @@ -80,7 +81,8 @@ function draftFromRevision(r: PolicyRevision | null): PolicyDraft { const rules = r ? AcquisitionPolicyRules.safeParse(r.rules).data : undefined; const kinds = r ? z.array(z.string()).safeParse((r.scope as { mediaKinds?: unknown }).mediaKinds).data : undefined; return { - policyKey: r?.policyKey ?? "", + // 新草案预填 default:占位符看起来像已填的值,留空提交会报"填写策略键" + policyKey: r?.policyKey ?? "default", title: r?.title ?? "", summary: r?.summary ?? "", scopeKinds: kinds ?? [], @@ -293,7 +295,7 @@ function PolicyGroup({ } description={ published - ? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${published.publishedBy ?? "—"} 发布` + ? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${actorText(published.publishedBy)} 发布` : "未发布,不会自动执行" } actions={ @@ -492,10 +494,12 @@ function PolicyDetailSheet({ items={[ { label: "状态", value: }, { label: "作用范围", value: scopeText(rev.scope) }, - { label: "创建", value: `${formatDateTime(rev.createdAt)} · ${rev.createdBy ?? "—"}` }, + { label: "创建", value: `${formatDateTime(rev.createdAt)} · ${actorText(rev.createdBy)}` }, { label: "发布", - value: rev.publishedAt ? `${formatDateTime(rev.publishedAt)} · ${rev.publishedBy ?? "—"}` : "未发布", + value: rev.publishedAt + ? `${formatDateTime(rev.publishedAt)} · ${actorText(rev.publishedBy)}` + : "未发布", }, { label: "说明", value: rev.summary, wide: true }, ]} diff --git a/apps/web/app/components/settings/pi-test.tsx b/apps/web/app/components/settings/pi-test.tsx index c3423589..a11710dc 100644 --- a/apps/web/app/components/settings/pi-test.tsx +++ b/apps/web/app/components/settings/pi-test.tsx @@ -18,6 +18,15 @@ const ERROR_KIND: Record = { other: "失败", }; +const SOURCE_TEXT: Record = { "stored credential": "已存凭证" }; + +/** 上游返回的多是英文原文:先给一句怎么办,原文留作排查 */ +const ERROR_HINT: Record = { + network: "连不上上游,检查 Base URL、端口和网络", + auth: "凭证被拒,检查 API Key 或重新订阅登录", + model: "模型不可用,换一个模型 id", +}; + export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?: string | null }) { const test = useTestPiUpstream(); const [result, setResult] = useState(null); @@ -60,10 +69,13 @@ export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?: {result.latencyMs} ms{result.model ? ` · ${result.model}` : ""} - {result.source ? ` · ${result.source}` : ""} + {result.source ? ` · ${SOURCE_TEXT[result.source] ?? result.source}` : ""} {!result.ok && result.message ? ( - {result.message} + + {ERROR_HINT[result.errorKind ?? ""] ? `${ERROR_HINT[result.errorKind ?? ""]}。原始错误:` : ""} + {result.message} + ) : null}
) : null} diff --git a/apps/web/app/components/ui/breadcrumb.tsx b/apps/web/app/components/ui/breadcrumb.tsx index 7f41d968..0f81a397 100644 --- a/apps/web/app/components/ui/breadcrumb.tsx +++ b/apps/web/app/components/ui/breadcrumb.tsx @@ -76,7 +76,7 @@ function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span" {...props} > - More + 更多 ); } diff --git a/apps/web/app/components/ui/dialog.tsx b/apps/web/app/components/ui/dialog.tsx index 372ca18b..dc11b060 100644 --- a/apps/web/app/components/ui/dialog.tsx +++ b/apps/web/app/components/ui/dialog.tsx @@ -59,7 +59,7 @@ function DialogContent({ className="absolute top-4 right-4 z-50 grid size-8 place-items-center rounded-full opacity-70 hover:bg-foreground/[0.06] ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" > - Close + 关闭 )} @@ -94,7 +94,7 @@ function DialogFooter({ {children} {showCloseButton && ( - + )}
diff --git a/apps/web/app/components/ui/sheet.tsx b/apps/web/app/components/ui/sheet.tsx index 5eb0e109..b178cec4 100644 --- a/apps/web/app/components/ui/sheet.tsx +++ b/apps/web/app/components/ui/sheet.tsx @@ -66,7 +66,7 @@ function SheetContent({ {showCloseButton && ( - Close + 关闭 )} diff --git a/apps/web/app/lib/labels.ts b/apps/web/app/lib/labels.ts index b2a2aed6..acc06bb4 100644 --- a/apps/web/app/lib/labels.ts +++ b/apps/web/app/lib/labels.ts @@ -124,3 +124,35 @@ export function spanSec(r: { startedAt: string | null; finishedAt: string | null const end = r.finishedAt ? new Date(r.finishedAt).getTime() : Date.now(); return (end - new Date(r.startedAt).getTime()) / 1000; } + +/** 服务端记录的操作者串(`admin_session:` / `api_key:` / `policy:` / `system`)→ 界面文字,不露内部 ID */ +export function actorText(v: string | null | undefined): string { + if (!v) return "—"; + const i = v.indexOf(":"); + const kind = i < 0 ? v : v.slice(0, i); + const rest = i < 0 ? "" : v.slice(i + 1); + if (kind === "admin_session" || kind === "dev_admin" || kind === "user") return "管理员"; + if (kind === "agent" || kind === "agent_run") return "Agent"; + if (kind === "schedule" || kind === "scheduler") return "定时任务"; + if (kind === "api_key") return "API Key"; + if (kind === "policy") return `收录策略 ${rest}`; + if (v === "system") return "系统"; + return v; +} + +export const ACTOR_TYPES = [ + { value: "user", label: "管理员" }, + { value: "api_key", label: "API Key" }, + { value: "agent", label: "Agent" }, + { value: "system", label: "系统" }, +]; +export const actorTypeLabel = (t: string) => ACTOR_TYPES.find((a) => a.value === t)?.label ?? t; + +/** 下载计划 statusReason(`added`、`organize:succeeded` 等)→ 中文;不认识的原样显示 */ +const PLAN_REASON: Record = { + added: "已添加到下载器", + "organize:succeeded": "整理成功", + "organize:partial": "部分整理成功", + "organize:failed": "整理失败", +}; +export const planReasonText = (v: string | null | undefined) => (v ? (PLAN_REASON[v] ?? v) : null); diff --git a/apps/web/app/routes/console/audit.tsx b/apps/web/app/routes/console/audit.tsx index 8f74c4be..e3892a3a 100644 --- a/apps/web/app/routes/console/audit.tsx +++ b/apps/web/app/routes/console/audit.tsx @@ -8,18 +8,12 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; -import { formatRelative } from "~/lib/labels.ts"; +import { ACTOR_TYPES, actorTypeLabel, formatRelative } from "~/lib/labels.ts"; import { formatDateTime } from "~/lib/utils.ts"; export const meta = () => [{ title: "审计日志 · UHD 媒体自动化控制台" }]; -const ACTOR_TYPES = [ - { value: "user", label: "管理员" }, - { value: "api_key", label: "API Key" }, - { value: "agent", label: "Agent" }, - { value: "system", label: "系统" }, -]; -const actorLabel = (t: string) => ACTOR_TYPES.find((a) => a.value === t)?.label ?? t; +const actorLabel = actorTypeLabel; const ACTION_LABEL: Record = { "admin.setup": "初始化管理员", @@ -43,6 +37,20 @@ const ACTION_LABEL: Record = { "schedule.run_now": "立即执行定时任务", "schedule.skip": "跳过定时任务", "acquisition_plan.manual_submit": "手动提交下载计划", + "engine.run_retry": "重跑引擎运行", + "capability.invoke": "Agent 调用能力", + "site.created": "登记站点", + "site.updated": "修改站点", + "feed_source.created": "新建 Feed", + "identity.release_assigned": "指定资源身份", + "schedule.update": "修改定时任务", + "files.mkdir": "新建目录", + "files.rename": "重命名", + "files.move": "移动文件", + "files.copy": "复制文件", + "files.organize": "整理文件", + "files.delete": "删除文件", + "files.trash_restore": "从回收站还原", }; const VERB: Record = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" }; const NOUN: Record = { diff --git a/apps/web/app/routes/console/downloads.plans.tsx b/apps/web/app/routes/console/downloads.plans.tsx index 09b52d83..137e29c1 100644 --- a/apps/web/app/routes/console/downloads.plans.tsx +++ b/apps/web/app/routes/console/downloads.plans.tsx @@ -12,8 +12,10 @@ import { consolePath } from "~/components/console/nav.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { useQueryState } from "~/components/console/use-query-state.ts"; +import { PRIORITY } from "~/components/library/labels.ts"; import { Button } from "~/components/ui/button.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; +import { planReasonText } from "~/lib/labels.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; export const meta = () => [{ title: "下载计划 · UHD 媒体自动化控制台" }]; @@ -41,7 +43,7 @@ export default function DownloadPlansPage() { cell: (p) => (

- {PURPOSE[p.purpose]} · 优先级 {p.priority} + {PURPOSE[p.purpose]} · 优先级 {PRIORITY[p.priority as keyof typeof PRIORITY] ?? p.priority}

{p.id.slice(0, 8)}

@@ -63,7 +65,7 @@ export default function DownloadPlansPage() { key: "reason", header: "说明", hideBelow: "lg", - cell: (p) => {p.statusReason ?? "—"}, + cell: (p) => {planReasonText(p.statusReason) ?? "—"}, }, { key: "at", @@ -107,7 +109,7 @@ export default function DownloadPlansPage() { open={!!openPlan} onOpenChange={(o) => !o && setOpenPlan(null)} title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""} - description={openPlan?.statusReason ?? undefined} + description={planReasonText(openPlan?.statusReason) ?? undefined} footer={ linkedDownload ? (