feat(web): 渠道配置面板重做(按模式动态显隐 + 下拉 + 真鉴权键 + 掩码不回填)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

新增:channelFieldRules.ts(纯函数规则层:选择器归一化 / 按 transport×mode 显隐与必填 /
写回补丁 / 模式提示文案)、channelConnFields.tsx(「连接配置」区:只渲染当前组合生效的字段,
逐字段「谁连谁」说明 + 必填/可选标记 + 掩码清除开关)、channelAccountId.tsx(账号 id 与
「覆盖/新建」状态 Tag)、channelPersonaField.tsx(从表单抽出,逻辑逐字保留)。
qq-obv11:transport / mode 改下拉;remote_url 仅 ws+client 显示且必填;listen_addr 按
server / http+client 条件显示;api_base_url 仅 http(http+client 必填);report_path 仅 http;
access_token 掩码;self_id 仅记录。每种组合顶部一句 modeHints;保存前拦截必填缺失并红框点名。
⚠️ 修掉一处数据毁伤:后端把 access_token 掩码成 ****abcd 下发,旧代码回填进表单 ⇒
「打开就保存」会把掩码写成真 token。现在掩码不回填、只记 configuredKeys,并提示
「已配置,留空 = 不改动」;要清空必须显式打开「清除已配置的 access_token」开关
(顺带给掩码秘密补上了以前没有的「清除」能力)。可见字段清空发 ""、隐藏字段不进补丁(缺位 = 保留)。
Token 误导修掉:PLATFORM_GUIDE.authKey 声明平台真正读取的键 —— qq-obv11 不再渲染 bot_token
输入框、不再标必填(改指向 access_token);渠道列表与只读配置卡的凭据行同步改用真实 authKey。
空 id:结论是后端契约(UpsertChannel 空 id = 新建),前端不碰后端修好:记住 savedId
(留空 = 覆盖当前条目)+ 改新 id 时内联二次确认 + 保存后用返回的 accountId 刷新 + 状态 Tag。
测试:新增真组件 SSR 渲染测试(68 checks)+ 规则单测(58 checks);17 个前端测试文件逐跑全绿;
tsc --noEmit 与 npm run build 通过(产物 index-CK_2RXKo.js)。
This commit is contained in:
AskaEth committed 2026-10-05 10:15:19 +08:00
1 parent 8c437866e7
commit c4ea02a887
12 files changed
+1358 -132

No files matched your search

@@ -0,0 +1,163 @@
import { Icon } from "../../components/Icon";
import { Switch } from "../../components/primitives";
import { type PlatformField } from "./channelFields";
import { modeHint, modeSummary, requiredFieldKeys, visibleFields } from "./channelFieldRules";
/** qq-obv11 这类「字段含义随 transport × mode 变化」的平台,字段不能平铺 —— 平铺会
* 同时制造「填了用不上」和「没填起不来」两种事故。这里只渲染当前组合下真正生效的字段,
* 并逐字段给出「谁连谁 / 空值代表什么 / 现在是否必填」。
*
* 校验规则(必填、清空、写回补丁)全在 `channelFieldRules.ts` 的纯函数里,本组件不判断
* 规则、只呈现结果,单测因此不需要渲染 React。 */
export interface PlatformConnFieldsProps {
platform: string;
values: Readonly<Record<string, string>>;
onChange: (key: string, value: string) => void;
/** 盘上已有值的键(含只有掩码可看的秘密):用来显示「已配置」并算作必填已满足。 */
configuredKeys: readonly string[];
/** 本次保存被拦下时点名的必填字段键(红框 + 一句话)。 */
missingKeys: readonly string[];
/** 主人显式要求清除的掩码秘密键(面板不持有原文,留空 ≠ 清除)。 */
clearedKeys: readonly string[];
onToggleClear: (key: string, cleared: boolean) => void;
}
export function PlatformConnFields({
platform,
values,
onChange,
configuredKeys,
missingKeys,
clearedKeys,
onToggleClear,
}: PlatformConnFieldsProps): React.ReactNode {
const fields = visibleFields(platform, values);
if (fields.length === 0) return null;
const hint = modeHint(platform, values);
const summary = modeSummary(platform, values);
// 必填清单一次算完(当前组合下哪些字段必填),避免逐字段重复判定。
const required = new Set(requiredFieldKeys(platform, values));
const missing = new Set(missingKeys);
const configured = new Set(configuredKeys);
const cleared = new Set(clearedKeys);
return (
<div className="space-y-3">
{hint ? (
<div className="flex items-start gap-1.5 rounded-[var(--mu-radius)] bg-[var(--mu-accent)]/15 px-3 py-2 text-xs leading-relaxed text-[var(--mu-accent-hi)]">
<Icon name="check" size={14} className="mt-0.5 shrink-0 text-[var(--mu-accent)]" />
<span>{hint}</span>
</div>
) : null}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{fields.map((f) => (
<ConnField
key={f.key}
field={f}
value={values[f.key] ?? ""}
onChange={(v) => onChange(f.key, v)}
required={required.has(f.key)}
missing={missing.has(f.key)}
summary={summary}
alreadyConfigured={configured.has(f.key)}
clearRequested={cleared.has(f.key)}
onToggleClear={(next) => onToggleClear(f.key, next)}
/>
))}
</div>
</div>
);
}
/** 一个字段:标题 + 必填/可选标记 + 输入(下拉或文本框)+ 一行中文说明 + 状态行。 */
function ConnField({
field,
value,
onChange,
required,
missing,
summary,
alreadyConfigured,
clearRequested,
onToggleClear,
}: {
field: PlatformField;
value: string;
onChange: (v: string) => void;
required: boolean;
missing: boolean;
/** 当前 transport + mode 摘要,只用于错误文案。 */
summary: string;
alreadyConfigured: boolean;
clearRequested: boolean;
onToggleClear: (next: boolean) => void;
}): React.ReactNode {
// 掩码秘密:面板只看到「已配置」,原文拿不回来 —— 留空保持原值,要删掉必须显式开关。
const maskedSecret = field.secret === true && alreadyConfigured;
const options = field.options ?? [];
const unknownOption = value !== "" && options.length > 0 && !options.some((o) => o.value === value);
return (
<div className="space-y-1">
<span className="flex flex-wrap items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
{field.label}
{required ? (
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">
必填
</span>
) : (
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-surface-5)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-lo)]">
可选
</span>
)}
</span>
{options.length > 0 ? (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={field.label}
className={missing && required ? inputClsError : inputCls}
>
{unknownOption ? <option value={value}>{value}(不在候选内,保存时原样保留)</option> : null}
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
type={field.secret ? "password" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={clearRequested}
className={missing && required ? inputClsError : inputCls}
/>
)}
<span className="block text-[11px] leading-relaxed text-[var(--mu-text-lo)]">{field.guide}</span>
{missing && required ? (
<span className="block text-[11px] text-[var(--mu-err)]">
当前接入方式({summary})下必填,留空适配器起不来。
</span>
) : null}
{maskedSecret ? (
<span className="block space-y-1">
<span className="block text-[11px] text-[var(--mu-text-lo)]">
已配置(后端只下发掩码,面板不持有原文):留空 = 不改动。
</span>
<Switch
checked={clearRequested}
onChange={onToggleClear}
label={`清除已配置的 ${field.key}(保存后生效)`}
/>
</span>
) : null}
</div>
);
}
const inputCls =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
const inputClsError =
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none ring-1 ring-[var(--mu-err)] focus:ring-[var(--mu-err)]";