用户报「点开连输入框都看不见」,我前两轮量的是货架那条路 —— 那条本来就是好的。 **走详情页进去才踩得到**,两处叠在一起: ① **抽屉被钉进详情卡里**。`.g-drawer` 是 `position: fixed`,而详情页容器 `.g-detail` 带着入场动画的 `transform` —— **带 transform 的祖先会成为 fixed 的包含块**, 于是 `inset: 0` 解析成那张卡的盒子:实测面板矩形 520×**129**,头尾一挤, 中间的输入框整片没了。改走 `createPortal` 挂到 body,与 DOM 位置解绑。 ② **portal 之后面板变透明**。整套令牌(--card / --cline / --ink …)定义在 `.gov-root` 上, 挂到 body 就出了那个作用域:实测 `background-color: rgba(0, 0, 0, 0)` —— 顶栏与页面内容从抽屉里透出来,字叠字。修法是把 `gov-root` 类跟着portal 一起挂, 并给面板背景与边框留了字面兜底值。 顺带:z-index 60 → 120(压过顶栏,先前顶栏那一条压在抽屉标题上)。 实测两条路都验:详情页与货架各开一次,面板均为 (760, 0, 520, 577)、背景 rgb(255,255,255)、 四个输入框全在屏内;详情页测试连接照旧只测不存(判词是厂商原话),Esc 与遮罩关得掉。 五闸绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
/**
|
|
* feature `gov-system` —— 系统管理:LLM 实例四元绑定、通道实例
|
|
*
|
|
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
|
|
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
|
|
* 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。
|
|
*/
|
|
import { useEffect, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import type { LlmInstanceView, LlmProvider } from '@contracts';
|
|
import { probeLlmDraftRoute, probeLlmInstanceRoute, upsertLlmInstanceRoute } from '@contracts';
|
|
import { call } from '@web-shared/typed-fetch.ts';
|
|
import { Ico } from '@web-shared/Ico.tsx';
|
|
import { Card, Chip, NList, NRow, Note, PageHead, QueryGate, Rows, St } from '@web-shared/gov/primitives.tsx';
|
|
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
|
import { useGovChannels, useGovLlm, useGovLlmProviders } from '@web-shared/gov/gov-api.ts';
|
|
|
|
export type HomeProps = { tab: string; nav: Nav };
|
|
|
|
/**
|
|
* Provider 的人读名 —— **只是标签**:默认端点、结构化出参口径、推理回包这些
|
|
* 由服务端的 provider-profile 说了算(那份是唯一真相,前端不复制一份 URL 表来漂移)。
|
|
*/
|
|
const PROVIDER_LABEL: Readonly<Record<LlmProvider, string>> = {
|
|
'openai-compatible': 'OpenAI 兼容端点',
|
|
openai: 'OpenAI',
|
|
deepseek: 'DeepSeek',
|
|
anthropic: 'Anthropic',
|
|
qwen: '通义千问',
|
|
};
|
|
|
|
/** 端点只显示主机名 —— 卡上要的是「连的是哪家」,不是整条 URL */
|
|
const hostOf = (url: string): string => {
|
|
try {
|
|
return new URL(url).host;
|
|
} catch {
|
|
return url === '' ? '—' : url;
|
|
}
|
|
};
|
|
|
|
/** 表单草稿:apiKey 是**只写字段**,留空 = 保持原值(服务端永不回显密钥) */
|
|
type LlmDraft = {
|
|
id?: string;
|
|
name: string;
|
|
provider: LlmProvider;
|
|
baseUrl: string;
|
|
model: string;
|
|
apiKey: string;
|
|
systemUse: boolean;
|
|
enabled: boolean;
|
|
};
|
|
|
|
const draftOf = (i: LlmInstanceView): LlmDraft => ({
|
|
id: i.id,
|
|
name: i.name,
|
|
provider: i.provider,
|
|
baseUrl: i.baseUrl,
|
|
model: i.model,
|
|
apiKey: '',
|
|
systemUse: i.systemUse,
|
|
enabled: i.enabled,
|
|
});
|
|
|
|
const EMPTY_DRAFT: LlmDraft = {
|
|
name: '',
|
|
provider: 'deepseek',
|
|
baseUrl: '',
|
|
model: '',
|
|
apiKey: '',
|
|
systemUse: false,
|
|
enabled: true,
|
|
};
|
|
|
|
/**
|
|
* 接入模型 —— **右侧抽屉**,照模型接入面的通行做法排:
|
|
* ① 先挑供应商(一排可点的牌子,选中即预填它的默认端点)
|
|
* ② 再填连接(端点 + 密钥,密钥旁边就是「测试连接」—— 填完当场知道通不通)
|
|
* ③ 最后是型号与用途。底部一条固定动作栏。
|
|
*
|
|
* 为什么是抽屉不是页内卡:配置是**一次性的岔路**,做完就该回到货架。
|
|
* 从前那版把表单摊在页面上,输入框拉到 1100px 宽、动作钮掉出屏幕 —— 那不是表单,是把字段倒在地上。
|
|
*/
|
|
function LlmDrawer({
|
|
draft,
|
|
onCancel,
|
|
onSaved,
|
|
}: {
|
|
draft: LlmDraft;
|
|
onCancel: () => void;
|
|
onSaved: (saved: LlmInstanceView) => void;
|
|
}) {
|
|
const [d, setD] = useState<LlmDraft>(draft);
|
|
const [showKey, setShowKey] = useState(false);
|
|
const [err, setErr] = useState<string | undefined>(undefined);
|
|
const [probed, setProbed] = useState<{ ok: boolean; latencyMs?: number; error?: string } | undefined>(undefined);
|
|
const providers = useGovLlmProviders();
|
|
const isNew = draft.id === undefined;
|
|
// Esc 关抽屉 —— 抽屉盖住整屏,不给一条键盘出路是把人关在里面
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onCancel();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [onCancel]);
|
|
const profile = (providers.data?.providers ?? []).find((p) => p.provider === d.provider);
|
|
// openai-compatible 没有默认端点:不自填就连不上,这条闸前端先拦一道(服务端也拦)
|
|
const urlNeeded = (profile?.defaultBaseUrl ?? '') === '' && d.baseUrl.trim() === '';
|
|
const invalid = d.name.trim() === '' || d.model.trim() === '' || urlNeeded;
|
|
|
|
/** 换供应商时把端点跟着换成它的默认值 —— 除非用户已经手填过别的 */
|
|
const pickProvider = (next: string, nextDefault: string) => {
|
|
const prevDefault = profile?.defaultBaseUrl ?? '';
|
|
const untouched = d.baseUrl.trim() === '' || d.baseUrl.trim() === prevDefault;
|
|
setD({ ...d, provider: next as LlmProvider, ...(untouched ? { baseUrl: nextDefault } : {}) });
|
|
setProbed(undefined);
|
|
};
|
|
|
|
const save = useMutation({
|
|
mutationFn: () =>
|
|
call<LlmInstanceView>(upsertLlmInstanceRoute, {
|
|
body: {
|
|
...(d.id === undefined ? {} : { id: d.id }),
|
|
name: d.name.trim(),
|
|
provider: d.provider,
|
|
baseUrl: d.baseUrl.trim(),
|
|
model: d.model.trim(),
|
|
...(d.apiKey === '' ? {} : { apiKey: d.apiKey }),
|
|
systemUse: d.systemUse,
|
|
enabled: d.enabled,
|
|
},
|
|
}),
|
|
onSuccess: onSaved,
|
|
onError: (e: unknown) => setErr((e as { message?: string }).message ?? '存不下'),
|
|
});
|
|
|
|
/**
|
|
* 测试连接 —— **只测不存**:拿表单上正填的四元去试,一个字节不落库。
|
|
* 密钥留空就用这条实例已存的那把(改端点、换型号不必重打钥匙)。
|
|
*/
|
|
const test = useMutation({
|
|
mutationFn: () =>
|
|
call<{ ok: boolean; latencyMs?: number; error?: string }>(probeLlmDraftRoute, {
|
|
body: {
|
|
...(d.id === undefined ? {} : { id: d.id }),
|
|
name: d.name.trim(),
|
|
provider: d.provider,
|
|
baseUrl: d.baseUrl.trim(),
|
|
model: d.model.trim(),
|
|
...(d.apiKey === '' ? {} : { apiKey: d.apiKey }),
|
|
},
|
|
}),
|
|
onSuccess: setProbed,
|
|
onError: (e: unknown) => setProbed({ ok: false, error: (e as { message?: string }).message ?? '测试失败' }),
|
|
});
|
|
|
|
/*
|
|
* **挂到 body 上**(2026-07-30 实测踩到):抽屉是 `position: fixed`,而详情页容器
|
|
* `.g-detail` 带着入场动画的 `transform` —— 带 transform 的祖先会成为 fixed 的包含块,
|
|
* 于是抽屉被钉进那张卡的盒子里,量出来只有 520×129:头尾一挤,输入框整片没了。
|
|
* 走 portal 就与 DOM 位置解绑,谁在祖先上加动画都影响不到它。
|
|
*/
|
|
return createPortal(
|
|
/*
|
|
* `gov-root` 这个类必须跟着一起挂:整套令牌(--card / --cline / --ink …)
|
|
* 定义在 `.gov-root` 上,而 portal 把节点挂到了 body —— 出了那个作用域,
|
|
* `var(--card)` 解析为空,面板背景直接变透明(实测 rgba(0,0,0,0),
|
|
* 顶栏与页面内容从抽屉里透出来)。挂上它,portal 与令牌两头都成立。
|
|
*/
|
|
<div className="gov-root g-drawer" role="dialog" aria-modal="true" aria-label={isNew ? '接入模型' : `配置 ${draft.name}`}>
|
|
<button className="g-drawer-veil" aria-label="关闭" onClick={onCancel} />
|
|
<div className="g-drawer-p">
|
|
<header>
|
|
<div>
|
|
<b>{isNew ? '接入模型' : `配置 ${draft.name}`}</b>
|
|
<span>活动只声明用途名 —— 换厂商换型号不动活动一行代码</span>
|
|
</div>
|
|
<button className="x" aria-label="关闭" onClick={onCancel}>
|
|
<Ico name="x" s={15} />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="g-drawer-b">
|
|
{/*
|
|
* 排布按「决定性」从大到小,且**一屏装得下** —— 上一版把 118px 的供应商牌子摆在最前,
|
|
* 结果「模型」「用途名」这两个定义身份的字段掉到折线以下:点开看不见输入框。
|
|
* 现在:供应商收成一行小签 → 身份两栏并排 → 连接 → 开关。
|
|
*/}
|
|
<section>
|
|
<h4>供应商</h4>
|
|
<div className="g-chiprow">
|
|
{(providers.data?.providers ?? []).map((p) => (
|
|
<button
|
|
key={p.provider}
|
|
className={'g-chip' + (p.provider === d.provider ? ' on' : '')}
|
|
title={p.defaultBaseUrl === '' ? '没有默认端点,须自填' : p.defaultBaseUrl}
|
|
onClick={() => pickProvider(p.provider, p.defaultBaseUrl)}
|
|
>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h4>身份</h4>
|
|
<div className="g-fld2">
|
|
<label className="g-fld">
|
|
<span>用途名</span>
|
|
<input className="g-input" value={d.name} placeholder="如:quote-extractor" onChange={(e) => setD({ ...d, name: e.target.value })} />
|
|
<small>活动里声明的就是这个名</small>
|
|
</label>
|
|
<label className="g-fld">
|
|
<span>模型</span>
|
|
<input className="g-input mono" value={d.model} placeholder="如:deepseek-chat" onChange={(e) => setD({ ...d, model: e.target.value })} />
|
|
<small>厂商侧型号名</small>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h4>连接</h4>
|
|
<label className="g-fld">
|
|
<span>API 端点</span>
|
|
<input
|
|
className="g-input mono"
|
|
value={d.baseUrl}
|
|
placeholder={urlNeeded ? '必填,如 https://your-gateway/v1' : '留空取默认端点'}
|
|
onChange={(e) => setD({ ...d, baseUrl: e.target.value })}
|
|
/>
|
|
{urlNeeded && <small className="warn">这家没有默认端点 —— 不填连不上</small>}
|
|
</label>
|
|
<label className="g-fld">
|
|
<span>API Key</span>
|
|
<span className="g-inwrap">
|
|
<input
|
|
className="g-input mono"
|
|
type={showKey ? 'text' : 'password'}
|
|
autoComplete="off"
|
|
value={d.apiKey}
|
|
placeholder={isNew ? 'sk-…' : '已绑定 · 留空保持原值'}
|
|
onChange={(e) => setD({ ...d, apiKey: e.target.value })}
|
|
/>
|
|
<button className="eye" aria-label={showKey ? '隐藏' : '显示'} onClick={() => setShowKey(!showKey)}>
|
|
<Ico name={showKey ? 'eye-off' : 'eye'} s={13} />
|
|
</button>
|
|
</span>
|
|
<small>密文存治理台,永不回显</small>
|
|
</label>
|
|
<div className="g-testrow">
|
|
<button className="g-btn xs" disabled={test.isPending || d.model.trim() === '' || urlNeeded} onClick={() => test.mutate()}>
|
|
<Ico name={test.isPending ? 'hourglass' : 'zap'} s={11} />
|
|
{test.isPending ? '测试中…' : '测试连接'}
|
|
</button>
|
|
{probed !== undefined && (
|
|
<span className={'r' + (probed.ok ? ' ok' : ' bad')}>
|
|
{probed.ok ? `连通 · ${probed.latencyMs ?? '—'} ms` : (probed.error ?? '不通')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="tight">
|
|
<div className="g-swrow">
|
|
<label>
|
|
<input type="checkbox" checked={d.enabled} onChange={(e) => setD({ ...d, enabled: e.target.checked })} />
|
|
启用
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" checked={d.systemUse} onChange={(e) => setD({ ...d, systemUse: e.target.checked })} />
|
|
系统用途
|
|
</label>
|
|
<small>系统用途:叙述性小结 / 汇报生成 / 蒸馏钩子走它</small>
|
|
</div>
|
|
</section>
|
|
|
|
{err !== undefined && <Note tone="warn">{err}</Note>}
|
|
</div>
|
|
|
|
<footer>
|
|
<button className="g-btn" onClick={onCancel}>
|
|
取消
|
|
</button>
|
|
<button className="g-btn primary" disabled={save.isPending || invalid} onClick={() => save.mutate()}>
|
|
{save.isPending ? '保存中…' : isNew ? '接入' : '保存'}
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 一张实例卡 —— 自检状态归它自己:通了报延迟,不通**把厂商原话摆在卡里**。
|
|
* 原话不改写:「密钥无效」和「端点不通」是两件完全不同的事,改写成一句「连不上」
|
|
* 就等于把要人去修的那条线索抹了(实测:种子里的密钥是占位串,自检当场就说清了)。
|
|
*/
|
|
function LlmCard({ i, nav, onEdit }: { i: LlmInstanceView; nav: Nav; onEdit: () => void }) {
|
|
const [out, setOut] = useState<{ ok: boolean; latencyMs?: number; error?: string } | undefined>(undefined);
|
|
const probe = useMutation({
|
|
mutationFn: () => call<{ ok: boolean; latencyMs?: number; error?: string }>(probeLlmInstanceRoute, { params: { id: i.id } }),
|
|
onSuccess: setOut,
|
|
onError: (e: unknown) => setOut({ ok: false, error: (e as { message?: string }).message ?? '自检失败' }),
|
|
});
|
|
return (
|
|
<div className={'g-fcard' + (i.enabled ? '' : ' off')}>
|
|
<button className="hit" aria-label={`打开 ${i.name}`} onClick={() => nav.push({ k: 'llm', id: i.id })} />
|
|
<div className="hd">
|
|
<span className="ico">
|
|
<Ico name="cpu" s={17} />
|
|
</span>
|
|
<div className="ttl">
|
|
<b>{i.name}</b>
|
|
{/* 副标说清「连的是哪家的什么型号」—— 端点主机名进计量块 */}
|
|
<em>
|
|
{PROVIDER_LABEL[i.provider]} · {i.model}
|
|
</em>
|
|
</div>
|
|
<span className={'pill' + (i.enabled ? ' on' : '')}>{i.enabled ? '启用' : '停用'}</span>
|
|
<button
|
|
className="mbtn"
|
|
aria-label="连通自检"
|
|
title="连通自检:真发一次最小调用"
|
|
disabled={probe.isPending}
|
|
onClick={() => probe.mutate()}
|
|
>
|
|
<Ico name={probe.isPending ? 'hourglass' : 'zap'} s={13} />
|
|
</button>
|
|
<button className="mbtn" aria-label="配置" title="配置" onClick={onEdit}>
|
|
<Ico name="pen-line" s={13} />
|
|
</button>
|
|
</div>
|
|
<div className="plate">
|
|
<span className={i.apiKeyBound ? '' : 'bad'}>
|
|
<b>{i.apiKeyBound ? '已绑' : '未绑'}</b>
|
|
<i>密钥</i>
|
|
</span>
|
|
<span>
|
|
<b>{i.systemUse ? '是' : '否'}</b>
|
|
<i>系统用途</i>
|
|
</span>
|
|
<span className="wide">
|
|
<b>{hostOf(i.baseUrl)}</b>
|
|
<i>端点</i>
|
|
</span>
|
|
</div>
|
|
{out !== undefined && (
|
|
<div className={'probe' + (out.ok ? ' ok' : ' bad')}>
|
|
<Ico name={out.ok ? 'circle-check' : 'alert-circle'} s={12} />
|
|
{out.ok ? `连通 · ${out.latencyMs ?? '—'} ms` : (out.error ?? '不通')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ModelsHome({ nav }: HomeProps) {
|
|
return <SystemHome tab="llm" nav={nav} />;
|
|
}
|
|
|
|
export function SystemHome({ tab, nav }: HomeProps) {
|
|
const channels = useGovChannels();
|
|
|
|
if (tab === 'chan') {
|
|
return (
|
|
<>
|
|
<NList>
|
|
{(channels.data?.channels ?? []).map((ch) => (
|
|
<NRow
|
|
key={ch.id}
|
|
tone={ch.enabled ? 'brand' : 'mute'}
|
|
ico="mail"
|
|
title={ch.name}
|
|
sub={ch.address}
|
|
cols={[
|
|
{ k: '归属键规则', v: ch.extractCount },
|
|
{ k: '载荷映射', v: ch.mappingCount },
|
|
{ k: '地址窗口', v: <span className="g-mono">{Math.round(ch.addressWindowMs / 3600_000)}h</span> },
|
|
{ k: '默认认领岗位', v: ch.fallbackPosition ?? '—' },
|
|
]}
|
|
right={
|
|
<St tone={ch.enabled ? 'ok' : 'mute'} ico={ch.enabled ? 'circle-check' : 'power'}>
|
|
{ch.enabled ? '已启用' : '已停用'}
|
|
</St>
|
|
}
|
|
onClick={() => nav.push({ k: 'chan', id: ch.id })}
|
|
/>
|
|
))}
|
|
</NList>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return <LlmShelf nav={nav} />;
|
|
}
|
|
|
|
/**
|
|
* 模型实例货架 —— **能配**:卡上直接改四元绑定、开关与密钥,还能当场自检连通。
|
|
* 从前这一屏只读:进来只看得见「密钥已绑定」,却没有一处能绑 —— 那不叫配置面。
|
|
*/
|
|
function LlmShelf({ nav }: { nav: Nav }) {
|
|
const llm = useGovLlm();
|
|
const queryClient = useQueryClient();
|
|
const [editing, setEditing] = useState<LlmDraft | null>(null);
|
|
const instances = llm.data?.instances ?? [];
|
|
|
|
return (
|
|
<QueryGate query={llm}>
|
|
<>
|
|
{editing !== null && (
|
|
<LlmDrawer
|
|
draft={editing}
|
|
onCancel={() => setEditing(null)}
|
|
onSaved={() => {
|
|
void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] });
|
|
setEditing(null);
|
|
}}
|
|
/>
|
|
)}
|
|
<div className="g-shelf">
|
|
{instances.map((i) => (
|
|
<LlmCard key={i.id} i={i} nav={nav} onEdit={() => setEditing(draftOf(i))} />
|
|
))}
|
|
<button className="g-fcard ghost" onClick={() => setEditing({ ...EMPTY_DRAFT })}>
|
|
<Ico name="plus" s={18} />
|
|
新建模型实例
|
|
</button>
|
|
</div>
|
|
</>
|
|
</QueryGate>
|
|
);
|
|
}
|
|
|
|
export function LlmPage({ id }: { id: string }) {
|
|
const q = useGovLlm();
|
|
const queryClient = useQueryClient();
|
|
const [editing, setEditing] = useState(false);
|
|
const i = (q.data?.instances ?? []).find((x) => x.id === id);
|
|
if (i === undefined) return <Note>实例不存在。</Note>;
|
|
return (
|
|
<>
|
|
<PageHead
|
|
ico="cpu"
|
|
kicker={`LLM 实例 · ${i.provider}`}
|
|
title={i.name}
|
|
chips={
|
|
<>
|
|
<Chip ico="hash">{i.model}</Chip>
|
|
<Chip tone={i.apiKeyBound ? 'ok' : 'warn'}>{i.apiKeyBound ? '密钥已绑定' : '密钥未绑定'}</Chip>
|
|
{i.systemUse && <Chip tone="brand">系统用途</Chip>}
|
|
</>
|
|
}
|
|
desc="名称间接层:活动只声明自定义模型名,四元绑定在系统侧。换厂商、换端点、换密钥都不动活动一行代码。"
|
|
/>
|
|
{editing ? (
|
|
<LlmDrawer
|
|
draft={draftOf(i)}
|
|
onCancel={() => setEditing(false)}
|
|
onSaved={() => {
|
|
void queryClient.invalidateQueries({ queryKey: ['gov', 'llm'] });
|
|
setEditing(false);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Card
|
|
ico="settings"
|
|
title="四元绑定"
|
|
extra={
|
|
<button className="g-btn xs" onClick={() => setEditing(true)}>
|
|
<Ico name="pen-line" s={11} /> 配置
|
|
</button>
|
|
}
|
|
>
|
|
<Rows
|
|
items={[
|
|
{ k: 'Provider', v: `${PROVIDER_LABEL[i.provider]} · ${i.provider}` },
|
|
{ k: 'Base URL', v: i.baseUrl, mono: true },
|
|
{ k: 'Model', v: i.model, mono: true },
|
|
{ k: 'API-Key', v: i.apiKeyBound ? '已绑定(密文存储,永不回显)' : '未绑定' },
|
|
{ k: '启用', v: i.enabled ? '启用中' : '已停用' },
|
|
{ k: '系统用途', v: i.systemUse ? '是 —— 叙述性小结 / 汇报生成 / 蒸馏钩子走它' : '否' },
|
|
]}
|
|
/>
|
|
</Card>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ChanPage({ id }: { id: string }) {
|
|
const q = useGovChannels();
|
|
const ch = (q.data?.channels ?? []).find((x) => x.id === id);
|
|
if (ch === undefined) return <Note>通道不存在。</Note>;
|
|
return (
|
|
<>
|
|
<PageHead
|
|
ico="mail"
|
|
kicker={`通道实例 · ${ch.kind}`}
|
|
title={ch.name}
|
|
chips={
|
|
<>
|
|
<Chip ico="at-sign">{ch.address}</Chip>
|
|
<Chip tone={ch.enabled ? 'ok' : 'mute'}>{ch.enabled ? '已启用' : '已停用'}</Chip>
|
|
</>
|
|
}
|
|
desc="归属解析三级逐级短路:会话线程 → 监听键 → 地址窗口;歧义或失败一律判无主——宁交人,不错投。"
|
|
/>
|
|
<Card ico="settings" title="归属与映射">
|
|
<Rows
|
|
items={[
|
|
{ k: '归属键规则', v: `${ch.extractCount} 条` },
|
|
{ k: '载荷映射', v: `${ch.mappingCount} 条` },
|
|
{ k: '地址窗口', v: `${Math.round(ch.addressWindowMs / 3600_000)} 小时`, mono: true },
|
|
{ k: '默认认领岗位', v: ch.fallbackPosition ?? '—' },
|
|
]}
|
|
/>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|
|
|