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

+2 -2
View File
@@ -82,7 +82,7 @@ const ACTION_OPTIONS = [
"downloader_instance",
"media_server_instance",
"path_mapping",
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
...AuditAction.options.map((a) => ({ value: a, label: `${actionLabel(a)} · ${a}` })),
];
@@ -134,7 +134,7 @@ export default function AuditPage() {
<>
<PageHeader title="审计" description="登录、设置、密钥与实例变更,新的在前。不记录凭证内容。" />
<FilterBar
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
onReset={
filtered
? () => {
@@ -78,7 +78,7 @@ export default function DownloadPlansPage() {
return (
<>
<PageHeader title="下载面板" description="候选被选中后生成计划,同时预留版本目标、空间与预算。" />
<PageHeader title="下载面板" description="候选被选中后生成计划,同时预留版本目标、空间与预算。" />
<DownloadsTabs />
<FilterBar onReset={f.active ? f.reset : undefined}>
<FilterSelect
+1 -1
View File
@@ -91,7 +91,7 @@ export default function DownloadsPage() {
<>
<PageHeader
title="下载面板"
description="阈值暂停不能手动绕过;无进展只记录,不自动换源。"
description="阈值暂停不能手动绕过;无进展只记录,不自动换源。"
actions={
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
+12 -12
View File
@@ -97,7 +97,7 @@ import { cn, formatBytes } from "~/lib/utils.ts";
const HEALTH_REASON: Record<string, string> = {
root_missing: "挂载点不存在",
sentinel_missing: "目录里没有哨兵文件,挂载可能没生效",
sentinel_missing: "目录里没有哨兵文件,挂载可能没生效",
timeout: "探测超时",
permission_denied: "没有读取权限",
};
@@ -365,7 +365,7 @@ export default function FilesPage() {
setQInput("");
};
const open = (e: FileEntry) => {
if (e.escape) return toast.error("链接指向登记目录之外,不能打开");
if (e.escape) return toast.error("链接指向登记目录之外,不能打开");
if (e.type === "dir") go({ root: rootId, path: e.path });
else patch({ sel: e.path, idf: null });
};
@@ -397,7 +397,7 @@ export default function FilesPage() {
{
onSuccess: (r) => {
const ok = r.items.filter((i) => i.status === "ok").length;
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
description: r.items.some((i) => i.savedToAsset) ? "媒体信息已写入资产" : "未登记文件的结果缓存 24 小时",
});
},
@@ -598,7 +598,7 @@ export default function FilesPage() {
<p className="mt-1.5 max-w-md text-caption text-muted-foreground">
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}。离线期间资产状态不变。
</p>
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
<Button
variant="outline"
size="sm"
@@ -629,7 +629,7 @@ export default function FilesPage() {
plain
icon={SearchX}
title="没有符合筛选的项"
description={q ? `没有匹配“${q}”的项。支持子串和 glob,如 *.mkv` : undefined}
description={q ? `没有匹配「${q}」的项。支持子串和 glob,如 *.mkv` : undefined}
action={
<Button variant="outline" size="sm" onClick={clearFilters}>
<X />
@@ -682,7 +682,7 @@ export default function FilesPage() {
const blockedTip = (op: Parameters<typeof opDisabledReason>[0]) => {
if (!root) return null;
const bad = selected.map((e) => opDisabledReason(op, e, root)).filter(Boolean) as string[];
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
};
const moreItems = (): MenuItem[] => {
const items: MenuItem[] = [];
@@ -706,7 +706,7 @@ export default function FilesPage() {
.filter((e) => opApplies("identify", e))
.map((e) => (root ? opDisabledReason("organize", e, root) : null))
.filter(Boolean) as string[];
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
return bad.length ? `${bad[0]}(${bad.length} 项)` : null;
})();
const moveTip = blockedTip("move");
const deleteTip = blockedTip("delete");
@@ -796,7 +796,7 @@ export default function FilesPage() {
}
if (e.key === "ArrowDown") gridRef.current?.focus();
}}
placeholder="名称或 glob,如 *.mkv"
placeholder="名称或 glob,如 *.mkv"
aria-label="搜索当前目录"
className="h-8 pl-9"
/>
@@ -819,7 +819,7 @@ export default function FilesPage() {
<Button
variant="ghost"
size="icon-sm"
aria-label={order === "asc" ? "升序,点此改为降序" : "降序,点此改为升序"}
aria-label={order === "asc" ? "升序,点此改为降序" : "降序,点此改为升序"}
title={order === "asc" ? "升序" : "降序"}
onClick={() => patch({ order: order === "asc" ? "desc" : null })}
>
@@ -861,7 +861,7 @@ export default function FilesPage() {
key: "mkdir",
label: "新建目录",
icon: FolderPlus,
disabled: root?.writable ? null : root?.kind === "download" ? "下载目录里不能新建目录" : "只读目录,不能新建",
disabled: root?.writable ? null : root?.kind === "download" ? "下载目录里不能新建目录" : "只读目录,不能新建",
onSelect: () => setDialog({ kind: "mkdir" }),
},
{
@@ -1046,7 +1046,7 @@ export default function FilesPage() {
? undefined
: root?.kind === "download"
? "下载目录里不能新建目录"
: "只读目录,不能新建"
: "只读目录,不能新建"
}
>
<Button
@@ -1070,7 +1070,7 @@ export default function FilesPage() {
{first?.truncated ? (
<div role="status" className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning">
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
目录过大,只读了前 {first.total.toLocaleString("zh-CN")} 项。用搜索或进入子目录。
</div>
) : null}
+4 -4
View File
@@ -163,10 +163,10 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
</div>
<p className="mt-0.5 text-caption text-muted-foreground">
{paused
? "自动收录、下载、整理已停止,手动操作仍可用。"
? "自动收录、下载、整理已停止,手动操作仍可用。"
: readOnly
? "只核对,不写入。确认无误后关闭。"
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
? "只核对,不写入。确认无误后关闭。"
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
</p>
</div>
</div>
@@ -201,7 +201,7 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
</Button>
}
title="暂停全部自动化?"
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
confirmLabel="暂停自动化"
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
/>
+8 -8
View File
@@ -141,7 +141,7 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
description={
<>
{option.description ? `${option.description}。` : ""}
{option.capabilityId ? `由能力 ${option.capabilityId} 执行,仍做授权校验。` : null}
{option.capabilityId ? `由能力 ${option.capabilityId} 执行,仍做授权校验。` : null}
</>
}
confirmLabel={option.label}
@@ -155,7 +155,7 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
...(identity ? { identity } : {}),
})
.then(
() => toast.success(`已处理:${option.label}`),
() => toast.success(`已处理:${option.label}`),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
@@ -176,7 +176,7 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
</SelectContent>
</Select>
</FormField>
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
<FormField label="TMDB ID" htmlFor={`${uid}-tmdb`} required hint="纯数字,如 872585">
<Input
id={`${uid}-tmdb`}
inputMode="numeric"
@@ -191,7 +191,7 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
<EntityPicker value={entity} onChange={setEntity} />
</FormField>
) : null}
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
<FormField label="备注" htmlFor={`${uid}-note`} hint="可选,记在处理结果里">
<Textarea id={`${uid}-note`} value={note} onChange={(e) => setNote(e.target.value)} rows={2} />
</FormField>
</ConfirmDialog>
@@ -208,8 +208,8 @@ function DismissButton({ d }: { d: Decision }) {
忽略
</Button>
}
title="忽略这条事项?"
description="只关闭提醒,问题本身不变;同一根因出现新证据时会重新提出。"
title="忽略这条事项?"
description="只关闭提醒,问题本身不变;同一根因出现新证据时会重新提出。"
confirmLabel="忽略"
onConfirm={() =>
resolve.mutateAsync({ id: d.id, dismiss: true }).then(
@@ -343,7 +343,7 @@ export default function InboxPage() {
<>
<PageHeader
title="待处理"
description="Agent 调查和重试后仍未解决的事项,按作品和问题类型合并"
description="Agent 调查和重试后仍未解决的事项,按作品和问题类型合并"
actions={
<>
<span className="hidden items-center gap-1.5 text-meta text-muted-foreground sm:inline-flex">
@@ -379,7 +379,7 @@ export default function InboxPage() {
key={k}
label={DECISION_KIND[k]}
value={v.count}
hint={`最高级别:${SEVERITY[v.sev].label}`}
hint={`最高级别:${SEVERITY[v.sev].label}`}
tone={SEVERITY[v.sev].tone}
active={kind === k}
onClick={() => set("kind", kind === k ? "" : k)}
+8 -8
View File
@@ -101,7 +101,7 @@ function PathsPanel({ detail }: { detail: LibraryDetail }) {
plain
icon={FolderTree}
title="还没有入库文件"
description="版本目标满足后,文件位置显示在这里。"
description="版本目标满足后,文件位置显示在这里。"
className="py-8"
/>
) : (
@@ -173,7 +173,7 @@ function IdentityPanel({ detail }: { detail: LibraryDetail }) {
return (
<Panel
title="外部身份"
description="以本地 ID 为准,外部身份可修改"
description="以本地 ID 为准,外部身份可修改"
actions={
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<PencilLine />
@@ -287,7 +287,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
) : null}
{cands.length ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground">
候选:
候选:
{cands.map((c) => `${METADATA_SOURCE[c.source] ?? c.source}「${String(c.value)}」`).join(" / ")}
</p>
) : null}
@@ -305,8 +305,8 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
<LockOpen />
</Button>
}
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
description="取消人工修改,下次刷新按来源优先级取值。"
title={`解锁「${ENTITY_FIELD_LABEL[field]}」?`}
description="取消人工修改,下次刷新按来源优先级取值。"
confirmLabel="解锁"
onConfirm={() =>
unlock.mutateAsync(field).then(
@@ -335,7 +335,7 @@ function MetadataPanel({ detail, entity }: { detail: LibraryDetail; entity: Medi
</ul>
{entity?.aliases.length ? (
<div className="mt-3 border-t border-border/60 pt-3">
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
<p className="mb-1.5 text-meta text-muted-foreground">别名(用于模糊召回)</p>
<ChipList
items={entity.aliases.map((a) => (
<span key={a.id}>
@@ -360,7 +360,7 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
plain
icon={CalendarClock}
title="还没有版本目标"
description="新建订阅或版本目标后,按目标补齐和升级。"
description="新建订阅或版本目标后,按目标补齐和升级。"
action={
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>去订阅</Link>
@@ -456,7 +456,7 @@ function UnitsPanel({ entity, detail }: { entity: ReturnType<typeof useEntityDet
a.segment.localeCompare(b.segment),
);
return (
<Panel title="内容单元" description="临时单元按解析结果创建,确认或改编号后锁定" bodyClassName="p-0 sm:p-0">
<Panel title="内容单元" description="临时单元按解析结果创建,确认或改编号后锁定" bodyClassName="p-0 sm:p-0">
<DataTable
plain
columns={columns}
+2 -2
View File
@@ -62,7 +62,7 @@ export default function LibraryPage() {
<>
<PageHeader
title="媒体概览"
description="本地账本:作品、版本目标、资产与外部身份。自动匹配不覆盖人工锁定的外部身份。"
description="本地账本:作品、版本目标、资产与外部身份。自动匹配不覆盖人工锁定的外部身份。"
actions={
view === "unidentified" ? (
<Button asChild variant="outline" size="sm">
@@ -208,7 +208,7 @@ function WorksView() {
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选。", icon: LibraryIcon }
: {
title: "还没有作品",
description: "扫描库根或新建订阅后,作品会出现在这里。",
description: "扫描库根或新建订阅后,作品会出现在这里。",
icon: LibraryIcon,
action: (
<Button asChild variant="outline" size="sm">
+5 -5
View File
@@ -107,7 +107,7 @@ function FileRow({ f }: { f: PublicOrganizationFile }) {
</div>
{reason ? (
<p className={cn("text-caption break-words", f.failure ? "text-destructive" : "text-muted-foreground")}>
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
{f.failure ? "失败" : "跳过"}原因:{reasonText(reason)}
</p>
) : null}
<p className="text-meta text-muted-foreground">
@@ -153,10 +153,10 @@ export default function OrganizeRecordPage() {
size="sm"
onClick={() =>
openAgentDrawer({
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
label: `整理记录:${r.media?.title ?? r.id.slice(0, 8)}`,
recordId: r.id,
entityId: r.media?.entityId,
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
goal: "排查这次整理为什么没有全部成功,给出恢复办法",
})
}
>
@@ -180,7 +180,7 @@ export default function OrganizeRecordPage() {
<StatusBadge>{ACTION_LABEL[r.action]}</StatusBadge>
<StatusBadge>
{TRIGGER_LABEL[r.trigger]}
{r.trigger === "agent" ? "(计为自动)" : ""}
{r.trigger === "agent" ? "(计为自动)" : ""}
</StatusBadge>
<span className="text-meta text-muted-foreground tabular-nums">
{r.counts.succeeded}/{r.counts.total} 成功 · 失败 {r.counts.failed} · 跳过 {r.counts.skipped}
@@ -243,7 +243,7 @@ export default function OrganizeRecordPage() {
</ul>
) : (
<p className="px-4 pb-4 text-caption text-muted-foreground">
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
{r.status === "queued" ? "排队中,还没有文件明细。" : "没有文件级结果。"}
</p>
)}
</section>
+2 -2
View File
@@ -120,7 +120,7 @@ export default function OrganizePage() {
<>
<PageHeader
title="整理记录"
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
description="每次整理的前后路径与逐文件结果,自动和手动整理都记在这里。"
actions={
<Button size="sm" onClick={() => navigate("/console/files")}>
<FolderInput />
@@ -180,7 +180,7 @@ export default function OrganizePage() {
empty={{
icon: FileStack,
title: filtered ? "没有符合筛选的整理记录" : "还没有整理记录",
description: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
description: "下载完成后会自动整理,也可以在文件管理里选中文件整理。",
}}
/>
</>
+4 -4
View File
@@ -76,7 +76,7 @@ function problems(d: SystemHealth): string[] {
const out: string[] = [];
const stale = d.processes.filter((p) => p.stale);
if (stale.length) out.push(`${stale.length} 个进程超过 45 秒没有心跳`);
if (!d.processes.some((p) => !p.stale && p.processKind !== "api")) out.push("没有在线的 worker,任务不会被执行");
if (!d.processes.some((p) => !p.stale && p.processKind !== "api")) out.push("没有在线的 worker,任务不会被执行");
const lag = d.processes.filter((p) => !p.stale && p.eventLoopLagMaxMs >= 1000);
if (lag.length) out.push(`${lag.length} 个进程事件循环延迟超过 1 秒`);
if (d.db.level === "warn" || d.db.level === "critical") out.push("数据库延迟或连接数偏高");
@@ -116,7 +116,7 @@ function Banner({ d }: { d: SystemHealth }) {
<p className="font-medium text-foreground">
{ok ? "全部正常" : lv.label}
<span className="ml-2 font-normal text-muted-foreground">
检查于 {formatRelative(d.checkedAt)} · API {d.api.version},已运行 {formatDuration(d.api.uptimeSec)}
检查于 {formatRelative(d.checkedAt)} · API {d.api.version},已运行 {formatDuration(d.api.uptimeSec)}
</span>
</p>
{list.length ? (
@@ -262,7 +262,7 @@ function History() {
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
) : (
<div className="grid gap-6 lg:grid-cols-2">
<DetailSection title="事件循环延迟峰值(毫秒)">
<DetailSection title="事件循环延迟峰值(毫秒)">
<HistoryChart data={h.data} metric="lagMaxMs" unit="毫秒" hours={hours} />
</DetailSection>
<DetailSection title="SSE 连接数">
@@ -280,7 +280,7 @@ function Disks({ d }: { d: SystemHealth }) {
return (
<Card title="磁盘" icon={HardDrive} level={worst(d.disks.map((x) => x.level))}>
<p className="mb-3 text-meta text-muted-foreground">
阈值:{t.warnPercent}% 需要关注,{t.criticalPercent}% 需要处理(取自缓存设置)
阈值:{t.warnPercent}% 需要关注,{t.criticalPercent}% 需要处理(取自缓存设置)
</p>
<ul className="space-y-3">
{d.disks.map((x) => {
+1 -1
View File
@@ -100,7 +100,7 @@ export default function TasksPage() {
<>
<PageHeader
title="任务"
description="每次运行都是一条任务,不论定时、事件、手动还是 Agent 触发。"
description="每次运行都是一条任务,不论定时、事件、手动还是 Agent 触发。"
actions={
<Button
variant="outline"
+1 -1
View File
@@ -7,7 +7,7 @@ export default function NotFound() {
<div className="grid min-h-screen place-items-center bg-background px-4">
<EmptyState
title="页面不存在"
description="检查地址,或回到首页。"
description="检查地址,或回到首页。"
action={
<Link to="/console" className={buttonVariants({ variant: "outline" })}>
回到首页