Files
one_divine_lot/src/client/views/SettingsSection.jsx
T

818 lines
38 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
*
* 结构(R-011 迭代 09):
* - 三个子 tabTab 设置 / 策略分组 / QMT 连接配置
* - Tab 设置:统一管理所有会话 tab(内置 + 策略分组混排),拖动排序(落点立即持久化)+ 显示/隐藏开关;
* 任何 tab 均不支持重命名/删除(产品约束-009、UI约束-003)
* - 策略分组:策略 CRUD(新增/重命名/删除确认)+ 提示「顺序与显示请在 Tab 设置中调整」
* - QMT 连接配置:R-004 卡片形式(迭代 03)
*/
import { useState, useEffect, useCallback, Fragment } from 'react';
import { useRpc } from './connection.jsx';
import { ToastProvider, useToast } from './Toast.jsx';
import { StrategyFieldsEditor } from './StrategyFieldsEditor.jsx'; // R-013:策略自定义字段编辑器
import { ExpandChevron } from './ExpandChevron.jsx'; // 展开箭头与策略 tab 表格行图标统一
/** 确认弹窗 */
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
return (
<div style={{
position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.4))',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
}}>
<div style={{ background: 'var(--dsw-alias-bg-layer-1, #fff)', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
<h3 style={{ margin: '0 0 12px', fontSize: 16 }}>{title}</h3>
<div style={{ fontSize: 14, color: 'var(--dsw-alias-label-secondary, #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 var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}>
取消
</button>
<button onClick={onConfirm} style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-button-contrast-fill, #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 ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l4, #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: 'var(--dsw-alias-button-contrast-fill, #fff)', transition: 'left 0.2s',
}} />
</button>
);
}
/** Tab 设置子 tabR-011:统一管理所有会话 tab —— 内置 + 策略分组混排,拖动排序 + 显隐开关) */
function TabSettings() {
const [tabs, setTabs] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [dragId, setDragId] = useState(null);
const [overId, setOverId] = 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 && Array.isArray(res.value)) setTabs(res.value);
else setError((res && res.error && res.error.message) || '加载失败');
} catch (e) {
setError(e.message);
}
}, [call]);
useEffect(() => { load(); }, [load]);
// 整表持久化(拖动落点 / 显隐切换共用;Q1 落点立即持久化)
const persist = async (nextTabs, msg) => {
setSaving(true);
try {
const res = await call('one-divine-lot/tabs/update', { args: { tabs: nextTabs } });
if (res && res.ok) {
setTabs(res.value);
toast.success(msg + '(刷新页面后生效)');
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
await load();
}
} catch (e) {
toast.error(e.message);
await load();
} finally {
setSaving(false);
}
};
const toggleVisible = async (t) => {
if (!tabs) return;
const next = tabs.map((x) => (x.id === t.id ? { ...x, visible: !x.visible } : x));
await persist(next, '「' + t.name + '」已' + (next.find((x) => x.id === t.id).visible ? '显示' : '隐藏'));
};
// 原生 HTML5 拖动:落点重排 + 立即持久化(Q1)
const handleDrop = async (targetId) => {
if (!tabs || !dragId || dragId === targetId) { setDragId(null); setOverId(null); return; }
const from = tabs.findIndex((x) => x.id === dragId);
const to = tabs.findIndex((x) => x.id === targetId);
if (from < 0 || to < 0) { setDragId(null); setOverId(null); return; }
const next = tabs.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
// 重写 order(整表连续)
const ordered = next.map((x, i) => ({ ...x, order: i }));
setDragId(null);
setOverId(null);
await persist(ordered, '「' + moved.name + '」已移动');
};
const badgeStyle = (kind) => ({
fontSize: 11, borderRadius: 8, padding: '0 6px', marginRight: 6, flex: 'none',
color: kind === 'builtin' ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-state-warn-primary, #6a1b9a)',
border: kind === 'builtin' ? '1px solid var(--dsw-alias-border-l3, #90caf9)' : '1px solid var(--dsw-alias-border-l3, #ce93d8)',
});
return (
<div>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
拖动调整所有标签页顺序开关控制显示/隐藏内置与策略标签可任意混排名称不支持在此修改策略名称在策略分组中修改
</div>
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
{!tabs && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
{tabs && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={{ width: 44, padding: '8px 6px' }}></th>
<th style={{ padding: '8px 6px' }}>标签页</th>
<th style={{ width: 80, padding: '8px 6px' }}>显示</th>
</tr>
</thead>
<tbody>
{tabs.map((t) => (
<tr
key={t.id}
draggable={!saving}
onDragStart={(e) => { setDragId(t.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOverId(t.id); }}
onDragLeave={() => { if (overId === t.id) setOverId(null); }}
onDrop={(e) => { e.preventDefault(); handleDrop(t.id); }}
style={{
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', cursor: saving ? 'default' : 'grab',
background: overId === t.id ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, transparent)' : 'transparent',
}}
>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-tertiary, #999)', textAlign: 'center' }}></td>
<td style={{ padding: '8px 6px' }}>
<span style={badgeStyle(t.kind)}>{t.kind === 'builtin' ? '内置' : '策略'}</span>
{t.name}
</td>
<td style={{ padding: '8px 6px' }}>
<Switch checked={t.visible !== false} onChange={() => toggleVisible(t)} disabled={saving} />
</td>
</tr>
))}
</tbody>
</table>
)}
</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);
// R-013:当前展开配置字段的策略 id(null=无展开)
const [expandedId, setExpandedId] = useState(null);
const [fieldSaving, setFieldSaving] = useState(false);
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);
}
};
/** R-013:保存某策略的自定义字段定义(整表 strategies/update */
const handleSaveConfig = async (id, configSchema) => {
setFieldSaving(true);
try {
const res = await call('one-divine-lot/strategies/update', {
args: { strategies: strategies.map((s) => (s.id === id ? { ...s, configSchema } : s)) },
});
if (res && res.ok) {
handleChanged('策略自定义字段已保存');
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setFieldSaving(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);
}
};
return (
<div>
{changedMsg && (
<div style={{
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
background: 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 10%, var(--dsw-alias-bg-layer-1, #fff))', color: 'var(--dsw-alias-state-business-primary, #1565c0)', fontSize: 13, border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
}}>
{changedMsg} <strong>刷新页面后生效</strong>
</div>
)}
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #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 var(--dsw-alias-border-l2, #ccc)', borderRadius: 4 }}
/>
<button
onClick={handleAdd}
disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
>
+ 新增策略
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
顺序与显示请在Tab 设置中调整
</div>
{!strategies && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
{strategies && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={{ padding: '8px 6px' }}>策略</th>
<th style={{ padding: '8px 6px' }}>ID</th>
<th style={{ padding: '8px 6px' }}>操作</th>
</tr>
</thead>
<tbody>
{strategies.map((s) => (
<Fragment key={s.id}>
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<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>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<button
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); }}
style={{ padding: '2px 4px', cursor: 'pointer', border: 'none', background: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', lineHeight: 0 }}
title={expandedId === s.id ? '收起配置' : '展开配置自定义字段'}
><ExpandChevron expanded={expandedId === s.id} /></button>
{s.name}
</span>
)}
</td>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 12 }}>{s.id}</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); setEditingId(null); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
>{expandedId === s.id ? '收起字段' : '配置字段'}</button>
<button
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
>重命名</button>
<button
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary, #c62828)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
>删除</button>
</td>
</tr>
{expandedId === s.id && (
<tr>
<td colSpan={3} style={{ padding: '4px 10px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<StrategyFieldsEditor
strategy={s}
saving={fieldSaving}
onSave={(fields) => handleSaveConfig(s.id, fields)}
/>
</td>
</tr>
)}
</Fragment>
))}
</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('tabs'); // tabs | 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 var(--dsw-alias-border-l2, #ddd)', marginBottom: 16 }}>
{[
{ key: 'tabs', label: 'Tab 设置' },
{ 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 var(--dsw-alias-state-success-primary, #2e7d32)' : '2px solid transparent',
background: 'none', fontSize: 14, color: activeTab === tab.key ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-label-secondary, #666)', fontWeight: activeTab === tab.key ? 600 : 400,
}}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'tabs' && <TabSettings />}
{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 var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 };
return (
<div style={{
border: isActive ? '1px solid var(--dsw-alias-state-success-primary, #2e7d32)' : '1px solid var(--dsw-alias-border-l2, #ddd)',
background: isActive ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 8%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
borderRadius: 8,
padding: '12px 14px',
display: 'flex', flexDirection: 'column', gap: 8,
minWidth: 0, overflow: 'hidden',
boxShadow: 'var(--dsw-shadow-lv3, 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: 'var(--dsw-alias-state-success-primary, #2e7d32)', border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', borderRadius: 8, padding: '0 6px' }}>生效中</span>}
{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>}
</div>
{/* 地址行:省略+悬停 */}
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', minWidth: 0 }}>
<EllipsisText text={conn.baseUrl} maxWidth={240} />
</div>
{/* 测试结果行:可换行但受卡片约束;超长悬停看全文 */}
{testResult && (
<div style={{
fontSize: 12, lineHeight: '16px', color: testResult.ok ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)',
background: testResult.ok ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))',
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: 'var(--dsw-alias-state-success-primary, #2e7d32)', borderColor: 'var(--dsw-alias-state-success-primary, #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: 'var(--dsw-alias-state-error-primary, #c62828)', borderColor: 'var(--dsw-alias-state-error-primary, #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 [mcpStatus, setMcpStatus] = useState(null); // R-020/迭代18MCP 状态
const [mcpChecking, setMcpChecking] = useState(false);
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]);
const loadMcp = useCallback(async (refresh) => {
try {
const res = await call('one-divine-lot/qmt-connections/mcp-status', { args: { refresh: !!refresh } });
if (res && res.ok) setMcpStatus(res.value);
} catch { /* 静默 */ }
}, [call]);
useEffect(() => { load(); loadMcp(false); }, [load, loadMcp]);
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);
loadMcp(false); // R-020/迭代18:激活切换后刷新 MCP 状态
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: 'var(--dsw-alias-label-secondary, #666)', minWidth: 0 }}>
激活配置为当前生效连接切换立即生效默认配置在插件启动时自动激活
</div>
<button onClick={openAdd} disabled={saving} style={{ flex: 'none', padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}>
+ 新增连接
</button>
</div>
{/* R-020/迭代18:MCP 连接状态条(随激活连接;绿=已连接+N工具 / 黄=连接中 / 红=断开 / 灰=未启用) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, minWidth: 0, fontSize: 12 }}>
<span style={{ flex: 'none', fontWeight: 600 }}>MCP 连接</span>
<span style={{
flex: 'none', width: 10, height: 10, borderRadius: '50%',
background: mcpStatus?.state === 'connected'
? 'var(--dsw-alias-state-success-primary, #2e7d32)'
: mcpStatus?.state === 'connecting'
? 'var(--dsw-alias-state-warn-primary, #f9a825)'
: mcpStatus?.state === 'error'
? 'var(--dsw-alias-state-error-primary, #c62828)'
: 'var(--dsw-alias-label-tertiary, #bbb)',
}} />
<EllipsisText
text={mcpStatus?.state === 'connected'
? '已连接' + (mcpStatus.toolCount != null ? '' + mcpStatus.toolCount + ' 个工具)' : '') + ' · ' + (mcpStatus.url || '')
: mcpStatus?.state === 'connecting' ? '连接中…'
: mcpStatus?.state === 'error' ? '断开 · ' + String(mcpStatus.error || '').slice(0, 80)
: mcpStatus ? '未启用(无激活连接)' : '检测中…'}
maxWidth={520}
title={mcpStatus?.error ? String(mcpStatus.error) : (mcpStatus?.url || '')}
/>
<button
onClick={async () => {
if (mcpChecking) return;
setMcpChecking(true);
try { await loadMcp(true); } finally { setMcpChecking(false); }
}}
disabled={mcpChecking}
style={{ flex: 'none', padding: '2px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}
>
{mcpChecking ? '检测中…' : '检测 MCP'}
</button>
</div>
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
{!state && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
{state && state.list.length === 0 && (
<div style={{ padding: '24px 0', color: 'var(--dsw-alias-label-secondary, #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 var(--dsw-alias-border-l2, #ddd)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2, #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: 'var(--dsw-alias-label-secondary, #666)' }}>
连接名称
<input type="text" placeholder="如:本机 QMT" value={formName}
onChange={(e) => setFormName(e.target.value)}
style={{ padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, fontSize: 13 }} />
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary, #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 var(--dsw-alias-border-l2, #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: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}>
保存
</button>
<button onClick={handleTestForm} disabled={testingId !== null}
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>
{testingId === '__form__' ? '测试中…' : '测试连接'}
</button>
<button onClick={closeForm} disabled={saving}
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>
取消
</button>
{testResult && testResult.key === '__form__' && (
<span style={{
fontSize: 12, color: testResult.ok ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)',
background: testResult.ok ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))', 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>
);
}