Files
Cyrene_v2/frontend/web/src/admin/modules/channels/AdapterConfigSummary.tsx
T
AskaEth a057a0f61f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 流控可配 —— 陌生人私聊额度走账号配置 directMsgPerMin(裁决 13)
- 后端:账号顶层 directMsgPerMin(缺失/0 = 保护性默认 6 条/分;正值 = 该值;负值 = 关闭并留带 source 的 WARN)
- 日志如实报出**实际生效**额度(adapter.(*FlowGate).DirectPerMin()),告警文案不再写死 6
- 面板:渠道配置表单加字段(含帮助文案)+ 回显/写回链路(LIMIT_KEYS 四键)+ 账号卡片摘要
- 文案纠错:AdapterConfigSummary 的 msgPerMin 由「私聊每分钟上限」改为「非主人消息每分钟总上限」(原描述是误导),并列补 directMsgPerMin 行
- 变异实测:缺键丢默认 / 负值当 1 条每分 / 前端吞负值 —— 三个都精确打红并还原
- ⚠️ 已知缺口(下一片补,gateway 面):GET/PUT /admin/channels 尚未下发/解码该键 ⇒ 面板暂时回显空、保存会把 -1 重置为 0
2026-10-05 13:50:29 +08:00

184 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}