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:
2026-09-01 13:21:49 +08:00
parent 5a723d514e
commit bb3f8bd28d
57 changed files with 5244 additions and 397 deletions
+219
View File
@@ -0,0 +1,219 @@
/**
* 会话头部 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 ? '#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>
);
}