style(copy): punctuation rules and site-wide fix with a CI check (QA U11)

- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
1 parent 9b017906cf
commit 52402864d6
143 files changed
+985 -606

No files matched your search

@@ -87,7 +87,7 @@ export function IdentityDialog({
setErrors({});
set.mutate(r.data, {
onSuccess: () => {
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
onOpenChange(false);
},
onError: (err) => toast.error(errorMessage(err, "修改失败")),
@@ -102,7 +102,7 @@ export function IdentityDialog({
<DialogHeader>
<DialogTitle>修改外部身份</DialogTitle>
<DialogDescription>
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
同类型的旧 TMDB 身份会退役,保留修订记录。加人工锁后,自动匹配和刷新不再改动。
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
@@ -249,7 +249,7 @@ export function FieldLockDialog({
<Dialog open={!!field} onOpenChange={(o) => !lock.isPending && onOpenChange(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
<DialogDescription>锁定后刷新元数据不改这个字段。已绑定的路径不变。</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
@@ -383,7 +383,7 @@ export function MergeDialog({
<DialogHeader>
<DialogTitle>合并作品</DialogTitle>
<DialogDescription>
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
</DialogDescription>
</DialogHeader>
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
@@ -411,10 +411,10 @@ export function MergeDialog({
setConfirm(o);
if (!o && !merge.isSuccess) return;
}}
title="合并作品?"
title="合并作品?"
description={
target
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」,完成后跳转到目标作品。`
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」,完成后跳转到目标作品。`
: ""
}
confirmLabel="合并"
@@ -80,7 +80,7 @@ function AddAlias({ entity }: { entity: PickedEntity }) {
value={f.alias}
aria-invalid={!!errors.alias}
onChange={(e) => setF((x) => ({ ...x, alias: e.target.value }))}
placeholder="如:沙丘2"
placeholder="如:沙丘 2"
/>
</FormField>
<FormField label="类型" htmlFor="alias-kind">
@@ -170,8 +170,8 @@ export function AliasesPanel() {
<Trash2 />
</Button>
}
title={`删除别名「${a.alias}」?`}
description="删除后不再用这个别名匹配作品,已确认的外部身份不受影响。"
title={`删除别名「${a.alias}」?`}
description="删除后不再用这个别名匹配作品,已确认的外部身份不受影响。"
confirmLabel="删除"
destructive
onConfirm={async () => {
@@ -14,7 +14,7 @@ export type PickedEntity = { id: string; title: string; year: number | null; kin
export function EntityPicker({
value,
onChange,
placeholder = "搜索本地作品(标题/原名)",
placeholder = "搜索本地作品(标题/原名)",
id,
}: {
value: PickedEntity | null;
@@ -127,7 +127,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
>
{queued ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
已进待处理,在那里选择候选或忽略。
已进待处理,在那里选择候选或忽略。
</p>
) : null}
<KvList
@@ -156,7 +156,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
)}
</DetailSection>
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
</DetailSheet>
);
}
@@ -58,7 +58,7 @@ export function PoolTab() {
<div className="min-w-0">
<p className="font-mono text-caption break-all text-foreground">{r.release.title}</p>
<p className="mt-0.5 truncate text-meta text-muted-foreground">
{r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)}
{r.release.subtitle ? `${r.release.subtitle} · ` : ""}解析:{querySummary(r.query)}
</p>
</div>
),
@@ -149,7 +149,7 @@ export function PoolTab() {
empty={{
icon: SearchX,
title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源",
description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。",
description: "证据不足或冲突的资源会出现在这里,有新证据时自动重试。",
}}
hasMore={pool.hasNextPage}
onLoadMore={() => pool.fetchNextPage()}
+4 -4
View File
@@ -128,7 +128,7 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
reject: { label: "拒绝", tone: "destructive" },
unknown_field: { label: "字段未知", tone: "warning" },
skipped: { label: "跳过", tone: "neutral" },
deferred: { label: "延后:已有计划在途", tone: "warning" },
deferred: { label: "延后:已有计划在途", tone: "warning" },
};
export const DECISION_KIND: Record<DecisionKind, string> = {
@@ -174,7 +174,7 @@ export const POOL_STATUS: Record<string, ToneLabel> = {
};
export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }> = {
soft: { label: "软偏好", hint: "影响排序,不排除" },
soft: { label: "软偏好", hint: "影响排序,不排除" },
hard: { label: "硬规则", hint: "命中即排除" },
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
target: { label: "版本目标", hint: "为作品新建一个版本目标" },
@@ -214,7 +214,7 @@ export const PRIORITY: Record<AcquisitionPriority, string> = {
export const SUB_STATUS: Record<SubscriptionStatus, ToneLabel> = {
active: { label: "进行中", tone: "success" },
paused: { label: "已暂停", tone: "warning" },
pending: { label: "待定(元数据未就绪)", tone: "warning" },
pending: { label: "待定(元数据未就绪)", tone: "warning" },
completed: { label: "已完成", tone: "neutral" },
cancelled: { label: "已取消", tone: "neutral" },
};
@@ -268,7 +268,7 @@ export const opts = <K extends string>(rec: Record<K, string | { label: string }
/** 版本目标 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
const SLOT_REASON: Record<string, string> = {
"blocked:no_main_video": "没有可入库的主视频",
"deferred:slot_busy": "已有计划在途,等它结束再评估",
"deferred:slot_busy": "已有计划在途,等它结束再评估",
verified: "已核验入库",
verified_partial: "部分核验入库",
hard_conditions_met: "硬条件全部满足",
@@ -31,7 +31,7 @@ const jsonObject = (name: string) =>
} catch {
// 落到下面的错误
}
ctx.addIssue({ code: "custom", message: `${name}必须是 JSON 对象,如 {"mediaKinds":["movie"]}` });
ctx.addIssue({ code: "custom", message: `${name}必须是 JSON 对象,如 {"mediaKinds":["movie"]}` });
return z.NEVER;
});
@@ -108,8 +108,8 @@ export function PreferenceFormDialog({
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
<DialogDescription>
{tag
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-2" noValidate>
@@ -120,7 +120,7 @@ export function PreferenceFormDialog({
value={draft.label}
aria-invalid={!!errors.label}
onChange={(e) => set("label", e.target.value)}
placeholder="如:偏爱科幻"
placeholder="如:偏爱科幻"
/>
</FormField>
<FormField label="类型" htmlFor="pref-kind" hint={PREF_KIND[draft.kind].hint}>
@@ -157,7 +157,7 @@ export function PreferenceFormDialog({
onChange={(e) => set("scope", e.target.value)}
/>
</FormField>
<FormField label="规则(JSON)" htmlFor="pref-rule" error={errors.rule} hint="结构化条件,由代码执行">
<FormField label="规则(JSON)" htmlFor="pref-rule" error={errors.rule} hint="结构化条件,由代码执行">
<Textarea
id="pref-rule"
rows={4}
@@ -78,7 +78,7 @@ export function SlotCard({
</dl>
{latest.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-2 text-meta text-muted-foreground">最近候选及选用理由(每个资源最近一次评估)</p>
<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">
+16 -16
View File
@@ -42,8 +42,8 @@ export const CUTS = [
] as const;
export const CONTENT_TYPES = ["main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction"] as const;
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
const SOURCE_LABEL: Record<string, string> = { BluRay: "BluRay(压制)", "BluRay-Disc": "原盘" };
const CUT_LABEL: Record<string, string> = { default: "默认(无剪辑标记)" };
const CT_LABEL: Record<string, string> = {
main: "正片",
extra: "花絮",
@@ -52,7 +52,7 @@ const CT_LABEL: Record<string, string> = {
preview: "预告",
reaction: "reaction",
};
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const UNKNOWN_LABEL = { hold: "等待补充(不下载)", reject: "拒绝", accept: "放行" } as const;
const GB = 1024 ** 3;
const o = <T extends string>(list: readonly T[], labels: Record<string, string> = {}) =>
@@ -235,7 +235,7 @@ export function summarizeHard(j: JsonObject | undefined, fallback?: JsonObject):
if (!h) return ["规则无法解析"];
if (h.resolutions?.length) out.push(`分辨率 ${h.resolutions.join(" / ")}`);
if (h.sources?.length) out.push(`来源 ${h.sources.map((s) => SOURCE_LABEL[s] ?? s).join(" / ")}`);
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
if (h.requireEncode) out.push("仅蓝光压制(不含原盘/Remux)");
if (h.minSizeBytes !== undefined || h.maxSizeBytes !== undefined)
out.push(
`体积 ${h.minSizeBytes !== undefined ? formatBytes(h.minSizeBytes, 1) : "0"} – ${
@@ -282,7 +282,7 @@ export function summarizeUpgrade(j: JsonObject | undefined): string[] {
export function summarizeCompletion(j: JsonObject | undefined): string {
const c = Completion.safeParse(j ?? {}).data;
if (!c) return "未设置";
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
return c.kind === "movie" ? "按内容版本核对(电影)" : "按内容单元覆盖核对(剧集/综艺)";
}
// ── 编辑器 ──
@@ -312,7 +312,7 @@ export function SlotRulesEditor({
onChange={(v) => set("resolutions", v)}
/>
</FormField>
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
<FormField label="交付来源" hint="BluRay 指蓝光压制;原盘与 Remux 单列">
<ChipToggleGroup
label="交付来源"
options={o(SOURCES, SOURCE_LABEL)}
@@ -354,7 +354,7 @@ export function SlotRulesEditor({
</FormField>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
<FormField label="必需字幕语言" htmlFor={`${id}-subs`} hint="逗号分隔;zh 可被 zh-Hans/zh-Hant 满足">
<Input
id={`${id}-subs`}
value={value.requiredSubtitles}
@@ -370,7 +370,7 @@ export function SlotRulesEditor({
/>
</FormField>
</div>
<FormField label="内容版本(剪辑)" hint="不选即不限">
<FormField label="内容版本(剪辑)" hint="不选即不限">
<ChipToggleGroup
label="内容版本"
options={o(CUTS, CUT_LABEL)}
@@ -386,7 +386,7 @@ export function SlotRulesEditor({
onChange={(v) => set("contentTypes", v)}
/>
</FormField>
<FormField label="字段未知时" hint="资源缺少判断字段(如字幕)时">
<FormField label="字段未知时" hint="资源缺少判断字段(如字幕)时">
<Select
value={value.unknownPolicy || "__none"}
onValueChange={(v) => set("unknownPolicy", v === "__none" ? "" : (v as SlotRulesDraft["unknownPolicy"]))}
@@ -408,7 +408,7 @@ export function SlotRulesEditor({
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
软偏好 · 通过硬条件后排序,越靠前越优先
软偏好 · 通过硬条件后排序,越靠前越优先
</legend>
<FormField label="HDR">
<ChipToggleGroup
@@ -437,14 +437,14 @@ export function SlotRulesEditor({
onChange={(v) => set("softCodecs", v)}
/>
</FormField>
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
<FormField label="偏好发布组" htmlFor={`${id}-grp`} hint="逗号分隔,越靠前越好">
<Input id={`${id}-grp`} value={value.softGroups} onChange={(e) => set("softGroups", e.target.value)} />
</FormField>
</fieldset>
<fieldset className="space-y-3 border-t border-border/60 pt-4">
<legend className="mb-1 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
升级 · 生成替换计划,不直接覆盖旧文件
升级 · 生成替换计划,不直接覆盖旧文件
</legend>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<p className="text-caption font-medium text-foreground">允许自动升级</p>
@@ -459,7 +459,7 @@ export function SlotRulesEditor({
label="最小来源等级提升"
htmlFor={`${id}-tier`}
error={errors.minTierSteps}
hint="默认 1;同等级不反复更换"
hint="默认 1;同等级不反复更换"
>
<Input
id={`${id}-tier`}
@@ -481,7 +481,7 @@ export function SlotRulesEditor({
/>
</FormField>
</div>
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
<FormField label="合格压制组名单" htmlFor={`${id}-allow`} hint="设置后,名单外的压制不触发自动升级">
<Input
id={`${id}-allow`}
value={value.encodeGroupAllowlist}
@@ -511,8 +511,8 @@ export function SlotRulesEditor({
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="__none">未设置</SelectItem>
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
<SelectItem value="movie">按内容版本核对(电影)</SelectItem>
<SelectItem value="units">按内容单元覆盖核对(剧集/综艺)</SelectItem>
</SelectContent>
</Select>
{errors.form ? <p className="text-meta text-destructive">{errors.form}</p> : null}