/** * 渠道「静默窗口合并」卡(M23)。真源:internal/services/gateway/admin_coalesce.go。 * * 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」 * 不该让她回三次 —— 她在群里等一小段静默,把这几句**合并成一轮**回答(批内每条仍是 * 独立的一条消息,绝不拼成一条)。 * * 三条口径落在文案里,因为它们是这张卡的产品承诺: * 1. **主人私聊没有这个窗口**:她的私聊一条一条立刻回(`ownerPrivate:"never"` 是后端 * 下发的只读事实,面板**不给**开关 —— 那条车道由身份元数据决定,配置翻不开); * 2. **越界不拒绝、会夹紧**:规则随读下发(`rules`),面板就地校验,后端仍会 clamp * 并留 WARN —— 不让主人白跑,也不假装「你配的数就是生效的数」; * 3. **保存即热生效**:桥每次发车前重读同一份配置,所以 `hot:true` 是事实。 * * 呈现与取数分层:`CoalesceCardBody` 只吃状态(渲染测试直接喂 fixture), * `ChannelCoalesceCard` 负责取数 + 表单草稿 + 保存。 */ import { useCallback, useEffect, useState } from "react"; import { request } from "../../../lib/api/core"; import { Button, Surface, Tag } from "../../../components/primitives"; import { Notice } from "../../components"; import { useAuditStore } from "../../audit"; /** 一组窗口参数(后端 AdminCoalescePolicy 的同形投影)。 */ export interface CoalescePolicyView { enabled: boolean; windowMs: number; maxWaitMs: number; maxBatchMessages: number; applyToStrangers: boolean; strangerWindowMs: number; } /** 取值规则(后端 AdminCoalesceRules)。 */ export interface CoalesceRules { windowMs: [number, number]; maxWaitMs: [number, number]; maxBatchMessages: [number, number]; clamp: string; flushReasons: string[]; } /** GET/PUT 的 data 形状(后端 AdminCoalesceView)。 */ export interface CoalesceView { path: string; present: boolean; hot: boolean; default: CoalescePolicyView; patch: Record; conversations: Record; rules: CoalesceRules; ownerPrivate: string; } /** 表单草稿:一律字符串(输入框的原样值),保存时才转数字。 */ export interface CoalesceDraft { enabled: string; windowMs: string; maxWaitMs: string; maxBatchMessages: string; applyToStrangers: string; strangerWindowMs: string; } /** 只读事实的文案(渲染测试钉住它必须出现)。 */ export const OWNER_PRIVATE_NOTE = "主人私聊不受此设置影响:她的私聊没有静默窗口,一条一条立刻回(由身份元数据决定,面板没有开关)"; /** 热生效文案。 */ export const COALESCE_HOT_LABEL = "已热生效(下一次发车就用新值,不用重启后端)"; /** 字段顺序与中文标签(表单项由它驱动,避免手写六段重复代码)。 */ export const COALESCE_FIELDS: ReadonlyArray<{ key: keyof CoalesceDraft; label: string; kind: "bool" | "int"; hint: string; }> = [ { key: "enabled", label: "启用群聊静默窗口", kind: "bool", hint: "关掉 = 恢复到「到达即回、一条一轮」" }, { key: "windowMs", label: "静默窗口(毫秒)", kind: "int", hint: "新消息会重置计时(等她把话说完)" }, { key: "maxWaitMs", label: "绝对上限(毫秒)", kind: "int", hint: "从这一批第一条算起,到点必发车" }, { key: "maxBatchMessages", label: "单批上限(条)", kind: "int", hint: "超出不是丢弃,是多出来的排成下一批" }, { key: "applyToStrangers", label: "对陌生人私聊生效", kind: "bool", hint: "关掉 = 陌生人私聊逐条回" }, { key: "strangerWindowMs", label: "陌生人私聊窗口(毫秒)", kind: "int", hint: "仅在「对陌生人私聊生效」时使用" }, ]; /** draftFromView 把生效值铺成草稿。 */ export function draftFromView(view: CoalesceView): CoalesceDraft { return { enabled: String(view.default.enabled), windowMs: String(view.default.windowMs), maxWaitMs: String(view.default.maxWaitMs), maxBatchMessages: String(view.default.maxBatchMessages), applyToStrangers: String(view.default.applyToStrangers), strangerWindowMs: String(view.default.strangerWindowMs), }; } /** parseCoalescePatch 把草稿按**三态**转成补丁:只有与当前生效值不同的键才写。 */ export function parseCoalescePatch( view: CoalesceView, draft: CoalesceDraft ): { patch: { default: Record }; errors: Record; changed: string[] } { const errors: Record = {}; const out: Record = {}; const changed: string[] = []; const intIn = (key: string, value: string, lo: number, hi: number): number | null => { const trimmed = value.trim(); if (!/^\d+$/.test(trimmed)) { errors[key] = "要填整数(毫秒/条)"; return null; } const n = Number(trimmed); if (n < lo || n > hi) { errors[key] = `取值范围 ${lo}–${hi}`; return null; } return n; }; const window = intIn("windowMs", draft.windowMs, view.rules.windowMs[0], view.rules.windowMs[1]); const maxWait = window === null ? null : intIn("maxWaitMs", draft.maxWaitMs, Math.max(view.rules.maxWaitMs[0], window), view.rules.maxWaitMs[1]); const batch = intIn("maxBatchMessages", draft.maxBatchMessages, view.rules.maxBatchMessages[0], view.rules.maxBatchMessages[1]); const strangerWindow = intIn( "strangerWindowMs", draft.strangerWindowMs, view.rules.windowMs[0], view.rules.windowMs[1] ); const enabled = draft.enabled === "true"; const applyToStrangers = draft.applyToStrangers === "true"; const put = (key: string, next: number | boolean, current: number | boolean): void => { if (next !== current) { out[key] = next; changed.push(key); } }; if (window !== null) put("windowMs", window, view.default.windowMs); if (maxWait !== null) put("maxWaitMs", maxWait, view.default.maxWaitMs); if (batch !== null) put("maxBatchMessages", batch, view.default.maxBatchMessages); if (strangerWindow !== null) put("strangerWindowMs", strangerWindow, view.default.strangerWindowMs); put("enabled", enabled, view.default.enabled); put("applyToStrangers", applyToStrangers, view.default.applyToStrangers); return { patch: { default: out }, errors, changed }; } /** fetchCoalesce 读一次配置(面板配置面,writeAuth = owner|admin)。 */ export function fetchCoalesce(): Promise { return request("/admin/channels/coalesce"); } /** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */ export function saveCoalesce(patch: { default: Record }): Promise { return request("/admin/channels/coalesce", { method: "PUT", body: JSON.stringify(patch), }); } /** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */ export interface CoalesceBodyProps { view: CoalesceView; draft: CoalesceDraft; errors: Record; busy: boolean; receipt: string | null; savedView: CoalesceView | null; onDraftChange: (key: keyof CoalesceDraft, value: string) => void; onSave: () => void; } /** CoalesceCardBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */ export function CoalesceCardBody({ view, draft, errors, busy, receipt, savedView, onDraftChange, onSave, }: CoalesceBodyProps): React.ReactNode { const hasErrors = Object.keys(errors).length > 0; const inputCls = "w-36 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)]"; return (
{COALESCE_HOT_LABEL} {view.path} {view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"}
{OWNER_PRIVATE_NOTE} {COALESCE_FIELDS.map((field) => (
{field.label} {field.kind === "bool" ? ( ) : ( onDraftChange(field.key, e.target.value)} /> )} {field.key}
{field.hint}
{errors[field.key] ?
⚠ {errors[field.key]}
: null}
))}
{hasErrors ? ( 有坏值,未提交(后端一个字都不会改) ) : null}
{receipt ? {receipt} : null} {savedView ? (
保存后回显(后端重新读盘的真值):
窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "} {savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"}
{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}
) : null} {Object.keys(view.conversations).length > 0 ? (
每会话覆盖(在配置文件里维护,键 = 平台|渠道): {Object.entries(view.conversations).map(([key, policy]) => (
{key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条
))}
) : null}

规则:窗口 {view.rules.windowMs[0]}–{view.rules.windowMs[1]}ms、绝对上限 ≥ 窗口且 ≤ {view.rules.maxWaitMs[1]}ms、单批{" "} {view.rules.maxBatchMessages[0]}–{view.rules.maxBatchMessages[1]} 条;越界值由后端夹紧({view.rules.clamp})并留日志。 发车原因:{view.rules.flushReasons.join(" / ")}。

); } /** ChannelCoalesceCard 负责取数、表单草稿与保存。 */ export function ChannelCoalesceCard(): React.ReactNode { const [view, setView] = useState(null); const [draft, setDraft] = useState(null); const [errors, setErrors] = useState>({}); const [busy, setBusy] = useState(false); const [fail, setFail] = useState(null); const [receipt, setReceipt] = useState(null); const [savedView, setSavedView] = useState(null); const log = useAuditStore((s) => s.log); const reload = useCallback(() => { setFail(null); return fetchCoalesce() .then((next) => { setView(next); setDraft(draftFromView(next)); }) .catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取窗口配置失败")); }, []); useEffect(() => { void reload(); }, [reload]); const onDraftChange = useCallback((key: keyof CoalesceDraft, value: string) => { setDraft((prev) => (prev ? { ...prev, [key]: value } : prev)); setReceipt(null); }, []); const onSave = useCallback(() => { if (!view || !draft) return; const check = parseCoalescePatch(view, draft); setErrors(check.errors); if (Object.keys(check.errors).length > 0) return; if (check.changed.length === 0) { setReceipt("没有改动(一个键都没写)"); return; } setBusy(true); setFail(null); saveCoalesce(check.patch) .then((next) => { setView(next); setDraft(draftFromView(next)); setSavedView(next); setErrors({}); setReceipt(`已保存:${check.changed.join(", ")}。${COALESCE_HOT_LABEL}`); log("channels.coalesce", "ok"); }) .catch((e: unknown) => { setFail(e instanceof Error ? e.message : "保存窗口配置失败"); log("channels.coalesce", "error"); }) .finally(() => setBusy(false)); }, [view, draft, log]); return (

群聊静默窗口合并

GET / PUT /api/v1/admin/channels/coalesce
{fail ? {fail} : null} {view === null || draft === null ? ( {fail ? "读取失败" : "读取中…"} ) : ( )}
); }