Files
one_divine_lot/src/client/views/SettingsSection.jsx
T
kyugao bb3f8bd28d 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
2026-09-01 13:21:49 +08:00

723 lines
29 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.
/**
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
*
* 结构:
* - 三个子 tab:通用设置 / 策略分组 / QMT 连接配置(迭代 03,R-004
* - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」
* - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示
*/
import { useState, useEffect, useCallback } from 'react';
import { useRpc } from './connection.jsx';
import { ToastProvider, useToast } from './Toast.jsx';
/** 确认弹窗 */
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
}}>
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
<h3 style={{ margin: '0 0 12px', fontSize: 16 }}>{title}</h3>
<div style={{ fontSize: 14, color: '#555', marginBottom: 20 }}>{message}</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button onClick={onCancel} style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff' }}>
取消
</button>
<button onClick={onConfirm} style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#c62828', color: '#fff' }}>
确认
</button>
</div>
</div>
</div>
);
}
/** switch 组件 */
function Switch({ checked, onChange, disabled }) {
return (
<button
onClick={() => !disabled && onChange(!checked)}
disabled={disabled}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
background: checked ? '#2e7d32' : '#9e9e9e', position: 'relative', padding: 0, transition: 'background 0.2s',
opacity: disabled ? 0.6 : 1,
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 22 : 2, width: 20, height: 20,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
}} />
</button>
);
}
/** 通用设置子 tab */
function GeneralSettings({ onChanged }) {
const [tabConfig, setTabConfig] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const call = useRpc();
const toast = useToast();
const load = useCallback(async () => {
try {
const res = await call('one-divine-lot/tabs', {});
if (res && res.ok) setTabConfig(res.value);
else setError((res && res.error && res.error.message) || '加载失败');
} catch (e) {
setError(e.message);
}
}, [call]);
useEffect(() => { load(); }, [load]);
const toggle = async (key, label) => {
if (!tabConfig) return;
setSaving(true);
try {
const next = { ...tabConfig, [key]: !tabConfig[key] };
const res = await call('one-divine-lot/tabs/update', { args: { tabs: next } });
if (res && res.ok) {
setTabConfig(next);
toast.success(`${label}」已${next[key] ? '显示' : '隐藏'}`);
onChanged?.(label, next[key]);
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const items = [
{ key: 'allPositions', label: '全部持仓', desc: '会话窗中的「全部持仓」标签页' },
{ key: 'tradeRecords', label: '交易记录', desc: '会话窗中的「交易记录」标签页(开发中)' },
{ key: 'watchlist', label: '关注列表', desc: '会话窗中的「关注列表」标签页(开发中)' },
];
return (
<div>
{error && <div style={{ color: '#c62828', marginBottom: 12 }}>{error}</div>}
{!tabConfig && !error && <div style={{ color: '#666' }}>加载中...</div>}
{tabConfig && items.map((item) => (
<div key={item.key} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 0', borderBottom: '1px solid #eee',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{item.label}</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{item.desc}</div>
</div>
<Switch checked={tabConfig[item.key] !== false} onChange={() => toggle(item.key, item.label)} disabled={saving} />
</div>
))}
</div>
);
}
/** 策略分组子 tab(原策略 CRUD) */
function StrategyGroupSettings() {
const [strategies, setStrategies] = useState(null);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState(null);
const [editingName, setEditingName] = useState('');
const [confirmDelete, setConfirmDelete] = useState(null);
const [changedMsg, setChangedMsg] = useState(null);
const call = useRpc();
const toast = useToast();
const load = useCallback(async () => {
try {
const res = await call('one-divine-lot/strategies', {});
if (res && res.ok) setStrategies(res.value);
else setError((res && res.error && res.error.message) || '加载失败');
} catch (e) {
setError(e.message);
}
}, [call]);
useEffect(() => { load(); }, [load]);
const handleChanged = useCallback((msg) => {
setChangedMsg(msg);
toast.success(msg + '(刷新页面后生效)');
load();
}, [load, toast]);
const handleAdd = async () => {
const name = newName.trim();
if (!name) { toast.error('请输入策略名称'); return; }
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/add', { args: { name } });
if (res && res.ok) {
setNewName('');
handleChanged('策略「' + name + '」已添加');
} else {
toast.error((res && res.error && res.error.message) || '添加失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const handleRename = async (id) => {
const name = editingName.trim();
if (!name) { toast.error('名称不能为空'); return; }
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/update', {
args: { strategies: strategies.map((s) => (s.id === id ? { ...s, name } : s)) },
});
if (res && res.ok) {
setEditingId(null);
handleChanged('策略已重命名');
} else {
toast.error((res && res.error && res.error.message) || '重命名失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id) => {
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/remove', { args: { strategyId: id } });
if (res && res.ok) {
setConfirmDelete(null);
handleChanged('策略已删除,份额已回到未分配');
} else {
toast.error((res && res.error && res.error.message) || '删除失败');
setConfirmDelete(null);
}
} catch (e) {
toast.error(e.message);
setConfirmDelete(null);
} finally {
setSaving(false);
}
};
const handleMove = async (id, dir) => {
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/move', { args: { strategyId: id, dir } });
if (res && res.ok) {
await load();
} else {
toast.error((res && res.error && res.error.message) || '排序失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const toggleVisible = async (id) => {
if (!strategies) return;
setSaving(true);
try {
const next = strategies.map((s) => (s.id === id ? { ...s, visible: !s.visible } : s));
const res = await call('one-divine-lot/strategies/update', { args: { strategies: next } });
if (res && res.ok) {
setStrategies(next);
handleChanged('显示设置已更新');
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
return (
<div>
{changedMsg && (
<div style={{
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
background: '#e3f2fd', color: '#1565c0', fontSize: 13, border: '1px solid #90caf9',
}}>
{changedMsg} <strong>刷新页面后生效</strong>
</div>
)}
{error && <div style={{ color: '#c62828', marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
type="text"
placeholder="新策略名称(如:长线持有)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
style={{ flex: 1, padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4 }}
/>
<button
onClick={handleAdd}
disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}
>
+ 新增策略
</button>
</div>
{!strategies && !error && <div style={{ color: '#666' }}>加载中...</div>}
{strategies && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px 6px' }}>排序</th>
<th style={{ padding: '8px 6px' }}>策略</th>
<th style={{ padding: '8px 6px' }}>ID</th>
<th style={{ padding: '8px 6px' }}>显示</th>
<th style={{ padding: '8px 6px' }}>操作</th>
</tr>
</thead>
<tbody>
{strategies.map((s, idx) => (
<tr key={s.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => handleMove(s.id, 'up')}
disabled={saving || idx === 0}
style={{ padding: '2px 6px', cursor: idx === 0 ? 'not-allowed' : 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff', marginRight: 4 }}
></button>
<button
onClick={() => handleMove(s.id, 'down')}
disabled={saving || idx === strategies.length - 1}
style={{ padding: '2px 6px', cursor: idx === strategies.length - 1 ? 'not-allowed' : 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff' }}
></button>
</td>
<td style={{ padding: '8px 6px' }}>
{editingId === s.id ? (
<span>
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename(s.id)}
style={{ padding: '4px 6px', border: '1px solid #ccc', borderRadius: 3, marginRight: 6 }}
/>
<button onClick={() => handleRename(s.id)} disabled={saving} style={{ padding: '2px 8px', cursor: 'pointer' }}>保存</button>
<button onClick={() => setEditingId(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>取消</button>
</span>
) : (
s.name
)}
</td>
<td style={{ padding: '8px 6px', color: '#666', fontSize: 12 }}>{s.id}</td>
<td style={{ padding: '8px 6px' }}>
<Switch checked={s.visible !== false} onChange={() => toggleVisible(s.id)} disabled={saving} />
</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff', marginRight: 4 }}
>重命名</button>
<button
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 3, background: '#fff', color: '#c62828' }}
>删除</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{confirmDelete && (
<ConfirmDialog
title={'删除策略「' + confirmDelete.name + '」'}
message="该策略下已分配的份额将回到「未分配」,数据不会丢失。确定删除?"
onConfirm={() => handleDelete(confirmDelete.id)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
}
/** 设置 section 主组件:三个子 tab */
function SettingsSectionInner(props) {
const [activeTab, setActiveTab] = useState('general'); // general | strategies | qmt
return (
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
<h2 style={{ margin: '0 0 16px' }}>神之一手</h2>
{/* 子 tab 导航 */}
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid #ddd', marginBottom: 16 }}>
{[
{ key: 'general', label: '通用设置' },
{ key: 'strategies', label: '策略分组' },
{ key: 'qmt', label: 'QMT 连接配置' },
].map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '8px 16px', cursor: 'pointer', border: 'none', borderBottom: activeTab === tab.key ? '2px solid #2e7d32' : '2px solid transparent',
background: 'none', fontSize: 14, color: activeTab === tab.key ? '#2e7d32' : '#666', fontWeight: activeTab === tab.key ? 600 : 400,
}}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'general' && <GeneralSettings onChanged={(label, visible) => {
// 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示)
}} />}
{activeTab === 'strategies' && <StrategyGroupSettings />}
{activeTab === 'qmt' && <QmtConnectionsSettings />}
</div>
);
}
/** 导出组件(包 ToastProvider */
export function SettingsSection(props) {
return (
<ToastProvider>
<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约束-0022026-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>
);
}