fix: replace raw codes and internal IDs in the console with Chinese labels
Found in v0.2 visual QA (docs/plan/09-visual-qa.md): - actor strings (admin_session:<uuid>, user:<uuid>, system) -> 管理员/系统/API Key in policy revisions, subscriptions, inbox, trash, business tasks, organize detail - cache volume without key shows the downloader name instead of instance:<uuid> - download timeline/plan: task and organize statuses, plan purpose, priority and status reason localized; audit gets labels for capability/files/site/feed actions - organize record reason uses conflict detail (没有可入库的主视频) instead of blocked:no_main_video; slot/candidate reasons drop inflight:<uuid> - identity decision summary no longer shows no_viable_candidate - upstream test: 'Connection error.' classified as network, with a Chinese hint - new policy draft prefills key default (placeholder looked like a value) - dialog/sheet close sr-only text in Chinese Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d7eb6a45d0
commit
46d37d87fe
23 files changed
+176
-43
No files matched your search
@@ -1673,9 +1673,15 @@ export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[
|
|||||||
.execute(),
|
.execute(),
|
||||||
]);
|
]);
|
||||||
const iso = (v: Date | null) => (v ? v.toISOString() : null);
|
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) =>
|
const reason = (code: string | null, err: unknown) =>
|
||||||
code
|
code
|
||||||
? { code: code.split(":")[0]!, message: code }
|
? { code: code.split(":")[0]!, message: conflictText(err) || code }
|
||||||
: err
|
: err
|
||||||
? {
|
? {
|
||||||
code: (err as { code?: string }).code ?? "error",
|
code: (err as { code?: string }).code ?? "error",
|
||||||
|
|||||||
@@ -523,6 +523,16 @@ function manualIdentityOptions(sourceReleaseId: string) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 事项摘要里给人看的原因;原始代码仍记在 resolution_evidence */
|
||||||
|
const DETAIL_TEXT: Record<string, string> = {
|
||||||
|
no_viable_candidate: "没有可信候选",
|
||||||
|
unparseable_title: "标题无法解析",
|
||||||
|
weak_evidence: "证据不足",
|
||||||
|
multiple_candidates_same_level: "多个候选同级",
|
||||||
|
automation_off: "自动化已关闭",
|
||||||
|
};
|
||||||
|
const detailText = (d: string) => DETAIL_TEXT[d] ?? d;
|
||||||
|
|
||||||
async function toPool(
|
async function toPool(
|
||||||
rt: SourcesRuntime,
|
rt: SourcesRuntime,
|
||||||
release: SourceReleaseRow,
|
release: SourceReleaseRow,
|
||||||
@@ -583,7 +593,7 @@ async function toPool(
|
|||||||
kind: "identity_ambiguous",
|
kind: "identity_ambiguous",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
title: `待确认身份:${release.title}`,
|
title: `待确认身份:${release.title}`,
|
||||||
summary: `${candidates.length} 个候选证据等级相近(${detail})。选择一个或忽略`,
|
summary: `${candidates.length} 个候选证据等级相近(${detailText(detail)})。选择一个或忽略`,
|
||||||
subjectType: "source_release",
|
subjectType: "source_release",
|
||||||
subjectId: release.id,
|
subjectId: release.id,
|
||||||
dedupeKey: `identity:${release.id}`,
|
dedupeKey: `identity:${release.id}`,
|
||||||
@@ -609,7 +619,7 @@ async function toPool(
|
|||||||
summary:
|
summary:
|
||||||
reason === "lookup_failed"
|
reason === "lookup_failed"
|
||||||
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
|
? `元数据查询多次失败(${detail}),已停止自动复查。手动指定作品或忽略`
|
||||||
: `没有匹配的作品(${detail})。手动指定 TMDB ID、选本地作品或忽略`,
|
: `没有匹配的作品(${detailText(detail)})。手动指定 TMDB ID、选本地作品或忽略`,
|
||||||
subjectType: "source_release",
|
subjectType: "source_release",
|
||||||
subjectId: release.id,
|
subjectId: release.id,
|
||||||
dedupeKey: `identity:${release.id}`,
|
dedupeKey: `identity:${release.id}`,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const DECISION_FLAG: Record<string, { label: string; tone: OverlayFlag["tone"] }
|
|||||||
reject: { label: "未选", tone: "warning" },
|
reject: { label: "未选", tone: "warning" },
|
||||||
unknown_field: { label: "字段未知", tone: "warning" },
|
unknown_field: { label: "字段未知", tone: "warning" },
|
||||||
skipped: { label: "被跳过", tone: "neutral" },
|
skipped: { label: "被跳过", tone: "neutral" },
|
||||||
|
deferred: { label: "延后", tone: "neutral" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 该资源在各 Slot 上的最近一次评估(服务端每个 (Slot, 资源) 只保留一条) */
|
/** 该资源在各 Slot 上的最近一次评估(服务端每个 (Slot, 资源) 只保留一条) */
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type TaskKind,
|
type TaskKind,
|
||||||
type TaskStatus,
|
type TaskStatus,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
|
import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts";
|
||||||
import { PLAN_STATUS_LABEL } from "./downloads.ts";
|
import { PLAN_STATUS_LABEL } from "./downloads.ts";
|
||||||
import { apiParsed } from "./parse.ts";
|
import { apiParsed } from "./parse.ts";
|
||||||
|
|
||||||
@@ -96,6 +97,16 @@ export function useRecordsForDownload(downloadTaskId: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TASK_STATUS_TEXT: Record<string, string> = {
|
||||||
|
pending: "排队",
|
||||||
|
running: "运行中",
|
||||||
|
waiting: "等待",
|
||||||
|
succeeded: "成功",
|
||||||
|
failed: "失败",
|
||||||
|
cancelled: "已取消",
|
||||||
|
dead: "放弃",
|
||||||
|
};
|
||||||
|
|
||||||
const TASK_KIND_LABEL: Partial<Record<TaskKind, string>> = {
|
const TASK_KIND_LABEL: Partial<Record<TaskKind, string>> = {
|
||||||
submitDownload: "提交下载",
|
submitDownload: "提交下载",
|
||||||
verifyAndImport: "核验入库",
|
verifyAndImport: "核验入库",
|
||||||
@@ -227,7 +238,7 @@ export function buildTimeline(input: {
|
|||||||
key: `rec-${r.id}`,
|
key: `rec-${r.id}`,
|
||||||
stage: "organization",
|
stage: "organization",
|
||||||
at: r.startedAt ?? r.createdAt,
|
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}` : ""}`,
|
detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`,
|
||||||
tone: RECORD_TONE[r.status],
|
tone: RECORD_TONE[r.status],
|
||||||
href: `/console/organize/${r.id}`,
|
href: `/console/organize/${r.id}`,
|
||||||
@@ -262,7 +273,7 @@ export function buildTimeline(input: {
|
|||||||
key: `task-${t.id}`,
|
key: `task-${t.id}`,
|
||||||
stage: "engine",
|
stage: "engine",
|
||||||
at: t.startedAt ?? t.createdAt,
|
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:
|
detail:
|
||||||
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null,
|
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null,
|
||||||
tone: TASK_STATUS_TONE[t.status],
|
tone: TASK_STATUS_TONE[t.status],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useQueryState } from "~/components/console/use-query-state.ts";
|
|||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
import { errorMessage, TTL_SECOND } from "~/lib/api.ts";
|
import { errorMessage, TTL_SECOND } from "~/lib/api.ts";
|
||||||
|
import { actorText } from "~/lib/labels.ts";
|
||||||
import { formatDateTime } from "~/lib/utils.ts";
|
import { formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
// 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。
|
// 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。
|
||||||
@@ -201,7 +202,7 @@ export function BusinessTasks() {
|
|||||||
key: "by",
|
key: "by",
|
||||||
header: "触发",
|
header: "触发",
|
||||||
hideBelow: "lg",
|
hideBelow: "lg",
|
||||||
cell: (t) => <span className="text-muted-foreground">{t.triggeredBy ?? "—"}</span>,
|
cell: (t) => <span className="text-muted-foreground">{actorText(t.triggeredBy)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "at",
|
key: "at",
|
||||||
@@ -294,7 +295,7 @@ export function TaskSummary({ t }: { t: Task }) {
|
|||||||
<KvList
|
<KvList
|
||||||
items={[
|
items={[
|
||||||
{ label: "尝试", value: `${t.attempts}/${t.maxAttempts}` },
|
{ label: "尝试", value: `${t.attempts}/${t.maxAttempts}` },
|
||||||
{ label: "触发", value: t.triggeredBy },
|
{ label: "触发", value: actorText(t.triggeredBy) },
|
||||||
{ label: "创建", value: formatDateTime(t.createdAt) },
|
{ label: "创建", value: formatDateTime(t.createdAt) },
|
||||||
{ label: "开始", value: formatDateTime(t.startedAt) },
|
{ label: "开始", value: formatDateTime(t.startedAt) },
|
||||||
{ label: "结束", value: formatDateTime(t.finishedAt) },
|
{ label: "结束", value: formatDateTime(t.finishedAt) },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Brush, HardDrive, SlidersHorizontal } from "lucide-react";
|
|||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCacheCleanup, useCacheVolumes } from "~/api/downloads.ts";
|
import { useCacheCleanup, useCacheVolumes } from "~/api/downloads.ts";
|
||||||
|
import { downloaders } from "~/api/instances.ts";
|
||||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
import { consolePath, settingsPath } from "~/components/console/nav.ts";
|
||||||
@@ -13,7 +14,18 @@ import { cn, formatBytes } from "~/lib/utils.ts";
|
|||||||
|
|
||||||
type Volume = Dashboard["cache"]["volumes"][number];
|
type Volume = Dashboard["cache"]["volumes"][number];
|
||||||
|
|
||||||
|
/** 缓存卷显示名:没填缓存卷标识时服务端用 `instance:<id>`,换成下载器实例名,不露内部 ID */
|
||||||
|
export function useVolumeLabel() {
|
||||||
|
const list = downloaders.useList();
|
||||||
|
return (v: Pick<Volume, "volumeKey" | "instanceIds">) => {
|
||||||
|
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 }) {
|
function VolumeBar({ v }: { v: Volume }) {
|
||||||
|
const volumeLabel = useVolumeLabel();
|
||||||
const pct = v.usedPercent;
|
const pct = v.usedPercent;
|
||||||
const tripped = pct !== null && pct >= v.hardThresholdPercent;
|
const tripped = pct !== null && pct >= v.hardThresholdPercent;
|
||||||
const nearing = pct !== null && !tripped && pct >= v.resumePercent;
|
const nearing = pct !== null && !tripped && pct >= v.resumePercent;
|
||||||
@@ -22,7 +34,7 @@ function VolumeBar({ v }: { v: Volume }) {
|
|||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<span className="flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground">
|
<span className="flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
<HardDrive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<HardDrive className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate">{v.volumeKey}</span>
|
<span className="truncate">{volumeLabel(v)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-meta text-muted-foreground tabular-nums">
|
<span className="text-meta text-muted-foreground tabular-nums">
|
||||||
{pct === null ? "使用率未知" : `${pct.toFixed(1)}%`}
|
{pct === null ? "使用率未知" : `${pct.toFixed(1)}%`}
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { planReasonText } from "~/lib/labels.ts";
|
||||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
import { BusinessTimeline } from "./business-timeline.tsx";
|
import { BusinessTimeline } from "./business-timeline.tsx";
|
||||||
import { OrganizeDialog } from "./organize-dialog.tsx";
|
import { OrganizeDialog } from "./organize-dialog.tsx";
|
||||||
|
|
||||||
|
const PLAN_PURPOSE_LABEL: Record<string, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
|
||||||
|
|
||||||
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
|
/** 进度:细线 + 文字;总量未知时不伪造百分比 */
|
||||||
export function ProgressLine({ t }: { t: DownloadTask }) {
|
export function ProgressLine({ t }: { t: DownloadTask }) {
|
||||||
const known = t.sizeBytes !== null;
|
const known = t.sizeBytes !== null;
|
||||||
@@ -166,9 +169,9 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
|||||||
<KvList
|
<KvList
|
||||||
items={[
|
items={[
|
||||||
{ label: "状态", value: PLAN_STATUS_LABEL[plan.data.status] },
|
{ label: "状态", value: PLAN_STATUS_LABEL[plan.data.status] },
|
||||||
{ label: "用途", value: plan.data.purpose },
|
{ label: "用途", value: PLAN_PURPOSE_LABEL[plan.data.purpose] ?? plan.data.purpose },
|
||||||
{ label: "预留空间", value: formatBytes(plan.data.reservedBytes, 1) },
|
{ label: "预留空间", value: formatBytes(plan.data.reservedBytes, 1) },
|
||||||
{ label: "说明", value: plan.data.statusReason, wide: true },
|
{ label: "说明", value: planReasonText(plan.data.statusReason), wide: true },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<JsonView className="mt-2" label="评估依据" value={plan.data.evaluation} />
|
<JsonView className="mt-2" label="评估依据" value={plan.data.evaluation} />
|
||||||
|
|||||||
@@ -264,3 +264,19 @@ export const opts = <K extends string>(rec: Record<K, string | { label: string }
|
|||||||
const v = rec[value];
|
const v = rec[value];
|
||||||
return { value, label: typeof v === "string" ? v : v.label };
|
return { value, label: typeof v === "string" ? v : v.label };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 版本目标 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
|
||||||
|
const SLOT_REASON: Record<string, string> = {
|
||||||
|
"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(";");
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { AcquisitionSlot, CandidateEvaluation } from "@umt/contracts";
|
|||||||
import { Link2 } from "lucide-react";
|
import { Link2 } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { formatDateTime } from "~/lib/utils.ts";
|
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 { Chip, ChipList, Tone } from "./parts.tsx";
|
||||||
import { summarizeCompletion, summarizeHard, summarizeSoft, summarizeUpgrade } from "./slot-rules.tsx";
|
import { summarizeCompletion, summarizeHard, summarizeSoft, summarizeUpgrade } from "./slot-rules.tsx";
|
||||||
|
|
||||||
@@ -45,7 +45,9 @@ export function SlotCard({
|
|||||||
<span className="ml-auto text-meta text-muted-foreground tabular-nums">r{slot.revision}</span>
|
<span className="ml-auto text-meta text-muted-foreground tabular-nums">r{slot.revision}</span>
|
||||||
{actions}
|
{actions}
|
||||||
</header>
|
</header>
|
||||||
{slot.statusReason ? <p className="mt-2 text-caption text-muted-foreground">{slot.statusReason}</p> : null}
|
{slot.statusReason ? (
|
||||||
|
<p className="mt-2 text-caption text-muted-foreground">{slotReasonText(slot.statusReason)}</p>
|
||||||
|
) : null}
|
||||||
<dl className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
<dl className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<dt className="mb-1 text-meta text-muted-foreground">硬条件</dt>
|
<dt className="mb-1 text-meta text-muted-foreground">硬条件</dt>
|
||||||
@@ -94,7 +96,9 @@ export function SlotCard({
|
|||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
{c.reasons.length ? (
|
{c.reasons.length ? (
|
||||||
<p className="mt-1 text-meta break-words text-muted-foreground">{c.reasons.join(";")}</p>
|
<p className="mt-1 text-meta break-words text-muted-foreground">
|
||||||
|
{c.reasons.map(slotReasonText).filter(Boolean).join(";")}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-micro text-muted-foreground tabular-nums">评估于 {formatDateTime(c.evaluatedAt)}</p>
|
<p className="text-micro text-muted-foreground tabular-nums">评估于 {formatDateTime(c.evaluatedAt)}</p>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
|||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { actorText } from "~/lib/labels.ts";
|
||||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||||
import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts";
|
import { CANDIDATE_DECISION, MEDIA_KIND, POLICY_STATUS } from "./labels.ts";
|
||||||
import { ChipList, ChipToggleGroup, Panel, Tone } from "./parts.tsx";
|
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 rules = r ? AcquisitionPolicyRules.safeParse(r.rules).data : undefined;
|
||||||
const kinds = r ? z.array(z.string()).safeParse((r.scope as { mediaKinds?: unknown }).mediaKinds).data : undefined;
|
const kinds = r ? z.array(z.string()).safeParse((r.scope as { mediaKinds?: unknown }).mediaKinds).data : undefined;
|
||||||
return {
|
return {
|
||||||
policyKey: r?.policyKey ?? "",
|
// 新草案预填 default:占位符看起来像已填的值,留空提交会报"填写策略键"
|
||||||
|
policyKey: r?.policyKey ?? "default",
|
||||||
title: r?.title ?? "",
|
title: r?.title ?? "",
|
||||||
summary: r?.summary ?? "",
|
summary: r?.summary ?? "",
|
||||||
scopeKinds: kinds ?? [],
|
scopeKinds: kinds ?? [],
|
||||||
@@ -293,7 +295,7 @@ function PolicyGroup({
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
published
|
published
|
||||||
? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${published.publishedBy ?? "—"} 发布`
|
? `当前生效 r${published.revision} · ${formatDateTime(published.publishedAt)} 由 ${actorText(published.publishedBy)} 发布`
|
||||||
: "未发布,不会自动执行"
|
: "未发布,不会自动执行"
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
@@ -492,10 +494,12 @@ function PolicyDetailSheet({
|
|||||||
items={[
|
items={[
|
||||||
{ label: "状态", value: <Tone v={POLICY_STATUS[rev.status]} /> },
|
{ label: "状态", value: <Tone v={POLICY_STATUS[rev.status]} /> },
|
||||||
{ label: "作用范围", value: scopeText(rev.scope) },
|
{ label: "作用范围", value: scopeText(rev.scope) },
|
||||||
{ label: "创建", value: `${formatDateTime(rev.createdAt)} · ${rev.createdBy ?? "—"}` },
|
{ label: "创建", value: `${formatDateTime(rev.createdAt)} · ${actorText(rev.createdBy)}` },
|
||||||
{
|
{
|
||||||
label: "发布",
|
label: "发布",
|
||||||
value: rev.publishedAt ? `${formatDateTime(rev.publishedAt)} · ${rev.publishedBy ?? "—"}` : "未发布",
|
value: rev.publishedAt
|
||||||
|
? `${formatDateTime(rev.publishedAt)} · ${actorText(rev.publishedBy)}`
|
||||||
|
: "未发布",
|
||||||
},
|
},
|
||||||
{ label: "说明", value: rev.summary, wide: true },
|
{ label: "说明", value: rev.summary, wide: true },
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ const ERROR_KIND: Record<string, string> = {
|
|||||||
other: "失败",
|
other: "失败",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SOURCE_TEXT: Record<string, string> = { "stored credential": "已存凭证" };
|
||||||
|
|
||||||
|
/** 上游返回的多是英文原文:先给一句怎么办,原文留作排查 */
|
||||||
|
const ERROR_HINT: Record<string, string> = {
|
||||||
|
network: "连不上上游,检查 Base URL、端口和网络",
|
||||||
|
auth: "凭证被拒,检查 API Key 或重新订阅登录",
|
||||||
|
model: "模型不可用,换一个模型 id",
|
||||||
|
};
|
||||||
|
|
||||||
export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?: string | null }) {
|
export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?: string | null }) {
|
||||||
const test = useTestPiUpstream();
|
const test = useTestPiUpstream();
|
||||||
const [result, setResult] = useState<PiUpstreamTestResult | null>(null);
|
const [result, setResult] = useState<PiUpstreamTestResult | null>(null);
|
||||||
@@ -60,10 +69,13 @@ export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?:
|
|||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{result.latencyMs} ms{result.model ? ` · ${result.model}` : ""}
|
{result.latencyMs} ms{result.model ? ` · ${result.model}` : ""}
|
||||||
{result.source ? ` · ${result.source}` : ""}
|
{result.source ? ` · ${SOURCE_TEXT[result.source] ?? result.source}` : ""}
|
||||||
</span>
|
</span>
|
||||||
{!result.ok && result.message ? (
|
{!result.ok && result.message ? (
|
||||||
<span className="w-full break-words text-destructive">{result.message}</span>
|
<span className="w-full break-words text-destructive">
|
||||||
|
{ERROR_HINT[result.errorKind ?? ""] ? `${ERROR_HINT[result.errorKind ?? ""]}。原始错误:` : ""}
|
||||||
|
{result.message}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span"
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
<span className="sr-only">More</span>
|
<span className="sr-only">更多</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
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"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">关闭</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
@@ -94,7 +94,7 @@ function DialogFooter({
|
|||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close asChild>
|
<DialogPrimitive.Close asChild>
|
||||||
<Button variant="outline">Close</Button>
|
<Button variant="outline">关闭</Button>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function SheetContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 grid size-8 place-items-center rounded-full opacity-70 hover:bg-foreground/[0.06] transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 grid size-8 place-items-center rounded-full opacity-70 hover:bg-foreground/[0.06] transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
<XIcon className="size-4" />
|
<XIcon className="size-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">关闭</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
|
|||||||
@@ -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();
|
const end = r.finishedAt ? new Date(r.finishedAt).getTime() : Date.now();
|
||||||
return (end - new Date(r.startedAt).getTime()) / 1000;
|
return (end - new Date(r.startedAt).getTime()) / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 服务端记录的操作者串(`admin_session:<id>` / `api_key:<id>` / `policy:<key>` / `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<string, string> = {
|
||||||
|
added: "已添加到下载器",
|
||||||
|
"organize:succeeded": "整理成功",
|
||||||
|
"organize:partial": "部分整理成功",
|
||||||
|
"organize:failed": "整理失败",
|
||||||
|
};
|
||||||
|
export const planReasonText = (v: string | null | undefined) => (v ? (PLAN_REASON[v] ?? v) : null);
|
||||||
@@ -8,18 +8,12 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
|||||||
import { JsonView } from "~/components/console/json-view.tsx";
|
import { JsonView } from "~/components/console/json-view.tsx";
|
||||||
import { KvList } from "~/components/console/kv-list.tsx";
|
import { KvList } from "~/components/console/kv-list.tsx";
|
||||||
import { PageHeader } from "~/components/console/page-header.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";
|
import { formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
export const meta = () => [{ title: "审计日志 · UHD 媒体自动化控制台" }];
|
export const meta = () => [{ title: "审计日志 · UHD 媒体自动化控制台" }];
|
||||||
|
|
||||||
const ACTOR_TYPES = [
|
const actorLabel = actorTypeLabel;
|
||||||
{ 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 ACTION_LABEL: Record<string, string> = {
|
const ACTION_LABEL: Record<string, string> = {
|
||||||
"admin.setup": "初始化管理员",
|
"admin.setup": "初始化管理员",
|
||||||
@@ -43,6 +37,20 @@ const ACTION_LABEL: Record<string, string> = {
|
|||||||
"schedule.run_now": "立即执行定时任务",
|
"schedule.run_now": "立即执行定时任务",
|
||||||
"schedule.skip": "跳过定时任务",
|
"schedule.skip": "跳过定时任务",
|
||||||
"acquisition_plan.manual_submit": "手动提交下载计划",
|
"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<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
|
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
|
||||||
const NOUN: Record<string, string> = {
|
const NOUN: Record<string, string> = {
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { consolePath } from "~/components/console/nav.ts";
|
|||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||||
|
import { PRIORITY } from "~/components/library/labels.ts";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
|
import { planReasonText } from "~/lib/labels.ts";
|
||||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
export const meta = () => [{ title: "下载计划 · UHD 媒体自动化控制台" }];
|
export const meta = () => [{ title: "下载计划 · UHD 媒体自动化控制台" }];
|
||||||
@@ -41,7 +43,7 @@ export default function DownloadPlansPage() {
|
|||||||
cell: (p) => (
|
cell: (p) => (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-foreground">
|
<p className="text-foreground">
|
||||||
{PURPOSE[p.purpose]} · 优先级 {p.priority}
|
{PURPOSE[p.purpose]} · 优先级 {PRIORITY[p.priority as keyof typeof PRIORITY] ?? p.priority}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate font-mono text-meta text-muted-foreground">{p.id.slice(0, 8)}</p>
|
<p className="truncate font-mono text-meta text-muted-foreground">{p.id.slice(0, 8)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +65,7 @@ export default function DownloadPlansPage() {
|
|||||||
key: "reason",
|
key: "reason",
|
||||||
header: "说明",
|
header: "说明",
|
||||||
hideBelow: "lg",
|
hideBelow: "lg",
|
||||||
cell: (p) => <span className="line-clamp-1 text-muted-foreground">{p.statusReason ?? "—"}</span>,
|
cell: (p) => <span className="line-clamp-1 text-muted-foreground">{planReasonText(p.statusReason) ?? "—"}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "at",
|
key: "at",
|
||||||
@@ -107,7 +109,7 @@ export default function DownloadPlansPage() {
|
|||||||
open={!!openPlan}
|
open={!!openPlan}
|
||||||
onOpenChange={(o) => !o && setOpenPlan(null)}
|
onOpenChange={(o) => !o && setOpenPlan(null)}
|
||||||
title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""}
|
title={openPlan ? `${PURPOSE[openPlan.purpose]}计划` : ""}
|
||||||
description={openPlan?.statusReason ?? undefined}
|
description={planReasonText(openPlan?.statusReason) ?? undefined}
|
||||||
footer={
|
footer={
|
||||||
linkedDownload ? (
|
linkedDownload ? (
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "~/components/ui/dialog.tsx";
|
} from "~/components/ui/dialog.tsx";
|
||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
|
import { actorText } from "~/lib/labels.ts";
|
||||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
const DAY = 86_400_000;
|
const DAY = 86_400_000;
|
||||||
@@ -160,9 +161,7 @@ export default function FilesTrashPage() {
|
|||||||
title={`${formatDateTime(r.deletedAt)}${r.actor ? ` · ${r.actor}` : ""}`}
|
title={`${formatDateTime(r.deletedAt)}${r.actor ? ` · ${r.actor}` : ""}`}
|
||||||
>
|
>
|
||||||
<span className="block whitespace-nowrap">{relTime(r.deletedAt)}</span>
|
<span className="block whitespace-nowrap">{relTime(r.deletedAt)}</span>
|
||||||
{r.actor ? (
|
{r.actor ? <span className="block truncate text-meta">{actorText(r.actor)}</span> : null}
|
||||||
<span className="block truncate text-meta">{r.actor.replace(/^admin_session:/, "管理员 ")}</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { consolePath } from "~/components/console/nav.ts";
|
|||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
|
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
@@ -302,6 +303,7 @@ function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
||||||
|
const volumeLabel = useVolumeLabel();
|
||||||
return (
|
return (
|
||||||
<Section title="缓存水位" icon={HardDrive} to={consolePath("downloads")}>
|
<Section title="缓存水位" icon={HardDrive} to={consolePath("downloads")}>
|
||||||
{c.volumes.length === 0 ? (
|
{c.volumes.length === 0 ? (
|
||||||
@@ -321,7 +323,7 @@ function CacheSection({ c }: { c: Dashboard["cache"] }) {
|
|||||||
return (
|
return (
|
||||||
<li key={v.volumeKey} className="min-w-0">
|
<li key={v.volumeKey} className="min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
<span className="truncate font-medium">{v.volumeKey}</span>
|
<span className="truncate font-medium">{volumeLabel(v)}</span>
|
||||||
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
|
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
|
||||||
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
|
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
@@ -441,7 +443,7 @@ function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) {
|
|||||||
<li key={r.recordId} className="min-w-0 py-2">
|
<li key={r.recordId} className="min-w-0 py-2">
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
<Link to={consolePath("organize", r.recordId)} className="min-w-0 truncate hover:underline">
|
<Link to={consolePath("organize", r.recordId)} className="min-w-0 truncate hover:underline">
|
||||||
{r.title ?? "未命名"}
|
{r.title ?? "未关联作品"}
|
||||||
</Link>
|
</Link>
|
||||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Button } from "~/components/ui/button.tsx";
|
|||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { actorText } from "~/lib/labels.ts";
|
||||||
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
import { useSse, useSseStatus } from "~/lib/sse.ts";
|
||||||
import { formatDateTime } from "~/lib/utils.ts";
|
import { formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
@@ -249,7 +250,7 @@ function DecisionCard({ d }: { d: Decision }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-3 text-meta text-muted-foreground">
|
<p className="mt-3 text-meta text-muted-foreground">
|
||||||
{d.resolvedBy ? `${d.resolvedBy} ` : ""}于 {formatDateTime(d.resolvedAt)}
|
{d.resolvedBy ? `${actorText(d.resolvedBy)} ` : ""}于 {formatDateTime(d.resolvedAt)}
|
||||||
{resolution?.optionKey
|
{resolution?.optionKey
|
||||||
? ` 选择「${d.options.find((o) => o.key === resolution.optionKey)?.label ?? resolution.optionKey}」`
|
? ` 选择「${d.options.find((o) => o.key === resolution.optionKey)?.label ?? resolution.optionKey}」`
|
||||||
: " 关闭"}
|
: " 关闭"}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { KvList } from "~/components/console/kv-list.tsx";
|
|||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import { actorTypeLabel } from "~/lib/labels.ts";
|
||||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
const ACTION_LABEL = {
|
const ACTION_LABEL = {
|
||||||
@@ -194,7 +195,10 @@ export default function OrganizeRecordPage() {
|
|||||||
{ label: "开始", value: formatDateTime(r.startedAt) },
|
{ label: "开始", value: formatDateTime(r.startedAt) },
|
||||||
{ label: "完成", value: formatDateTime(r.finishedAt) },
|
{ label: "完成", value: formatDateTime(r.finishedAt) },
|
||||||
{ label: "用时", value: duration(r.startedAt, r.finishedAt) },
|
{ label: "用时", value: duration(r.startedAt, r.finishedAt) },
|
||||||
{ label: "触发者", value: `${r.actor.type}${r.actor.id ? ` · ${r.actor.id}` : ""}` },
|
{
|
||||||
|
label: "触发者",
|
||||||
|
value: `${actorTypeLabel(r.actor.type)}${r.actor.id && r.actor.type !== "system" ? ` · ${r.actor.id}` : ""}`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "外部身份",
|
label: "外部身份",
|
||||||
value: r.media?.externalIds.map((e) => `${e.provider}:${e.entityType}:${e.externalId}`).join(", "),
|
value: r.media?.externalIds.map((e) => `${e.provider}:${e.entityType}:${e.externalId}`).join(", "),
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { actorText } from "~/lib/labels.ts";
|
||||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||||
import { legacyRedirectLoader } from "./redirects.ts";
|
import { legacyRedirectLoader } from "./redirects.ts";
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose
|
|||||||
? `上次 ${formatDateTime(sub.lastSearchedAt)}${sub.nextSearchAt ? ` · 下次 ${formatDateTime(sub.nextSearchAt)}` : " · 周期搜索已关"}`
|
? `上次 ${formatDateTime(sub.lastSearchedAt)}${sub.nextSearchAt ? ` · 下次 ${formatDateTime(sub.nextSearchAt)}` : " · 周期搜索已关"}`
|
||||||
: "尚未搜索",
|
: "尚未搜索",
|
||||||
},
|
},
|
||||||
{ label: "创建", value: `${formatDateTime(sub.createdAt)} · ${sub.createdBy ?? "—"}` },
|
{ label: "创建", value: `${formatDateTime(sub.createdAt)} · ${actorText(sub.createdBy)}` },
|
||||||
{ label: "策略版本", value: sub.policyRevisionId ?? "按当前已发布策略", mono: !!sub.policyRevisionId },
|
{ label: "策略版本", value: sub.policyRevisionId ?? "按当前已发布策略", mono: !!sub.policyRevisionId },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ export function classifyUpstreamError(err: unknown): UpstreamError {
|
|||||||
return new UpstreamError("auth", message);
|
return new UpstreamError("auth", message);
|
||||||
if (/\b404\b|model[^\n]{0,40}(not found|does not exist|unknown)/i.test(raw))
|
if (/\b404\b|model[^\n]{0,40}(not found|does not exist|unknown)/i.test(raw))
|
||||||
return new UpstreamError("model", message);
|
return new UpstreamError("model", message);
|
||||||
if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|fetch failed|network|timed? ?out|socket/i.test(raw))
|
if (
|
||||||
|
/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNRESET|fetch failed|connection error|network|timed? ?out|socket/i.test(
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
)
|
||||||
return new UpstreamError("network", message);
|
return new UpstreamError("network", message);
|
||||||
return new UpstreamError("other", message);
|
return new UpstreamError("other", message);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user