4ac8a29805
迭代09 (R-011): Tab 设置统一管理所有会话 tab
- settings.tabs 布尔对象 → 统一有序数组 (kind: builtin|strategy)
- 旧配置 schema union 兼容 + 读取归一化无感迁移
- strategies 收窄为 {id,name},策略行 name join 自动跟随改名
- 策略增删联动 tabs 条目 (add 追加末尾 / remove 联动删除)
- 客户端统一注册读 tabs 数组,内置与策略可任意混排
- 设置页 Tab 设置子 tab:混排表格 + 拖动手柄 + 显隐开关 + 落点立即持久化
- 策略分组瘦身:仅 新增/重命名/删除 + 指引提示
- 回归测试: test-r011-tabs (23) + test-r011-api (12)
迭代10 (R-012): UI 适配 DSH 浅色/深色/跟随系统主题
- 10 个视图文件 141 处硬编码色 → var(--dsw-alias-*, fallback)
- 语义映射: bg-layer/label/border/state/button-contrast 等
- 淡底 color-mix 自适应;遮罩 bg-mask-1;阴影 shadow-lv3
220 lines
9.3 KiB
React
220 lines
9.3 KiB
React
/**
|
||
* 会话头部 QMT 连接快捷切换 chip + 健康状态控件(R-004 Q9/Q10;2026-09-01 重做)
|
||
*
|
||
* - 挂载 slot:conversation.session.header.actions(与 DSH 内置 PTC 模式标签并排)
|
||
* - 形态:
|
||
* - chip「QMT: <激活配置名> ▾」:点开下拉列出全部配置(激活项勾选),仅切换激活
|
||
* - chip 右侧独立「QMT 健康」控件:小圆点(绿=健康 / 红=异常),展示 QMT 连接状态
|
||
* - 数据:qmt-health 端点(服务端代理 GET 激活配置 /health)
|
||
*
|
||
* 2026-09-01:移除全盘订阅状态标记(暂缓,重开筛选做);QMT health 检查独立保留。
|
||
*/
|
||
|
||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { useRpc } from './connection.jsx';
|
||
import { ToastProvider, useToast } from './Toast.jsx';
|
||
|
||
/** 下拉菜单项 */
|
||
function MenuItem({ item, selected, onSelect }) {
|
||
return (
|
||
<button
|
||
onClick={() => onSelect(item)}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left',
|
||
padding: '8px 12px', border: 'none', cursor: 'pointer', background: selected ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'transparent',
|
||
fontSize: 13, color: 'var(--dsw-alias-label-primary, #333)',
|
||
}}
|
||
onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)'; }}
|
||
onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||
>
|
||
<span style={{ width: 16, flex: 'none', color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 700 }}>{selected ? '✓' : ''}</span>
|
||
<span style={{ flex: 1, minWidth: 0 }}>
|
||
<span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name}</span>
|
||
<span style={{ display: 'block', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.baseUrl}</span>
|
||
</span>
|
||
{item.isDefault && <span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-state-business-primary, #1565c0)', border: '1px solid var(--dsw-alias-border-l3, #90caf9)', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* QMT 健康状态控件(2026-09-01)
|
||
* - 位置:QMT 下拉框右侧
|
||
* - 显示:绿灯(health OK)/ 红灯(health 异常)
|
||
* - 每 5s 轮询 qmt-health
|
||
*/
|
||
function QmtHealthIndicator() {
|
||
const [health, setHealth] = useState(null); // { healthy, latencyMs, ... } | null(未获取)
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const call = useRpc();
|
||
|
||
// 读服务端缓存(默认,秒回)
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const res = await call('one-divine-lot/qmt-health', {});
|
||
if (res && res.ok) setHealth(res.value);
|
||
// 失败保留上次状态
|
||
} catch (e) { /* 静默 */ }
|
||
}, [call]);
|
||
|
||
// 点击触发即时探测(refresh=true,刷新服务端缓存)
|
||
const refresh = useCallback(async () => {
|
||
if (refreshing) return;
|
||
setRefreshing(true);
|
||
try {
|
||
const res = await call('one-divine-lot/qmt-health', { args: { refresh: true } });
|
||
if (res && res.ok) setHealth(res.value);
|
||
} catch (e) { /* 静默 */ }
|
||
finally { setRefreshing(false); }
|
||
}, [call, refreshing]);
|
||
|
||
useEffect(() => {
|
||
load(); // 打开即读缓存
|
||
const timer = setInterval(load, 30000); // 30s 轮询缓存(服务端 5 分钟更新,前端低频同步)
|
||
return () => clearInterval(timer);
|
||
}, [load]);
|
||
|
||
// 状态推导:null=灰(未知/加载中),healthy=true=绿,false=红
|
||
const color = health === null ? 'var(--dsw-alias-label-tertiary, #bbb)' : (health.healthy ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #d32f2f)');
|
||
const title = health === null
|
||
? 'QMT 健康状态获取中…(点击立即检测)'
|
||
: (health.healthy
|
||
? 'QMT 连接健康(' + (health.latencyMs ?? '-') + 'ms,检测于 ' + new Date(health.checkedAt ?? Date.now()).toLocaleTimeString() + ',点击重新检测)'
|
||
: 'QMT 连接异常' + (health.healthError ? ':' + String(health.healthError).slice(0, 40) : '') + '(点击重新检测)');
|
||
|
||
return (
|
||
<span
|
||
title={title}
|
||
onClick={refresh}
|
||
role="button"
|
||
aria-label="QMT 健康状态(点击重新检测)"
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
width: 10, height: 10, borderRadius: '50%', background: color, flex: 'none',
|
||
boxShadow: health === null ? 'none' : '0 0 4px ' + color,
|
||
cursor: 'pointer',
|
||
opacity: refreshing ? 0.5 : 1,
|
||
transition: 'opacity .2s',
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ChipInner() {
|
||
const [state, setState] = useState(null); // { list, activeId, defaultId, active }
|
||
const [open, setOpen] = useState(false);
|
||
const [switching, setSwitching] = useState(false);
|
||
const rootRef = useRef(null);
|
||
const call = useRpc();
|
||
const toast = useToast();
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const res = await call('one-divine-lot/qmt-connections', {});
|
||
if (res && res.ok) setState(res.value);
|
||
} catch (e) { /* 头部控件静默失败,不打扰会话 */ }
|
||
}, [call]);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
// 外点关闭
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const onDocClick = (e) => {
|
||
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', onDocClick);
|
||
return () => document.removeEventListener('mousedown', onDocClick);
|
||
}, [open]);
|
||
|
||
const activate = async (item) => {
|
||
setOpen(false);
|
||
if (!item || switching) return;
|
||
if (state && item.id === state.activeId) return;
|
||
setSwitching(true);
|
||
try {
|
||
const res = await call('one-divine-lot/qmt-connections/activate', { args: { id: item.id } });
|
||
if (res && res.ok) {
|
||
setState(res.value);
|
||
toast.success('QMT 连接已切换:' + item.name);
|
||
} else {
|
||
toast.error((res && res.error && res.error.message) || '切换失败');
|
||
}
|
||
} catch (e) {
|
||
toast.error(e.message);
|
||
} finally {
|
||
setSwitching(false);
|
||
}
|
||
};
|
||
|
||
if (!state) return null; // 服务端未就绪/加载失败时不显示
|
||
|
||
const active = state.active || state.list.find((c) => c.id === state.activeId) || null;
|
||
const label = active ? active.name : (state.list.length === 0 ? '未配置' : '未激活');
|
||
|
||
return (
|
||
<div ref={rootRef} style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||
<button
|
||
type="button"
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
disabled={switching}
|
||
onClick={() => setOpen(!open)}
|
||
title="QMT 连接快捷切换(管理配置请进 设置 → 神之一手 → QMT 连接配置)"
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: 180, height: 22,
|
||
padding: '0 8px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 12,
|
||
lineHeight: '22px', color: 'var(--dsw-alias-label-secondary, #666)',
|
||
background: 'var(--dsw-alias-fill-tsp-secondary, rgba(0,0,0,0.05))',
|
||
whiteSpace: 'nowrap', overflow: 'hidden',
|
||
opacity: switching ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<span style={{ fontWeight: 600 }}>QMT:</span>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||
<span style={{ fontSize: 10, flex: 'none' }}>▾</span>
|
||
</button>
|
||
{/* QMT 健康状态控件(下拉框右侧,绿灯=健康/红灯=异常) */}
|
||
<QmtHealthIndicator />
|
||
{open && (
|
||
<div
|
||
role="menu"
|
||
style={{
|
||
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 10001,
|
||
minWidth: 220, maxWidth: 300, background: 'var(--dsw-alias-bg-layer-1, #fff)', border: '1px solid var(--dsw-alias-border-l2, #ddd)',
|
||
borderRadius: 8, boxShadow: 'var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,0.15))', padding: '4px 0',
|
||
}}
|
||
>
|
||
<div style={{ padding: '4px 12px', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)' }}>切换 QMT 连接</div>
|
||
{state.list.length === 0 ? (
|
||
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)' }}>
|
||
暂无连接配置,请进 设置 → 神之一手 → QMT 连接配置 添加
|
||
</div>
|
||
) : (
|
||
state.list.map((c) => (
|
||
<MenuItem
|
||
key={c.id}
|
||
item={{ ...c, isDefault: c.id === state.defaultId }}
|
||
selected={c.id === state.activeId}
|
||
onSelect={activate}
|
||
/>
|
||
))
|
||
)}
|
||
<div style={{ borderTop: '1px solid var(--dsw-alias-border-l1, #eee)', marginTop: 4, padding: '6px 12px', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||
管理配置请进 设置 → 神之一手 → QMT 连接配置
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 导出(包 ToastProvider,与 SettingsSection 同模式) */
|
||
export function QmtConnectionChip(props) {
|
||
return (
|
||
<ToastProvider>
|
||
<ChipInner {...props} />
|
||
</ToastProvider>
|
||
);
|
||
}
|