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

@@ -26,9 +26,9 @@ import { SettingsBlock } from "./settings-block.tsx";
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
{ value: "read", label: "read", hint: "只读查询全部数据" },
{ value: "organize.read", label: "organize.read", hint: "只读整理记录,供外部增量同步" },
{ value: "organize.read", label: "organize.read", hint: "只读整理记录,供外部增量同步" },
{ value: "organize.execute", label: "organize.execute", hint: "发起整理" },
{ value: "admin", label: "admin", hint: "全部管理权限,含改设置" },
{ value: "admin", label: "admin", hint: "全部管理权限,含改设置" },
];
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
@@ -38,7 +38,7 @@ function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; on
<DialogContent>
<DialogHeader>
<DialogTitle>API Key 已创建</DialogTitle>
<DialogDescription>明文只显示这一次,关闭后无法再查看。</DialogDescription>
<DialogDescription>明文只显示这一次,关闭后无法再查看。</DialogDescription>
</DialogHeader>
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3">
<code className="min-w-0 flex-1 font-mono text-caption break-all text-foreground">{plaintext}</code>
@@ -52,7 +52,7 @@ function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; on
setCopied(true);
toast.success("已复制");
} catch {
toast.error("复制失败,手动选中复制");
toast.error("复制失败,手动选中复制");
}
}}
>
@@ -83,7 +83,7 @@ export function ApiKeysSection() {
const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes });
if (!parsed.success) {
const fe = zodFieldErrors(parsed.error.issues);
if (fe.name) fe.name = "名称必填,80 字以内";
if (fe.name) fe.name = "名称必填,80 字以内";
if (fe.scopes) fe.scopes = "至少选一个权限范围";
setErrors(fe);
return;
@@ -105,7 +105,7 @@ export function ApiKeysSection() {
section="api-keys"
anchor="keys"
title="已签发的 Key"
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
description="给媒体服务器插件、脚本等外部程序用,按权限范围授权。本机 Token 在「账户与安全」。"
plain
actions={
<Button size="sm" onClick={() => setOpen(true)}>
@@ -226,7 +226,7 @@ export function ApiKeysSection() {
open={!!revoking}
onOpenChange={(o) => !o && setRevoking(null)}
title={`撤销「${revoking?.name ?? ""}」?`}
description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
description="使用这个 Key 的程序立即失去访问权限,无法恢复。"
confirmLabel="撤销 Key"
destructive
onConfirm={async () => {
@@ -16,12 +16,12 @@ const GiB = 1024 ** 3;
const CACHE_CARD: CardSpec = {
anchor: "cache",
title: "缓存水位",
description: "缓存卷使用率到硬阈值时停止新增下载并暂停纳管任务;降到恢复水位以下只恢复因阈值暂停的任务。",
description: "缓存卷使用率到硬阈值时停止新增下载并暂停纳管任务;降到恢复水位以下只恢复因阈值暂停的任务。",
fields: [
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "低于硬阈值" },
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复水位" },
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
],
fromSettings: (s) => ({
"cache.hardThresholdPercent": n(s.cache.hardThresholdPercent),
@@ -42,7 +42,7 @@ const CACHE_CARD: CardSpec = {
const BUDGET_CARD: CardSpec = {
anchor: "budget",
title: "每日预算",
description: "任一项到上限后不再建新的下载计划;前两项按自然日计。",
description: "任一项到上限后不再建新的下载计划;前两项按自然日计。",
fields: [
{ name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" },
{ name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" },
@@ -73,7 +73,7 @@ const RETENTION_CARD: CardSpec = {
name: "retention.seedRetentionDays",
label: "源文件保留天数",
kind: "number",
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
},
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
],
@@ -149,7 +149,7 @@ function Switches() {
icon={s.automation.enabled ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
tone={s.automation.enabled ? "success" : "warning"}
title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`}
description="关闭后自动收录、下载、整理全部停止;查询与手动操作不受影响。"
description="关闭后自动收录、下载、整理全部停止;查询与手动操作不受影响。"
checked={s.automation.enabled}
disabled={update.isPending}
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
@@ -159,7 +159,7 @@ function Switches() {
icon={<ShieldAlert className="h-4 w-4" />}
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
checked={s.automation.readOnlyReconcile}
disabled={update.isPending}
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账")}
@@ -87,7 +87,7 @@ function DownloadersTable() {
config={{
noun: "下载器实例",
description:
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
"只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和路径映射。",
emptyHint: "添加 qBittorrent 后才能提交下载。",
drawerExtra: (r) => <PathMappingsPanel viewKind="downloader" instanceId={r.id} />,
columns: [
@@ -113,7 +113,7 @@ function DownloadersTable() {
],
rowActions: (r) => <TestButton pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 qB" },
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 qB" },
{
name: "baseUrl",
label: "WebUI 地址",
@@ -181,7 +181,7 @@ function MediaServersTable() {
hooks={{ list, create, update, remove } as never}
config={{
noun: "媒体服务器",
description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和路径映射。",
description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和路径映射。",
drawerExtra: (r) => <PathMappingsPanel viewKind="media_server" instanceId={r.id} />,
columns: [
{
@@ -211,7 +211,7 @@ function MediaServersTable() {
],
},
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" },
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" },
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
{ name: "enabled", label: "启用", kind: "switch" },
],
@@ -258,8 +258,9 @@ export function LibraryRootsTable() {
hooks={{ list, create, update, remove } as never}
config={{
noun: "库根",
description: "登记或改路径时会在目录里写哨兵文件 .uhd-root,据此判断库根是否在线;目录不存在或不可写时无法保存。",
deleteHint: "只删除登记,磁盘上的文件不动。",
description:
"登记或改路径时会在目录里写哨兵文件 .uhd-root,据此判断库根是否在线;目录不存在或不可写时无法保存。",
deleteHint: "只删除登记,磁盘上的文件不动。",
columns: [
{
key: "name",
@@ -287,7 +288,7 @@ export function LibraryRootsTable() {
],
rowActions: (r) => <TestButton label="检查哨兵" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
{
name: "localPath",
@@ -304,7 +305,7 @@ export function LibraryRootsTable() {
kind: "select",
options: [
{ value: "register_only", label: "只登记" },
{ value: "managed", label: "自动维护(补齐、升级)" },
{ value: "managed", label: "自动维护(补齐、升级)" },
],
},
{ name: "enabled", label: "启用", kind: "switch" },
+3 -3
View File
@@ -9,15 +9,15 @@ import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx";
const ORGANIZE_CARD: CardSpec = {
anchor: "organize",
title: "整理方式",
description: "复制入库时源文件留在缓存继续做种,保留天数在自动化分区设置。",
description: "复制入库时源文件留在缓存继续做种,保留天数在自动化分区设置。",
fields: [
{
name: "organize.defaultMode",
label: "默认整理方式",
kind: "select",
options: [
{ value: "copy", label: "复制(源文件继续做种)" },
{ value: "move", label: "移动(移走源文件)" },
{ value: "copy", label: "复制(源文件继续做种)" },
{ value: "move", label: "移动(移走源文件)" },
],
},
{
@@ -17,7 +17,7 @@ const TMDB_CARD: CardSpec = {
name: "metadata.tmdbApiKey",
label: "API Key",
kind: "password",
hint: "v3 API Key 或 v4 Read Access Token;留空不修改",
hint: "v3 API Key 或 v4 Read Access Token;留空不修改",
wide: true,
},
{
@@ -25,7 +25,7 @@ const TMDB_CARD: CardSpec = {
label: "Base URL",
kind: "url",
placeholder: "https://api.themoviedb.org",
hint: "自建反代时填写;留空用官方地址",
hint: "自建反代时填写;留空用官方地址",
wide: true,
},
],
+5 -5
View File
@@ -56,7 +56,7 @@ function GlobalNaming() {
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
@@ -100,7 +100,7 @@ function GlobalNaming() {
<span className="min-w-0">
<span className="block text-body text-foreground">已有目录的作品固定文件名</span>
<span className="block text-meta text-muted-foreground">
开启后,这些作品的新文件按建立目录时的模板版本命名
开启后,这些作品的新文件按建立目录时的模板版本命名
</span>
</span>
<Switch
@@ -127,7 +127,7 @@ function RootNaming() {
section="library"
anchor="naming-roots"
title="库根命名覆盖"
description="按类型覆盖全局模板;留空的类型沿用全局。"
description="按类型覆盖全局模板;留空的类型沿用全局。"
>
{roots.isPending ? (
<LoadingState rows={2} />
@@ -146,7 +146,7 @@ function RootNaming() {
{list.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name}
{r.naming && Object.keys(r.naming).length ? "(已覆盖)" : ""}
{r.naming && Object.keys(r.naming).length ? "(已覆盖)" : ""}
</SelectItem>
))}
</SelectContent>
@@ -187,7 +187,7 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
toast.error(split ? "模板有错误,未保存" : errorMessage(err, "保存失败"));
}
};
@@ -88,12 +88,12 @@ export function PathMappingsPanel({
>
<p className="mb-2 text-meta text-muted-foreground">
把{viewKind === "downloader" ? "下载器" : "媒体服务器"}
看到的路径换成本服务能访问的路径。前缀越长越优先,同长比优先级。
看到的路径换成本服务能访问的路径。前缀越长越优先,同长比优先级。
</p>
{list.isPending ? (
<p className="text-caption text-muted-foreground">加载中…</p>
) : rows.length === 0 ? (
<p className="text-caption text-muted-foreground">还没有映射,路径两边一致时不用加。</p>
<p className="text-caption text-muted-foreground">还没有映射,路径两边一致时不用加。</p>
) : (
<ul className="divide-y divide-border/60 rounded-lg border border-border/60">
{rows.map((m) => {
@@ -104,7 +104,7 @@ export function PathMappingsPanel({
<p className="truncate">{m.remotePrefix}</p>
<p className="truncate text-muted-foreground">→ {m.localPrefix}</p>
{rootName(m.libraryRootId) ? (
<p className="font-sans text-muted-foreground">库根:{rootName(m.libraryRootId)}</p>
<p className="font-sans text-muted-foreground">库根:{rootName(m.libraryRootId)}</p>
) : null}
</div>
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
@@ -209,8 +209,8 @@ export function PathMappingsPanel({
<ConfirmDialog
open={!!deleting}
onOpenChange={(o) => !o && setDeleting(null)}
title="删除路径映射?"
description="依赖这条映射的路径换算会失效,磁盘文件不动。"
title="删除路径映射?"
description="依赖这条映射的路径换算会失效,磁盘文件不动。"
confirmLabel="删除映射"
destructive
onConfirm={async () => {
@@ -94,7 +94,7 @@ export function ModelInput({
disabled={disabled}
aria-invalid={invalid}
autoComplete="off"
placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中选,或手填 id` : "模型 id")}
placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中选,或手填 id` : "模型 id")}
/>
<datalist id={listId}>
{models.map((m) => (
+5 -5
View File
@@ -22,9 +22,9 @@ const SOURCE_TEXT: Record<string, string> = { "stored credential": "已存凭证
/** 上游返回的多是英文原文:先给一句怎么办,原文留作排查 */
const ERROR_HINT: Record<string, string> = {
network: "连不上上游,检查 Base URL、端口和网络",
auth: "凭证被拒,检查 API Key 或重新订阅登录",
model: "模型不可用,换一个模型 id",
network: "连不上上游,检查 Base URL、端口和网络",
auth: "凭证被拒,检查 API Key 或重新订阅登录",
model: "模型不可用,换一个模型 id",
};
export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?: string | null }) {
@@ -60,7 +60,7 @@ export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?:
</Button>
</div>
<p className="text-meta text-muted-foreground">
「测试」只检查凭证和模型,不产生费用。最小补全会调用一次模型(最多 16 个 token)。
「测试」只检查凭证和模型,不产生费用。最小补全会调用一次模型(最多 16 个 token)。
</p>
{result ? (
<div className="flex flex-wrap items-center gap-2 text-caption">
@@ -73,7 +73,7 @@ export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?:
</span>
{!result.ok && result.message ? (
<span className="w-full break-words text-destructive">
{ERROR_HINT[result.errorKind ?? ""] ? `${ERROR_HINT[result.errorKind ?? ""]}。原始错误:` : ""}
{ERROR_HINT[result.errorKind ?? ""] ? `${ERROR_HINT[result.errorKind ?? ""]}。原始错误:` : ""}
{result.message}
</span>
) : null}
@@ -230,7 +230,7 @@ function UpstreamDrawer({
{upstream.oauthExpiresAt ? (
<>
<dt className="text-muted-foreground">令牌到期</dt>
<dd>{formatDateTime(upstream.oauthExpiresAt)}(到期前自动刷新)</dd>
<dd>{formatDateTime(upstream.oauthExpiresAt)}(到期前自动刷新)</dd>
</>
) : null}
<dt className="text-muted-foreground">最近验证</dt>
@@ -268,7 +268,7 @@ function UpstreamDrawer({
}
>
{upstream.hasCredential && upstream.status === "ok" ? (
<p className="text-caption text-muted-foreground">已登录。退出只删除本地令牌,不会撤销提供方侧的授权。</p>
<p className="text-caption text-muted-foreground">已登录。退出只删除本地令牌,不会撤销提供方侧的授权。</p>
) : (
<PiOAuthLogin upstream={upstream} provider={provider} />
)}
@@ -285,7 +285,7 @@ function UpstreamDrawer({
disabled={pending}
/>
<FieldControl
spec={{ name: "enabled", label: "启用", kind: "switch", hint: "停用后,使用它的运行会失败" }}
spec={{ name: "enabled", label: "启用", kind: "switch", hint: "停用后,使用它的运行会失败" }}
value={v.enabled}
onChange={(x) => setV((s) => ({ ...s, enabled: x === true }))}
disabled={pending}
@@ -294,7 +294,7 @@ function UpstreamDrawer({
label="默认模型"
htmlFor="pu-model"
error={errors.defaultModel}
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
className="sm:col-span-2"
>
<ModelInput
@@ -312,7 +312,7 @@ function UpstreamDrawer({
label: "思考档位",
kind: "select",
options: THINKING_OPTIONS,
hint: `当前 ${THINKING_LABEL[upstream.thinkingLevel]};运行时按模型支持的档位取近`,
hint: `当前 ${THINKING_LABEL[upstream.thinkingLevel]};运行时按模型支持的档位取近`,
}}
value={v.thinking}
onChange={(x) => setV((s) => ({ ...s, thinking: x as PiThinkingLevel }))}
@@ -369,9 +369,9 @@ function UpstreamDrawer({
<ConfirmDialog
open={deleting}
onOpenChange={setDeleting}
title={`删除上游「${upstream.name}」?`}
title={`删除上游「${upstream.name}」?`}
description={
upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭证一并删除,无法恢复。" : "凭证一并删除,无法恢复。"
upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭证一并删除,无法恢复。" : "凭证一并删除,无法恢复。"
}
confirmLabel="删除上游"
destructive
@@ -49,12 +49,12 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: ()
: (created?.name ?? "");
const description =
step === "provider"
? "第 1 步,共 3 步:选 provider"
? "第 1 步,共 3 步:选 provider"
: step === "config"
? "第 2 步,共 3 步:凭证与模型"
? "第 2 步,共 3 步:凭证与模型"
: created?.authKind === "oauth"
? "第 3 步,共 3 步:登录并测试。上游已创建。"
: "第 3 步,共 3 步:测试。上游已创建。";
? "第 3 步,共 3 步:登录并测试。上游已创建。"
: "第 3 步,共 3 步:测试。上游已创建。";
return (
<DetailSheet open={open} onOpenChange={(o) => !o && close()} title={title} description={description} width="lg">
@@ -95,7 +95,7 @@ function ProviderPicker({ onPick }: { onPick: (p: PiProviderInfo) => void }) {
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="筛选 provider"
placeholder="筛选,例如 openai"
placeholder="筛选,例如 openai"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-9"
@@ -180,7 +180,7 @@ function ConfigStep({
const fe: Record<string, string> = parsed.success ? {} : zodFieldErrors(parsed.error.issues);
for (const f of provider.envFields) if (f.required && !env[f.name]?.trim()) fe[`env.${f.name}`] = `${f.label}必填`;
if (authKind === "api_key" && !apiKey.trim() && !custom) fe.apiKey = "填写 API Key";
if (fe.name) fe.name = "名称必填,64 字以内";
if (fe.name) fe.name = "名称必填,64 字以内";
if (fe["extra.contextWindow"]) fe.contextWindow = "填 1024 以上的整数";
if (!parsed.success || Object.keys(fe).length) {
setErrors(fe);
@@ -236,7 +236,7 @@ function ConfigStep({
kind: "password",
required: !custom,
wide: true,
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示前后 4 位",
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示前后 4 位",
}}
value={apiKey}
error={errors.apiKey}
@@ -246,7 +246,7 @@ function ConfigStep({
) : null}
{authKind === "none" && provider.group === "cloud" ? (
<p className="mb-3 text-caption text-muted-foreground sm:col-span-2">
不存密钥,运行时读服务端进程的环境凭证(AWS 凭证链 / Google ADC)。
不存密钥,运行时读服务端进程的环境凭证(AWS 凭证链 / Google ADC)。
</p>
) : null}
{showBaseUrl ? (
@@ -375,7 +375,7 @@ function VerifyStep({
<FormField
label="默认模型"
htmlFor="pv-model"
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
hint={models.data?.refreshError ? `模型列表刷新失败:${models.data.refreshError}` : "可从列表选,也可手填"}
>
<ModelInput id="pv-model" value={model} onChange={setModel} models={models.data?.items ?? []} />
</FormField>
@@ -214,7 +214,7 @@ function ResourceFormDrawer<Row extends { id: string }>({
open={!!row}
onOpenChange={(o) => !o && !pending && onClose()}
title={`${mode === "create" ? "添加" : "编辑"}${config.noun}`}
description="凭证只写入,保存后不回显。"
description="凭证只写入,保存后不回显。"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>