init
This commit is contained in:
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 服务端 HTTP API:通过 webServer 注册 /odl/api/* 路由
|
||||
*
|
||||
* 为什么不用 RPC(connection.rpc.intercept('/api')):
|
||||
* DSH 的 /api 通道只能有一个 interceptor(api-gateway 已占用),
|
||||
* 再 intercept 会抛 "already has an interceptor" 导致插件 apply 失败。
|
||||
* 因此改用 webServer 自开路由(与 dsh-better-sidebar 的 /sidebar/api 同模式)。
|
||||
*
|
||||
* 端点(POST /odl/api/<method>,body = { args: {...} }):
|
||||
* positions → 全量持仓
|
||||
* strategies → 策略列表(含 visible)
|
||||
* strategies/update → 更新策略列表(整表)
|
||||
* strategies/add → 新增策略 { name }(O3)
|
||||
* strategies/remove → 删除策略 { strategyId },联动清份额(O3)
|
||||
* strategies/move → 排序 { strategyId, dir: up|down }(O3)
|
||||
* strategy-positions → 某策略持仓 { strategyId }
|
||||
* unallocated → 未分配持仓
|
||||
* summary → 分仓摘要
|
||||
* add-shares → 添加份额 { code, strategyId, shares }
|
||||
* move-all-shares → 全部移入 { code, strategyId }(O6)
|
||||
* remove-shares → 移出份额 { code, strategyId, shares }
|
||||
* remove-all-shares → 一键清零 { strategyId }(O6)
|
||||
*/
|
||||
|
||||
import { getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy, getTabConfig, updateTabConfig } from './settings.js';
|
||||
|
||||
const API_PREFIX = '/odl/api/';
|
||||
|
||||
/** 响应 JSON */
|
||||
function writeJson(res, status, data) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/** 读取 JSON body */
|
||||
async function readJsonBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 端点方法表 */
|
||||
const METHODS = new Set([
|
||||
'positions',
|
||||
'strategies',
|
||||
'strategies/update',
|
||||
'strategies/add',
|
||||
'strategies/remove',
|
||||
'strategies/move',
|
||||
'strategy-positions',
|
||||
'unallocated',
|
||||
'summary',
|
||||
'add-shares',
|
||||
'move-all-shares',
|
||||
'remove-shares',
|
||||
'remove-all-shares',
|
||||
'tabs',
|
||||
'tabs/update',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 注册 HTTP API
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||||
* @param {object} runtime { manager, settings }
|
||||
*/
|
||||
export function registerApi(ctx, { manager, settings }) {
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/odl/api',
|
||||
handler: async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
|
||||
return;
|
||||
}
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
||||
const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : void 0;
|
||||
if (!method || !METHODS.has(method)) {
|
||||
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method: ' + method } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readJsonBody(req);
|
||||
const args = body?.args ?? {};
|
||||
let value;
|
||||
switch (method) {
|
||||
case 'positions':
|
||||
value = await manager.getAllPositions();
|
||||
break;
|
||||
case 'strategies':
|
||||
value = getStrategies(settings);
|
||||
break;
|
||||
case 'strategies/update':
|
||||
await updateStrategies(settings, args.strategies);
|
||||
value = getStrategies(settings);
|
||||
break;
|
||||
case 'strategies/add':
|
||||
value = await addStrategy(settings, args.name);
|
||||
break;
|
||||
case 'strategies/remove':
|
||||
// 删除策略 + 联动清空该策略下所有份额(份额回未分配)
|
||||
await removeStrategy(settings, args.strategyId);
|
||||
await manager.clearStrategyShares(args.strategyId);
|
||||
value = getStrategies(settings);
|
||||
break;
|
||||
case 'strategies/move':
|
||||
value = await moveStrategy(settings, args.strategyId, args.dir);
|
||||
break;
|
||||
case 'tabs':
|
||||
value = getTabConfig(settings);
|
||||
break;
|
||||
case 'tabs/update':
|
||||
await updateTabConfig(settings, args.tabs);
|
||||
value = getTabConfig(settings);
|
||||
break;
|
||||
case 'strategy-positions':
|
||||
value = await manager.getStrategyPositions(args.strategyId);
|
||||
break;
|
||||
case 'unallocated':
|
||||
value = await manager.getUnallocatedPositions();
|
||||
break;
|
||||
case 'summary':
|
||||
value = await manager.getSummary();
|
||||
break;
|
||||
case 'add-shares':
|
||||
value = await manager.addToStrategy(args.code, args.strategyId, args.shares);
|
||||
break;
|
||||
case 'move-all-shares':
|
||||
value = await manager.moveAllUnallocatedToStrategy(args.code, args.strategyId);
|
||||
break;
|
||||
case 'remove-shares':
|
||||
value = await manager.removeFromStrategy(args.code, args.strategyId, args.shares);
|
||||
break;
|
||||
case 'remove-all-shares':
|
||||
value = await manager.clearStrategyShares(args.strategyId);
|
||||
break;
|
||||
default:
|
||||
writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method' } });
|
||||
return;
|
||||
}
|
||||
writeJson(res, 200, { ok: true, value });
|
||||
} catch (error) {
|
||||
writeJson(res, 500, {
|
||||
ok: false,
|
||||
error: { code: error?.code ?? 'internal', message: error?.message ?? String(error), details: {} },
|
||||
});
|
||||
}
|
||||
},
|
||||
}), 'one-divine-lot.api');
|
||||
|
||||
ctx.logger?.info('[one-divine-lot] HTTP API 已注册 (/odl/api/*)');
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 神之一手(one_divine_lot)客户端插件
|
||||
*
|
||||
* 注册:
|
||||
* - conversation.view tabs:全部持仓 / 交易记录 / 关注列表(通用,按 tabConfig 显隐)+ 策略 tabs(按策略配置)
|
||||
* - settings.section:神之一手设置菜单(通用设置 + 策略分组)
|
||||
*
|
||||
* 迭代 02:
|
||||
* - O2 策略 tab 动态化:策略配置驱动注册
|
||||
* - 设置 tab 化扩展:三个通用 tab(全部持仓/交易记录/关注列表)由 tabConfig 控制显隐
|
||||
* - 刷新页面 = 插件重载 = 按最新配置重新注册(无事件机制)
|
||||
*/
|
||||
|
||||
import { createElement } from 'react';
|
||||
import { AllPositionsTab } from './views/AllPositionsTab.jsx';
|
||||
import { StrategyTab } from './views/StrategyTab.jsx';
|
||||
import { PlaceholderTab } from './views/PlaceholderTab.jsx';
|
||||
import { SettingsSection } from './views/SettingsSection.jsx';
|
||||
import { ConnectionProvider } from './views/connection.jsx';
|
||||
|
||||
export const inject = ['slots', 'connection'];
|
||||
|
||||
async function fetchStrategies() {
|
||||
try {
|
||||
const res = await fetch('/odl/api/strategies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data && data.ok ? data.value : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTabConfig() {
|
||||
try {
|
||||
const res = await fetch('/odl/api/tabs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data && data.ok) {
|
||||
return {
|
||||
allPositions: data.value.allPositions !== false,
|
||||
tradeRecords: data.value.tradeRecords !== false,
|
||||
watchlist: data.value.watchlist !== false,
|
||||
};
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return { allPositions: true, tradeRecords: true, watchlist: true };
|
||||
}
|
||||
|
||||
function strategyTabId(strategyId) {
|
||||
return 'odl-strategy-' + strategyId;
|
||||
}
|
||||
|
||||
const GENERAL_TABS = [
|
||||
{ key: 'allPositions', id: 'odl-all-positions', label: '全部持仓', order: 10,
|
||||
render: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)) },
|
||||
{ key: 'tradeRecords', id: 'odl-trade-records', label: '交易记录', order: 11,
|
||||
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '交易记录' })) },
|
||||
{ key: 'watchlist', id: 'odl-watchlist', label: '关注列表', order: 12,
|
||||
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })) },
|
||||
];
|
||||
|
||||
export function apply(ctx) {
|
||||
const connection = ctx.get('connection');
|
||||
if (!connection) {
|
||||
ctx.logger?.warn?.('[one-divine-lot] connection 服务不可用');
|
||||
}
|
||||
|
||||
const withConnection = (render) => (props) =>
|
||||
createElement(ConnectionProvider, { connection }, render(props));
|
||||
|
||||
ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'one-divine-lot',
|
||||
order: 50,
|
||||
label: () => '神之一手',
|
||||
}, withConnection((props) => createElement(SettingsSection, props)));
|
||||
|
||||
let tabDisposers = [];
|
||||
let disposed = false;
|
||||
|
||||
const disposeAllTabs = () => {
|
||||
for (const d of tabDisposers) {
|
||||
try { d(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
tabDisposers = [];
|
||||
};
|
||||
|
||||
const registerGeneralTabs = (tabConfig) => {
|
||||
for (const tab of GENERAL_TABS) {
|
||||
if (tabConfig[tab.key] === false) continue;
|
||||
const dispose = ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: tab.id,
|
||||
order: tab.order,
|
||||
label: () => tab.label,
|
||||
}, tab.render(withConnection));
|
||||
tabDisposers.push(dispose);
|
||||
}
|
||||
};
|
||||
|
||||
const registerStrategyTabs = (strategies) => {
|
||||
const visible = strategies.filter((s) => s.visible !== false);
|
||||
visible.forEach((s, idx) => {
|
||||
const dispose = ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: strategyTabId(s.id),
|
||||
order: 13 + idx,
|
||||
label: () => s.name,
|
||||
}, withConnection((props) => createElement(StrategyTab, { ...props, strategyId: s.id, strategyName: s.name })));
|
||||
tabDisposers.push(dispose);
|
||||
});
|
||||
return visible.length;
|
||||
};
|
||||
|
||||
const registerAllTabs = async () => {
|
||||
const [strategies, tabConfig] = await Promise.all([fetchStrategies(), fetchTabConfig()]);
|
||||
if (disposed) return;
|
||||
disposeAllTabs();
|
||||
registerGeneralTabs(tabConfig);
|
||||
const n = registerStrategyTabs(strategies);
|
||||
ctx.logger?.info?.('[one-divine-lot] 已注册通用 tabs + ' + n + ' 个策略 tab');
|
||||
};
|
||||
|
||||
registerAllTabs();
|
||||
|
||||
ctx.effect(() => {
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposeAllTabs();
|
||||
};
|
||||
}, 'one-divine-lot.client');
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 全部持仓 tab 组件
|
||||
* 显示全量真实持仓(QMT),标注每只票的份额分配
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { LoadState } from './LoadState.jsx';
|
||||
|
||||
export function AllPositionsTab(props) {
|
||||
const [positions, setPositions] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const call = useRpc();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await call('one-divine-lot/summary');
|
||||
if (res && res.ok) setPositions(res.value);
|
||||
else setError((res && res.error && res.error.message) || '加载失败');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [call]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||
<h3 style={{ margin: '0 0 12px' }}>全部持仓({positions ? positions.positions.length : 0} 只)</h3>
|
||||
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>已分配</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>未分配</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>分配详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(positions?.positions ?? []).map((p) => (
|
||||
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.code}</td>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.name}</td>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.totalVolume}</td>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.allocated}</td>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? '#e65100' : '#2e7d32' }}>{p.unallocated}</td>
|
||||
<td style={{ padding: '8px 6px', lineHeight: '24px', fontSize: 12, color: '#555' }}>
|
||||
{Object.entries(p.shares).map(([sid, n]) => sid + ': ' + n).join(', ') || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</LoadState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 三态加载组件(迭代 02 O5:数据加载失败兜底)
|
||||
*
|
||||
* 状态:
|
||||
* - loading:显示「加载中...」
|
||||
* - error:显示失败原因 + 重试按钮(自动重试 2 次后进入手动重试态)
|
||||
* - success:渲染 children
|
||||
*
|
||||
* 用法:
|
||||
* <LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
||||
* <Table ... />
|
||||
* </LoadState>
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
const RETRY_INTERVAL_MS = 2000;
|
||||
|
||||
export function LoadState({ loading, error, onRetry, autoRetry = 2, children }) {
|
||||
const [autoRetriesLeft, setAutoRetriesLeft] = useState(autoRetry);
|
||||
const [manualError, setManualError] = useState(null);
|
||||
const prevErrorRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
// 检测 error 变化 → 触发自动重试
|
||||
useEffect(() => {
|
||||
const errChanged = error !== prevErrorRef.current;
|
||||
prevErrorRef.current = error;
|
||||
if (error && errChanged && autoRetriesLeft > 0 && !loading) {
|
||||
setAutoRetriesLeft((n) => n - 1);
|
||||
timerRef.current = setTimeout(() => {
|
||||
onRetry?.();
|
||||
}, RETRY_INTERVAL_MS);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [error, autoRetriesLeft, loading, onRetry]);
|
||||
|
||||
// loading 时重置自动重试计数(新加载开始)
|
||||
useEffect(() => {
|
||||
if (loading) setAutoRetriesLeft(autoRetry);
|
||||
}, [loading, autoRetry]);
|
||||
|
||||
const handleManualRetry = useCallback(() => {
|
||||
setManualError(null);
|
||||
setAutoRetriesLeft(autoRetry);
|
||||
onRetry?.();
|
||||
}, [onRetry, autoRetry]);
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 16, color: '#666' }}>加载中...</div>;
|
||||
}
|
||||
|
||||
const errMsg = error || manualError;
|
||||
if (errMsg) {
|
||||
return (
|
||||
<div style={{ padding: 16, color: '#c62828' }}>
|
||||
<div style={{ marginBottom: 8 }}>数据加载失败:{errMsg}</div>
|
||||
<button
|
||||
onClick={handleManualRetry}
|
||||
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 4, background: '#fff', color: '#c62828' }}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 占位 tab 组件(迭代 02 设置 tab 化扩展)
|
||||
* 交易记录 / 关注列表 当前为空占位,功能后续迭代实现
|
||||
*/
|
||||
|
||||
export function PlaceholderTab({ title }) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: '#999' }}>
|
||||
<div style={{ fontSize: 16, marginBottom: 8 }}>{title}</div>
|
||||
<div style={{ fontSize: 13 }}>功能开发中,敬请期待</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 策略持仓 tab 组件(迭代 02)
|
||||
*
|
||||
* 功能:
|
||||
* - 显示某策略下的持仓份额(O2:策略名/份额由外部传入,支持动态策略)
|
||||
* - 添加(从全部持仓选标的+填份额,保留)
|
||||
* - 移出(指定数量,保留)
|
||||
* - 全部移入(O6:行内按钮,该票全部未分配份额移入当前策略)
|
||||
* - 一键清零(O6:顶部按钮,弹窗确认后清空当前策略全部份额)
|
||||
* - 操作反馈:Toast 轻提示
|
||||
* - 加载失败兜底:LoadState 三态
|
||||
*
|
||||
* 添加持仓区域为响应式布局(内联 <style> + 媒体查询):
|
||||
* - 宽屏:三元素一行(选择标的 | 份额 | 确认按钮)
|
||||
* - 中屏(<720px):确认按钮换行到第二行,右对齐
|
||||
* - 窄屏(<480px):每元素一行,两端对齐
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { LoadState } from './LoadState.jsx';
|
||||
import { ToastProvider, useToast } from './Toast.jsx';
|
||||
|
||||
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */
|
||||
const ADD_FORM_CSS = `
|
||||
.odl-add-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.odl-add-form .odl-field {
|
||||
flex: 1 1 260px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
.odl-add-form .odl-field select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
.odl-add-form .odl-shares {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
.odl-add-form .odl-shares input {
|
||||
width: 90px;
|
||||
padding: 4px;
|
||||
}
|
||||
.odl-add-form .odl-submit {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
background: #2e7d32;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.odl-add-form .odl-field {
|
||||
flex-basis: calc(100% - 150px);
|
||||
}
|
||||
.odl-add-form .odl-submit {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.odl-add-form .odl-field,
|
||||
.odl-add-form .odl-shares {
|
||||
flex: 1 1 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.odl-add-form .odl-submit {
|
||||
flex: 1 1 100%;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/** 注入样式到文档(只注入一次) */
|
||||
let styleInjected = false;
|
||||
function ensureAddFormCss() {
|
||||
if (styleInjected || typeof document === 'undefined') return;
|
||||
const el = document.createElement('style');
|
||||
el.textContent = ADD_FORM_CSS;
|
||||
document.head.appendChild(el);
|
||||
styleInjected = true;
|
||||
}
|
||||
|
||||
/** 确认弹窗(复用 SettingsSection 的样式,独立实现避免耦合) */
|
||||
function ConfirmDialog({ title, message, onConfirm, onCancel, confirmText = '确认' }) {
|
||||
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' }}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyTabInner({ strategyId, strategyName }) {
|
||||
const [positions, setPositions] = useState(null);
|
||||
const [allPositions, setAllPositions] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [selectedCode, setSelectedCode] = useState('');
|
||||
const [addShares, setAddShares] = useState('');
|
||||
const [removeTarget, setRemoveTarget] = useState(null); // { code, shares }
|
||||
const [confirmClear, setConfirmClear] = useState(false); // 一键清零确认
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [strategyRes, allRes] = await Promise.all([
|
||||
call('one-divine-lot/strategy-positions', { args: { strategyId } }),
|
||||
call('one-divine-lot/unallocated', {}),
|
||||
]);
|
||||
if (strategyRes && strategyRes.ok) setPositions(strategyRes.value);
|
||||
if (allRes && allRes.ok) setAllPositions(allRes.value);
|
||||
if ((strategyRes && !strategyRes.ok) || (allRes && !allRes.ok)) {
|
||||
setError((strategyRes?.error?.message) || (allRes?.error?.message) || '加载失败');
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [call, strategyId]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureAddFormCss();
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selectedCode || !addShares) return;
|
||||
const res = await call('one-divine-lot/add-shares', {
|
||||
args: { code: selectedCode, strategyId, shares: Number(addShares) },
|
||||
});
|
||||
if (res && res.ok) {
|
||||
setShowAdd(false);
|
||||
setSelectedCode('');
|
||||
setAddShares('');
|
||||
toast.success('已添加份额');
|
||||
load();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '添加失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (code) => {
|
||||
if (!removeTarget || !removeTarget.shares) return;
|
||||
const res = await call('one-divine-lot/remove-shares', {
|
||||
args: { code, strategyId, shares: Number(removeTarget.shares) },
|
||||
});
|
||||
if (res && res.ok) {
|
||||
setRemoveTarget(null);
|
||||
toast.success('已移出份额');
|
||||
load();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '移出失败');
|
||||
}
|
||||
};
|
||||
|
||||
/** 全部移入(O6 单票级):该票全部未分配份额移入当前策略 */
|
||||
const handleMoveAllIn = async (code) => {
|
||||
const res = await call('one-divine-lot/move-all-shares', {
|
||||
args: { code, strategyId },
|
||||
});
|
||||
if (res && res.ok) {
|
||||
toast.success('未分配份额已全部移入');
|
||||
load();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '全部移入失败');
|
||||
}
|
||||
};
|
||||
|
||||
/** 一键清零(O6 策略级):清空当前策略全部份额 */
|
||||
const handleClearAll = async () => {
|
||||
const res = await call('one-divine-lot/remove-all-shares', {
|
||||
args: { strategyId },
|
||||
});
|
||||
if (res && res.ok) {
|
||||
setConfirmClear(false);
|
||||
const affected = Array.isArray(res.value) ? res.value.length : 0;
|
||||
toast.success(`已清空策略份额(${affected} 只标的回到未分配)`);
|
||||
load();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '清空失败');
|
||||
setConfirmClear(false);
|
||||
}
|
||||
};
|
||||
|
||||
const name = strategyName || (strategyId === 'grid-supermarket' ? '网格策略持仓' : strategyId === 'manual-t' ? '做T持仓' : strategyId);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>{name}({(positions ?? []).length} 只)</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{positions && positions.length > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 4, background: '#fff', color: '#c62828' }}
|
||||
>
|
||||
一键清零
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#f5f5f5' }}
|
||||
>
|
||||
{showAdd ? '取消' : '+ 添加持仓'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
||||
{showAdd && (
|
||||
<div className="odl-add-form">
|
||||
<label className="odl-field">
|
||||
选择标的(未分配持仓):
|
||||
<select value={selectedCode} onChange={(e) => setSelectedCode(e.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{(allPositions || []).map((p) => (
|
||||
<option key={p.code} value={p.code}>
|
||||
{p.code} {p.name}(未分配 {p.unallocated})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="odl-shares">
|
||||
份额(股数):
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step={100}
|
||||
value={addShares}
|
||||
onChange={(e) => setAddShares(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="odl-submit" onClick={handleAdd}>
|
||||
确认添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{positions && positions.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#999' }}>
|
||||
暂无持仓,点击「+ 添加持仓」从全部持仓中添加
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>策略份额</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>总持仓</th>
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(positions ?? []).map((p) => (
|
||||
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '8px 6px' }}>{p.code}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{p.name}</td>
|
||||
<td style={{ padding: '8px 6px', fontWeight: 'bold' }}>{p.shares}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{p.volume}</td>
|
||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
||||
{removeTarget && removeTarget.code === p.code ? (
|
||||
<span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step={100}
|
||||
value={removeTarget.shares}
|
||||
onChange={(e) => setRemoveTarget({ code: p.code, shares: e.target.value })}
|
||||
style={{ padding: 2, width: 70, marginRight: 6 }}
|
||||
/>
|
||||
<button onClick={() => handleRemove(p.code)} style={{ padding: '2px 8px', cursor: 'pointer' }}>
|
||||
确认
|
||||
</button>
|
||||
<button onClick={() => setRemoveTarget(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<button
|
||||
onClick={() => handleMoveAllIn(p.code)}
|
||||
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #2e7d32', borderRadius: 3, background: '#fff', color: '#2e7d32', marginRight: 4 }}
|
||||
title="将该票全部未分配份额移入本策略"
|
||||
>
|
||||
全部移入
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRemoveTarget({ code: p.code, shares: p.shares })}
|
||||
style={{ padding: '2px 8px', cursor: 'pointer' }}
|
||||
>
|
||||
移出
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</LoadState>
|
||||
|
||||
{confirmClear && (
|
||||
<ConfirmDialog
|
||||
title={`一键清零「${name}」`}
|
||||
message="将清空该策略下所有持仓的份额分配,全部回到「未分配」。此操作不可撤销,确定继续?"
|
||||
onConfirm={handleClearAll}
|
||||
onCancel={() => setConfirmClear(false)}
|
||||
confirmText="确认清零"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 导出组件(包 ToastProvider) */
|
||||
export function StrategyTab(props) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<StrategyTabInner {...props} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 轻提示组件(迭代 02 O6/O3:操作反馈)
|
||||
* 全局 Toast 容器 + useToast hook
|
||||
*
|
||||
* 用法:
|
||||
* const toast = useToast();
|
||||
* toast.success('保存成功');
|
||||
* toast.error('操作失败: xxx');
|
||||
* <ToastHost /> // 挂载一次在根组件
|
||||
*/
|
||||
|
||||
import { useState, useCallback, createContext, useContext } from 'react';
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
let toastIdSeq = 0;
|
||||
|
||||
export function ToastHost() {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const remove = useCallback((id) => {
|
||||
setToasts((list) => list.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback((type, message) => {
|
||||
const id = ++toastIdSeq;
|
||||
setToasts((list) => [...list, { id, type, message }]);
|
||||
setTimeout(() => remove(id), 3000);
|
||||
}, [remove]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderRadius: 6,
|
||||
color: '#fff',
|
||||
background: t.type === 'success' ? '#2e7d32' : '#c62828',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
fontSize: 13,
|
||||
maxWidth: 360,
|
||||
}}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const remove = useCallback((id) => {
|
||||
setToasts((list) => list.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
const push = useCallback((type, message) => {
|
||||
const id = ++toastIdSeq;
|
||||
setToasts((list) => [...list, { id, type, message }]);
|
||||
setTimeout(() => remove(id), 3000);
|
||||
}, [remove]);
|
||||
|
||||
const toast = {
|
||||
success: (m) => push('success', m),
|
||||
error: (m) => push('error', m),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={toast}>
|
||||
{children}
|
||||
<ToastHost />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取 toast 方法(需在 ToastProvider 内) */
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 客户端 API 调用 hook
|
||||
* 通过 fetch 调用服务端插件 HTTP API(/odl/api/<method>)
|
||||
*/
|
||||
|
||||
import { useContext, createContext, useCallback } from 'react';
|
||||
|
||||
const ConnectionContext = createContext(null);
|
||||
|
||||
export function ConnectionProvider({ connection, children }) {
|
||||
return (
|
||||
<ConnectionContext.Provider value={connection}>
|
||||
{children}
|
||||
</ConnectionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 标准化 endpoint:剥离 one-divine-lot/ 前缀(服务端 /odl/api/ 已隐含 namespace) */
|
||||
function normalizeEndpoint(endpoint) {
|
||||
if (typeof endpoint === 'string' && endpoint.startsWith('one-divine-lot/')) {
|
||||
return endpoint.slice('one-divine-lot/'.length);
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/** 获取 API 调用函数(fetch /odl/api)—— useCallback 稳定引用,避免每次渲染新建导致 effect 循环 */
|
||||
export function useRpc() {
|
||||
return useCallback(async (endpoint, payload = {}) => {
|
||||
try {
|
||||
const method = normalizeEndpoint(endpoint);
|
||||
const res = await fetch('/odl/api/' + method, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data;
|
||||
} catch (e) {
|
||||
return { ok: false, error: { message: 'API 调用异常: ' + (e?.message ?? String(e)) } };
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* QMT Bridge REST 数据源适配器
|
||||
*
|
||||
* 设计约束:
|
||||
* - 技术约束-001:统一数据源抽象(业务层不感知具体数据源)
|
||||
* - 技术约束-003:直连 QMT Bridge RESTful 接口(不经过 MCP)
|
||||
*
|
||||
* 服务地址:http://192.168.3.43:8610(可通过 config 覆盖)
|
||||
*/
|
||||
|
||||
const DEFAULT_BASE_URL = 'http://192.168.3.43:8610';
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
export class QmtBridgeRestDataSource {
|
||||
constructor({ baseUrl = DEFAULT_BASE_URL, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
||||
this.name = 'qmt-bridge-rest';
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/** 基础 HTTP 请求(fetch + 超时) */
|
||||
async request(path, { method = 'GET', body } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`QMT Bridge REST ${method} ${path} 失败: HTTP ${res.status}`);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(`QMT Bridge REST 请求超时: ${path} (${this.timeoutMs}ms)`);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** 可用性检查 */
|
||||
async isAvailable() {
|
||||
try {
|
||||
const data = await this.request('/health');
|
||||
return data?.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 资金摘要(/trade/asset) */
|
||||
async getAsset() {
|
||||
const res = await this.request('/trade/asset');
|
||||
const a = Array.isArray(res?.data) ? res.data[0] : res?.data;
|
||||
if (!a) throw new Error('QMT 资金数据为空');
|
||||
return {
|
||||
accountId: a.m_strAccountID ?? '',
|
||||
status: a.m_strStatus ?? '',
|
||||
tradingDate: a.m_strTradingDate ?? '',
|
||||
totalAsset: a.m_dAssetBalance ?? 0,
|
||||
available: a.m_dAvailable ?? 0,
|
||||
stockValue: a.m_dStockValue ?? 0,
|
||||
balance: a.m_dBalance ?? 0,
|
||||
positionProfit: a.m_dPositionProfit ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** 全量持仓(/trade/positions) */
|
||||
async getPositions() {
|
||||
const res = await this.request('/trade/positions');
|
||||
const data = res?.data;
|
||||
const list = Array.isArray(data) ? data : data?.positions;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map((p) => this.mapPosition(p));
|
||||
}
|
||||
|
||||
/** 持仓字段映射:语义字段优先,回退 m_ 原始字段 */
|
||||
mapPosition(p) {
|
||||
return {
|
||||
code: p.stock_code ?? p.m_strInstrumentID ?? '',
|
||||
name: p.stock_name ?? p.m_strInstrumentName ?? '',
|
||||
exchange: p.m_strExchangeName ?? '',
|
||||
volume: p.volume ?? p.m_nVolume ?? 0,
|
||||
available: p.available ?? p.m_nCanUseVolume ?? 0,
|
||||
frozenVolume: p.frozen_volume ?? p.m_nFrozenVolume ?? 0,
|
||||
avgPrice: +(p.avg_price ?? p.m_dAvgOpenPrice ?? 0),
|
||||
price: +(p.price ?? p.m_dLastPrice ?? 0),
|
||||
marketValue: +(p.market_value ?? p.m_dInstrumentValue ?? 0),
|
||||
profit: +(p.profit ?? p.m_dFloatProfit ?? 0),
|
||||
profitPct: +(p.profit_pct ?? (p.m_dProfitRate != null ? p.m_dProfitRate * 100 : 0)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 统一数据源抽象(设计约束:技术约束-001)
|
||||
* 业务层只依赖这些接口,不感知具体数据源实现(当前:QMT Bridge REST)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Position 统一持仓模型
|
||||
* @property {string} code 证券代码(含交易所后缀,如 600719.SH)
|
||||
* @property {string} name 证券名称
|
||||
* @property {string} exchange 交易所名称
|
||||
* @property {number} volume 总持仓数量
|
||||
* @property {number} available 可用数量
|
||||
* @property {number} frozenVolume 冻结数量
|
||||
* @property {number} avgPrice 成本均价
|
||||
* @property {number} price 最新价
|
||||
* @property {number} marketValue 市值
|
||||
* @property {number} profit 浮动盈亏
|
||||
* @property {number} profitPct 盈亏比例(%)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AssetSummary 统一资金模型
|
||||
* @property {string} accountId 账户
|
||||
* @property {string} status 登录状态
|
||||
* @property {string} tradingDate 交易日 YYYYMMDD
|
||||
* @property {number} totalAsset 总资产
|
||||
* @property {number} available 可用资金
|
||||
* @property {number} stockValue 股票市值
|
||||
* @property {number} balance 账户余额
|
||||
* @property {number} positionProfit 持仓盈亏(账户级)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 数据源统一接口:一个数据源适配器必须实现这些方法
|
||||
* @typedef {Object} DataSource
|
||||
* @property {string} name 数据源名称
|
||||
* @property {() => Promise<AssetSummary>} getAsset 资金摘要
|
||||
* @property {() => Promise<Position[]>} getPositions 全量持仓
|
||||
* @property {() => Promise<boolean>} isAvailable 可用性检查
|
||||
*/
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 神之一手(one_divine_lot)DSH 插件入口
|
||||
*
|
||||
* 迭代 01:分仓管理工具
|
||||
* - S2 数据源直连 QMT Bridge REST(技术约束-003)
|
||||
* - S3 设置管理(策略=标签 + 显示开关)
|
||||
* - S4 分仓数据本地存储
|
||||
* - S5 分仓逻辑(份额分配/查询)
|
||||
* - S6 服务端 HTTP API(webServer /odl/api/*)
|
||||
* - S7 客户端 tab 展示(客户端插件)
|
||||
*
|
||||
* 依据:docs/02-计划/计划-分仓管理工具.md、docs/04-迭代记录/01-分仓管理工具/
|
||||
* 设计约束:技术约束-001/003、产品约束-001/002/003
|
||||
*/
|
||||
|
||||
import { QmtBridgeRestDataSource } from './data-source/qmt-bridge-rest.js';
|
||||
import { registerSettings } from './settings.js';
|
||||
import { AllocationStorage } from './storage.js';
|
||||
import { PositionManager } from './position/manager.js';
|
||||
import { registerApi } from './api.js';
|
||||
|
||||
const name = 'one-divine-lot';
|
||||
|
||||
// 依赖服务:settings(S3)、webServer(S6 api)
|
||||
const inject = ['settings', 'webServer'];
|
||||
|
||||
async function apply(ctx, config) {
|
||||
const logger = ctx.logger;
|
||||
|
||||
// S2: 数据源(REST 直连 QMT Bridge)
|
||||
const dataSource = new QmtBridgeRestDataSource({
|
||||
baseUrl: config?.qmtBaseUrl,
|
||||
timeoutMs: config?.qmtTimeoutMs,
|
||||
});
|
||||
|
||||
// S3: 设置管理(策略=标签 + 显示开关)
|
||||
const settings = registerSettings(ctx, config);
|
||||
|
||||
// S4: 分仓数据本地存储
|
||||
const storage = new AllocationStorage({
|
||||
dataDir: config?.dataDir,
|
||||
});
|
||||
|
||||
// S5: 分仓逻辑(份额分配/查询)
|
||||
const manager = new PositionManager({ dataSource, storage });
|
||||
|
||||
// S6: 服务端 HTTP API
|
||||
registerApi(ctx, { manager, settings });
|
||||
|
||||
// 启动时可用性检查(日志,不阻塞)
|
||||
dataSource.isAvailable().then((ok) => {
|
||||
logger.info(`[one-divine-lot] QMT Bridge REST 可用: ${ok}`);
|
||||
}).catch((e) => {
|
||||
logger.warn(`[one-divine-lot] QMT Bridge REST 检查失败: ${e.message}`);
|
||||
});
|
||||
|
||||
ctx.effect(() => {
|
||||
logger.info('[one-divine-lot] 插件已加载(S1-S6 就绪)');
|
||||
return () => {
|
||||
logger.info('[one-divine-lot] 插件已释放');
|
||||
};
|
||||
}, 'one-divine-lot.dispose');
|
||||
}
|
||||
|
||||
export { name, inject, apply };
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 分仓逻辑:持仓 ↔ 策略份额分配
|
||||
*
|
||||
* 设计约束:产品约束-001(全量持仓)、产品约束-002/003(标签体系)
|
||||
* 2026-08-27 S5 讨论定稿:
|
||||
* - Q1 按股数分配;Q2 允许未分配余额(份额之和 ≤ 总持仓);
|
||||
* - Q3 策略仅为人为分组(不实现策略管理逻辑);
|
||||
* - Q4 不做统计(无市值/盈亏/占比聚合);
|
||||
* - Q5 UI = 策略 tab 内编辑(添加默认 0 起填 + 指定数量移出)。
|
||||
*
|
||||
* 数据存储(S4 storage):{ [code]: { [strategyId]: shares } }
|
||||
* 全部持仓来自数据源(QMT),份额为本地维护元数据。
|
||||
*/
|
||||
|
||||
export class PositionManager {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {import('../data-source/types.js').DataSource} opts.dataSource 数据源(QMT REST)
|
||||
* @param {import('../storage.js').AllocationStorage} opts.storage 分仓存储
|
||||
*/
|
||||
constructor({ dataSource, storage }) {
|
||||
this.dataSource = dataSource;
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
/** 全量持仓(QMT 真实数据) */
|
||||
async getAllPositions() {
|
||||
return this.dataSource.getPositions();
|
||||
}
|
||||
|
||||
/** 单只持仓 */
|
||||
async getPosition(code) {
|
||||
const positions = await this.getAllPositions();
|
||||
return positions.find((p) => p.code === code) ?? null;
|
||||
}
|
||||
|
||||
/** 总持仓量(某只票) */
|
||||
async getTotalVolume(code) {
|
||||
const pos = await this.getPosition(code);
|
||||
return pos ? pos.volume : 0;
|
||||
}
|
||||
|
||||
/** 某只票的份额分配 { strategyId: shares } */
|
||||
async getShares(code) {
|
||||
return this.storage.get(code);
|
||||
}
|
||||
|
||||
/** 某策略下的持仓(份额 > 0 的标的) */
|
||||
async getStrategyPositions(strategyId) {
|
||||
const [positions, allocations] = await Promise.all([
|
||||
this.getAllPositions(),
|
||||
this.storage.getAll(),
|
||||
]);
|
||||
return positions
|
||||
.map((p) => {
|
||||
const shares = allocations[p.code]?.[strategyId] ?? 0;
|
||||
return shares > 0 ? { ...p, shares } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 未分配持仓(总持仓 - 已分配 > 0 的标的) */
|
||||
async getUnallocatedPositions() {
|
||||
const [positions, allocations] = await Promise.all([
|
||||
this.getAllPositions(),
|
||||
this.storage.getAll(),
|
||||
]);
|
||||
return positions
|
||||
.map((p) => {
|
||||
const alloc = allocations[p.code] ?? {};
|
||||
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
|
||||
const unallocated = p.volume - allocated;
|
||||
return unallocated > 0 ? { ...p, shares: alloc, unallocated } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到策略(Q5:份额从 0 起填,每次只确定分配到当前策略的份额)
|
||||
* @param {string} code 证券代码
|
||||
* @param {string} strategyId 策略 id
|
||||
* @param {number} shares 要添加到该策略的股数(> 0)
|
||||
*/
|
||||
async addToStrategy(code, strategyId, shares) {
|
||||
const total = await this.getTotalVolume(code);
|
||||
if (total <= 0) throw new Error(`标的 ${code} 无持仓`);
|
||||
const num = Number(shares);
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
throw new Error('份额必须为正数');
|
||||
}
|
||||
// 校验:当前已分配 + 新增 ≤ 总持仓
|
||||
const current = await this.getShares(code);
|
||||
const currentAllocated = Object.values(current).reduce((s, n) => s + n, 0);
|
||||
if (currentAllocated + num > total) {
|
||||
throw Object.assign(new Error(
|
||||
`份额超限: ${code} 总持仓 ${total},已分配 ${currentAllocated},添加 ${num} 超出`
|
||||
), { code: 'share-limit' });
|
||||
}
|
||||
const next = { ...current, [strategyId]: num };
|
||||
await this.storage.set(code, next);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从策略移出(Q5:指定数量移出)
|
||||
* @param {string} code 证券代码
|
||||
* @param {string} strategyId 策略 id
|
||||
* @param {number} shares 要移出的股数(> 0,且 ≤ 该策略当前份额)
|
||||
*/
|
||||
async removeFromStrategy(code, strategyId, shares) {
|
||||
const current = await this.getShares(code);
|
||||
const currentShares = current[strategyId] ?? 0;
|
||||
const num = Number(shares);
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
throw new Error('移出份额必须为正数');
|
||||
}
|
||||
if (num > currentShares) {
|
||||
throw Object.assign(new Error(
|
||||
`移出超限: ${code} 策略 ${strategyId} 当前 ${currentShares},移出 ${num} 超出`
|
||||
), { code: 'share-exceed' });
|
||||
}
|
||||
const next = { ...current };
|
||||
const remain = currentShares - num;
|
||||
if (remain > 0) {
|
||||
next[strategyId] = remain;
|
||||
} else {
|
||||
delete next[strategyId];
|
||||
}
|
||||
await this.storage.set(code, next);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分仓摘要(Q4:仅份额,无市值/盈亏统计)
|
||||
* @returns {{ positions: Array, strategies: Object<string, number>, allocatedTotal: number, unallocated: Array }}
|
||||
*/
|
||||
async getSummary() {
|
||||
const [positions, allocations] = await Promise.all([
|
||||
this.getAllPositions(),
|
||||
this.storage.getAll(),
|
||||
]);
|
||||
|
||||
// 每个策略的份额汇总
|
||||
const strategies = {};
|
||||
// 每只票:{ totalVolume, shares: {sid: n}, allocated, unallocated }
|
||||
const positionsDetail = positions.map((p) => {
|
||||
const alloc = allocations[p.code] ?? {};
|
||||
const allocated = Object.values(alloc).reduce((s, n) => s + n, 0);
|
||||
const unallocated = p.volume - allocated;
|
||||
for (const [sid, n] of Object.entries(alloc)) {
|
||||
strategies[sid] = (strategies[sid] ?? 0) + n;
|
||||
}
|
||||
return {
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
totalVolume: p.volume,
|
||||
shares: alloc,
|
||||
allocated,
|
||||
unallocated,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
positions: positionsDetail,
|
||||
strategies, // { strategyId: 总份额 }
|
||||
allocatedTotal: Object.values(strategies).reduce((s, n) => s + n, 0),
|
||||
unallocatedPositions: positionsDetail.filter((p) => p.unallocated > 0),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 全部移入(迭代 02 O6):把某只票的全部未分配份额移入指定策略
|
||||
* 未分配 = 总持仓 - 已分配;一次 add(shares = 未分配数)
|
||||
* @param {string} code 证券代码
|
||||
* @param {string} strategyId 策略 id
|
||||
* @returns {Promise<Object<string, number>>} 更新后的份额分配
|
||||
*/
|
||||
async moveAllUnallocatedToStrategy(code, strategyId) {
|
||||
const total = await this.getTotalVolume(code);
|
||||
if (total <= 0) throw new Error(`标的 ${code} 无持仓`);
|
||||
const current = await this.getShares(code);
|
||||
const currentAllocated = Object.values(current).reduce((s, n) => s + n, 0);
|
||||
const unallocated = total - currentAllocated;
|
||||
if (unallocated <= 0) {
|
||||
throw Object.assign(new Error(`标的 ${code} 无未分配份额`), { code: 'no-unallocated' });
|
||||
}
|
||||
const next = { ...current, [strategyId]: (current[strategyId] ?? 0) + unallocated };
|
||||
await this.storage.set(code, next);
|
||||
return this.getShares(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键清零(迭代 02 O6):清空某策略下所有股票的份额(份额回未分配)
|
||||
* @param {string} strategyId 策略 id
|
||||
* @returns {Promise<string[]>} 受影响标的代码列表
|
||||
*/
|
||||
async clearStrategyShares(strategyId) {
|
||||
return this.storage.removeStrategyShares(strategyId);
|
||||
}
|
||||
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 设置管理:策略(标签)配置
|
||||
*
|
||||
* 设计约束:产品约束-002/003/004(标签体系、标签自定义 + 显示开关、删除份额回未分配)
|
||||
* 通过 ctx.settings.register('one-divine-lot', schema) 注册策略配置 namespace。
|
||||
*
|
||||
* 数据结构:
|
||||
* strategies: [{ id, name, visible, order }]
|
||||
* 预置:网格超市、手动做T(R-003 O3-5:可删除,彻底自由)
|
||||
*
|
||||
* 迭代 02 新增(R-003 O3 策略 CRUD):
|
||||
* - generateStrategyId(name):中文/任意名 → 英文 slug id
|
||||
* - addStrategy / renameStrategy / moveStrategy / removeStrategy
|
||||
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
|
||||
*/
|
||||
|
||||
import z from '@deepseek-ai/schemastery';
|
||||
|
||||
/** 策略设置 schema(schemastery 定义,settings.register 要求 schema 对象) */
|
||||
export const strategySchema = z.object({
|
||||
strategies: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
name: z.string().required(),
|
||||
visible: z.boolean().default(true),
|
||||
order: z.number().default(0),
|
||||
})).default([
|
||||
{ id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 },
|
||||
{ id: 'manual-t', name: '手动做T', visible: true, order: 1 },
|
||||
]),
|
||||
// 通用 tab 显隐配置(设置 tab 化扩展,2026-08-28)
|
||||
tabs: z.object({
|
||||
allPositions: z.boolean().default(true), // 全部持仓
|
||||
tradeRecords: z.boolean().default(true), // 交易记录(未来 tab,本期空占位)
|
||||
watchlist: z.boolean().default(true), // 关注列表(未来 tab,本期空占位)
|
||||
}).default({
|
||||
allPositions: true,
|
||||
tradeRecords: true,
|
||||
watchlist: true,
|
||||
}),
|
||||
});
|
||||
|
||||
/** 默认通用 tab 配置 */
|
||||
export const DEFAULT_TABS = {
|
||||
allPositions: true,
|
||||
tradeRecords: true,
|
||||
watchlist: true,
|
||||
};
|
||||
|
||||
/** 默认策略(首次注册时的初始值) */
|
||||
export const DEFAULT_STRATEGIES = [
|
||||
{ id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 },
|
||||
{ id: 'manual-t', name: '手动做T', visible: true, order: 1 },
|
||||
];
|
||||
|
||||
/** 简易拼音映射表(常用汉字 → 拼音,用于 slug 生成兜底) */
|
||||
const PINYIN_MAP = {
|
||||
// 完整短语映射(优先级最高)
|
||||
'网格超市': 'grid-supermarket',
|
||||
'手动做T': 'manual-t',
|
||||
'长线持有': 'long-term-hold',
|
||||
'短线交易': 'short-term-trade',
|
||||
'波段操作': 'swing-trade',
|
||||
'价值投资': 'value-invest',
|
||||
'趋势跟踪': 'trend-follow',
|
||||
'新股申购': 'ipo-subscribe',
|
||||
'打板': 'board',
|
||||
'低吸': 'dip-buy',
|
||||
'高抛': 'high-sell',
|
||||
'做T': 't',
|
||||
// 词映射
|
||||
'长线': 'long-term',
|
||||
'短线': 'short-term',
|
||||
'持有': 'hold',
|
||||
'波段': 'swing',
|
||||
'价值': 'value',
|
||||
'网格': 'grid',
|
||||
'策略': 'strategy',
|
||||
'趋势': 'trend',
|
||||
'新股': 'new-stock',
|
||||
'打新': 'ipo',
|
||||
// 单字映射
|
||||
'超': 'super',
|
||||
'市': 'market',
|
||||
'场': 'market',
|
||||
'手': 'manual',
|
||||
'动': 'action',
|
||||
'长': 'long',
|
||||
'短': 'short',
|
||||
'持': 'hold',
|
||||
'波': 'swing',
|
||||
'值': 'value',
|
||||
'格': 'grid',
|
||||
'略': 'strategy',
|
||||
'趋': 'trend',
|
||||
'势': 'trend',
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成策略英文 id(slug)
|
||||
* 规则:常见中文策略名映射拼音 → 去空格/转小写/连字符 → 非 ASCII 字符用序号兜底
|
||||
* @param {string} name 策略名称
|
||||
* @param {string[]} existingIds 已存在的 id(用于去重)
|
||||
* @returns {string} 唯一英文 id
|
||||
*/
|
||||
export function generateStrategyId(name, existingIds = []) {
|
||||
const raw = String(name ?? '').trim();
|
||||
if (!raw) return '';
|
||||
|
||||
// 1. 整名映射(常见策略名直接映射,优先级最高)
|
||||
const whole = PINYIN_MAP[raw];
|
||||
if (whole) return dedupe(whole, existingIds);
|
||||
|
||||
// 2. 最长子串贪婪匹配(把名字切成已映射的词/字片段)
|
||||
let slug = '';
|
||||
let i = 0;
|
||||
while (i < raw.length) {
|
||||
let matched = false;
|
||||
for (let len = Math.min(4, raw.length - i); len >= 1; len--) {
|
||||
const seg = raw.slice(i, i + len);
|
||||
const p = PINYIN_MAP[seg];
|
||||
if (p) {
|
||||
slug += p + '-';
|
||||
i += len;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
const ch = raw[i];
|
||||
if (/[a-zA-Z0-9]/.test(ch)) {
|
||||
slug += ch.toLowerCase() + '-';
|
||||
}
|
||||
i++;
|
||||
}
|
||||
let base = slug.replace(/-+$/, '').replace(/^-+/, '') || 'strategy';
|
||||
|
||||
// 3. 规范化(去空格、连字符规整、移除非法字符)
|
||||
base = base
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|| 'strategy';
|
||||
|
||||
return dedupe(base, existingIds);
|
||||
}
|
||||
|
||||
/** 去重:若 id 已存在,追加序号 */
|
||||
function dedupe(base, existingIds) {
|
||||
let candidate = base;
|
||||
let i = 2;
|
||||
while (existingIds.includes(candidate)) {
|
||||
candidate = `${base}-${i}`;
|
||||
i++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册策略设置 namespace,返回 SettingsScope
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||||
*/
|
||||
export function registerSettings(ctx, config) {
|
||||
const ns = config.settingsNamespace ?? 'one-divine-lot';
|
||||
const scope = ctx.settings.register(ns, strategySchema, {
|
||||
// 组合 base(如需从 composition 注入默认值)
|
||||
base: config.strategies ? { strategies: config.strategies } : undefined,
|
||||
});
|
||||
ctx.logger?.info(`[one-divine-lot] 设置已注册: ${ns}`);
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取策略列表(含 visible 过滤)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
*/
|
||||
export function getStrategies(scope) {
|
||||
const value = scope?.get() ?? {};
|
||||
const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
|
||||
// 统一按 order 排序(设置页 / tab 栏 / 逻辑层共用同一顺序)
|
||||
return list.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可见策略列表(visible === true,按 order 排序)
|
||||
*/
|
||||
export function getVisibleStrategies(scope) {
|
||||
return getStrategies(scope)
|
||||
.filter((s) => s.visible !== false)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新策略(整表更新)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {Array} strategies 完整策略列表
|
||||
*/
|
||||
export async function updateStrategies(scope, strategies) {
|
||||
await scope.update({ strategies });
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增策略(O3)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {string} name 策略名称
|
||||
* @returns {Promise<object>} 新策略对象
|
||||
*/
|
||||
export async function addStrategy(scope, name) {
|
||||
const list = getStrategies(scope);
|
||||
const id = generateStrategyId(name, list.map((s) => s.id));
|
||||
const strategy = {
|
||||
id,
|
||||
name: String(name ?? '').trim(),
|
||||
visible: true,
|
||||
order: list.reduce((max, s) => Math.max(max, s.order ?? 0), -1) + 1,
|
||||
};
|
||||
await updateStrategies(scope, [...list, strategy]);
|
||||
return strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名策略(O3;分配数据挂 id 不挂名称,改名不影响)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {string} id 策略 id
|
||||
* @param {string} name 新名称
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
export async function renameStrategy(scope, id, name) {
|
||||
const list = getStrategies(scope);
|
||||
const target = list.find((s) => s.id === id);
|
||||
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
|
||||
const next = list.map((s) => (s.id === id ? { ...s, name: String(name ?? '').trim() || s.name } : s));
|
||||
await updateStrategies(scope, next);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动策略(排序,上移/下移)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {string} id 策略 id
|
||||
* @param {'up'|'down'} dir 方向
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
export async function moveStrategy(scope, id, dir) {
|
||||
const list = getStrategies(scope)
|
||||
.slice()
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
const idx = list.findIndex((s) => s.id === id);
|
||||
if (idx < 0) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
|
||||
const swapIdx = dir === 'up' ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= list.length) return false; // 已在边界
|
||||
const a = list[idx];
|
||||
const b = list[swapIdx];
|
||||
const orderA = a.order ?? 0;
|
||||
const orderB = b.order ?? 0;
|
||||
const next = list.map((s) => {
|
||||
if (s.id === a.id) return { ...s, order: orderB };
|
||||
if (s.id === b.id) return { ...s, order: orderA };
|
||||
return s;
|
||||
});
|
||||
await updateStrategies(scope, next);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除策略(O3;份额清理由上层编排 storage)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {string} id 策略 id
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
export async function removeStrategy(scope, id) {
|
||||
const list = getStrategies(scope);
|
||||
const target = list.find((s) => s.id === id);
|
||||
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
|
||||
await updateStrategies(scope, list.filter((s) => s.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取通用 tab 显隐配置(设置 tab 化扩展)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @returns {{ allPositions: boolean, tradeRecords: boolean, watchlist: boolean }}
|
||||
*/
|
||||
export function getTabConfig(scope) {
|
||||
const value = scope?.get() ?? {};
|
||||
const tabs = value.tabs ?? {};
|
||||
return {
|
||||
allPositions: tabs.allPositions !== false,
|
||||
tradeRecords: tabs.tradeRecords !== false,
|
||||
watchlist: tabs.watchlist !== false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新通用 tab 显隐配置(整表更新)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {object} tabs { allPositions?, tradeRecords?, watchlist? }
|
||||
*/
|
||||
export async function updateTabConfig(scope, tabs) {
|
||||
const current = scope?.get() ?? {};
|
||||
const next = {
|
||||
...current,
|
||||
tabs: {
|
||||
allPositions: tabs.allPositions !== false,
|
||||
tradeRecords: tabs.tradeRecords !== false,
|
||||
watchlist: tabs.watchlist !== false,
|
||||
},
|
||||
};
|
||||
await scope.update(next);
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 本地存储:分仓分配数据(allocations.json)
|
||||
*
|
||||
* 设计约束:产品约束-003(分仓数据本地保存并更新管理)
|
||||
* QMT 只提供全量真实持仓,策略分仓分配是本地维护的元数据。
|
||||
*
|
||||
* 文件位置:<dataDir>/allocations.json
|
||||
* 结构:
|
||||
* {
|
||||
* "version": 1,
|
||||
* "allocations": {
|
||||
* "600719.SH": { "grid-supermarket": 1000, "manual-t": 800 }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
const DEFAULT_DATA_DIR = join(homedir(), '.dsh', 'one-divine-lot');
|
||||
const FILE_NAME = 'allocations.json';
|
||||
|
||||
export class AllocationStorage {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.dataDir] 数据目录(默认 ~/.dsh/one-divine-lot)
|
||||
*/
|
||||
constructor({ dataDir = DEFAULT_DATA_DIR } = {}) {
|
||||
this.dataDir = dataDir;
|
||||
this.filePath = join(dataDir, FILE_NAME);
|
||||
this.data = { version: 1, allocations: {} };
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
/** 加载数据(首次调用时从磁盘读取) */
|
||||
async load() {
|
||||
if (this.loaded) return this.data;
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.data = {
|
||||
version: parsed.version ?? 1,
|
||||
allocations: parsed.allocations ?? {},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw new Error(`分仓数据读取失败: ${err.message}`);
|
||||
}
|
||||
// 文件不存在:首次使用,用默认空数据
|
||||
}
|
||||
this.loaded = true;
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/** 保存数据到磁盘(原子写:临时文件 + rename) */
|
||||
async save() {
|
||||
await fs.mkdir(this.dataDir, { recursive: true });
|
||||
const tmpPath = `${this.filePath}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmpPath, JSON.stringify(this.data, null, 2), 'utf8');
|
||||
await fs.rename(tmpPath, this.filePath);
|
||||
}
|
||||
|
||||
/** 读取某只股票的分配份额 { strategyId: shares } */
|
||||
async get(code) {
|
||||
await this.load();
|
||||
return { ...(this.data.allocations[code] ?? {}) };
|
||||
}
|
||||
|
||||
/** 获取全部分仓分配 */
|
||||
async getAll() {
|
||||
await this.load();
|
||||
return { ...this.data.allocations };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置某只股票的份额分配
|
||||
* @param {string} code 证券代码
|
||||
* @param {Object<string, number>} shares 策略份额映射,如 { 'grid-supermarket': 1000 }
|
||||
*/
|
||||
async set(code, shares) {
|
||||
await this.load();
|
||||
// 过滤掉 0/无效值
|
||||
const cleaned = {};
|
||||
for (const [sid, n] of Object.entries(shares ?? {})) {
|
||||
const num = Number(n);
|
||||
if (Number.isFinite(num) && num > 0) cleaned[sid] = num;
|
||||
}
|
||||
if (Object.keys(cleaned).length > 0) {
|
||||
this.data.allocations[code] = cleaned;
|
||||
} else {
|
||||
delete this.data.allocations[code];
|
||||
}
|
||||
await this.save();
|
||||
return this.get(code);
|
||||
}
|
||||
|
||||
|
||||
/** 删除某只股票的分配 */
|
||||
async remove(code) {
|
||||
await this.load();
|
||||
delete this.data.allocations[code];
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空某策略在所有股票下的份额(迭代 02:O3 删除策略联动 / O6 一键清零)
|
||||
* 删除该 strategyId 的所有分配,返回受影响(原本有份额)的股票代码列表
|
||||
* @param {string} strategyId 策略 id
|
||||
* @returns {Promise<string[]>} 受影响标的代码列表
|
||||
*/
|
||||
async removeStrategyShares(strategyId) {
|
||||
await this.load();
|
||||
const affected = [];
|
||||
for (const [code, shares] of Object.entries(this.data.allocations)) {
|
||||
if (shares && shares[strategyId] !== undefined) {
|
||||
affected.push(code);
|
||||
delete shares[strategyId];
|
||||
// 该票已无任何分配 → 移除整条记录
|
||||
if (Object.keys(shares).length === 0) {
|
||||
delete this.data.allocations[code];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (affected.length > 0) {
|
||||
await this.save();
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user