Files
one_divine_lot/src/client/views/QmtConnectionChip.jsx
T
kyugao 4ac8a29805 feat(Tab设置+UI主题): 迭代09 Tab设置统一管理 + 迭代10 UI适配DSH主题
迭代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
2026-09-02 14:00:56 +08:00

220 lines
9.3 KiB
React
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.
/**
* 会话头部 QMT 连接快捷切换 chip + 健康状态控件(R-004 Q9/Q102026-09-01 重做)
*
* - 挂载 slotconversation.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>
);
}