This commit is contained in:
2026-08-29 16:20:33 +08:00
commit cba31428dc
52 changed files with 6553 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
/**
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
*
* 结构:
* - 两个子 tab:通用设置 / 策略分组
* - 通用设置:三个 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
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: '策略分组' },
].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 />}
</div>
);
}
/** 导出组件(包 ToastProvider */
export function SettingsSection(props) {
return (
<ToastProvider>
<SettingsSectionInner {...props} />
</ToastProvider>
);
}