feat(R-013): 策略自定义字段完整实现 + 列显隐配置 + 单元格编辑
功能实现(迭代 11): - 数据层: strategies.configSchema 自定义字段定义(text/number/boolean/enum + def + unit 单位可空) + strategy_holdings "values" JSON 列(幂等 ALTER)+ readValues/writeValues + 归一化 - API: holdings/values-update 端点(服务端按 configSchema 校验: number有限/enum在选项/boolean布尔) - 设置页: 「策略分组」策略行展开配置字段(StrategyFieldsEditor 独立组件) - 持仓表: 自定义字段列化 + 列显隐/排序(ColumnSettingsPopover,每策略独立 strategyColumns 配置) - 单元格点击编辑(FieldCellEditor): 文本/数字输入框、布尔开关、枚举下拉 + ✓ 确认 - UI 细节: ✎ 可编辑标记(值后)、编辑列宽稳定、数字输入框固定 5 位宽 修复: - SettingsSection Fragment/StrategyFieldsEditor 未导入导致的配置页白屏 - z.dict schema dts 推断 cosmokit 引用(显式类型注解) - 自定义字段从展开交易明细移出(字段已列化) 文档: R-013/迭代11 技术方案/验收标准/迭代目标 同步(D6 演进 + unit + 列配置) 回归: test-r013-custom-fields 21/21 + test-r013-columns 16/16 + tabs 23/23
This commit is contained in:
+13
-1
@@ -11,6 +11,7 @@
|
||||
import {
|
||||
getStrategies, updateStrategies, addStrategy, removeStrategy,
|
||||
getTabs, updateTabs,
|
||||
getStrategyColumns, updateStrategyColumns,
|
||||
} from '../settings.js';
|
||||
|
||||
/** 策略/份额/tabs 端点方法表 */
|
||||
@@ -27,6 +28,9 @@ export const STRATEGY_METHODS = new Set([
|
||||
'remove-shares',
|
||||
'tabs',
|
||||
'tabs/update',
|
||||
'holdings/values-update', // R-013:写某持仓行的自定义字段值
|
||||
'strategy-columns', // 迭代11列显隐:读某策略列配置(归一化)
|
||||
'strategy-columns/update', // 迭代11列显隐:写某策略列配置
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -66,7 +70,15 @@ export async function handleStrategy(method, args, { manager, settings }) {
|
||||
return await manager.moveAllUnallocatedToStrategy(args.code, args.strategyId);
|
||||
case 'remove-shares':
|
||||
return await manager.removeFromStrategy(args.code, args.strategyId, args.shares);
|
||||
case 'holdings/values-update':
|
||||
// R-013:写持仓行自定义字段值(服务端按 configSchema 校验在 manager 内完成)
|
||||
return await manager.updateHoldingValues(args.holdingId, args.values);
|
||||
case 'strategy-columns':
|
||||
return getStrategyColumns(settings, args.strategyId);
|
||||
case 'strategy-columns/update':
|
||||
await updateStrategyColumns(settings, args.strategyId, args.columns);
|
||||
return getStrategyColumns(settings, args.strategyId);
|
||||
default:
|
||||
throw Object.assign(new Error('unknown strategy method: ' + method), { code: 'not-found' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 策略持仓表列设置弹层(迭代 11 列显隐扩展,2026-09-02)
|
||||
* 管理可配置列(基础数据列 + 自定义字段列):显隐勾选 + ↑↓ 排序 + 立即持久化
|
||||
* 固定列(代码/名称/操作/展开箭头)不在列表内,恒显示
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { useToast } from './Toast.jsx';
|
||||
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {Array} props.columns 可配置列 [{key,label,kind,visible}](归一化结果,含不可见列)
|
||||
* @param {string} props.strategyId
|
||||
* @param {Function} props.onClose 关闭
|
||||
* @param {Function} props.onSaved 保存成功回调
|
||||
*/
|
||||
export function ColumnSettingsPopover({ columns, strategyId, onClose, onSaved }) {
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
const [list, setList] = useState(() => (Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragKey, setDragKey] = useState(null);
|
||||
|
||||
const toggle = (key) => setList((ls) => ls.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c)));
|
||||
|
||||
/** ↑↓ 排序(同 Tab 设置简化版:用箭头替代 DnD,零依赖稳妥) */
|
||||
const move = (index, dir) => {
|
||||
setList((ls) => {
|
||||
const next = ls.slice();
|
||||
const to = index + dir;
|
||||
if (to < 0 || to >= next.length) return ls;
|
||||
const [item] = next.splice(index, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// 提交整列配置(含不可见列,保位置)
|
||||
const payload = list.map((c) => ({ key: c.key, visible: c.visible }));
|
||||
const res = await call('one-divine-lot/strategy-columns/update', { args: { strategyId, columns: payload } });
|
||||
if (res && res.ok) {
|
||||
toast.success('列设置已保存');
|
||||
onSaved?.();
|
||||
onClose?.();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rowBtn = { padding: '0 5px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 11, lineHeight: '16px' };
|
||||
const kindTag = (kind) => ({
|
||||
fontSize: 10, borderRadius: 6, padding: '0 4px', marginRight: 4,
|
||||
color: kind === 'base' ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-state-warn-primary, #6a1b9a)',
|
||||
border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 34, zIndex: 1000,
|
||||
background: 'var(--dsw-alias-bg-layer-1, #fff)', border: '1px solid var(--dsw-alias-border-l2, #ddd)', borderRadius: 8,
|
||||
boxShadow: 'var(--dsw-shadow-lv3, 0 4px 12px rgba(0,0,0,0.12))', padding: 12, width: 260,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10 }}>列设置</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 8 }}>勾选显示列,↑↓ 调整顺序(代码/名称/操作固定)</div>
|
||||
<div style={{ maxHeight: 280, overflowY: 'auto' }}>
|
||||
{list.map((c, i) => (
|
||||
<div key={c.key} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0', fontSize: 13 }}>
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
<button onClick={() => move(i, -1)} disabled={i === 0} style={{ ...rowBtn, opacity: i === 0 ? 0.3 : 1 }}>↑</button>
|
||||
<button onClick={() => move(i, 1)} disabled={i === list.length - 1} style={{ ...rowBtn, opacity: i === list.length - 1 ? 0.3 : 1 }}>↓</button>
|
||||
</span>
|
||||
<input type="checkbox" checked={c.visible !== false} onChange={() => toggle(c.key)} />
|
||||
<span style={kindTag(c.kind)}>{c.kind === 'base' ? '数据' : '字段'}</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6, marginTop: 10 }}>
|
||||
<button onClick={save} disabled={saving} style={{ padding: '4px 14px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary, #1565c0)', color: 'var(--dsw-alias-button-contrast-fill, #fff)', fontSize: 12 }}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button onClick={onClose} style={{ padding: '4px 14px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 自定义字段单元格内联编辑器(迭代 11,2026-09-02 老师选 A:点击单元格即编辑)
|
||||
* 按字段类型渲染:text=输入框 / number=数字输入 / boolean=开关点击切换 / enum=下拉
|
||||
* 保存调 holdings/values-update(按该行 values 合并——读当前值做基底,只改本字段)
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { useToast } from './Toast.jsx';
|
||||
|
||||
const inputStyle = {
|
||||
padding: '2px 4px', border: '1px solid var(--dsw-alias-state-business-primary, #1565c0)',
|
||||
borderRadius: 3, fontSize: 12, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
// 宽度由内容决定(ch 随文本长度),切换编辑时列宽不跳动
|
||||
width: 'auto',
|
||||
minWidth: '3ch',
|
||||
maxWidth: '160px',
|
||||
};
|
||||
|
||||
/** 编辑态确认按钮(✓):紧凑 inline,紧贴输入框,宽度增量小(列宽变化轻微) */
|
||||
const actionBtn = {
|
||||
padding: '0 3px', cursor: 'pointer', border: 'none', background: 'none',
|
||||
fontSize: 13, lineHeight: '15px', marginLeft: 1, verticalAlign: 'middle',
|
||||
flex: 'none',
|
||||
};
|
||||
const okBtn = { ...actionBtn, color: 'var(--dsw-alias-state-success-primary, #2e7d32)' };
|
||||
const cancelBtn = { ...actionBtn, color: 'var(--dsw-alias-label-tertiary, #999)' };
|
||||
|
||||
/**
|
||||
* @param {object} props
|
||||
* @param {object} props.fieldDef configSchema 字段定义 {key,label,type,enum,def}
|
||||
* @param {object|null} props.values 该行当前全部字段值(合并基底)
|
||||
* @param {number} props.holdingId
|
||||
* @param {Function} props.onDone 保存/取消后回调(刷新父数据)
|
||||
*/
|
||||
export function FieldCellEditor({ fieldDef, values, holdingId, onDone }) {
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
const has = values && Object.prototype.hasOwnProperty.call(values, fieldDef.key);
|
||||
const cur = has ? values[fieldDef.key] : fieldDef.def;
|
||||
const [saving, setSaving] = useState(false);
|
||||
// 文本/数字草稿;布尔/枚举直接由控件 onChange 触发提交
|
||||
const [text, setText] = useState(() => (fieldDef.type === 'number' ? (cur == null ? '' : String(cur)) : (cur == null ? '' : String(cur))));
|
||||
|
||||
/** 提交新值(payload 为整行 values:读当前做基底合并本字段) */
|
||||
const commit = async (newVal, hasNewVal) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const base = (values && typeof values === 'object') ? { ...values } : {};
|
||||
if (hasNewVal) base[fieldDef.key] = newVal;
|
||||
else delete base[fieldDef.key]; // 空值 → 删键(缺省回退 def)
|
||||
const res = await call('one-divine-lot/holdings/values-update', { args: { holdingId, values: base } });
|
||||
if (res && res.ok) {
|
||||
onDone?.();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '保存失败');
|
||||
setSaving(false);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 行内键盘:Enter 保存 / Esc 取消 */
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Enter') saveText();
|
||||
else if (e.key === 'Escape') onDone?.();
|
||||
};
|
||||
|
||||
const saveText = () => {
|
||||
const s = String(text ?? '').trim();
|
||||
if (fieldDef.type === 'number') {
|
||||
if (s === '') return commit(null, false); // 清空
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n)) { toast.error('需为数字'); return; }
|
||||
return commit(n, true);
|
||||
}
|
||||
// text:空串 → 删键,否则存值
|
||||
return commit(s === '' ? null : s, s !== '');
|
||||
};
|
||||
|
||||
if (fieldDef.type === 'boolean') {
|
||||
// 布尔:点击单元格进入后,这里是开关 —— 但开关点击应提交,需阻止再进入。直接渲染开关提交。
|
||||
return (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', fontSize: 12 }}>
|
||||
<input type="checkbox" checked={!!cur} disabled={saving} onChange={(e) => commit(e.target.checked, true)} />
|
||||
{cur ? '开' : '关'}
|
||||
</label>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldDef.type === 'enum') {
|
||||
return (
|
||||
<select
|
||||
autoFocus
|
||||
style={inputStyle}
|
||||
value={cur ?? ''}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === '') return commit(null, false);
|
||||
commit(v, true);
|
||||
}}
|
||||
onBlur={() => onDone?.()}
|
||||
>
|
||||
<option value="">(空)</option>
|
||||
{(Array.isArray(fieldDef.enum) ? fieldDef.enum : []).map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// text / number:输入框 + Enter 保存 / Esc 取消 + ✓ 确认 / ✕ 取消按钮
|
||||
const inputRef = React.useRef(null);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
style={fieldDef.type === 'number'
|
||||
? { ...inputStyle, width: '7ch' } // 数字:统一约 5 位数宽度(含内边距),稳定不跳
|
||||
: { ...inputStyle, width: Math.max(String(text ?? '').length + 3, 4) + 'ch' }}
|
||||
type={fieldDef.type === 'number' ? 'number' : 'text'}
|
||||
value={text}
|
||||
disabled={saving}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
onBlur={() => { if (!saving) saveText(); }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title="确认保存"
|
||||
style={okBtn}
|
||||
disabled={saving}
|
||||
onMouseDown={(e) => e.preventDefault()} // 阻止 input 失焦(避免 blur 先保存)
|
||||
onClick={saveText}
|
||||
>✓</button>
|
||||
<button
|
||||
type="button"
|
||||
title="取消"
|
||||
style={cancelBtn}
|
||||
disabled={saving}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onDone?.()}
|
||||
>✕</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,10 @@
|
||||
* - QMT 连接配置:R-004 卡片形式(迭代 03)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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:策略自定义字段编辑器
|
||||
|
||||
/** 确认弹窗 */
|
||||
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
|
||||
@@ -183,6 +184,9 @@ function StrategyGroupSettings() {
|
||||
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();
|
||||
|
||||
@@ -244,6 +248,25 @@ function StrategyGroupSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
/** 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 {
|
||||
@@ -310,7 +333,8 @@ function StrategyGroupSettings() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{strategies.map((s) => (
|
||||
<tr key={s.id} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<Fragment key={s.id}>
|
||||
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<td style={{ padding: '8px 6px' }}>
|
||||
{editingId === s.id ? (
|
||||
<span>
|
||||
@@ -325,11 +349,22 @@ function StrategyGroupSettings() {
|
||||
<button onClick={() => setEditingId(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>取消</button>
|
||||
</span>
|
||||
) : (
|
||||
s.name
|
||||
<span>
|
||||
<button
|
||||
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); }}
|
||||
style={{ padding: '2px 6px', cursor: 'pointer', border: 'none', background: 'none', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', marginRight: 6 }}
|
||||
title={expandedId === s.id ? '收起配置' : '展开配置自定义字段'}
|
||||
>{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 }}
|
||||
@@ -340,6 +375,19 @@ function StrategyGroupSettings() {
|
||||
>删除</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)}
|
||||
onClose={() => setExpandedId(null)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -722,4 +770,4 @@ function QmtConnectionsSettings() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 策略自定义字段编辑器(R-013 迭代 11)
|
||||
* 管理某策略的 configSchema:字段列表 + 添加/编辑/删除 + 类型选择(文本/数字/布尔/枚举)
|
||||
* 保存 = 调父组件 onSave(fields)(父组件整表 strategies/update)
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useToast } from './Toast.jsx';
|
||||
|
||||
const TYPE_LABEL = { text: '文本', number: '数字', boolean: '布尔', enum: '枚举' };
|
||||
|
||||
/** 样式常量 */
|
||||
const inputStyle = { padding: '4px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, marginRight: 6, fontSize: 12, background: 'var(--dsw-alias-bg-layer-1, #fff)' };
|
||||
const btnStyle = { padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4, fontSize: 12 };
|
||||
const solidBtn = (bg) => ({ ...btnStyle, border: 'none', background: bg, color: 'var(--dsw-alias-button-contrast-fill, #fff)' });
|
||||
const dangerBtn = { ...btnStyle, borderColor: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-state-error-primary, #c62828)' };
|
||||
|
||||
/**
|
||||
* 字段编辑器
|
||||
* @param {object} props
|
||||
* @param {object} props.strategy 策略(含 configSchema)
|
||||
* @param {Function} props.onSave 保存回调 (fields) => Promise
|
||||
* @param {Function} props.onClose 收起回调
|
||||
* @param {boolean} props.saving 保存中
|
||||
*/
|
||||
export function StrategyFieldsEditor({ strategy, onSave, onClose, saving }) {
|
||||
const toast = useToast();
|
||||
const [fields, setFields] = useState(() => (Array.isArray(strategy?.configSchema) ? strategy.configSchema.map((x) => ({ ...x })) : []));
|
||||
// 表单草稿:null=关闭;{ index:-1=新增 | 编辑下标, ... }
|
||||
const [draft, setDraft] = useState(null);
|
||||
|
||||
const emptyDraft = () => ({ index: -1, key: '', label: '', type: 'text', unit: '', enumText: '', defText: '', defBool: false, defEnum: '' });
|
||||
|
||||
/** 由 label 生成 key(转小写 ascii slug;冲突去重) */
|
||||
const makeKey = (label, usedKeys) => {
|
||||
const base = String(label ?? '').trim().toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'field';
|
||||
let candidate = base;
|
||||
let i = 2;
|
||||
while (usedKeys.includes(candidate)) { candidate = base + '-' + i; i++; }
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const startAdd = () => setDraft(emptyDraft());
|
||||
const startEdit = (index) => {
|
||||
const fld = fields[index];
|
||||
setDraft({
|
||||
index,
|
||||
key: fld.key,
|
||||
label: fld.label ?? '',
|
||||
type: fld.type ?? 'text',
|
||||
unit: fld.unit ?? '',
|
||||
enumText: Array.isArray(fld.enum) ? fld.enum.join(',') : '',
|
||||
defText: fld.type === 'boolean' ? '' : (fld.def != null ? String(fld.def) : ''),
|
||||
defBool: fld.type === 'boolean' ? !!fld.def : false,
|
||||
defEnum: fld.type === 'enum' ? (fld.def ?? '') : '',
|
||||
});
|
||||
};
|
||||
|
||||
const removeField = (index) => {
|
||||
setFields(fields.filter((_, i) => i !== index));
|
||||
if (draft?.index === index) setDraft(null);
|
||||
};
|
||||
|
||||
const saveDraft = () => {
|
||||
if (!draft) return;
|
||||
const label = draft.label.trim();
|
||||
if (!label) { toast.error('字段名称不能为空'); return; }
|
||||
if (!['text', 'number', 'boolean', 'enum'].includes(draft.type)) { toast.error('字段类型无效'); return; }
|
||||
let enumOptions = [];
|
||||
if (draft.type === 'enum') {
|
||||
enumOptions = draft.enumText.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
if (enumOptions.length === 0) { toast.error('枚举类型需填写选项(逗号分隔)'); return; }
|
||||
}
|
||||
const usedKeys = fields.filter((_, i) => i !== draft.index).map((x) => x.key);
|
||||
const key = draft.key.trim() || makeKey(label, usedKeys);
|
||||
if (usedKeys.includes(key)) { toast.error('字段标识已存在: ' + key); return; }
|
||||
let defVal;
|
||||
if (draft.type === 'text') defVal = draft.defText;
|
||||
else if (draft.type === 'number') defVal = draft.defText.trim() === '' ? null : Number(draft.defText);
|
||||
else if (draft.type === 'boolean') defVal = draft.defBool;
|
||||
else defVal = draft.defEnum || null;
|
||||
const field = { key, label, type: draft.type, unit: String(draft.unit ?? '').trim() };
|
||||
if (draft.type === 'enum') field.enum = enumOptions;
|
||||
if (defVal !== null && defVal !== undefined && defVal !== '') field.def = defVal;
|
||||
if (draft.index === -1) setFields([...fields, field]);
|
||||
else setFields(fields.map((f2, i) => (i === draft.index ? field : f2)));
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
const saveAll = async () => {
|
||||
if (draft) { toast.error('请先完成正在编辑的字段'); return; }
|
||||
await onSave(fields);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px 12px', background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)', borderRadius: 6, margin: '4px 0 8px', fontSize: 13 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<strong style={{ fontSize: 13 }}>自定义字段({fields.length})</strong>
|
||||
<span>
|
||||
<button onClick={startAdd} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>+ 添加字段</button>
|
||||
<button onClick={saveAll} disabled={saving} style={solidBtn('var(--dsw-alias-state-business-primary, #1565c0)')}>{saving ? '保存中...' : '保存字段'}</button>
|
||||
<button onClick={onClose} style={btnStyle}>收起</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && !draft && (
|
||||
<div style={{ color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 6 }}>尚未配置字段 —— 点击「+ 添加字段」为本策略添加自定义字段(该策略下每个持仓将按此定义填写值)。</div>
|
||||
)}
|
||||
|
||||
{fields.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 8 }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 12 }}>
|
||||
<th style={{ padding: '4px 6px' }}>展示名</th>
|
||||
<th style={{ padding: '4px 6px' }}>key</th>
|
||||
<th style={{ padding: '4px 6px' }}>类型</th>
|
||||
<th style={{ padding: '4px 6px' }}>选项/默认</th>
|
||||
<th style={{ padding: '4px 6px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((fld, i) => (
|
||||
<tr key={fld.key + '-' + i} style={{ borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<td style={{ padding: '4px 6px' }}>{fld.label || fld.key}</td>
|
||||
<td style={{ padding: '4px 6px', color: 'var(--dsw-alias-label-secondary, #888)', fontSize: 12 }}>{fld.key}</td>
|
||||
<td style={{ padding: '4px 6px' }}>{TYPE_LABEL[fld.type] || fld.type}</td>
|
||||
<td style={{ padding: '4px 6px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||
{fld.type === 'enum' ? (Array.isArray(fld.enum) ? fld.enum.join(' / ') : '')
|
||||
: fld.type === 'boolean' ? (fld.def ? '默认开' : '默认关')
|
||||
: ((fld.def != null ? '默认 ' + fld.def + (fld.unit ? ' ' + fld.unit : '') : '') + (fld.unit && fld.def == null ? '单位 ' + fld.unit : ''))}
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', whiteSpace: 'nowrap' }}>
|
||||
<button onClick={() => startEdit(i)} style={btnStyle}>编辑</button>
|
||||
<button onClick={() => removeField(i)} style={dangerBtn}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{draft && (
|
||||
<div style={{ border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, padding: 8, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginTop: 6 }}>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<input style={inputStyle} placeholder="字段展示名(如:网格间距)" value={draft.label} onChange={(e) => setDraft({ ...draft, label: e.target.value })} />
|
||||
<input style={{ ...inputStyle, width: 120 }} placeholder="key(留空自动生成)" value={draft.key} onChange={(e) => setDraft({ ...draft, key: e.target.value })} />
|
||||
<select style={inputStyle} value={draft.type} onChange={(e) => setDraft({ ...draft, type: e.target.value })}>
|
||||
<option value="text">文本</option>
|
||||
<option value="number">数字</option>
|
||||
<option value="boolean">布尔</option>
|
||||
<option value="enum">枚举</option>
|
||||
</select>
|
||||
<input style={{ ...inputStyle, width: 60 }} placeholder="单位(可选)" title="展示在值后的单位(如 % / 元 / 手),可为空" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} />
|
||||
</div>
|
||||
{draft.type === 'enum' && (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<input style={{ ...inputStyle, width: 220 }} placeholder="枚举选项(逗号分隔,如:低,中,高)" value={draft.enumText} onChange={(e) => setDraft({ ...draft, enumText: e.target.value })} />
|
||||
<select style={inputStyle} value={draft.defEnum} onChange={(e) => setDraft({ ...draft, defEnum: e.target.value })}>
|
||||
<option value="">默认(可选)</option>
|
||||
{draft.enumText.split(/[,,]/).map((s) => s.trim()).filter(Boolean).map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{draft.type === 'text' && (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<input style={{ ...inputStyle, width: 200 }} placeholder="默认值(可选)" value={draft.defText} onChange={(e) => setDraft({ ...draft, defText: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
{draft.type === 'number' && (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<input style={{ ...inputStyle, width: 200 }} type="number" placeholder="默认值(可选)" value={draft.defText} onChange={(e) => setDraft({ ...draft, defText: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
{draft.type === 'boolean' && (
|
||||
<label style={{ marginBottom: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<input type="checkbox" checked={draft.defBool} onChange={(e) => setDraft({ ...draft, defBool: e.target.checked })} /> 默认开
|
||||
</label>
|
||||
)}
|
||||
<div>
|
||||
<button onClick={saveDraft} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>确定</button>
|
||||
<button onClick={() => setDraft(null)} style={btnStyle}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { LoadState } from './LoadState.jsx';
|
||||
import { ToastProvider, useToast } from './Toast.jsx';
|
||||
import { useMarket } from '../market/MarketDataProvider.jsx';
|
||||
import { PriceCell } from './PriceCell.jsx';
|
||||
import { ColumnSettingsPopover } from './ColumnSettingsPopover.jsx'; // 迭代11:列显隐设置
|
||||
import { FieldCellEditor } from './FieldCellEditor.jsx'; // 迭代11:自定义字段单元格点击编辑
|
||||
|
||||
/** 价格格式化(2 位小数) */
|
||||
function fmtPrice(v) {
|
||||
@@ -121,12 +123,40 @@ const ADD_FORM_CSS = `
|
||||
}
|
||||
`;
|
||||
|
||||
/** 可编辑自定义字段单元格样式(迭代11:明显的可点击记号:悬停高亮 + 铅笔图标) */
|
||||
const EDITABLE_CELL_CSS = `
|
||||
.odl-cell-editable {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px dashed transparent;
|
||||
transition: background .12s ease, border-color .12s ease;
|
||||
max-width: 100%;
|
||||
}
|
||||
.odl-cell-editable .odl-edit-ico {
|
||||
font-size: 11px;
|
||||
opacity: .55;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary, #1565c0);
|
||||
}
|
||||
.odl-cell-editable:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover, #eef4fb);
|
||||
border-color: var(--dsw-alias-state-business-primary, #1565c0);
|
||||
}
|
||||
.odl-cell-editable:hover .odl-edit-ico {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
/** 注入样式到文档(只注入一次) */
|
||||
let styleInjected = false;
|
||||
function ensureAddFormCss() {
|
||||
if (styleInjected || typeof document === 'undefined') return;
|
||||
const el = document.createElement('style');
|
||||
el.textContent = ADD_FORM_CSS;
|
||||
el.textContent = ADD_FORM_CSS + '\n' + EDITABLE_CELL_CSS;
|
||||
document.head.appendChild(el);
|
||||
styleInjected = true;
|
||||
}
|
||||
@@ -147,6 +177,13 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
// 涨幅排序(2026-09-02):sortKey='pctChange' 时按涨幅排序;null=默认顺序
|
||||
const [sortKey, setSortKey] = useState(null); // 'pctChange' | null
|
||||
const [sortDir, setSortDir] = useState('desc'); // 'asc' | 'desc'
|
||||
// R-013:本策略自定义字段定义(configSchema;从 strategies 端点取)
|
||||
const [strategySchema, setStrategySchema] = useState(null);
|
||||
// 迭代11列显隐:列配置(归一化有序数组 [{key,label,kind,visible}])+ 列设置弹层开关
|
||||
const [columns, setColumns] = useState(null);
|
||||
const [columnOpen, setColumnOpen] = useState(false);
|
||||
// 迭代11:正在单元格内编辑的自定义字段({code, key} | null)
|
||||
const [editingCell, setEditingCell] = useState(null);
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
const { getPrice, registerCodes } = useMarket();
|
||||
@@ -155,10 +192,19 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [strategyRes, allRes] = await Promise.all([
|
||||
const [strategyRes, allRes, strategiesRes, columnsRes] = await Promise.all([
|
||||
call('one-divine-lot/strategy-positions', { args: { strategyId } }),
|
||||
call('one-divine-lot/unallocated', {}),
|
||||
call('one-divine-lot/strategies', {}),
|
||||
call('one-divine-lot/strategy-columns', { args: { strategyId } }),
|
||||
]);
|
||||
// 迭代11列显隐:列配置(含不可见列,保序;渲染时过滤 visible)
|
||||
if (columnsRes && columnsRes.ok) setColumns(columnsRes.value);
|
||||
// R-013:取本策略 configSchema(字段定义,决定展开区渲染哪些字段)
|
||||
if (strategiesRes && strategiesRes.ok) {
|
||||
const st = (Array.isArray(strategiesRes.value) ? strategiesRes.value : []).find((x) => x.id === strategyId);
|
||||
setStrategySchema(Array.isArray(st?.configSchema) ? st.configSchema : []);
|
||||
}
|
||||
if (strategyRes && strategyRes.ok) {
|
||||
setPositions(strategyRes.value);
|
||||
// 上报策略持仓 code 给行情 Provider(去重:不再由 Provider 自拉 positions)
|
||||
@@ -279,6 +325,82 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
return rows;
|
||||
}, [positions, sortKey, sortDir, getPrice]);
|
||||
|
||||
// 迭代11列显隐:可见列(表头/行渲染依据;未加载配置时=null,回退旧固定列)
|
||||
const visibleColumns = useMemo(() => {
|
||||
if (!Array.isArray(columns)) return null;
|
||||
return columns.filter((c) => c.visible !== false);
|
||||
}, [columns]);
|
||||
|
||||
/** 渲染某数据列单元格内容(col: {key,label,kind})—— 返回 React 节点 */
|
||||
const renderDataCell = (col, p) => {
|
||||
if (col.kind === 'field') {
|
||||
// 自定义字段列:p.values[key] ?? def;点击单元格进入内联编辑(老师选 A)
|
||||
const fieldDef = (strategySchema ?? []).find((f2) => f2.key === col.key);
|
||||
if (!fieldDef) return null; // configSchema 已删该字段(列配置滞后,读时已跟随,双保险)
|
||||
const isEditing = editingCell && editingCell.code === p.code && editingCell.key === col.key;
|
||||
if (isEditing) {
|
||||
return (
|
||||
<FieldCellEditor
|
||||
fieldDef={fieldDef}
|
||||
values={p.values}
|
||||
holdingId={p.holdingId}
|
||||
onDone={() => { setEditingCell(null); load(); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// 展示态:可点击(进入编辑)
|
||||
const has = p.values && Object.prototype.hasOwnProperty.call(p.values, col.key);
|
||||
const v = has ? p.values[col.key] : fieldDef.def;
|
||||
let shown;
|
||||
if (fieldDef.type === 'boolean') shown = v ? '开' : '关';
|
||||
else if (v === undefined || v === null || v === '') shown = '—';
|
||||
else shown = String(v) + (fieldDef.unit ? fieldDef.unit : ''); // 值后拼单位(可空)
|
||||
const clickable = p.holdingId != null; // 无 holding 锚点(未分配)不可编辑
|
||||
if (!clickable) {
|
||||
return <span title={col.label}>{shown}</span>;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="odl-cell-editable"
|
||||
title={'点击编辑「' + col.label + '」(当前: ' + shown + ')'}
|
||||
onClick={(e) => { e.stopPropagation(); setEditingCell({ code: p.code, key: col.key }); }}
|
||||
>
|
||||
{shown}
|
||||
<span className="odl-edit-ico" aria-hidden="true">✎</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// 基础数据列
|
||||
switch (col.key) {
|
||||
case 'lastPrice':
|
||||
return <PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />;
|
||||
case 'pctChange':
|
||||
return <span style={pctStyle(p.pctChange)}>{fmtPct(p.pctChange)}</span>;
|
||||
case 'lastClose':
|
||||
return <span>{fmtPrice(getPrice(p.code)?.lastClose)}</span>;
|
||||
case 'avgPrice':
|
||||
return <span>{fmtPrice(p.avgPrice)}</span>;
|
||||
case 'lastTradePrice':
|
||||
return <span>{fmtPrice(p.lastTradePrice)}</span>;
|
||||
case 'shares':
|
||||
return <span style={{ fontWeight: 'bold' }}>{p.shares}</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** 表头列 label(排序箭头仅涨幅支持) */
|
||||
const headerCell = (col) => {
|
||||
if (col.key === 'pctChange') {
|
||||
return (
|
||||
<th onClick={togglePctSort} style={{ padding: '8px 6px', lineHeight: '24px', cursor: 'pointer', userSelect: 'none' }}>
|
||||
{col.label}{sortKey === 'pctChange' ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
return <th style={{ padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }}>{col.label}</th>;
|
||||
};
|
||||
|
||||
const pctArrow = sortKey === 'pctChange' ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '';
|
||||
|
||||
return (
|
||||
@@ -292,6 +414,22 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
>
|
||||
{showAdd ? '取消' : '+ 添加持仓'}
|
||||
</button>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setColumnOpen(!columnOpen)}
|
||||
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 13, marginLeft: 8 }}
|
||||
>
|
||||
列设置
|
||||
</button>
|
||||
{columnOpen && columns && (
|
||||
<ColumnSettingsPopover
|
||||
columns={columns}
|
||||
strategyId={strategyId}
|
||||
onClose={() => setColumnOpen(false)}
|
||||
onSaved={load}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,12 +477,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px', width: 28 }}> </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 onClick={togglePctSort} style={{ padding: '8px 6px', lineHeight: '24px', cursor: 'pointer', userSelect: 'none' }}>涨幅{pctArrow}</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>
|
||||
{(visibleColumns ?? []).map((col) => headerCell(col))}
|
||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -393,12 +526,9 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
</td>
|
||||
<td style={{ padding: '8px 6px' }}>{p.code}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{p.name}</td>
|
||||
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
|
||||
<td style={{ padding: '8px 6px', ...pctStyle(p.pctChange) }}>{fmtPct(p.pctChange)}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{fmtPrice(getPrice(p.code)?.lastClose)}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{fmtPrice(p.avgPrice)}</td>
|
||||
<td style={{ padding: '8px 6px' }}>{fmtPrice(p.lastTradePrice)}</td>
|
||||
<td style={{ padding: '8px 6px', fontWeight: 'bold' }}>{p.shares}</td>
|
||||
{(visibleColumns ?? []).map((col) => (
|
||||
<td key={col.key} style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>{renderDataCell(col, p)}</td>
|
||||
))}
|
||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }} onClick={(e) => e.stopPropagation()}>
|
||||
{removeTarget && removeTarget.code === p.code ? (
|
||||
<span>
|
||||
@@ -438,7 +568,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
|
||||
<td colSpan={10} style={{ padding: '4px 8px 12px' }}>
|
||||
<td colSpan={4 + (visibleColumns?.length ?? 6)} style={{ padding: '4px 8px 12px' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>交易记录加载中...</div>
|
||||
) : !trades || trades.length === 0 ? (
|
||||
|
||||
+7
-2
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { QmtBridgeRestDataSource } from './data-source/QmtBridgeRestDataSource.js';
|
||||
import { registerSettings, resolveStartupConnection } from './settings.js';
|
||||
import { registerSettings, resolveStartupConnection, getStrategies } from './settings.js';
|
||||
import { DataStore } from './storage/DataStore.js';
|
||||
import { PositionManager } from './position/PositionManager.js';
|
||||
import { registerApi } from './api/index.js';
|
||||
@@ -70,7 +70,12 @@ async function apply(ctx, config) {
|
||||
qmtHealthMonitor.start();
|
||||
|
||||
// S5: 分仓逻辑(份额分配/查询)
|
||||
const manager = new PositionManager({ dataSource, storage });
|
||||
// R-013:注入策略自定义字段定义读取回调(updateHoldingValues 校验用;settings.getStrategies 已归一化 configSchema)
|
||||
const manager = new PositionManager({
|
||||
dataSource,
|
||||
storage,
|
||||
getStrategySchema: (strategyId) => getStrategies(settings).find((s) => s.id === strategyId)?.configSchema ?? [],
|
||||
});
|
||||
|
||||
// R-009:交易记录本地同步(启动预热 + 60s 定时 UPSERT 落库)
|
||||
const tradeSync = new TradeSync({ runtime: { dataSource, storage }, logger });
|
||||
|
||||
@@ -27,10 +27,15 @@ export class PositionManager {
|
||||
* @param {object} opts
|
||||
* @param {import('../data-source/data-source-types.js').DataSource} opts.dataSource 数据源(QMT REST)
|
||||
* @param {DataStore} opts.storage 数据集存储(R-006 DataStore)
|
||||
* @param {Function} [opts.getStrategySchema] 读策略自定义字段定义的回调 (strategyId) => configSchema[]
|
||||
* (R-013:由 index.js 注入,内部经 settings.getStrategies 取;缺省返回 [])
|
||||
*/
|
||||
constructor({ dataSource, storage }) {
|
||||
constructor({ dataSource, storage, getStrategySchema }) {
|
||||
this.dataSource = dataSource;
|
||||
this.storage = storage;
|
||||
this.getStrategySchema = typeof getStrategySchema === 'function'
|
||||
? getStrategySchema
|
||||
: () => [];
|
||||
}
|
||||
|
||||
/** 全量持仓(QMT 真实数据) */
|
||||
@@ -77,7 +82,8 @@ export class PositionManager {
|
||||
this.storage.getCurrentHoldings(strategyId), // 含 holding_id(同策略同 code 当前持仓唯一)
|
||||
]);
|
||||
const shareMap = new Map(dataset.map((d) => [d.code, d.shares]));
|
||||
const holdingMap = new Map(holdings.map((h) => [h.code, h.holdingId]));
|
||||
const holdingMap = new Map(holdings.map((h) => [h.code, h.holdingId])); // code → holdingId
|
||||
const valuesMap = new Map(holdings.map((h) => [h.code, h.values ?? null])); // R-013:code → 自定义字段值
|
||||
// R-010 Q4:批量取各 holding 的关联委托(查最后一笔成交价)
|
||||
const holdingIds = [...new Set(holdings.map((h) => h.holdingId).filter((x) => x != null))];
|
||||
const tradesByHolding = new Map();
|
||||
@@ -102,7 +108,7 @@ export class PositionManager {
|
||||
// getOrdersByHolding 已按 insert_ts DESC,第一条即最新
|
||||
lastTradePrice = +filled[0].tradedPrice;
|
||||
}
|
||||
return { ...p, shares, holdingId, avgPrice, lastTradePrice };
|
||||
return { ...p, shares, holdingId, avgPrice, lastTradePrice, values: valuesMap.get(p.code) ?? null }; // R-013
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -269,4 +275,69 @@ export class PositionManager {
|
||||
}
|
||||
return before.map((h) => h.code);
|
||||
}
|
||||
|
||||
// ===== 持仓自定义字段值(R-013 迭代 11)=====
|
||||
|
||||
/**
|
||||
* 校验字段值是否符合该策略 configSchema 定义(服务端校验,写回前调用)
|
||||
* 规则(技术约束-015):
|
||||
* - 空值/缺省允许(undefined/null/'' → 放行,UI 回退显示 def);
|
||||
* - number → Number(v) 必须有限数(空串已在空值分支放行);
|
||||
* - enum → 值必须 ∈ enum 选项;
|
||||
* - boolean → typeof 必须为 boolean;
|
||||
* - 未定义 key 的额外键放行(可扩展,Q1 老师确认)。
|
||||
* @param {Array} schema configSchema(该策略定义)
|
||||
* @param {object} values 待校验的键值对
|
||||
* @returns {string[]} 错误信息数组(空 = 通过)
|
||||
*/
|
||||
validateHoldingValues(schema, values) {
|
||||
const errors = [];
|
||||
const dict = schema && Array.isArray(schema) ? schema : [];
|
||||
for (const f of dict) {
|
||||
const v = values && typeof values === 'object' ? values[f.key] : undefined;
|
||||
if (v === undefined || v === null || v === '') continue; // 空值放行
|
||||
if (f.type === 'number') {
|
||||
if (!Number.isFinite(Number(v))) errors.push((f.label || f.key) + ' 需为数字');
|
||||
} else if (f.type === 'enum') {
|
||||
const options = Array.isArray(f.enum) ? f.enum : [];
|
||||
if (!options.includes(v)) errors.push((f.label || f.key) + ' 需在选项内: ' + options.join('/'));
|
||||
} else if (f.type === 'boolean') {
|
||||
if (typeof v !== 'boolean') errors.push((f.label || f.key) + ' 需为布尔');
|
||||
}
|
||||
// text:任意字符串放行
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新某持仓行的自定义字段值(R-013)
|
||||
* 定位当前持仓(closed_at IS NULL)→ 按所属策略 configSchema 校验 → 整体写回该行 values。
|
||||
* 额外键(未在 configSchema 定义)放行:values 保持用户提交的全量(可扩展)。
|
||||
* @param {number|string} holdingId 持仓 ID
|
||||
* @param {object} values 字段值键值对(key 对齐 configSchema;null/空对象 → 清除整行值)
|
||||
* @returns {Promise<{holdingId: number, values: object|null}>}
|
||||
*/
|
||||
async updateHoldingValues(holdingId, values) {
|
||||
const hid = Number(holdingId);
|
||||
const current = await this.storage.getCurrentHoldings();
|
||||
const holding = current.find((h) => h.holdingId === hid);
|
||||
if (!holding) {
|
||||
throw Object.assign(new Error('持仓不存在或已清仓: ' + holdingId), { code: 'holding-not-found' });
|
||||
}
|
||||
const schema = this.getStrategySchema(holding.strategyId);
|
||||
// null/空对象 → 清除该行值
|
||||
if (values == null || (typeof values === 'object' && Object.keys(values).length === 0)) {
|
||||
await this.storage.writeValues(hid, null);
|
||||
return { holdingId: hid, values: null };
|
||||
}
|
||||
if (typeof values !== 'object' || Array.isArray(values)) {
|
||||
throw Object.assign(new Error('字段值格式无效'), { code: 'field-validation' });
|
||||
}
|
||||
const errors = this.validateHoldingValues(schema, values);
|
||||
if (errors.length > 0) {
|
||||
throw Object.assign(new Error('字段校验失败: ' + errors.join(';')), { code: 'field-validation', errors });
|
||||
}
|
||||
await this.storage.writeValues(hid, values);
|
||||
return { holdingId: hid, values };
|
||||
}
|
||||
}
|
||||
+124
-8
@@ -33,16 +33,46 @@ export const DEFAULT_TABS = BUILTIN_TABS.map((t, i) => ({
|
||||
|
||||
/** 默认策略(首次注册时的初始值) */
|
||||
export const DEFAULT_STRATEGIES = [
|
||||
{ id: 'grid-supermarket', name: '网格超市' },
|
||||
{ id: 'manual-t', name: '手动做T' },
|
||||
{ id: 'grid-supermarket', name: '网格超市', configSchema: [] },
|
||||
{ id: 'manual-t', name: '手动做T', configSchema: [] },
|
||||
];
|
||||
|
||||
/**
|
||||
* 策略持仓表基础数据列目录(迭代 11 列显隐扩展)
|
||||
* 可配置区基础列:key / label / 默认是否显示(data=固定数据列;自定义字段列由 configSchema 动态合成)
|
||||
* 固定列(不在此目录,锁死):展开箭头 | 代码 | 名称(最前)| 操作(最后)
|
||||
*/
|
||||
export const COLUMN_META = [
|
||||
{ key: 'lastPrice', label: '现价', fixed: false },
|
||||
{ key: 'pctChange', label: '涨幅', fixed: false },
|
||||
{ key: 'lastClose', label: '昨收', fixed: false },
|
||||
{ key: 'avgPrice', label: '成本价', fixed: false },
|
||||
{ key: 'lastTradePrice', label: '最后一笔成交价', fixed: false },
|
||||
{ key: 'shares', label: '策略份额', fixed: false },
|
||||
];
|
||||
|
||||
/** 策略设置 schema(schemastery 定义,settings.register 要求 schema 对象) */
|
||||
/** @type {import('@deepseek-ai/schemastery').Schema<any>} 策略设置 schema(显式注解:含 z.dict,规避 dts 推断 cosmokit Dict 引用) */
|
||||
export const strategySchema = z.object({
|
||||
// 策略定义表(R-011 收窄:仅 id + name,visible/order 归 tabs 统一管理)
|
||||
// 策略定义表(R-011 收窄 + R-013 扩展 configSchema:id + name + 自定义字段定义)
|
||||
// R-013(技术约束-015):configSchema = 该策略需要的自定义字段定义(key/label/type/enum/def),
|
||||
// 随策略定义存 settings 不落库;旧策略缺省 [](兼容,Q4)。
|
||||
strategies: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
name: z.string().required(),
|
||||
configSchema: z.array(z.object({
|
||||
key: z.string().required(), // 稳健标识(英文 slug,与字段值 JSON 的 key 对齐)
|
||||
label: z.string(), // UI 展示名(可中文)
|
||||
type: z.union([
|
||||
z.const('text'),
|
||||
z.const('number'),
|
||||
z.const('boolean'),
|
||||
z.const('enum'),
|
||||
]).required(), // 字段类型
|
||||
enum: z.array(z.string()).default([]), // 仅 type=enum:预设选项
|
||||
def: z.any(), // 默认值(text=string / number=number / boolean=boolean / enum=枚举项)
|
||||
unit: z.string().default(''), // 单位(可为空;如 % / 元 / 手 —— 展示时拼在值后)
|
||||
})).default([]),
|
||||
})).default(DEFAULT_STRATEGIES),
|
||||
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
|
||||
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
|
||||
@@ -73,6 +103,14 @@ export const strategySchema = z.object({
|
||||
activeId: z.string().default(''),
|
||||
defaultId: z.string().default(''),
|
||||
}).default({ list: [], activeId: '', defaultId: '' }),
|
||||
// 策略持仓表列配置(迭代 11 扩展:每策略一套,仅存与默认不同的覆盖;读取归一化)
|
||||
// strategyColumns: { [strategyId]: [{ key, visible }] } —— key 覆盖 COLUMN_META 基础列 + 该策略 configSchema 字段列
|
||||
strategyColumns: z.dict(
|
||||
z.array(z.object({
|
||||
key: z.string().required(),
|
||||
visible: z.boolean().default(true),
|
||||
})).default([])
|
||||
).default({}),
|
||||
});
|
||||
|
||||
|
||||
@@ -195,21 +233,29 @@ export function registerSettings(ctx, config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取策略定义表(R-011 收窄:仅 id + name,无 visible/order)
|
||||
* 读取策略定义表(R-011 收窄 + R-013 扩展:{id, name, configSchema})
|
||||
* 归一化:旧存量数据缺 configSchema(R-013 前)→ 补空数组(Q4 兼容)。
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
*/
|
||||
export function getStrategies(scope) {
|
||||
const value = scope?.get() ?? {};
|
||||
return Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
|
||||
const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
|
||||
return list.map((s) => ({ ...s, configSchema: Array.isArray(s.configSchema) ? s.configSchema : [] }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新策略(整表更新)
|
||||
* 更新策略(整表更新;R-013:configSchema 随整表携带)
|
||||
* 写入归一化:项缺 configSchema → 补 [](防客户端旧格式整表提交丢定义)。
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {Array} strategies 完整策略列表
|
||||
*/
|
||||
export async function updateStrategies(scope, strategies) {
|
||||
await scope.update({ strategies });
|
||||
const next = (Array.isArray(strategies) ? strategies : []).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
configSchema: Array.isArray(s.configSchema) ? s.configSchema : [],
|
||||
}));
|
||||
await scope.update({ strategies: next });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +270,7 @@ export async function addStrategy(scope, name) {
|
||||
const strategy = {
|
||||
id,
|
||||
name: String(name ?? '').trim(),
|
||||
configSchema: [], // R-013:新策略默认无自定义字段(定义在设置页配置)
|
||||
};
|
||||
await updateStrategies(scope, [...list, strategy]);
|
||||
// R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾)
|
||||
@@ -369,6 +416,75 @@ export async function updateTabConfig(scope, tabs) {
|
||||
await updateTabs(scope, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* 策略持仓表列配置(迭代 11 列显隐扩展;产品约束-011、技术约束-017 待落)
|
||||
* 数据:settings.strategyColumns = { [strategyId]: [{key, visible}] }(仅存覆盖)
|
||||
* 归一化:基础列(COLUMN_META 默认序全显)+ 该策略 configSchema 字段列(configSchema 序全显)
|
||||
* → 应用 strategyColumns 覆盖(visible + 顺序)→ 返回有序列数组 [{key, label, fixed, visible}]
|
||||
* 固定列不在此配置:展开箭头/代码/名称(最前)、操作(最后)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 归一化某策略的列配置(基础列 + 自定义字段列,应用覆盖)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {string} strategyId
|
||||
* @returns {Array<{key: string, label: string, fixed: boolean, visible: boolean, kind: 'base'|'field'}>}
|
||||
*/
|
||||
export function normalizeStrategyColumns(scope, strategyId) {
|
||||
const value = scope?.get() ?? {};
|
||||
const strategies = Array.isArray(value.strategies) ? value.strategies : [];
|
||||
const strategy = strategies.find((s) => s.id === strategyId);
|
||||
const configSchema = Array.isArray(strategy?.configSchema) ? strategy.configSchema : [];
|
||||
const raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
|
||||
|
||||
// 1. 默认全列(基础列全显默认序 + 字段列全显 configSchema 序)
|
||||
const baseCols = COLUMN_META.map((c) => ({ key: c.key, label: c.label, fixed: false, kind: 'base' }));
|
||||
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field' }));
|
||||
const defaults = [...baseCols, ...fieldCols];
|
||||
|
||||
// 2. 应用覆盖:visible + 顺序(未覆盖的列保持默认,追加在默认序后)
|
||||
const override = Array.isArray(raw) ? raw : [];
|
||||
const visibleMap = new Map();
|
||||
for (const o of override) visibleMap.set(o.key, o.visible !== false);
|
||||
|
||||
// 覆盖中的自定义字段列若 configSchema 已删除 → 忽略(自动跟随删除)
|
||||
const activeKeys = new Set(defaults.map((c) => c.key));
|
||||
const validOverride = override.filter((o) => activeKeys.has(o.key));
|
||||
|
||||
// 3. 组装:先按覆盖顺序排,未覆盖列追加默认序
|
||||
const ordered = [];
|
||||
const placed = new Set();
|
||||
for (const o of validOverride) {
|
||||
const d = defaults.find((c) => c.key === o.key);
|
||||
if (d) { ordered.push({ ...d, visible: o.visible !== false }); placed.add(d.key); }
|
||||
}
|
||||
for (const d of defaults) {
|
||||
if (!placed.has(d.key)) {
|
||||
ordered.push({ ...d, visible: visibleMap.has(d.key) ? visibleMap.get(d.key) : true });
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/** 读取某策略列配置(归一化结果) */
|
||||
export function getStrategyColumns(scope, strategyId) {
|
||||
return normalizeStrategyColumns(scope, strategyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新某策略列配置(整表覆盖该策略的 columns)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {string} strategyId
|
||||
* @param {Array} columns 完整列数组 [{key, visible}](含不可见列——保留位置,visible 控制显示)
|
||||
*/
|
||||
export async function updateStrategyColumns(scope, strategyId, columns) {
|
||||
const current = scope?.get() ?? {};
|
||||
const store = current.strategyColumns && typeof current.strategyColumns === 'object' ? current.strategyColumns : {};
|
||||
const next = { ...store, [strategyId]: (Array.isArray(columns) ? columns : []).map((c) => ({ key: c.key, visible: c.visible !== false })) };
|
||||
await scope.update({ ...current, strategyColumns: next });
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* QMT 连接配置(R-004 已定稿;产品约束-005、技术约束-008)
|
||||
@@ -517,4 +633,4 @@ export async function setDefaultQmtConnection(scope, id) {
|
||||
const value = scope?.get() ?? {};
|
||||
await scope.update({ ...value, qmtConnections: { ...state, defaultId: id } });
|
||||
return { ...state, defaultId: id };
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,16 @@ export class DataStore {
|
||||
return this.sqlite.getHoldingHistory(code);
|
||||
}
|
||||
|
||||
/** 读取持仓自定义字段值(R-013:委托 SqliteStore) */
|
||||
readValues(holdingId) {
|
||||
return this.sqlite.readValues(holdingId);
|
||||
}
|
||||
|
||||
/** 写入持仓自定义字段值(R-013:整体替换该行 values JSON) */
|
||||
writeValues(holdingId, values) {
|
||||
return this.sqlite.writeValues(holdingId, values);
|
||||
}
|
||||
|
||||
// ===== 兼容 API(旧调用;PositionManager 迁移后不再使用 setDataset/removeDataset)=====
|
||||
|
||||
/** 读取某策略数据集([{code, shares}],兼容旧调用) */
|
||||
|
||||
@@ -98,6 +98,17 @@ CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
|
||||
`;
|
||||
|
||||
/** 解析 strategy_holdings."values" JSON 列(容错:空/脏返回 null;模块级,供 .map(this._mapHolding) 裸调用) */
|
||||
function parseValues(raw) {
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return v && typeof v === 'object' ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteStore {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
@@ -124,6 +135,8 @@ export class SqliteStore {
|
||||
this.db.exec(SCHEMA_SQL);
|
||||
// 存量库迁移:trade_orders 补手动归属列(R-009 二次定稿;幂等)
|
||||
this._ensureTradeAttributionColumns();
|
||||
// 存量库迁移:strategy_holdings 补自定义字段值列(R-013 迭代 11;幂等)
|
||||
this._ensureHoldingValuesColumn();
|
||||
return this.db;
|
||||
}
|
||||
|
||||
@@ -151,6 +164,57 @@ export class SqliteStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 strategy_holdings 存在 values 列(旧库 ALTER 补列,幂等;R-013 迭代 11)。
|
||||
* values = 该持仓行按所属策略 configSchema 存的自定义字段值(JSON 键值对,可含扩展键)。
|
||||
* 只读连接(外部脚本持锁时)抛 readonly → 容忍,真实迁移由插件进程执行。
|
||||
*/
|
||||
_ensureHoldingValuesColumn() {
|
||||
try {
|
||||
const cols = new Set(this.db.prepare('PRAGMA table_info(strategy_holdings)').all().map((c) => c.name));
|
||||
if (!cols.has('values')) {
|
||||
this.db.exec('ALTER TABLE strategy_holdings ADD COLUMN "values" TEXT');
|
||||
}
|
||||
} catch (err) {
|
||||
if (String(err?.message ?? '').includes('readonly')) {
|
||||
console.warn('[one-divine-lot] strategy_holdings values 列迁移跳过(只读连接,插件持锁中)');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 持仓自定义字段值(R-013 迭代 11)=====
|
||||
|
||||
/**
|
||||
* 读取某持仓行的自定义字段值(JSON 键值对)
|
||||
* @param {number} holdingId 持仓 ID
|
||||
* @returns {object|null} values 对象(未配置为 null)
|
||||
*/
|
||||
readValues(holdingId) {
|
||||
this.init();
|
||||
const row = this.db.prepare('SELECT "values" FROM strategy_holdings WHERE holding_id=?').get(holdingId);
|
||||
if (!row || row.values == null) return null;
|
||||
try {
|
||||
return JSON.parse(row.values);
|
||||
} catch {
|
||||
return null; // 脏数据容忍:解析失败视为无值
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入某持仓行的自定义字段值(整体替换该行 values JSON;可含扩展键)
|
||||
* @param {number} holdingId 持仓 ID
|
||||
* @param {object} values 字段值键值对(key 对齐 configSchema)
|
||||
* @returns {boolean} 是否更新成功
|
||||
*/
|
||||
writeValues(holdingId, values) {
|
||||
this.init();
|
||||
const payload = values && typeof values === 'object' ? JSON.stringify(values) : null;
|
||||
const rres = this.db.prepare('UPDATE strategy_holdings SET "values"=? WHERE holding_id=?').run(payload, holdingId);
|
||||
return rres.changes > 0;
|
||||
}
|
||||
|
||||
/** 关闭连接 */
|
||||
close() {
|
||||
if (this.db) {
|
||||
@@ -285,7 +349,7 @@ export class SqliteStore {
|
||||
const r = this.db.prepare(
|
||||
'INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at) VALUES (?,?,?,?,NULL)'
|
||||
).run(strategyId, code, Number(shares), now);
|
||||
return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null };
|
||||
return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null, values: null }; // R-013:新持仓无自定义字段值
|
||||
}
|
||||
|
||||
/** 加仓:当前持仓份额累加 */
|
||||
@@ -324,7 +388,7 @@ export class SqliteStore {
|
||||
_getActive(strategyId, code) {
|
||||
this.init();
|
||||
const row = this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL'
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL'
|
||||
).get(strategyId, code);
|
||||
return row ? this._mapHolding(row) : null;
|
||||
}
|
||||
@@ -334,11 +398,11 @@ export class SqliteStore {
|
||||
this.init();
|
||||
if (strategyId) {
|
||||
return this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL ORDER BY holding_id'
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL ORDER BY holding_id'
|
||||
).all(strategyId).map(this._mapHolding);
|
||||
}
|
||||
return this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE closed_at IS NULL ORDER BY strategy_id, holding_id'
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE closed_at IS NULL ORDER BY strategy_id, holding_id'
|
||||
).all().map(this._mapHolding);
|
||||
}
|
||||
|
||||
@@ -347,11 +411,11 @@ export class SqliteStore {
|
||||
this.init();
|
||||
if (code) {
|
||||
return this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE code=? ORDER BY holding_id'
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE code=? ORDER BY holding_id'
|
||||
).all(code).map(this._mapHolding);
|
||||
}
|
||||
return this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings ORDER BY holding_id'
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings ORDER BY holding_id'
|
||||
).all().map(this._mapHolding);
|
||||
}
|
||||
|
||||
@@ -363,9 +427,12 @@ export class SqliteStore {
|
||||
shares: row.shares,
|
||||
createdAt: row.created_at,
|
||||
closedAt: row.closed_at,
|
||||
values: parseValues(row.values), // R-013:自定义字段值(未配置 null;模块级函数,.map 裸传不依赖 this)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ===== 行情读写(market_quotes_cache)=====
|
||||
|
||||
/** 单码行情 */
|
||||
@@ -552,7 +619,7 @@ export class SqliteStore {
|
||||
if (codes.length === 0) return out;
|
||||
const placeholders = codes.map(() => '?').join(',');
|
||||
const rows = this.db.prepare(
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE code IN (' + placeholders + ')'
|
||||
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE code IN (' + placeholders + ')'
|
||||
).all(...codes);
|
||||
const holdingsByCode = new Map();
|
||||
for (const h of rows) {
|
||||
|
||||
Reference in New Issue
Block a user