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:
@@ -2,7 +2,7 @@
|
||||
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
|
||||
*
|
||||
* 结构:
|
||||
* - 两个子 tab:通用设置 / 策略分组
|
||||
* - 三个子 tab:通用设置 / 策略分组 / QMT 连接配置(迭代 03,R-004)
|
||||
* - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」
|
||||
* - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示
|
||||
*/
|
||||
@@ -352,9 +352,9 @@ function StrategyGroupSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置 section 主组件:两个子 tab */
|
||||
/** 设置 section 主组件:三个子 tab */
|
||||
function SettingsSectionInner(props) {
|
||||
const [activeTab, setActiveTab] = useState('general'); // general | strategies
|
||||
const [activeTab, setActiveTab] = useState('general'); // general | strategies | qmt
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
|
||||
@@ -365,6 +365,7 @@ function SettingsSectionInner(props) {
|
||||
{[
|
||||
{ key: 'general', label: '通用设置' },
|
||||
{ key: 'strategies', label: '策略分组' },
|
||||
{ key: 'qmt', label: 'QMT 连接配置' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
@@ -383,6 +384,7 @@ function SettingsSectionInner(props) {
|
||||
// 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示)
|
||||
}} />}
|
||||
{activeTab === 'strategies' && <StrategyGroupSettings />}
|
||||
{activeTab === 'qmt' && <QmtConnectionsSettings />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -394,4 +396,327 @@ export function SettingsSection(props) {
|
||||
<SettingsSectionInner {...props} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 长文本省略单元格:单行省略 + 悬停显示完整内容(避免错误信息等长文本撑行换行) */
|
||||
function EllipsisText({ text, maxWidth = 220, title, style }) {
|
||||
return (
|
||||
<span title={title || text} style={{
|
||||
display: 'inline-block', maxWidth, overflow: 'hidden',
|
||||
textOverflow: 'ellipsis', whiteSpace: 'nowrap', verticalAlign: 'bottom',
|
||||
...style,
|
||||
}}>{text}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** QMT 连接配置卡片(R-004;产品约束-005、UI约束-002;2026-08-29 老师反馈改卡片形式) */
|
||||
function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActivate, onSetDefault, onTest, onEdit, onDelete }) {
|
||||
const btn = { padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 };
|
||||
return (
|
||||
<div style={{
|
||||
border: isActive ? '1px solid #2e7d32' : '1px solid #ddd',
|
||||
background: isActive ? '#f1f8f2' : '#fff',
|
||||
borderRadius: 8,
|
||||
padding: '12px 14px',
|
||||
display: 'flex', flexDirection: 'column', gap: 8,
|
||||
minWidth: 0, overflow: 'hidden',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
{/* 标题行:名称 + 徽标 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||
<EllipsisText text={conn.name} maxWidth={140} style={{ fontWeight: 600, fontSize: 14 }} />
|
||||
{isActive && <span style={{ flex: 'none', fontSize: 11, color: '#2e7d32', border: '1px solid #2e7d32', borderRadius: 8, padding: '0 6px' }}>生效中</span>}
|
||||
{isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
||||
</div>
|
||||
|
||||
{/* 地址行:省略+悬停 */}
|
||||
<div style={{ fontSize: 12, color: '#666', minWidth: 0 }}>
|
||||
<EllipsisText text={conn.baseUrl} maxWidth={240} />
|
||||
</div>
|
||||
|
||||
{/* 测试结果行:可换行但受卡片约束;超长悬停看全文 */}
|
||||
{testResult && (
|
||||
<div style={{
|
||||
fontSize: 12, lineHeight: '16px', color: testResult.ok ? '#2e7d32' : '#c62828',
|
||||
background: testResult.ok ? '#e8f5e9' : '#fdecea',
|
||||
borderRadius: 4, padding: '4px 8px', minWidth: 0,
|
||||
}}>
|
||||
<EllipsisText text={testResult.text} maxWidth={9999} title={testResult.fullText || testResult.text} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作行:按钮自动换行排列,不挤压卡片 */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 2 }}>
|
||||
{!isActive && <button onClick={onActivate} disabled={busy} style={{ ...btn, color: '#2e7d32', borderColor: '#2e7d32' }}>激活</button>}
|
||||
{!isDefault && <button onClick={onSetDefault} disabled={busy} style={btn}>设为默认</button>}
|
||||
<button onClick={onTest} disabled={busy}>{busy ? '测试中…' : '测试连接'}</button>
|
||||
<button onClick={onEdit} disabled={busy} style={btn}>编辑</button>
|
||||
<button onClick={onDelete} disabled={busy} style={{ ...btn, color: '#c62828', borderColor: '#c62828' }}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** QMT 连接配置子 tab —— 卡片版(R-004;产品约束-005、UI约束-002) */
|
||||
function QmtConnectionsSettings() {
|
||||
const [state, setState] = useState(null); // { list, activeId, defaultId, active }
|
||||
const [error, setError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingId, setTestingId] = useState(null);
|
||||
const [testResult, setTestResult] = useState(null); // { key, ok, text, fullText }
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formUrl, setFormUrl] = useState('');
|
||||
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);
|
||||
else setError((res && res.error && res.error.message) || '加载失败');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, [call]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openAdd = () => { setEditingId(null); setFormName(''); setFormUrl(''); setFormOpen(true); setTestResult(null); };
|
||||
const openEdit = (c) => { setEditingId(c.id); setFormName(c.name); setFormUrl(c.baseUrl); setFormOpen(true); setTestResult(null); };
|
||||
const closeForm = () => { setFormOpen(false); setEditingId(null); };
|
||||
|
||||
const validateUrl = (raw) => {
|
||||
const url = String(raw || '').trim().replace(/\/+$/, '');
|
||||
if (!/^https?:\/\//i.test(url) || url.length <= 8) return null;
|
||||
return url;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = formName.trim();
|
||||
const url = validateUrl(formUrl);
|
||||
if (!name) { toast.error('请输入连接名称'); return; }
|
||||
if (!url) { toast.error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = editingId === null
|
||||
? await call('one-divine-lot/qmt-connections/add', { args: { name, baseUrl: url } })
|
||||
: await call('one-divine-lot/qmt-connections/update', { args: { id: editingId, name, baseUrl: url } });
|
||||
if (res && res.ok) {
|
||||
closeForm();
|
||||
toast.success(editingId === null ? '连接配置已添加' : '连接配置已保存');
|
||||
await load();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (c) => {
|
||||
if (state && c.id === state.activeId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/activate', { args: { id: c.id } });
|
||||
if (res && res.ok) {
|
||||
setState(res.value);
|
||||
toast.success('已激活:' + c.name + '(立即生效)');
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '激活失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (c) => {
|
||||
if (state && c.id === state.defaultId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/set-default', { args: { id: c.id } });
|
||||
if (res && res.ok) {
|
||||
setState((prev) => prev ? { ...prev, defaultId: c.id } : prev);
|
||||
toast.success('已设为默认:' + c.name + '(启动时自动激活)');
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '设置默认失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showTest = (key, v) => {
|
||||
const text = v.reachable
|
||||
? '可达 · ' + v.latencyMs + 'ms' + (v.status ? ' · status=' + v.status : '')
|
||||
: '不可达 · ' + (v.error || ('HTTP ' + v.httpStatus)) + ' · ' + v.latencyMs + 'ms';
|
||||
setTestResult({ key, ok: v.reachable === true, text, fullText: text });
|
||||
};
|
||||
|
||||
const handleTest = async (c) => {
|
||||
setTestingId(c.id);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/test', { args: { id: c.id } });
|
||||
if (res && res.ok) showTest(c.id, res.value);
|
||||
else {
|
||||
const msg = (res && res.error && res.error.message) || '测试失败';
|
||||
setTestResult({ key: c.id, ok: false, text: '不可达 · ' + msg, fullText: msg });
|
||||
}
|
||||
} catch (e) {
|
||||
setTestResult({ key: c.id, ok: false, text: '不可达 · ' + e.message, fullText: e.message });
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestForm = async () => {
|
||||
const url = validateUrl(formUrl);
|
||||
if (!url) { toast.error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'); return; }
|
||||
setTestingId('__form__');
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/test', { args: { baseUrl: url } });
|
||||
if (res && res.ok) showTest('__form__', res.value);
|
||||
else {
|
||||
const msg = (res && res.error && res.error.message) || '测试失败';
|
||||
setTestResult({ key: '__form__', ok: false, text: '不可达 · ' + msg, fullText: msg });
|
||||
}
|
||||
} catch (e) {
|
||||
setTestResult({ key: '__form__', ok: false, text: '不可达 · ' + e.message, fullText: e.message });
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/qmt-connections/remove', { args: { id } });
|
||||
setConfirmDelete(null);
|
||||
if (res && res.ok) {
|
||||
setState(res.value);
|
||||
const msgs = [];
|
||||
if (res.value.removedWasActive) msgs.push('激活已自动切换');
|
||||
if (res.value.removedWasDefault) msgs.push('默认标记已转移');
|
||||
toast.success('连接配置已删除' + (msgs.length ? '(' + msgs.join(';') + ')' : ''));
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
setConfirmDelete(null);
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 13, color: '#666', minWidth: 0 }}>
|
||||
激活配置为当前生效连接(切换立即生效);默认配置在插件启动时自动激活。
|
||||
</div>
|
||||
<button onClick={openAdd} disabled={saving} style={{ flex: 'none', padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
|
||||
+ 新增连接
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div style={{ color: '#c62828', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
|
||||
{!state && !error && <div style={{ color: '#666' }}>加载中...</div>}
|
||||
|
||||
{state && state.list.length === 0 && (
|
||||
<div style={{ padding: '24px 0', color: '#888', fontSize: 13 }}>
|
||||
暂无连接配置。点击「新增连接」录入第一个 QMT Bridge 地址(如 http://192.168.3.43:8610)。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.list.length > 0 && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
{state.list.map((c) => (
|
||||
<QmtConnectionCard
|
||||
key={c.id}
|
||||
conn={c}
|
||||
isActive={c.id === state.activeId}
|
||||
isDefault={c.id === state.defaultId}
|
||||
busy={saving || testingId === c.id}
|
||||
testResult={testResult && testResult.key === c.id ? testResult : null}
|
||||
onActivate={() => handleActivate(c)}
|
||||
onSetDefault={() => handleSetDefault(c)}
|
||||
onTest={() => handleTest(c)}
|
||||
onEdit={() => openEdit(c)}
|
||||
onDelete={() => setConfirmDelete({ id: c.id, name: c.name })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<div style={{ marginTop: 16, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa', maxWidth: 640 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>{editingId === null ? '新增连接配置' : '编辑连接配置'}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
|
||||
连接名称
|
||||
<input type="text" placeholder="如:本机 QMT" value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
|
||||
HTTP 地址
|
||||
<input type="text" placeholder="如 http://192.168.3.43:8610" value={formUrl}
|
||||
onChange={(e) => setFormUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
|
||||
保存
|
||||
</button>
|
||||
<button onClick={handleTestForm} disabled={testingId !== null}
|
||||
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
|
||||
{testingId === '__form__' ? '测试中…' : '测试连接'}
|
||||
</button>
|
||||
<button onClick={closeForm} disabled={saving}
|
||||
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
|
||||
取消
|
||||
</button>
|
||||
{testResult && testResult.key === '__form__' && (
|
||||
<span style={{
|
||||
fontSize: 12, color: testResult.ok ? '#2e7d32' : '#c62828',
|
||||
background: testResult.ok ? '#e8f5e9' : '#fdecea', borderRadius: 4, padding: '3px 8px',
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<EllipsisText text={testResult.text} maxWidth={360} title={testResult.fullText || testResult.text} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
title={'删除连接「' + confirmDelete.name + '」'}
|
||||
message={
|
||||
(state && state.list.length <= 1)
|
||||
? '这是最后一个连接配置,不能删除。'
|
||||
: '删除后:若为激活配置将自动切换到默认配置;若为默认配置,默认标记转移到列表第一条。确定删除?'
|
||||
}
|
||||
onConfirm={() => handleDelete(confirmDelete.id)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user