feat: 迭代03-05 交易记录接入、行情实时、QMT连接配置、data store 标准化
- R-004 QMT连接配置:多配置管理 + 会话头部快捷切换(03-QMT连接配置) - R-005 WS盘中价格实时更新:服务端中转+缓存+前端轮询(04-WS盘中价格实时更新) - R-006 策略数据 JSON data store 标准化(store.json/market.json/schema.json) - R-007 交易记录接入:当日订单+成交单表合并(委托主行+展开成交明细)、时间段查询(今日/本周/本月+手动起止)、费用计算(佣金费率万m_dCommission+最低5元、印花税卖出万5、过户费双向万0.1) - 架构治理:api 按领域拆分、component 目录、QmtHealthMonitor
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 会话头部 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 ? '#e8f5e9' : 'transparent',
|
||||
fontSize: 13, color: '#333',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||
onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<span style={{ width: 16, flex: 'none', color: '#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: '#999', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.baseUrl}</span>
|
||||
</span>
|
||||
{item.isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #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 ? '#bbb' : (health.healthy ? '#2e7d32' : '#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: color !== '#bbb' ? '0 0 4px ' + color : 'none',
|
||||
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: '#fff', border: '1px solid #ddd',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '4px 12px', fontSize: 11, color: '#999' }}>切换 QMT 连接</div>
|
||||
{state.list.length === 0 ? (
|
||||
<div style={{ padding: '10px 12px', fontSize: 12, color: '#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 #eee', marginTop: 4, padding: '6px 12px', fontSize: 11, color: '#999' }}>
|
||||
管理配置请进 设置 → 神之一手 → QMT 连接配置
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 导出(包 ToastProvider,与 SettingsSection 同模式) */
|
||||
export function QmtConnectionChip(props) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ChipInner {...props} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user