- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
184 lines
8.7 KiB
TypeScript
184 lines
8.7 KiB
TypeScript
import type { AdminChannelAccount } from "../../../lib/types";
|
||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||
import { Notice } from "../../components";
|
||
import { PLATFORM_FIELDS, PLATFORM_LABEL } from "../channelFields";
|
||
import { authKeyOf } from "../channelFieldRules";
|
||
|
||
/** 一条只读字段。 */
|
||
interface Field {
|
||
label: string;
|
||
value: string;
|
||
}
|
||
|
||
/** 通用连接键:该平台**真正读取**的凭据键(qq-obv11 = access_token、webhook = secret,
|
||
* 不是硬编码的 bot_token)+ base_url。平台专属键见 channelFields 的 PLATFORM_FIELDS。 */
|
||
function commonConnectionKeys(platform: string): string[] {
|
||
return [authKeyOf(platform), "base_url"];
|
||
}
|
||
|
||
/** 限流 / 会话额度**不在 config 里**:后端把它们作为账号顶层字段下发
|
||
* (`AdminChannelAccount.GroupMsgPerMin/MsgPerMin/MaxMessages/DirectMsgPerMin`,见
|
||
* `internal/services/gateway/admin_write_channel_config.go`),且 `omitempty`
|
||
* ⇒ 缺省 / 0 就是「不限」。读错来源会让面板把已设的限额显示成未设置。 */
|
||
function fmtLimit(value?: number): string {
|
||
return value === undefined || value === null || value === 0 ? "未设置(不限)" : String(value);
|
||
}
|
||
|
||
/** `directMsgPerMin` 的展示:它的 0 **不是**「不限」而是「保护性默认 6 条/分」
|
||
* (缺省绝不等于关掉陌生人私聊流控),负值才是主人显式关闭 —— 与其它三项的文案不同,
|
||
* 所以单独一个格式化器,免得把「未设置」误读成「敞口」。 */
|
||
function fmtDirectLimit(value?: number): string {
|
||
if (value === undefined || value === null || value === 0) return "未设置(保护性默认 6 条/分)";
|
||
return value < 0 ? `${value}(已关闭流控)` : String(value);
|
||
}
|
||
|
||
/** 只读展示用的值格式化:空值明确写成「未设置」,对象/数组走 JSON 串,其余原样。
|
||
* 绝不在这里造值——后端给什么显示什么(后端已对 token 类键做掩码)。 */
|
||
function fmtValue(value: unknown): string {
|
||
if (value === null || value === undefined) return "未设置";
|
||
if (typeof value === "string") return value.trim() === "" ? "未设置" : value;
|
||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch {
|
||
return "(无法序列化)";
|
||
}
|
||
}
|
||
|
||
/** 列表类字段:空列表写成「未设置」而不是一个空白的逗号串。 */
|
||
function fmtList(values?: readonly string[]): string {
|
||
return values && values.length > 0 ? values.join("、") : "未设置";
|
||
}
|
||
|
||
/** 该平台连接字段的键:凭据键 + base_url + 平台专属键(channelFields 是字段元数据的单一
|
||
* 真源,此处不另造一份字段表)。凭据键同时也是平台专属键时只算一次,避免同一行出现两遍。 */
|
||
function connectionKeys(platform: string): string[] {
|
||
return [...new Set([...commonConnectionKeys(platform), ...(PLATFORM_FIELDS[platform] ?? []).map((f) => f.key)])];
|
||
}
|
||
|
||
function connectionFields(platform: string, account: AdminChannelAccount): Field[] {
|
||
const cfg = account.config ?? {};
|
||
const defs = PLATFORM_FIELDS[platform] ?? [];
|
||
const labelOf = (key: string) => defs.find((f) => f.key === key)?.label ?? key;
|
||
const out: Field[] = [{ label: "账号 id", value: account.id.trim() || "default" }];
|
||
const seen = new Set<string>();
|
||
const push = (key: string) => {
|
||
if (seen.has(key)) return;
|
||
seen.add(key);
|
||
out.push({ label: labelOf(key), value: fmtValue(cfg[key]) });
|
||
};
|
||
for (const key of commonConnectionKeys(platform)) push(key);
|
||
for (const f of defs) push(f.key);
|
||
return out;
|
||
}
|
||
|
||
/** 业务字段:角色绑定 + 限流(账号顶层字段)+ 白/黑名单。 */
|
||
function businessFields(account: AdminChannelAccount): Field[] {
|
||
return [
|
||
{ label: "personaId(绑定角色)", value: account.personaId?.trim() ? account.personaId : "跟随默认角色" },
|
||
// msgPerMin 是**账号级非主人消息总量上限**,不是私聊上限 —— 旧文案写成「私聊每分钟
|
||
// 上限」会让主人以为它管的是陌生人私聊(真正管私聊的是下面那行 directMsgPerMin)。
|
||
{ label: "msgPerMin(非主人消息每分钟总上限)", value: fmtLimit(account.msgPerMin) },
|
||
{ label: "groupMsgPerMin(群每分钟上限)", value: fmtLimit(account.groupMsgPerMin) },
|
||
{ label: "directMsgPerMin(陌生人对私聊每分钟上限)", value: fmtDirectLimit(account.directMsgPerMin) },
|
||
{ label: "maxMessages(会话消息窗口)", value: fmtLimit(account.maxMessages) },
|
||
{ label: "allowGroups(群白名单)", value: fmtList(account.allowGroups) },
|
||
{ label: "denyGroups(群黑名单)", value: fmtList(account.denyGroups) },
|
||
{ label: "allowUsers(用户白名单)", value: fmtList(account.allowUsers) },
|
||
{ label: "denyUsers(用户黑名单)", value: fmtList(account.denyUsers) },
|
||
];
|
||
}
|
||
|
||
/** 平台回填的身份(连接后由适配器从平台取回)。 */
|
||
function identityFields(account: AdminChannelAccount): Field[] {
|
||
return [
|
||
{ label: "bot_id", value: fmtValue(account.botId) },
|
||
{ label: "bot_username", value: fmtValue(account.botUsername) },
|
||
{ label: "bot_nickname", value: fmtValue(account.botNickname) },
|
||
];
|
||
}
|
||
|
||
/** config 里其余的字段(如 kook 的 receive_mode / webhook_path,或「高级选项 →
|
||
* 额外 config」写下的一切),原样列出,一个都不吞。 */
|
||
function extraFields(platform: string, account: AdminChannelAccount): Field[] {
|
||
const skip = new Set(connectionKeys(platform));
|
||
const cfg = account.config ?? {};
|
||
return Object.keys(cfg)
|
||
.filter((key) => !skip.has(key))
|
||
.sort()
|
||
.map((key) => ({ label: key, value: fmtValue(cfg[key]) }));
|
||
}
|
||
|
||
function FieldGroup({ title, fields }: { title: string; fields: Field[] }): React.ReactNode {
|
||
return (
|
||
<div>
|
||
<div className="mb-1 text-xs font-semibold text-[var(--mu-text-lo)]">{title}</div>
|
||
{fields.length === 0 ? (
|
||
<div className="text-xs text-[var(--mu-text-lo)]">无</div>
|
||
) : (
|
||
<dl className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
|
||
{fields.map((f) => (
|
||
<div
|
||
key={f.label}
|
||
className="flex items-start justify-between gap-3 border-b border-[var(--mu-glass-border)]/50 py-1"
|
||
>
|
||
<dt className="shrink-0 text-xs text-[var(--mu-text-lo)]">{f.label}</dt>
|
||
<dd className="min-w-0 break-all text-right font-mono text-xs text-[var(--mu-text-hi)]">
|
||
{f.value}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 该配置的完整字段(只读展示,design/17 §2)。裁决 16.4.6 取「操作更便利」的一侧:
|
||
* 配置字段在列表页内联展开编辑(少跳一层),所以专属页不再复制一份表单,只把后端
|
||
* 给出的真实字段摊开,并给一个回到列表页编辑的入口。token 类键由后端掩码后下发,
|
||
* 前端只转述、不还原。 */
|
||
export function AdapterConfigSummary({
|
||
platform,
|
||
account,
|
||
unknown,
|
||
onEdit,
|
||
}: {
|
||
platform: string;
|
||
account?: AdminChannelAccount;
|
||
/** 渠道列表读取失败:账号字段无从确认,不能显示成「没配置」。 */
|
||
unknown: boolean;
|
||
/** 列表页的内联表单是唯一编辑入口,这里只提供跳转。 */
|
||
onEdit: () => void;
|
||
}): React.ReactNode {
|
||
const label = PLATFORM_LABEL[platform] ?? platform;
|
||
return (
|
||
<Surface gap="sm" className="space-y-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h3 className="text-sm font-semibold">配置字段(只读)</h3>
|
||
<Tag tone="neutral">{label}</Tag>
|
||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">编辑在列表页内联展开</span>
|
||
<Button size="sm" variant="soft" onClick={onEdit}>
|
||
去列表页编辑
|
||
</Button>
|
||
</div>
|
||
|
||
{unknown ? (
|
||
<Notice tone="err">读取失败(与后端失联),无法确认该配置的字段;请先确认后端在线后重试。</Notice>
|
||
) : !account ? (
|
||
<Notice tone="warn">
|
||
channels.json 中没有该账号(可能已被删除或改名);请回到「活动适配器列表」确认后再进入本页。
|
||
</Notice>
|
||
) : (
|
||
<>
|
||
<FieldGroup title="连接字段" fields={connectionFields(platform, account)} />
|
||
<FieldGroup title="平台回填身份(连接后自动获取)" fields={identityFields(account)} />
|
||
<FieldGroup title="业务字段" fields={businessFields(account)} />
|
||
<FieldGroup title="其他 config 字段" fields={extraFields(platform, account)} />
|
||
<Notice>字段取自后端的实时渠道配置(GET /api/v1/admin/channels);token 类键只下发掩码值,面板不持有原文。</Notice>
|
||
</>
|
||
)}
|
||
</Surface>
|
||
);
|
||
}
|