迭代22+23: 策略tab静态化 + 计算字段(数字/判定型)+ 网格超市信号字段
- R-027 静态化:策略恒为内置两项(不可增删改名,保留显隐/排序);设置页移除「策略分组」; 字段配置入口迁到策略 tab 内「列设置」旁(FieldConfigDialog + schema-update 单策略写入) - R-026 计算字段:自写公式引擎(中文变量、四则/括号/round·abs·min·max、缺值短路→—)+ 变量目录(行情/合约/持仓/自定义字段)+ strategy-positions 逐行现算(只读内存缓存,不落库、列只读) - R-028 判定型:比较运算 + and/or + inferResultKind + 结果类型一致性校验 + 判定列 ✓/— 渲染; 网格超市落地 可下空单=涨停价>基准值+网格大小、可下多单=跌停价<基准值-网格大小 - 变量选择改标签平铺(老师反馈);公式手册 docs/99-其他材料/计算字段公式说明.md - 约束同步:产品约束-002/003/004/009/013/014、技术约束-014/015/022/023/024、UI约束-002/003/005/008 - 回归:新增 test-r026/test-r027,更新 r011/r013/r017,17 个脚本全绿;typecheck/build 通过
This commit is contained in:
+6
-6
@@ -12,12 +12,12 @@
|
||||
*
|
||||
* 端点(POST /odl/api/<method>,body = { args: {...} }):
|
||||
* positions → 全量持仓
|
||||
* strategies → 策略列表(含 visible)
|
||||
* strategies/update → 更新策略列表(整表)
|
||||
* strategies/add → 新增策略 { name }
|
||||
* strategies/remove → 删除策略 { strategyId },联动清份额
|
||||
* strategies/move → 排序 { strategyId, dir }
|
||||
* strategy-positions → 某策略持仓 { strategyId }
|
||||
* strategies → 策略列表(R-027:恒为内置两项 + 各自 configSchema)
|
||||
* strategies/schema-update → 写某策略字段定义 { strategyId, configSchema }(R-027 唯一写路径)
|
||||
* formula/variables → 公式变量目录 { strategyId? }(R-026)
|
||||
* formula/trial → 公式试算 { strategyId, formula }(R-026)
|
||||
* strategy-positions → 某策略持仓 { strategyId }(含计算字段 computed)
|
||||
* (R-027 退役:strategies/update、strategies/add、strategies/remove、strategies/move)
|
||||
* strategy-holdings/history → 某策略已清仓历史持仓 { strategyId, range }(R-016)
|
||||
* unallocated → 未分配持仓
|
||||
* summary → 分仓摘要
|
||||
|
||||
+94
-18
@@ -2,18 +2,74 @@
|
||||
* 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31)
|
||||
*
|
||||
* 端点:
|
||||
* strategies / strategies/update / strategies/add / strategies/remove
|
||||
* strategies(读;R-027:恒为内置两项)
|
||||
* strategies/schema-update(R-027:写某策略字段定义,唯一写路径)
|
||||
* strategy-positions / unallocated / summary
|
||||
* strategy-holdings/history(R-016 迭代 14:已清仓历史持仓,范围筛选)
|
||||
* add-shares / move-all-shares / remove-shares
|
||||
* tabs / tabs/update
|
||||
* formula/variables(R-026:公式变量目录)/ formula/trial(R-026:试算)
|
||||
* holdings/values-update / strategy-columns / strategy-columns/update
|
||||
*
|
||||
* R-027 退役:strategies/update、strategies/add、strategies/remove(策略静态化,不可增删改名)
|
||||
*/
|
||||
|
||||
import {
|
||||
getStrategies, updateStrategies, addStrategy, removeStrategy,
|
||||
getStrategies, getStrategyFields, updateStrategyFields, assertBuiltinStrategy,
|
||||
getTabs, updateTabs,
|
||||
getStrategyColumns, updateStrategyColumns,
|
||||
} from '../settings.js';
|
||||
import { validateFormula, compileFormula, evaluateFormula, inferResultKind } from '../formula/evaluator.js';
|
||||
import { allowedVarNames, buildContext, catalogFor, BUILTIN_VAR_NAMES } from '../formula/variables.js';
|
||||
import { computeRows } from '../formula/FormulaService.js';
|
||||
|
||||
/** 字段类型白名单(R-026 含计算类型) */
|
||||
const FIELD_TYPE_WHITELIST = new Set(['text', 'number', 'boolean', 'enum', 'formula']);
|
||||
|
||||
/** 字段校验失败(前端 Toast + 公式框描红) */
|
||||
function fieldError(message) {
|
||||
return Object.assign(new Error(message), { code: 'field-validation' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段定义校验(R-027/R-026):结构 + 唯一性 + 名称冲突 + 公式(语法/变量/仅非公式字段)
|
||||
* @param {Array} fieldDefs 客户端提交的 configSchema
|
||||
*/
|
||||
function validateConfigSchema(fieldDefs) {
|
||||
const fields = Array.isArray(fieldDefs) ? fieldDefs : [];
|
||||
const keys = new Set();
|
||||
const labels = new Set();
|
||||
fields.forEach((f, i) => {
|
||||
const label = String(f?.label ?? '').trim();
|
||||
const key = String(f?.key ?? '').trim();
|
||||
if (!label) throw fieldError(`第 ${i + 1} 个字段:展示名不能为空`);
|
||||
if (!key) throw fieldError(`字段『${label}』:标识(key)不能为空`);
|
||||
if (!FIELD_TYPE_WHITELIST.has(f?.type)) throw fieldError(`字段『${label}』:类型无效`);
|
||||
if (keys.has(key)) throw fieldError(`字段标识重复: ${key}`);
|
||||
if (labels.has(label)) throw fieldError(`字段展示名重复: ${label}`);
|
||||
if (BUILTIN_VAR_NAMES.has(label)) throw fieldError(`变量名『${label}』与内置变量冲突,请修改字段展示名`);
|
||||
if (f.type === 'enum' && !(Array.isArray(f.enum) && f.enum.some((x) => String(x).trim()))) {
|
||||
throw fieldError(`字段『${label}』:枚举类型需填写选项`);
|
||||
}
|
||||
keys.add(key);
|
||||
labels.add(label);
|
||||
});
|
||||
// 公式校验:变量 = 内置名 ∪ 本策略非 formula 字段展示名(零循环依赖)
|
||||
const vars = allowedVarNames(fields);
|
||||
for (const f of fields) {
|
||||
if (f?.type !== 'formula') continue;
|
||||
const res = validateFormula(f.formula, vars);
|
||||
if (!res.ok) throw fieldError(`字段『${f.label}』公式错误:${res.error.message}`);
|
||||
// R-028:结果类型声明必须与公式形态一致(判定 = 顶层比较/and/or;数字 = 其余)
|
||||
const declared = f.resultKind === 'boolean' ? 'boolean' : 'number';
|
||||
const inferred = inferResultKind(compileFormula(f.formula).ast);
|
||||
if (declared !== inferred) {
|
||||
throw fieldError(declared === 'boolean'
|
||||
? `字段『${f.label}』:结果类型选了「判定」,但公式没有比较运算(如 涨停价 > 基准值 + 1)`
|
||||
: `字段『${f.label}』:公式结果是判定,请把「结果类型」改为「判定」`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** R-016 范围档 → 回溯毫秒(自然日近似,展示用途足够;技术约束-019)。
|
||||
* range='today' 不走回溯毫秒——二轮补充(2026-09-07 老师定):本地自然日 00:00 起清仓的持仓
|
||||
@@ -36,9 +92,9 @@ function localDayStartMs() {
|
||||
/** 策略/份额/tabs 端点方法表 */
|
||||
export const STRATEGY_METHODS = new Set([
|
||||
'strategies',
|
||||
'strategies/update',
|
||||
'strategies/add',
|
||||
'strategies/remove',
|
||||
'strategies/schema-update', // R-027:写某策略字段定义(唯一写路径)
|
||||
'formula/variables', // R-026:公式变量目录
|
||||
'formula/trial', // R-026:公式试算(取一行真实持仓)
|
||||
'strategy-positions',
|
||||
'unallocated',
|
||||
'summary',
|
||||
@@ -59,27 +115,47 @@ export const STRATEGY_METHODS = new Set([
|
||||
* @param {object} args
|
||||
* @param {object} runtime { manager, settings, storage }
|
||||
*/
|
||||
export async function handleStrategy(method, args, { manager, settings, storage }) {
|
||||
export async function handleStrategy(method, args, { manager, settings, storage, marketHub }) {
|
||||
switch (method) {
|
||||
case 'strategies':
|
||||
return getStrategies(settings);
|
||||
case 'strategies/update':
|
||||
await updateStrategies(settings, args.strategies);
|
||||
return getStrategies(settings);
|
||||
case 'strategies/add':
|
||||
return await addStrategy(settings, args.name);
|
||||
case 'strategies/remove':
|
||||
// 删除策略 + 联动清空该策略下所有份额(份额回未分配)
|
||||
await removeStrategy(settings, args.strategyId);
|
||||
await manager.clearStrategyShares(args.strategyId);
|
||||
return getStrategies(settings);
|
||||
case 'strategies/schema-update':
|
||||
// R-027:单策略字段定义写入(结构/唯一性/公式校验 → strategyFields)
|
||||
assertBuiltinStrategy(args.strategyId);
|
||||
validateConfigSchema(args.configSchema);
|
||||
return await updateStrategyFields(settings, args.strategyId, args.configSchema);
|
||||
case 'formula/variables':
|
||||
// R-026:公式变量目录(内置分组 + 本策略非 formula 字段)
|
||||
if (args.strategyId != null) assertBuiltinStrategy(args.strategyId);
|
||||
return catalogFor(getStrategyFields(settings, args.strategyId));
|
||||
case 'formula/trial': {
|
||||
// R-026:试算(用该策略一行真实持仓数据求值;无持仓返回 reason)
|
||||
assertBuiltinStrategy(args.strategyId);
|
||||
const fieldDefs = getStrategyFields(settings, args.strategyId);
|
||||
const check = validateFormula(args.formula, allowedVarNames(fieldDefs));
|
||||
if (!check.ok) throw fieldError(check.error.message);
|
||||
const rows = await manager.getStrategyPositions(args.strategyId);
|
||||
const row = rows.find((r) => (r.shares ?? 0) > 0) ?? rows[0] ?? null;
|
||||
if (!row) return { code: null, name: null, value: null, reason: 'no-holding' };
|
||||
const ctx = buildContext({
|
||||
row,
|
||||
quote: marketHub?.quotes?.get(row.code) ?? null,
|
||||
instrument: marketHub?.instruments?.get(row.code) ?? null,
|
||||
fieldDefs,
|
||||
});
|
||||
const value = evaluateFormula(args.formula, ctx);
|
||||
return { code: row.code, name: row.name ?? null, value, reason: value === null ? 'no-data' : null };
|
||||
}
|
||||
case 'tabs':
|
||||
return getTabs(settings);
|
||||
case 'tabs/update':
|
||||
await updateTabs(settings, args.tabs);
|
||||
return getTabs(settings);
|
||||
case 'strategy-positions':
|
||||
return await manager.getStrategyPositions(args.strategyId);
|
||||
case 'strategy-positions': {
|
||||
// R-026:含计算字段的策略,逐行现算(只读内存缓存;无 formula 字段零开销)
|
||||
const rows = await manager.getStrategyPositions(args.strategyId);
|
||||
return computeRows({ rows, fieldDefs: getStrategyFields(settings, args.strategyId), hub: marketHub });
|
||||
}
|
||||
case 'unallocated':
|
||||
return await manager.getUnallocatedPositions();
|
||||
case 'summary':
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* 策略持仓表列设置弹层(迭代 11 列显隐扩展,2026-09-02)
|
||||
* 管理可配置列(基础数据列 + 自定义字段列):显隐勾选 + ↑↓ 排序 + 立即持久化
|
||||
* 固定列(代码/名称/操作/展开箭头)不在列表内,恒显示
|
||||
* 策略持仓表列设置弹层(迭代 11 列显隐扩展 + 迭代 20 拖拽排序,2026-09-10)
|
||||
* 管理可配置列(基础数据列 + 自定义字段列):显隐勾选 + 原生 HTML5 拖拽排序(≡ 手柄)+ ↑↓ 兜底微调
|
||||
* 所有变更(勾选 / 拖拽 / ↑↓)立即持久化,无「保存」按钮;固定列(代码/名称/操作/展开箭头)不在列表内,恒显示
|
||||
* 拖拽模式复用 Tab 设置(R-011 SettingsSection 已验证):draggable 行 + 间隙高亮线 + drop 重排
|
||||
* 落点判定(2026-09-10 老师反馈修改):拖动时根据鼠标在目标行内的 Y 坐标(上半/下半)计算插入位置,
|
||||
* 在目标行上缘/下缘(两行之间的间隙)显示高亮线,明确指示插入该列之前/之后,而非整行高亮
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
@@ -20,41 +23,85 @@ export function ColumnSettingsPopover({ columns, strategyId, onClose, onSaved })
|
||||
const [list, setList] = useState(() => (Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragKey, setDragKey] = useState(null);
|
||||
// 落点:idx = 悬停目标行下标;half = 'top'(插入该行上缘=之前)/ 'bottom'(下缘=之后)
|
||||
const [overPos, setOverPos] = 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 () => {
|
||||
/** 立即持久化整份列配置(含不可见列,保位置);失败回滚到服务端最新配置 */
|
||||
const persist = async (nextList, msg) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// 提交整列配置(含不可见列,保位置)
|
||||
const payload = list.map((c) => ({ key: c.key, visible: c.visible }));
|
||||
const payload = nextList.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('列设置已保存');
|
||||
toast.success(msg);
|
||||
onSaved?.();
|
||||
onClose?.();
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '保存失败');
|
||||
setList(Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
setList(Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 勾选显隐:立即持久化 */
|
||||
const toggle = (key) => {
|
||||
const next = list.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c));
|
||||
const target = next.find((c) => c.key === key);
|
||||
setList(next);
|
||||
persist(next, (target && target.visible ? '已显示「' : '已隐藏「') + (target ? target.label : '') + '」');
|
||||
};
|
||||
|
||||
/** ↑↓ 兜底微调:立即持久化 */
|
||||
const move = (index, dir) => {
|
||||
const next = list.slice();
|
||||
const to = index + dir;
|
||||
if (to < 0 || to >= next.length) return;
|
||||
const [item] = next.splice(index, 1);
|
||||
next.splice(to, 0, item);
|
||||
setList(next);
|
||||
persist(next, '「' + item.label + '」已移动');
|
||||
};
|
||||
|
||||
/** 悬停:按鼠标在该行内的上下半区决定插入线画在上缘(之前)还是下缘(之后) */
|
||||
const handleOver = (e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const half = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom';
|
||||
setOverPos({ idx: index, half });
|
||||
};
|
||||
|
||||
/** 落点重排 + 立即持久化:drop 事件内直接按坐标判定,避免 state 闭包滞后 */
|
||||
const handleDrop = (e, index) => {
|
||||
e.preventDefault();
|
||||
if (!list || !dragKey) { setDragKey(null); setOverPos(null); return; }
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const before = e.clientY - rect.top < rect.height / 2;
|
||||
let to = before ? index : index + 1; // 目标行之前 / 之后(原始下标)
|
||||
const from = list.findIndex((c) => c.key === dragKey);
|
||||
if (from < 0 || to < 0 || to > list.length) { setDragKey(null); setOverPos(null); return; }
|
||||
// 无操作(原地):插入位置等于原位置或原位置的后一格(等同原槽位),跳过写库
|
||||
if (to === from || to === from + 1) { setDragKey(null); setOverPos(null); return; }
|
||||
const next = list.slice();
|
||||
const [moved] = next.splice(from, 1);
|
||||
const insertAt = to > from ? to - 1 : to; // 移除后目标下标左移
|
||||
next.splice(insertAt, 0, moved);
|
||||
setDragKey(null);
|
||||
setOverPos(null);
|
||||
setList(next);
|
||||
persist(next, '「' + moved.label + '」已移动');
|
||||
};
|
||||
|
||||
/** 拖拽结束清理(拖出弹层 / 取消等场景) */
|
||||
const handleDragEnd = () => {
|
||||
setDragKey(null);
|
||||
setOverPos(null);
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -62,33 +109,61 @@ export function ColumnSettingsPopover({ columns, strategyId, onClose, onSaved })
|
||||
border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
|
||||
});
|
||||
|
||||
// 间隙高亮线:悬停行上缘(该行之前)或下缘(该行之后);行自身有 3px 垂直 padding,线落在两行之间的间隙
|
||||
const gapLineStyle = (i) => {
|
||||
if (!overPos || overPos.idx !== i) return null;
|
||||
return {
|
||||
position: 'absolute', left: 6, right: 6, height: 3, borderRadius: 2,
|
||||
background: 'var(--dsw-alias-state-business-primary, #1565c0)',
|
||||
...(overPos.half === 'top' ? { top: 0 } : { bottom: 0 }),
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
boxShadow: 'var(--dsw-shadow-lv3, 0 4px 12px rgba(0,0,0,0.12))', padding: 12, width: 286,
|
||||
}}>
|
||||
<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' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 8 }}>
|
||||
勾选显示列,按住 ≡ 拖到两行之间的落点线(代码/名称/操作固定)
|
||||
</div>
|
||||
<div style={{ maxHeight: 280, overflowY: 'auto', position: 'relative' }}>
|
||||
{list.map((c, i) => (
|
||||
<div key={c.key} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0', fontSize: 13 }}>
|
||||
<div
|
||||
key={c.key}
|
||||
draggable={!saving}
|
||||
onDragStart={(e) => { setDragKey(c.key); setOverPos(null); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => handleOver(e, i)}
|
||||
onDrop={(e) => handleDrop(e, i)}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0', fontSize: 13, borderRadius: 4,
|
||||
cursor: saving ? 'default' : 'grab',
|
||||
}}
|
||||
>
|
||||
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
|
||||
<span title="按住拖动排序" style={{ color: 'var(--dsw-alias-label-tertiary, #999)', cursor: saving ? 'default' : 'grab', userSelect: 'none' }}>≡</span>
|
||||
<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>
|
||||
<button onClick={() => move(i, -1)} disabled={saving || i === 0} style={{ ...rowBtn, opacity: i === 0 ? 0.3 : 1 }}>↑</button>
|
||||
<button onClick={() => move(i, 1)} disabled={saving || 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)} />
|
||||
<input type="checkbox" checked={c.visible !== false} onChange={() => toggle(c.key)} disabled={saving} />
|
||||
<span style={kindTag(c.kind)}>{c.kind === 'base' ? '数据' : '字段'}</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.label}</span>
|
||||
<span
|
||||
style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={c.kind === 'field' && c.type === 'formula' ? '计算字段(只读)' : undefined}
|
||||
>
|
||||
{c.kind === 'field' && c.type === 'formula' ? 'ƒ ' + c.label : 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>
|
||||
<button onClick={onClose} disabled={saving} 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,123 @@
|
||||
/**
|
||||
* 字段配置弹层(迭代 22,R-026/R-027)
|
||||
* 锚定弹层(与列设置同源视觉):width 520 / maxHeight 70vh / 内容内部滚动
|
||||
* 底部 [关闭](次)+ [保存字段](主,saving 时 disabled);保存走子组件 ref.save()
|
||||
* 有未保存改动(dirty = 子组件 onDirtyChange 上报)时:关闭 / 外点 / Esc → 二次确认放弃
|
||||
*/
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { StrategyFieldsEditor } from './StrategyFieldsEditor.jsx';
|
||||
|
||||
export function FieldConfigDialog({ strategyId, strategyName, configSchema, onSaved, onClose }) {
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
||||
const editorRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
const dirtyRef = useRef(dirty);
|
||||
dirtyRef.current = dirty;
|
||||
|
||||
/** 关闭请求:有未保存改动 → 二次确认;否则直接关 */
|
||||
const requestClose = useCallback(() => {
|
||||
if (dirtyRef.current) setConfirmDiscard(true);
|
||||
else onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Esc / 外点关闭(D-8:未保存改动时二次确认)
|
||||
useEffect(() => {
|
||||
const onKey = (e) => { if (e.key === 'Escape') requestClose(); };
|
||||
const onDown = (e) => {
|
||||
// 以“锚点容器”(含字段配置/列设置按钮)为界:点按钮自身不算外点,
|
||||
// 否则按钮 onClick 与放弃确认会互相打架(dirty 时弹层被静默卸载)
|
||||
const anchor = panelRef.current?.parentElement ?? panelRef.current;
|
||||
if (anchor && !anchor.contains(e.target)) requestClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [requestClose]);
|
||||
|
||||
/** 保存字段:调子组件 save(strategies/schema-update)→ 成功 onSaved + 关闭 */
|
||||
const handleSave = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
let ok = false;
|
||||
try {
|
||||
ok = (await editorRef.current?.save?.()) === true;
|
||||
} catch { /* save 内部已提示 */ }
|
||||
setSaving(false);
|
||||
if (ok) {
|
||||
onSaved?.();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: 'absolute', right: 0, top: 34, zIndex: 1000,
|
||||
width: 520, maxWidth: 'calc(100vw - 24px)', maxHeight: '70vh',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
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))',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 'none', padding: '12px 16px 8px' }}>
|
||||
<strong style={{ fontSize: 14 }}>字段配置:{strategyName || strategyId}</strong>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowY: 'auto', flex: 1, minHeight: 0, padding: '0 16px' }}>
|
||||
<StrategyFieldsEditor
|
||||
ref={editorRef}
|
||||
strategyId={strategyId}
|
||||
configSchema={configSchema}
|
||||
saving={saving}
|
||||
onDirtyChange={setDirty}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 'none', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 16px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<button
|
||||
onClick={requestClose}
|
||||
disabled={saving}
|
||||
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 13 }}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ padding: '6px 20px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)', fontSize: 13, opacity: saving ? 0.7 : 1 }}
|
||||
>
|
||||
{saving ? '保存中…' : '保存字段'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{confirmDiscard && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.4))', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1100 }}>
|
||||
<div style={{ background: 'var(--dsw-alias-bg-layer-1, #fff)', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 16 }}>放弃未保存的改动</h3>
|
||||
<div style={{ fontSize: 14, color: 'var(--dsw-alias-label-secondary, #555)', marginBottom: 20 }}>有未保存的字段改动,确定放弃?</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<button
|
||||
onClick={() => setConfirmDiscard(false)}
|
||||
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfirmDiscard(false); onClose(); }}
|
||||
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
|
||||
>
|
||||
放弃改动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
/**
|
||||
* 神之一手设置 section
|
||||
*
|
||||
* 结构(R-011 迭代 09):
|
||||
* - 三个子 tab:Tab 设置 / 策略分组 / QMT 连接配置
|
||||
* - Tab 设置:统一管理所有会话 tab(内置 + 策略分组混排),拖动排序(落点立即持久化)+ 显示/隐藏开关;
|
||||
* 结构(迭代 22,R-027 后):
|
||||
* - 两个子 tab:Tab 设置 / QMT 连接配置(「策略分组」已移除,字段配置入口迁到策略 tab 内)
|
||||
* - Tab 设置:统一管理所有会话 tab(内置 + 策略混排),拖动排序(落点立即持久化)+ 显示/隐藏开关;
|
||||
* 任何 tab 均不支持重命名/删除(产品约束-009、UI约束-003)
|
||||
* - 策略分组:策略 CRUD(新增/重命名/删除确认)+ 提示「顺序与显示请在 Tab 设置中调整」
|
||||
* - QMT 连接配置:R-004 卡片形式(迭代 03)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, Fragment } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { ToastProvider, useToast } from './Toast.jsx';
|
||||
import { StrategyFieldsEditor } from './StrategyFieldsEditor.jsx'; // R-013:策略自定义字段编辑器
|
||||
import { ExpandChevron } from './ExpandChevron.jsx'; // 展开箭头与策略 tab 表格行图标统一
|
||||
|
||||
/** 确认弹窗 */
|
||||
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
|
||||
@@ -58,13 +55,14 @@ function Switch({ checked, onChange, disabled }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Tab 设置子 tab(R-011:统一管理所有会话 tab —— 内置 + 策略分组混排,拖动排序 + 显隐开关) */
|
||||
/** Tab 设置子 tab(R-011:统一管理所有会话 tab —— 内置 + 策略混排,拖动排序 + 显隐开关) */
|
||||
function TabSettings() {
|
||||
const [tabs, setTabs] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [dragId, setDragId] = useState(null);
|
||||
const [overId, setOverId] = useState(null);
|
||||
// 落点:idx = 悬停目标行下标;half = 'top'(该行上缘=插入前)/ 'bottom'(下缘=插入后)——迭代 21 间隙高亮线
|
||||
const [overPos, setOverPos] = useState(null);
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -106,19 +104,34 @@ function TabSettings() {
|
||||
await persist(next, '「' + t.name + '」已' + (next.find((x) => x.id === t.id).visible ? '显示' : '隐藏'));
|
||||
};
|
||||
|
||||
// 原生 HTML5 拖动:落点重排 + 立即持久化(Q1)
|
||||
const handleDrop = async (targetId) => {
|
||||
if (!tabs || !dragId || dragId === targetId) { setDragId(null); setOverId(null); return; }
|
||||
/** 悬停:按鼠标在该行内的上下半区决定间隙线画上缘(插入前)还是下缘(插入后) */
|
||||
const handleOver = (e, index) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const half = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom';
|
||||
setOverPos({ idx: index, half });
|
||||
};
|
||||
|
||||
/** 原生 HTML5 拖动:落点重排 + 立即持久化(Q1);迭代 21:drop 事件内按坐标判定插入前/后,原地跳过写库 */
|
||||
const handleDrop = async (e, index) => {
|
||||
e.preventDefault();
|
||||
if (!tabs || !dragId) { setDragId(null); setOverPos(null); return; }
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const before = e.clientY - rect.top < rect.height / 2;
|
||||
let to = before ? index : index + 1;
|
||||
const from = tabs.findIndex((x) => x.id === dragId);
|
||||
const to = tabs.findIndex((x) => x.id === targetId);
|
||||
if (from < 0 || to < 0) { setDragId(null); setOverId(null); return; }
|
||||
if (from < 0 || to < 0 || to > tabs.length) { setDragId(null); setOverPos(null); return; }
|
||||
// 原地(插入位置等于原位置或其下一格 = 原槽位)→ 跳过写库
|
||||
if (to === from || to === from + 1) { setDragId(null); setOverPos(null); return; }
|
||||
const next = tabs.slice();
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved);
|
||||
const insertAt = to > from ? to - 1 : to;
|
||||
next.splice(insertAt, 0, moved);
|
||||
// 重写 order(整表连续)
|
||||
const ordered = next.map((x, i) => ({ ...x, order: i }));
|
||||
setDragId(null);
|
||||
setOverId(null);
|
||||
setOverPos(null);
|
||||
await persist(ordered, '「' + moved.name + '」已移动');
|
||||
};
|
||||
|
||||
@@ -128,10 +141,21 @@ function TabSettings() {
|
||||
border: kind === 'builtin' ? '1px solid var(--dsw-alias-border-l3, #90caf9)' : '1px solid var(--dsw-alias-border-l3, #ce93d8)',
|
||||
});
|
||||
|
||||
// 间隙高亮线(迭代 21,与列设置弹层 R-024 全同):悬停行上缘(插入前)或下缘(插入后);
|
||||
// 表格行 tr 内不能直接嵌 div,故在每个 td 内各画一段线(position:relative 锚点),视觉连续
|
||||
const gapLineStyle = (i) => {
|
||||
if (!overPos || overPos.idx !== i) return null;
|
||||
return {
|
||||
position: 'absolute', left: 0, right: 0, height: 3, borderRadius: 2,
|
||||
background: 'var(--dsw-alias-state-business-primary, #1565c0)',
|
||||
...(overPos.half === 'top' ? { top: 0 } : { bottom: 0 }),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
|
||||
拖动调整所有标签页顺序,开关控制显示/隐藏;内置与策略标签可任意混排。名称不支持在此修改(策略名称在「策略分组」中修改)。
|
||||
拖动调整所有标签页顺序,开关控制显示/隐藏;内置与策略标签页可任意混排。名称不支持在此修改。
|
||||
</div>
|
||||
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
|
||||
{!tabs && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
|
||||
@@ -145,25 +169,28 @@ function TabSettings() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tabs.map((t) => (
|
||||
{tabs.map((t, i) => (
|
||||
<tr
|
||||
key={t.id}
|
||||
draggable={!saving}
|
||||
onDragStart={(e) => { setDragId(t.id); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOverId(t.id); }}
|
||||
onDragLeave={() => { if (overId === t.id) setOverId(null); }}
|
||||
onDrop={(e) => { e.preventDefault(); handleDrop(t.id); }}
|
||||
onDragStart={(e) => { setDragId(t.id); setOverPos(null); e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => handleOver(e, i)}
|
||||
onDrop={(e) => handleDrop(e, i)}
|
||||
onDragEnd={() => { setDragId(null); setOverPos(null); }}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', cursor: saving ? 'default' : 'grab',
|
||||
background: overId === t.id ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, transparent)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-tertiary, #999)', textAlign: 'center' }}>≡</td>
|
||||
<td style={{ padding: '8px 6px' }}>
|
||||
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-tertiary, #999)', textAlign: 'center', position: 'relative' }}>
|
||||
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}≡
|
||||
</td>
|
||||
<td style={{ padding: '8px 6px', position: 'relative' }}>
|
||||
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
|
||||
<span style={badgeStyle(t.kind)}>{t.kind === 'builtin' ? '内置' : '策略'}</span>
|
||||
{t.name}
|
||||
</td>
|
||||
<td style={{ padding: '8px 6px' }}>
|
||||
<td style={{ padding: '8px 6px', position: 'relative' }}>
|
||||
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
|
||||
<Switch checked={t.visible !== false} onChange={() => toggleVisible(t)} disabled={saving} />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -175,239 +202,9 @@ function TabSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 策略分组子 tab(原策略 CRUD) */
|
||||
function StrategyGroupSettings() {
|
||||
const [strategies, setStrategies] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
const [changedMsg, setChangedMsg] = useState(null);
|
||||
// R-013:当前展开配置字段的策略 id(null=无展开)
|
||||
const [expandedId, setExpandedId] = useState(null);
|
||||
const [fieldSaving, setFieldSaving] = useState(false);
|
||||
const call = useRpc();
|
||||
const toast = useToast();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await call('one-divine-lot/strategies', {});
|
||||
if (res && res.ok) setStrategies(res.value);
|
||||
else setError((res && res.error && res.error.message) || '加载失败');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}, [call]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleChanged = useCallback((msg) => {
|
||||
setChangedMsg(msg);
|
||||
toast.success(msg + '(刷新页面后生效)');
|
||||
load();
|
||||
}, [load, toast]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name) { toast.error('请输入策略名称'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/strategies/add', { args: { name } });
|
||||
if (res && res.ok) {
|
||||
setNewName('');
|
||||
handleChanged('策略「' + name + '」已添加');
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '添加失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (id) => {
|
||||
const name = editingName.trim();
|
||||
if (!name) { toast.error('名称不能为空'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/strategies/update', {
|
||||
args: { strategies: strategies.map((s) => (s.id === id ? { ...s, name } : s)) },
|
||||
});
|
||||
if (res && res.ok) {
|
||||
setEditingId(null);
|
||||
handleChanged('策略已重命名');
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '重命名失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** R-013:保存某策略的自定义字段定义(整表 strategies/update) */
|
||||
const handleSaveConfig = async (id, configSchema) => {
|
||||
setFieldSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/strategies/update', {
|
||||
args: { strategies: strategies.map((s) => (s.id === id ? { ...s, configSchema } : s)) },
|
||||
});
|
||||
if (res && res.ok) {
|
||||
handleChanged('策略自定义字段已保存');
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '保存失败');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setFieldSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await call('one-divine-lot/strategies/remove', { args: { strategyId: id } });
|
||||
if (res && res.ok) {
|
||||
setConfirmDelete(null);
|
||||
handleChanged('策略已删除,份额已回到未分配');
|
||||
} else {
|
||||
toast.error((res && res.error && res.error.message) || '删除失败');
|
||||
setConfirmDelete(null);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
setConfirmDelete(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{changedMsg && (
|
||||
<div style={{
|
||||
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
|
||||
background: 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 10%, var(--dsw-alias-bg-layer-1, #fff))', color: 'var(--dsw-alias-state-business-primary, #1565c0)', fontSize: 13, border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
|
||||
}}>
|
||||
{changedMsg} —— <strong>刷新页面后生效</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="新策略名称(如:长线持有)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||
style={{ flex: 1, padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={saving}
|
||||
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
|
||||
>
|
||||
+ 新增策略
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
|
||||
顺序与显示请在「Tab 设置」中调整。
|
||||
</div>
|
||||
|
||||
{!strategies && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
|
||||
{strategies && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
|
||||
<th style={{ padding: '8px 6px' }}>策略</th>
|
||||
<th style={{ padding: '8px 6px' }}>ID</th>
|
||||
<th style={{ padding: '8px 6px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{strategies.map((s) => (
|
||||
<Fragment key={s.id}>
|
||||
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<td style={{ padding: '8px 6px' }}>
|
||||
{editingId === s.id ? (
|
||||
<span>
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRename(s.id)}
|
||||
style={{ padding: '4px 6px', border: '1px solid #ccc', borderRadius: 3, marginRight: 6 }}
|
||||
/>
|
||||
<button onClick={() => handleRename(s.id)} disabled={saving} style={{ padding: '2px 8px', cursor: 'pointer' }}>保存</button>
|
||||
<button onClick={() => setEditingId(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>取消</button>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); }}
|
||||
style={{ padding: '2px 4px', cursor: 'pointer', border: 'none', background: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', lineHeight: 0 }}
|
||||
title={expandedId === s.id ? '收起配置' : '展开配置自定义字段'}
|
||||
><ExpandChevron expanded={expandedId === s.id} /></button>
|
||||
{s.name}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 12 }}>{s.id}</td>
|
||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
||||
<button
|
||||
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); setEditingId(null); }}
|
||||
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
|
||||
>{expandedId === s.id ? '收起字段' : '配置字段'}</button>
|
||||
<button
|
||||
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
|
||||
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
|
||||
>重命名</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
|
||||
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary, #c62828)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
|
||||
>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedId === s.id && (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ padding: '4px 10px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<StrategyFieldsEditor
|
||||
strategy={s}
|
||||
saving={fieldSaving}
|
||||
onSave={(fields) => handleSaveConfig(s.id, fields)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
title={'删除策略「' + confirmDelete.name + '」'}
|
||||
message="该策略下已分配的份额将回到「未分配」,数据不会丢失。确定删除?"
|
||||
onConfirm={() => handleDelete(confirmDelete.id)}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置 section 主组件:三个子 tab */
|
||||
/** 设置 section 主组件:两个子 tab */
|
||||
function SettingsSectionInner(props) {
|
||||
const [activeTab, setActiveTab] = useState('tabs'); // tabs | strategies | qmt
|
||||
const [activeTab, setActiveTab] = useState('tabs'); // tabs | qmt
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
|
||||
@@ -417,7 +214,6 @@ function SettingsSectionInner(props) {
|
||||
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', marginBottom: 16 }}>
|
||||
{[
|
||||
{ key: 'tabs', label: 'Tab 设置' },
|
||||
{ key: 'strategies', label: '策略分组' },
|
||||
{ key: 'qmt', label: 'QMT 连接配置' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
@@ -434,7 +230,6 @@ function SettingsSectionInner(props) {
|
||||
</div>
|
||||
|
||||
{activeTab === 'tabs' && <TabSettings />}
|
||||
{activeTab === 'strategies' && <StrategyGroupSettings />}
|
||||
{activeTab === 'qmt' && <QmtConnectionsSettings />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,68 @@
|
||||
/**
|
||||
* 策略自定义字段编辑器(R-013 迭代 11)
|
||||
* 管理某策略的 configSchema:字段列表 + 添加/编辑/删除 + 类型选择(文本/数字/布尔/枚举)
|
||||
* 保存 = 调父组件 onSave(fields)(父组件整表 strategies/update)
|
||||
* 策略字段编辑器(迭代 22 改造:弹层内容 + 单策略保存,R-026/R-027)
|
||||
* 管理某策略 configSchema:字段列表 + 添加/编辑/删除 + 类型(文本/数字/布尔/枚举/计算)
|
||||
* 计算(formula)分支:公式框(等宽)+ 变量**标签平铺**(按 行情/合约/持仓/自定义字段 分组,
|
||||
* 点选即插入公式框;数据来自 formula/variables,另加「自定义字段」组 = 本策略非 formula 字段展示名)
|
||||
* + 试算(formula/trial)+ 小数位 0-4
|
||||
* 保存 = 父弹层调 ref.save() → strategies/schema-update { strategyId, configSchema: fields }
|
||||
* dirty 经 onDirtyChange 上报(弹层据此做关闭二次确认)
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useRpc } from './connection.jsx';
|
||||
import { useToast } from './Toast.jsx';
|
||||
|
||||
const TYPE_LABEL = { text: '文本', number: '数字', boolean: '布尔', enum: '枚举' };
|
||||
const TYPE_LABEL = { text: '文本', number: '数字', boolean: '布尔', enum: '枚举', formula: '计算' };
|
||||
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
|
||||
|
||||
/** 样式常量 */
|
||||
/** 样式常量(沿用设置页既有风格) */
|
||||
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)' };
|
||||
|
||||
/** 小数位夹取 0-4 整数(默认 2) */
|
||||
function clampDecimals(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return 2;
|
||||
return Math.min(4, Math.max(0, Math.round(n)));
|
||||
}
|
||||
|
||||
/** 计算值/试算值格式化:toFixed(decimals) + 单位(如 6.67 % / 700.00 元) */
|
||||
function fmtValue(v, decimals, unit) {
|
||||
const s = Number(v).toFixed(clampDecimals(decimals));
|
||||
return unit ? s + ' ' + unit : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段编辑器
|
||||
* @param {object} props
|
||||
* @param {object} props.strategy 策略(含 configSchema)
|
||||
* @param {Function} props.onSave 保存回调 (fields) => Promise
|
||||
* @param {boolean} props.saving 保存中
|
||||
* @param {string} props.strategyId
|
||||
* @param {Array} props.configSchema 字段定义(strategies 端点)
|
||||
* @param {boolean} props.saving 保存中(弹层「保存字段」驱动)
|
||||
* @param {Function} props.onDirtyChange dirty 变化上报 (bool) => void
|
||||
*/
|
||||
export function StrategyFieldsEditor({ strategy, onSave, saving }) {
|
||||
export const StrategyFieldsEditor = forwardRef(function StrategyFieldsEditor({ strategyId, configSchema, saving, onDirtyChange }, ref) {
|
||||
const toast = useToast();
|
||||
const [fields, setFields] = useState(() => (Array.isArray(strategy?.configSchema) ? strategy.configSchema.map((x) => ({ ...x })) : []));
|
||||
const call = useRpc();
|
||||
const [fields, setFields] = useState(() => (Array.isArray(configSchema) ? configSchema.map((x) => ({ ...x })) : []));
|
||||
// 表单草稿:null=关闭;{ index:-1=新增 | 编辑下标, ... }
|
||||
const [draft, setDraft] = useState(null);
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
const [formulaError, setFormulaError] = useState(false);
|
||||
const [varGroups, setVarGroups] = useState(null);
|
||||
const [trial, setTrial] = useState(null); // { kind:'ok'|'empty'|'error', text }
|
||||
const [trialLoading, setTrialLoading] = useState(false);
|
||||
const formulaRef = useRef(null);
|
||||
|
||||
const emptyDraft = () => ({ index: -1, key: '', label: '', type: 'text', unit: '', enumText: '', defText: '', defBool: false, defEnum: '' });
|
||||
// R-026:变量目录(formula/variables;「自定义字段」组本地合成,见 menuGroups)
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
call('one-divine-lot/formula/variables', { args: { strategyId } }).then((res) => {
|
||||
if (alive && res?.ok && res.value) setVarGroups(res.value.groups ?? []);
|
||||
}).catch(() => { /* 失败静默:变量菜单只剩自定义字段组 */ });
|
||||
return () => { alive = false; };
|
||||
}, [call, strategyId]);
|
||||
|
||||
const emptyDraft = () => ({ index: -1, key: '', label: '', type: 'text', unit: '', enumText: '', defText: '', defBool: false, defEnum: '', formula: '', resultKind: 'number', decimals: 2 });
|
||||
|
||||
/** 由 label 生成 key(转小写 ascii slug;冲突去重) */
|
||||
const makeKey = (label, usedKeys) => {
|
||||
@@ -42,7 +77,13 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const startAdd = () => setDraft(emptyDraft());
|
||||
// dirty:与初始 configSchema 的 JSON 快照比较,经 onDirtyChange 上报
|
||||
const initialJson = useMemo(() => JSON.stringify(Array.isArray(configSchema) ? configSchema : []), [configSchema]);
|
||||
const dirty = useMemo(() => JSON.stringify(fields) !== initialJson, [fields, initialJson]);
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
|
||||
const startAdd = () => { setDraft(emptyDraft()); setTrial(null); setFormulaError(false); };
|
||||
|
||||
const startEdit = (index) => {
|
||||
const fld = fields[index];
|
||||
setDraft({
|
||||
@@ -55,7 +96,11 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
|
||||
defText: fld.type === 'boolean' ? '' : (fld.def != null ? String(fld.def) : ''),
|
||||
defBool: fld.type === 'boolean' ? !!fld.def : false,
|
||||
defEnum: fld.type === 'enum' ? (fld.def ?? '') : '',
|
||||
formula: fld.formula ?? '',
|
||||
resultKind: fld.resultKind === 'boolean' ? 'boolean' : 'number', // R-028
|
||||
decimals: typeof fld.decimals === 'number' ? fld.decimals : 2,
|
||||
});
|
||||
setTrial(null);
|
||||
};
|
||||
|
||||
const removeField = (index) => {
|
||||
@@ -67,45 +112,146 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
|
||||
if (!draft) return;
|
||||
const label = draft.label.trim();
|
||||
if (!label) { toast.error('字段名称不能为空'); return; }
|
||||
if (!['text', 'number', 'boolean', 'enum'].includes(draft.type)) { toast.error('字段类型无效'); return; }
|
||||
if (!['text', 'number', 'boolean', 'enum', 'formula'].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; }
|
||||
}
|
||||
if (draft.type === 'formula' && !String(draft.formula ?? '').trim()) {
|
||||
toast.error('公式不能为空');
|
||||
setFormulaError(true);
|
||||
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;
|
||||
let field;
|
||||
if (draft.type === 'formula') {
|
||||
field = {
|
||||
key, label, type: 'formula', unit: String(draft.unit ?? '').trim(),
|
||||
formula: String(draft.formula ?? '').trim(),
|
||||
resultKind: draft.resultKind === 'boolean' ? 'boolean' : 'number', // R-028
|
||||
decimals: clampDecimals(draft.decimals),
|
||||
};
|
||||
} else {
|
||||
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;
|
||||
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);
|
||||
setFormulaError(false);
|
||||
};
|
||||
|
||||
const saveAll = async () => {
|
||||
if (draft) { toast.error('请先完成正在编辑的字段'); return; }
|
||||
await onSave(fields);
|
||||
/** 插入变量到公式框光标处(无光标信息 → 追加末尾并补空格) */
|
||||
const insertVar = (name) => {
|
||||
setFormulaError(false);
|
||||
const cur = String(draft?.formula ?? '');
|
||||
const el = formulaRef.current;
|
||||
if (el && typeof el.selectionStart === 'number') {
|
||||
let before = cur.slice(0, el.selectionStart);
|
||||
let after = cur.slice(el.selectionEnd ?? el.selectionStart);
|
||||
if (before && !/\s$/.test(before)) before += ' ';
|
||||
if (after && !/^\s/.test(after)) after = ' ' + after;
|
||||
const next = before + name + after;
|
||||
setDraft({ ...draft, formula: next });
|
||||
const pos = (before + name).length;
|
||||
requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); });
|
||||
} else {
|
||||
setDraft({ ...draft, formula: cur ? cur + ' ' + name : name });
|
||||
}
|
||||
};
|
||||
|
||||
/** 试算:formula/trial(服务端取该策略真实持仓 + 缓存行情求值) */
|
||||
const runTrial = async () => {
|
||||
if (trialLoading || !draft) return;
|
||||
const formula = String(draft.formula ?? '').trim();
|
||||
if (!formula) { toast.error('公式不能为空'); return; }
|
||||
const decimals = clampDecimals(draft.decimals);
|
||||
const unit = String(draft.unit ?? '').trim();
|
||||
setTrialLoading(true);
|
||||
setTrial(null);
|
||||
try {
|
||||
const res = await call('one-divine-lot/formula/trial', { args: { strategyId, formula, decimals, unit } });
|
||||
if (res?.ok) {
|
||||
const v = res.value || {};
|
||||
if (typeof v.value === 'boolean') {
|
||||
// R-028:判定型试算(满足 = ✓ 字段名)
|
||||
setTrial({ kind: 'ok', text: '试算:' + (v.name ?? '—') + ' ' + (v.code ?? '—') + ' → ' + (v.value ? '✓ 满足' : '— 不满足') });
|
||||
} else if (v.value != null && Number.isFinite(Number(v.value))) {
|
||||
setTrial({ kind: 'ok', text: '试算:' + (v.name ?? '—') + ' ' + (v.code ?? '—') + ' → ' + fmtValue(Number(v.value), decimals, unit) });
|
||||
} else if (v.reason === 'no-holding') {
|
||||
setTrial({ kind: 'empty', text: '暂无可试算的持仓数据' });
|
||||
} else {
|
||||
setTrial({ kind: 'empty', text: '行情数据缺失,暂无法试算' });
|
||||
}
|
||||
} else {
|
||||
const msg = (res?.error?.message) || '试算失败';
|
||||
toast.error(msg);
|
||||
setTrial({ kind: 'error', text: msg });
|
||||
setFormulaError(true);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
setTrial({ kind: 'error', text: e.message });
|
||||
} finally {
|
||||
setTrialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存(弹层「保存字段」触发):strategies/schema-update → 成功 true(弹层负责 onSaved + 关闭) */
|
||||
useImperativeHandle(ref, () => ({
|
||||
save: async () => {
|
||||
if (draft) { toast.error('请先完成正在编辑的字段'); return false; }
|
||||
setSaveError(null);
|
||||
try {
|
||||
const res = await call('one-divine-lot/strategies/schema-update', { args: { strategyId, configSchema: fields } });
|
||||
if (res?.ok) { toast.success('字段已保存'); return true; }
|
||||
const msg = (res?.error?.message) || '保存失败';
|
||||
toast.error(msg);
|
||||
setSaveError(msg);
|
||||
// 服务端字段校验失败:公式框描红(表单不关闭,可重试)
|
||||
if (/公式|变量|语法|表达式|『/.test(msg)) setFormulaError(true);
|
||||
return false;
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
setSaveError(e.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}), [fields, draft, strategyId, call, toast]);
|
||||
|
||||
/** 变量菜单分组:服务端 行情/合约/持仓 + 「自定义字段」(本策略非 formula 字段展示名) */
|
||||
const menuGroups = useMemo(() => {
|
||||
const server = (Array.isArray(varGroups) ? varGroups : [])
|
||||
.filter((g) => g && g.group && g.group !== '自定义字段')
|
||||
.map((g) => ({ group: g.group, items: (Array.isArray(g.items) ? g.items : []).map((it) => ({ name: it.name, desc: it.desc })) }));
|
||||
const custom = fields
|
||||
.filter((f, i) => f.type !== 'formula' && i !== draft?.index)
|
||||
.map((f) => ({ name: String(f.label || f.key), desc: '本策略手填字段' }));
|
||||
return [...server, { group: '自定义字段', items: custom }];
|
||||
}, [varGroups, fields, draft?.index]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px 12px', background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)', borderRadius: 6, margin: '4px 0 8px', fontSize: 13 }}>
|
||||
<div style={{ padding: '10px 0 12px', 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>
|
||||
</span>
|
||||
<button onClick={startAdd} disabled={saving} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>+ 添加字段</button>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>{saveError}</div>
|
||||
)}
|
||||
|
||||
{fields.length === 0 && !draft && (
|
||||
<div style={{ color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 6 }}>尚未配置字段 —— 点击「+ 添加字段」为本策略添加自定义字段(该策略下每个持仓将按此定义填写值)。</div>
|
||||
<div style={{ color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 6 }}>尚未配置字段 —— 点击「+ 添加字段」为本策略添加(该策略下每个持仓按此定义填写值)。</div>
|
||||
)}
|
||||
|
||||
{fields.length > 0 && (
|
||||
@@ -115,7 +261,7 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
|
||||
<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>
|
||||
<th style={{ padding: '4px 6px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -124,9 +270,19 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
|
||||
<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' }}>
|
||||
{TYPE_LABEL[fld.type] || fld.type}
|
||||
{fld.type === 'formula' && fld.resultKind === 'boolean' ? '(判定)' : ''}
|
||||
</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 === 'formula' ? (
|
||||
<span
|
||||
title={fld.formula || ''}
|
||||
style={{ display: 'inline-block', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: MONO, verticalAlign: 'bottom', color: 'var(--dsw-alias-label-secondary, #666)' }}
|
||||
>
|
||||
{fld.formula || '—'}
|
||||
</span>
|
||||
) : 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>
|
||||
@@ -145,14 +301,108 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
|
||||
<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 })}>
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={draft.type}
|
||||
onChange={(e) => { setDraft({ ...draft, type: e.target.value }); setTrial(null); setFormulaError(false); }}
|
||||
>
|
||||
<option value="text">文本</option>
|
||||
<option value="number">数字</option>
|
||||
<option value="boolean">布尔</option>
|
||||
<option value="enum">枚举</option>
|
||||
<option value="formula">计算</option>
|
||||
</select>
|
||||
<input style={{ ...inputStyle, width: 60 }} placeholder="单位(可选)" title="展示在值后的单位(如 % / 元 / 手),可为空" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} />
|
||||
<input style={{ ...inputStyle, width: 60 }} placeholder="单位(可选)" title="展示在值后的单位(如 % / 元 / 手),可为空" value={draft.unit} onChange={(e) => { setDraft({ ...draft, unit: e.target.value }); setTrial(null); }} />
|
||||
{draft.type === 'formula' && (
|
||||
<label style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||
结果
|
||||
<select
|
||||
style={{ ...inputStyle, width: 76, marginLeft: 4 }}
|
||||
title="数字 = 算式结果(可设小数位);判定 = 比较结果(满足显示 ✓,不满足显示 —)"
|
||||
value={draft.resultKind}
|
||||
onChange={(e) => { setTrial(null); setFormulaError(false); setDraft({ ...draft, resultKind: e.target.value }); }}
|
||||
>
|
||||
<option value="number">数字</option>
|
||||
<option value="boolean">判定</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{draft.type === 'formula' && (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<textarea
|
||||
ref={formulaRef}
|
||||
rows={2}
|
||||
placeholder={draft.resultKind === 'boolean'
|
||||
? '判定公式,如:涨停价 > 基准值 + 网格大小'
|
||||
: '计算公式,如:(现价 ÷ 成本价 - 1) × 100'}
|
||||
value={draft.formula}
|
||||
onChange={(e) => { setFormulaError(false); setTrial(null); setDraft({ ...draft, formula: e.target.value }); }}
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box', fontFamily: MONO, fontSize: 12, padding: '4px 6px', borderRadius: 3,
|
||||
border: formulaError ? '1px solid var(--dsw-alias-state-error-primary, #c62828)' : '1px solid var(--dsw-alias-border-l2, #ccc)',
|
||||
background: 'var(--dsw-alias-bg-layer-1, #fff)', resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', marginTop: 4 }}>
|
||||
运算:+ - × ÷ 括号 ;判定:> < >= <= == != ,组合:and / or
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{menuGroups.map((g) => (
|
||||
<div key={g.group} style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 3, flexWrap: 'wrap' }}>
|
||||
<span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)', padding: '4px 2px 2px 0' }}>{g.group}</span>
|
||||
{g.items.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', padding: '3px 0 2px' }}>本策略暂无手填字段</span>
|
||||
) : g.items.map((it) => (
|
||||
<button
|
||||
key={it.name}
|
||||
onClick={() => insertVar(it.name)}
|
||||
title={(it.desc ? it.desc + ',' : '') + '点击插入到公式'}
|
||||
style={{
|
||||
padding: '2px 9px', fontSize: 12, lineHeight: '18px', borderRadius: 10, cursor: 'pointer',
|
||||
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
|
||||
background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
|
||||
color: 'var(--dsw-alias-label-primary, #333)',
|
||||
}}
|
||||
>
|
||||
{it.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
<button onClick={runTrial} disabled={trialLoading} style={{ ...btnStyle, marginRight: 0, opacity: trialLoading ? 0.6 : 1 }}>
|
||||
{trialLoading ? '试算中…' : '试算'}
|
||||
</button>
|
||||
{draft.resultKind !== 'boolean' && (
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||
小数位
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
step={1}
|
||||
value={draft.decimals}
|
||||
onChange={(e) => { setTrial(null); setDraft({ ...draft, decimals: e.target.value }); }}
|
||||
style={{ width: 56, padding: '2px 4px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}
|
||||
/>
|
||||
(0-4)
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{trial && (
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: trial.kind === 'ok' ? 'var(--dsw-alias-label-secondary, #666)' : 'var(--dsw-alias-state-error-primary, #c62828)' }}>
|
||||
{trial.text}
|
||||
</div>
|
||||
)}
|
||||
{formulaError && saveError && (
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>{saveError}</div>
|
||||
)}
|
||||
</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 })} />
|
||||
@@ -178,11 +428,11 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
|
||||
</label>
|
||||
)}
|
||||
<div>
|
||||
<button onClick={saveDraft} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>确定</button>
|
||||
<button onClick={() => setDraft(null)} style={btnStyle}>取消</button>
|
||||
<button onClick={saveDraft} disabled={saving} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>确定</button>
|
||||
<button onClick={() => setDraft(null)} disabled={saving} style={btnStyle}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ToastProvider, useToast } from './Toast.jsx';
|
||||
import { useMarket } from '../market/MarketDataProvider.jsx';
|
||||
import { PriceCell } from './PriceCell.jsx';
|
||||
import { ColumnSettingsPopover } from './ColumnSettingsPopover.jsx'; // 迭代11:列显隐设置
|
||||
import { FieldConfigDialog } from './FieldConfigDialog.jsx'; // 迭代22:字段配置弹层(含计算字段)
|
||||
import { FieldCellEditor } from './FieldCellEditor.jsx'; // 迭代11:自定义字段单元格点击编辑
|
||||
import { ExpandChevron } from './ExpandChevron.jsx'; // 行展开图标共享组件
|
||||
|
||||
@@ -59,6 +60,19 @@ function fmtPct(pct) {
|
||||
return sign + pct.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
/** 小数位夹取 0-4 整数(计算字段默认 2) */
|
||||
function clampDec(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return 2;
|
||||
return Math.min(4, Math.max(0, Math.round(n)));
|
||||
}
|
||||
|
||||
/** 计算字段值格式化:toFixed(decimals) + 单位(如 6.67 % / 700.00 元;迭代22) */
|
||||
function fmtComputed(v, decimals, unit) {
|
||||
const s = Number(v).toFixed(clampDec(decimals));
|
||||
return unit ? s + ' ' + unit : s;
|
||||
}
|
||||
|
||||
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */
|
||||
const ADD_FORM_CSS = `
|
||||
.odl-add-form {
|
||||
@@ -184,9 +198,11 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
const [sortDir, setSortDir] = useState('desc'); // 'asc' | 'desc'
|
||||
// R-013:本策略自定义字段定义(configSchema;从 strategies 端点取)
|
||||
const [strategySchema, setStrategySchema] = useState(null);
|
||||
// 迭代11列显隐:列配置(归一化有序数组 [{key,label,kind,visible}])+ 列设置弹层开关
|
||||
// 迭代11列显隐:列配置(归一化有序数组 [{key,label,kind,visible,type?}])+ 列设置弹层开关
|
||||
const [columns, setColumns] = useState(null);
|
||||
const [columnOpen, setColumnOpen] = useState(false);
|
||||
// 迭代22:字段配置弹层开关(R-026/R-027)
|
||||
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
|
||||
// 迭代11:正在单元格内编辑的自定义字段({code, key} | null)
|
||||
const [editingCell, setEditingCell] = useState(null);
|
||||
// R-016 迭代 14:历史持仓展示(title 旁开关 + 清仓范围筛选;会话级,不持久化,Q4)
|
||||
@@ -427,6 +443,8 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
if (col.kind === 'field') {
|
||||
const fieldDef0 = (strategySchema ?? []).find((f0) => f0.key === col.key);
|
||||
if (!fieldDef0) return null;
|
||||
// 迭代22:计算字段列历史行无 computed → 显示 —
|
||||
if (fieldDef0.type === 'formula') return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>—</span>;
|
||||
const has0 = p.values && Object.prototype.hasOwnProperty.call(p.values, col.key);
|
||||
const v0 = has0 ? p.values[col.key] : fieldDef0.def;
|
||||
let shown0;
|
||||
@@ -442,6 +460,26 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
// 自定义字段列:p.values[key] ?? def;点击单元格进入内联编辑(老师选 A)
|
||||
const fieldDef = (strategySchema ?? []).find((f2) => f2.key === col.key);
|
||||
if (!fieldDef) return null; // configSchema 已删该字段(列配置滞后,读时已跟随,双保险)
|
||||
// 迭代22:计算字段列只读——取值 row.computed[key],不挂 FieldCellEditor、不绑点击编辑
|
||||
if (fieldDef.type === 'formula') {
|
||||
const raw = p.computed ? p.computed[col.key] : undefined;
|
||||
// R-028 判定型:满足 → 「✓ 字段名」(success 绿);不满足 / 缺值 → —(tertiary)
|
||||
// 注意顺序:布尔必须先判(false 的 Number() 为 0,否则会被当成数字格式化)
|
||||
if (typeof raw === 'boolean') {
|
||||
return raw
|
||||
? <span title={'= ' + (fieldDef.formula ?? '')} style={{ color: 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>{'✓ ' + (fieldDef.label || col.label)}</span>
|
||||
: <span title={'= ' + (fieldDef.formula ?? '')} style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>—</span>;
|
||||
}
|
||||
const hasV = raw != null && Number.isFinite(Number(raw));
|
||||
return (
|
||||
<span
|
||||
title={'= ' + (fieldDef.formula ?? '')}
|
||||
style={!hasV ? { color: 'var(--dsw-alias-label-tertiary, #999)' } : undefined}
|
||||
>
|
||||
{hasV ? fmtComputed(Number(raw), fieldDef.decimals, fieldDef.unit) : '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const isEditing = editingCell && editingCell.code === p.code && editingCell.key === col.key;
|
||||
if (isEditing) {
|
||||
return (
|
||||
@@ -503,7 +541,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
}
|
||||
};
|
||||
|
||||
/** 表头列 label(排序箭头仅涨幅支持) */
|
||||
/** 表头列 label(排序箭头仅涨幅支持;迭代22:计算字段列 ƒ 前缀 + 悬停公式原文) */
|
||||
const headerCell = (col) => {
|
||||
if (col.key === 'pctChange') {
|
||||
return (
|
||||
@@ -512,7 +550,19 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
</th>
|
||||
);
|
||||
}
|
||||
return <th style={{ padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }}>{col.label}</th>;
|
||||
const fld0 = (strategySchema ?? []).find((f) => f.key === col.key);
|
||||
const isFormulaCol = col.kind === 'field' && fld0 && fld0.type === 'formula';
|
||||
return (
|
||||
<th
|
||||
style={{ padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }}
|
||||
title={isFormulaCol ? '= ' + (fld0.formula ?? '') : undefined}
|
||||
>
|
||||
{isFormulaCol && (
|
||||
<span title="计算字段(只读)" style={{ color: 'var(--dsw-alias-label-tertiary, #999)', marginRight: 2 }}>ƒ</span>
|
||||
)}
|
||||
{col.label}
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
const pctArrow = sortKey === 'pctChange' ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '';
|
||||
@@ -561,7 +611,13 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
)}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setColumnOpen(!columnOpen)}
|
||||
onClick={() => { setColumnOpen(false); setFieldConfigOpen(true); }}
|
||||
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>
|
||||
<button
|
||||
onClick={() => { setFieldConfigOpen(false); 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 }}
|
||||
>
|
||||
列设置
|
||||
@@ -574,6 +630,15 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
||||
onSaved={load}
|
||||
/>
|
||||
)}
|
||||
{fieldConfigOpen && (
|
||||
<FieldConfigDialog
|
||||
strategyId={strategyId}
|
||||
strategyName={name}
|
||||
configSchema={Array.isArray(strategySchema) ? strategySchema : []}
|
||||
onSaved={load}
|
||||
onClose={() => setFieldConfigOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 计算字段现算服务(R-026 / 迭代 22 阶段二)
|
||||
*
|
||||
* 职责:给定某策略的持仓行 + 字段定义,为每行算出全部 formula 字段的值(computed)。
|
||||
* 数据口径(技术方案 §2.4):
|
||||
* - 行情/合约**只读内存缓存**(QuoteHub.quotes / instruments),miss → 相关变量 null → 该格 `—`;
|
||||
* 不做读穿透(strategy-positions 是高频读路径,不引入网络往返)
|
||||
* - 无 formula 字段 → 直接返回原数组(零开销)
|
||||
* - 值不落库(策略定义存 settings,值每次现算)
|
||||
*/
|
||||
import { compileFormula, evaluateAst } from './evaluator.js';
|
||||
import { buildContext } from './variables.js';
|
||||
|
||||
/**
|
||||
* 为持仓行附加 computed
|
||||
* @param {object} opts
|
||||
* @param {Array} opts.rows strategy-positions 行
|
||||
* @param {Array} opts.fieldDefs 本策略字段定义
|
||||
* @param {object|null} [opts.hub] QuoteHub(读 quotes / instruments 内存缓存)
|
||||
* @param {(code:string)=>object|null} [opts.quoteOf] 自定义行情取数(测试注入)
|
||||
* @param {(code:string)=>object|null} [opts.instrumentOf] 自定义合约取数(测试注入)
|
||||
* @returns {Array} rows(含 formula 字段时每行附 computed: { [key]: number|null })
|
||||
*/
|
||||
export function computeRows({ rows, fieldDefs, hub = null, quoteOf, instrumentOf } = {}) {
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
const formulaFields = (Array.isArray(fieldDefs) ? fieldDefs : [])
|
||||
.filter((f) => f && f.type === 'formula' && String(f.formula ?? '').trim());
|
||||
if (list.length === 0 || formulaFields.length === 0) return list;
|
||||
|
||||
const compiled = formulaFields
|
||||
.map((f) => ({ key: f.key, ast: compileFormula(f.formula).ast }))
|
||||
.filter((c) => c.ast);
|
||||
if (compiled.length === 0) return list;
|
||||
|
||||
const getQuote = quoteOf ?? ((code) => hub?.quotes?.get(code) ?? null);
|
||||
const getInstrument = instrumentOf ?? ((code) => hub?.instruments?.get(code) ?? null);
|
||||
|
||||
return list.map((row) => {
|
||||
const code = row?.code;
|
||||
const quote = code ? getQuote(code) : null;
|
||||
const instrument = code ? getInstrument(code) : null;
|
||||
const ctx = buildContext({ row, quote, instrument, fieldDefs });
|
||||
const computed = {};
|
||||
for (const c of compiled) {
|
||||
let v = null;
|
||||
try { v = evaluateAst(c.ast, ctx); } catch { v = null; }
|
||||
// R-028:判定型(布尔)直通;数字型要求有限数;其余(缺值/异常)→ null
|
||||
computed[c.key] = (typeof v === 'boolean')
|
||||
? v
|
||||
: ((typeof v === 'number' && Number.isFinite(v)) ? v : null);
|
||||
}
|
||||
return { ...row, computed };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 公式引擎(R-026 / 迭代 22 阶段二):自写小型表达式解析与求值
|
||||
*
|
||||
* 设计约束:
|
||||
* - 零依赖、不使用 eval/Function(安全边界);
|
||||
* - 支持中文标识符(中文变量名,如 现价 / 成本价 / 份额);
|
||||
* - 四则运算 + 括号 + 一元负号 + 函数 round/abs/min/max;
|
||||
* - **判定(R-028)**:比较运算 > < >= <= == != 与逻辑 and/or(含 && ||、≥ ≤ ≠ 归一),
|
||||
* 结果可为布尔(判定型字段)或数字(数字型字段,由 `inferResultKind` 静态判定);
|
||||
* - 求值语义:引用的任一变量缺值(null/undefined/NaN)→ 整式返回 null(缺值短路);
|
||||
* 除零、非法运算、结果非有限 → null;绝不抛异常、绝不产出 NaN。
|
||||
*/
|
||||
|
||||
/** 函数白名单:名称 → [最小参数个数, 最大参数个数] */
|
||||
export const FUNCS = {
|
||||
round: [1, 2],
|
||||
abs: [1, 1],
|
||||
min: [1, Infinity],
|
||||
max: [1, Infinity],
|
||||
};
|
||||
|
||||
const MAX_LEN = 500; // 表达式长度上限
|
||||
const MAX_DEPTH = 32; // 递归深度上限
|
||||
|
||||
/** 全角 → 半角归一化(中文输入法友好:()+-×÷,*/都可以写) */
|
||||
function normalizeSrc(src) {
|
||||
return String(src ?? '')
|
||||
.replace(/[(]/g, '(').replace(/[)]/g, ')')
|
||||
.replace(/[+]/g, '+').replace(/[-—]/g, '-')
|
||||
.replace(/[×*]/g, '*').replace(/[÷/]/g, '/')
|
||||
.replace(/[,]/g, ',')
|
||||
.replace(/≥/g, '>=').replace(/≤/g, '<=').replace(/≠/g, '!=')
|
||||
.replace(/>/g, '>').replace(/</g, '<').replace(/=/g, '=');
|
||||
}
|
||||
|
||||
const IDENT_START = /[\p{L}_]/u;
|
||||
const IDENT_PART = /[\p{L}\p{Nd}_]/u;
|
||||
|
||||
/**
|
||||
* 词法分析 → token 数组
|
||||
* @returns {{ok: true, tokens: Array<{type:'num'|'ident'|'op', value:any, pos:number}>} | {ok:false, error:object}}
|
||||
*/
|
||||
function tokenize(src) {
|
||||
const s = normalizeSrc(src);
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
|
||||
if (c >= '0' && c <= '9' || (c === '.' && s[i + 1] >= '0' && s[i + 1] <= '9')) {
|
||||
let j = i;
|
||||
while (j < s.length && (s[j] >= '0' && s[j] <= '9' || s[j] === '.')) j++;
|
||||
const raw = s.slice(i, j);
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) return { ok: false, error: { code: 'syntax', message: `数字格式错误: ${raw}`, position: i } };
|
||||
tokens.push({ type: 'num', value: num, pos: i });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (IDENT_START.test(c)) {
|
||||
let j = i + 1;
|
||||
while (j < s.length && IDENT_PART.test(s[j])) j++;
|
||||
tokens.push({ type: 'ident', value: s.slice(i, j), pos: i });
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
const two = s.slice(i, i + 2);
|
||||
if (['>=', '<=', '==', '!=', '&&', '||'].includes(two)) {
|
||||
tokens.push({ type: 'op', value: two, pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ('+-*/()<>=!,'.includes(c)) {
|
||||
tokens.push({ type: 'op', value: c, pos: i });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
return { ok: false, error: { code: 'syntax', message: `无法识别的字符: ${c}`, position: i } };
|
||||
}
|
||||
return { ok: true, tokens };
|
||||
}
|
||||
|
||||
/** 递归下降解析 → AST */
|
||||
function parse(tokens) {
|
||||
let p = 0;
|
||||
let depth = 0;
|
||||
const peek = () => tokens[p];
|
||||
const isOp = (v) => { const t = peek(); return t && t.type === 'op' && t.value === v; };
|
||||
const err = (message, position) => { throw { code: 'syntax', message, position }; };
|
||||
|
||||
const CMP_OPS = new Set(['>', '<', '>=', '<=', '==', '!=']);
|
||||
const isKeyword = (t, kw) => !!(t && t.type === 'ident' && String(t.value).toLowerCase() === kw);
|
||||
|
||||
/** 最低优先级:or → and → 比较 → 加减 → 乘除 → 一元 → 主 */
|
||||
function expression() {
|
||||
let node = andExpr();
|
||||
while (isKeyword(peek(), 'or') || isOp('||')) {
|
||||
p++;
|
||||
node = { kind: 'binary', op: 'or', left: node, right: andExpr() };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function andExpr() {
|
||||
let node = comparison();
|
||||
while (isKeyword(peek(), 'and') || isOp('&&')) {
|
||||
p++;
|
||||
node = { kind: 'binary', op: 'and', left: node, right: comparison() };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function comparison() {
|
||||
let node = additive();
|
||||
while (peek() && peek().type === 'op' && CMP_OPS.has(peek().value)) {
|
||||
const op = tokens[p++].value;
|
||||
node = { kind: 'binary', op, left: node, right: additive() };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function additive() {
|
||||
let node = term();
|
||||
while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
|
||||
const op = tokens[p++].value;
|
||||
node = { kind: 'binary', op, left: node, right: term() };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function term() {
|
||||
let node = unary();
|
||||
while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
|
||||
const op = tokens[p++].value;
|
||||
node = { kind: 'binary', op, left: node, right: unary() };
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function unary() {
|
||||
if (isOp('-') || isOp('+')) {
|
||||
const op = tokens[p++].value;
|
||||
const node = unary();
|
||||
return op === '-' ? { kind: 'neg', value: node } : node;
|
||||
}
|
||||
return primary();
|
||||
}
|
||||
function primary() {
|
||||
if (depth++ > MAX_DEPTH) err('公式过于复杂(嵌套过深)', 0);
|
||||
try {
|
||||
const t = peek();
|
||||
if (!t) err('公式不完整', p > 0 ? tokens[p - 1].pos + 1 : 0);
|
||||
if (t.type === 'num') { p++; return { kind: 'num', value: t.value }; }
|
||||
if (t.type === 'ident') {
|
||||
p++;
|
||||
if (isOp('(')) {
|
||||
p++;
|
||||
const args = [];
|
||||
if (!isOp(')')) {
|
||||
args.push(expression());
|
||||
while (isOp(',')) { p++; args.push(expression()); }
|
||||
}
|
||||
if (!isOp(')')) err('缺少右括号', t.pos);
|
||||
p++;
|
||||
return { kind: 'call', name: t.value, args, pos: t.pos };
|
||||
}
|
||||
return { kind: 'var', name: t.value, pos: t.pos };
|
||||
}
|
||||
if (t.type === 'op' && t.value === '(') {
|
||||
p++;
|
||||
const node = expression();
|
||||
if (!isOp(')')) err('缺少右括号', t.pos);
|
||||
p++;
|
||||
return node;
|
||||
}
|
||||
err(`意外的符号: ${t.value}`, t.pos);
|
||||
} finally { depth--; }
|
||||
}
|
||||
|
||||
const ast = expression();
|
||||
if (p < tokens.length) {
|
||||
const t = tokens[p];
|
||||
err(`多余的内容: ${t.value}`, t.pos);
|
||||
}
|
||||
return ast;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编译公式(词法 + 语法),不校验变量名
|
||||
* @returns {{ok:true, ast:object} | {ok:false, error:{code:string,message:string,position:number}}}
|
||||
*/
|
||||
export function compileFormula(expr) {
|
||||
const src = String(expr ?? '').trim();
|
||||
if (!src) return { ok: false, error: { code: 'empty', message: '公式不能为空', position: 0 } };
|
||||
if (src.length > MAX_LEN) return { ok: false, error: { code: 'syntax', message: '公式过长(上限 ' + MAX_LEN + ' 字符)', position: MAX_LEN } };
|
||||
const tk = tokenize(src);
|
||||
if (!tk.ok) return tk;
|
||||
try {
|
||||
return { ok: true, ast: parse(tk.tokens) };
|
||||
} catch (e) {
|
||||
if (e && e.code === 'syntax') return { ok: false, error: { code: 'syntax', message: e.message, position: e.position ?? 0 } };
|
||||
return { ok: false, error: { code: 'syntax', message: '公式解析失败', position: 0 } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验公式:语法 + 变量存在性 + 函数名与参数个数
|
||||
* @param {string} expr 公式串
|
||||
* @param {Iterable<string>} allowedVars 允许的变量名(内置名 ∪ 本策略非公式字段展示名)
|
||||
* @returns {{ok:true, vars:string[]} | {ok:false, error:{code:string,message:string,position:number}}}
|
||||
*/
|
||||
export function validateFormula(expr, allowedVars) {
|
||||
const compiled = compileFormula(expr);
|
||||
if (!compiled.ok) return compiled;
|
||||
const allowed = new Set(allowedVars ?? []);
|
||||
const used = new Set();
|
||||
let failure = null;
|
||||
|
||||
const walk = (node) => {
|
||||
if (failure || !node) return;
|
||||
if (node.kind === 'var') {
|
||||
if (!allowed.has(node.name)) {
|
||||
failure = { code: 'unknown-var', message: `未知变量『${node.name}』——该字段已删除或名称有误`, position: node.pos ?? 0 };
|
||||
return;
|
||||
}
|
||||
used.add(node.name);
|
||||
return;
|
||||
}
|
||||
if (node.kind === 'call') {
|
||||
const arity = FUNCS[node.name];
|
||||
if (!arity) {
|
||||
failure = { code: 'unknown-func', message: `未知函数『${node.name}』(可用:round/abs/min/max)`, position: node.pos ?? 0 };
|
||||
return;
|
||||
}
|
||||
if (node.args.length < arity[0] || node.args.length > arity[1]) {
|
||||
failure = { code: 'arity', message: `函数『${node.name}』参数个数不对`, position: node.pos ?? 0 };
|
||||
return;
|
||||
}
|
||||
node.args.forEach(walk);
|
||||
return;
|
||||
}
|
||||
if (node.kind === 'binary') { walk(node.left); walk(node.right); return; }
|
||||
if (node.kind === 'neg') { walk(node.value); }
|
||||
};
|
||||
walk(compiled.ast);
|
||||
if (failure) return { ok: false, error: failure };
|
||||
return { ok: true, vars: [...used] };
|
||||
}
|
||||
|
||||
/** 数值判定:有限数才有效,否则 null(中间步骤不做归一,保精度) */
|
||||
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||
|
||||
/**
|
||||
* 最终结果浮点归一:消除 11.2-10.5=0.7000000000000002 ×1000 之类的噪声
|
||||
* (仅最终值处理;|v| ≥ 1e12 不动,避免精度损失)
|
||||
*/
|
||||
function normalizeResult(v) {
|
||||
if (typeof v === 'boolean') return v; // 判定型结果直通(R-028)
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
|
||||
if (Math.abs(v) >= 1e12) return v;
|
||||
const r = Math.round(v * 1e10) / 1e10;
|
||||
return Number.isFinite(r) ? r : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 求值(缺值短路:任一操作数为 null → 结果 null)
|
||||
* @param {object} ast compileFormula 的 ast
|
||||
* @param {Object<string, number|null>} ctx 变量上下文
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function evalNode(ast, ctx = {}) {
|
||||
if (!ast) return null;
|
||||
switch (ast.kind) {
|
||||
case 'num':
|
||||
return num(ast.value);
|
||||
case 'var': {
|
||||
const v = ctx[ast.name];
|
||||
if (v === null || v === undefined) return null;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
case 'neg': {
|
||||
const v = evalNode(ast.value, ctx);
|
||||
return v === null ? null : num(-v);
|
||||
}
|
||||
case 'binary': {
|
||||
const { op } = ast;
|
||||
// 逻辑(R-028):缺值短路(任一操作数 null → null)
|
||||
if (op === 'and' || op === 'or') {
|
||||
const l = evalNode(ast.left, ctx);
|
||||
if (l === null) return null;
|
||||
const r = evalNode(ast.right, ctx);
|
||||
if (r === null) return null;
|
||||
return op === 'and' ? (!!l && !!r) : (!!l || !!r);
|
||||
}
|
||||
const l = evalNode(ast.left, ctx);
|
||||
if (l === null) return null;
|
||||
const r = evalNode(ast.right, ctx);
|
||||
if (r === null) return null;
|
||||
// 比较(R-028:判定)
|
||||
if (op === '>') return l > r;
|
||||
if (op === '<') return l < r;
|
||||
if (op === '>=') return l >= r;
|
||||
if (op === '<=') return l <= r;
|
||||
if (op === '==') return l === r;
|
||||
if (op === '!=') return l !== r;
|
||||
if (op === '+') return num(l + r);
|
||||
if (op === '-') return num(l - r);
|
||||
if (op === '*') return num(l * r);
|
||||
if (op === '/') return r === 0 ? null : num(l / r);
|
||||
return null;
|
||||
}
|
||||
case 'call': {
|
||||
const args = ast.args.map((a) => evalNode(a, ctx));
|
||||
if (args.some((a) => a === null)) return null;
|
||||
if (ast.name === 'round') {
|
||||
const digits = args.length > 1 ? Math.trunc(args[1]) : 0;
|
||||
const f = Math.pow(10, Math.max(0, Math.min(10, digits)));
|
||||
return num(Math.round(args[0] * f) / f);
|
||||
}
|
||||
if (ast.name === 'abs') return num(Math.abs(args[0]));
|
||||
if (ast.name === 'min') return num(Math.min(...args));
|
||||
if (ast.name === 'max') return num(Math.max(...args));
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结果类型静态推断(R-028):顶层为比较/逻辑运算 → 'boolean'(判定型),否则 'number'
|
||||
* @param {object} ast compileFormula 的 ast
|
||||
* @returns {'boolean'|'number'}
|
||||
*/
|
||||
export function inferResultKind(ast) {
|
||||
if (!ast) return 'number';
|
||||
if (ast.kind === 'neg') return inferResultKind(ast.value);
|
||||
if (ast.kind === 'binary') {
|
||||
const { op } = ast;
|
||||
if (op === 'and' || op === 'or') return 'boolean';
|
||||
if (['>', '<', '>=', '<=', '==', '!='].includes(op)) return 'boolean';
|
||||
}
|
||||
return 'number';
|
||||
}
|
||||
|
||||
/** 对外求值入口:递归求值 + 最终结果浮点归一 */
|
||||
export function evaluateAst(ast, ctx = {}) {
|
||||
return normalizeResult(evalNode(ast, ctx));
|
||||
}
|
||||
|
||||
/** 便捷:编译 + 求值(单次用;多次求值请先 compileFormula 复用 ast) */
|
||||
export function evaluateFormula(expr, ctx) {
|
||||
const compiled = compileFormula(expr);
|
||||
if (!compiled.ok) return null;
|
||||
return evaluateAst(compiled.ast, ctx);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 公式变量目录(R-026 / 迭代 22 阶段二):服务端缓存数据集 → 中文变量名 → 取数
|
||||
*
|
||||
* 首批三组数据集(老师拍板 Q1):
|
||||
* - 行情盘口 QuoteHub.quotes(5s 内存快照):现价/昨收/今开/最高/最低/成交量/成交额
|
||||
* - 合约信息 QuoteHub.instruments(按交易日缓存):涨停价/跌停价
|
||||
* - 持仓账本(strategy-positions 行):份额/成本价/最后成交价
|
||||
* - 自定义字段(row.values):同为「本行上下文」,随字段定义动态展开
|
||||
* 计算字段(formula)不进目录 → 零循环依赖。
|
||||
*/
|
||||
|
||||
/** 内置变量声明(分组 → 数据源 → 变量名) */
|
||||
export const VARIABLE_GROUPS = [
|
||||
{
|
||||
group: '行情', source: 'quote',
|
||||
items: [
|
||||
{ name: '现价', field: 'lastPrice', desc: '最新成交价(盘口快照)' },
|
||||
{ name: '昨收', field: 'lastClose', desc: '昨日收盘价' },
|
||||
{ name: '今开', field: 'open', desc: '今日开盘价' },
|
||||
{ name: '最高', field: 'high', desc: '今日最高价' },
|
||||
{ name: '最低', field: 'low', desc: '今日最低价' },
|
||||
{ name: '成交量', field: 'volume', desc: '今日成交量(股)' },
|
||||
{ name: '成交额', field: 'amount', desc: '今日成交额(元)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: '合约', source: 'instrument',
|
||||
items: [
|
||||
{ name: '涨停价', field: 'upStopPrice', desc: '当日涨停价(合约信息)' },
|
||||
{ name: '跌停价', field: 'downStopPrice', desc: '当日跌停价(合约信息)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: '持仓', source: 'row',
|
||||
items: [
|
||||
{ name: '份额', field: 'shares', desc: '本策略下该标的分配份额' },
|
||||
{ name: '成本价', field: 'avgPrice', desc: '持仓成本价(QMT)' },
|
||||
{ name: '最后成交价', field: 'lastTradePrice', desc: '该持仓最后一笔实际成交价' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 内置变量名集合(保存校验用:字段展示名不得与之冲突) */
|
||||
export const BUILTIN_VAR_NAMES = new Set(
|
||||
VARIABLE_GROUPS.flatMap((g) => g.items.map((it) => it.name)),
|
||||
);
|
||||
|
||||
/** 非 formula 字段(可作为变量引用) */
|
||||
export function valueFieldDefs(fieldDefs) {
|
||||
return (Array.isArray(fieldDefs) ? fieldDefs : []).filter((f) => f && f.type && f.type !== 'formula');
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量目录(前端选择器数据):内置分组 + 「自定义字段」分组(本策略非 formula 字段展示名)
|
||||
* @param {Array} fieldDefs 本策略字段定义
|
||||
*/
|
||||
export function catalogFor(fieldDefs) {
|
||||
const groups = VARIABLE_GROUPS.map((g) => ({
|
||||
group: g.group,
|
||||
items: g.items.map((it) => ({ name: it.name, desc: it.desc })),
|
||||
}));
|
||||
const custom = valueFieldDefs(fieldDefs).map((f) => ({
|
||||
name: String(f.label || f.key),
|
||||
desc: [f.unit ? '单位 ' + f.unit : '', f.type === 'number' ? '数字字段' : f.type].filter(Boolean).join(' · '),
|
||||
}));
|
||||
if (custom.length > 0) groups.push({ group: '自定义字段', items: custom });
|
||||
return { builtin: [...BUILTIN_VAR_NAMES], groups };
|
||||
}
|
||||
|
||||
/** 变量名合法集合(内置名 ∪ 非 formula 字段展示名) */
|
||||
export function allowedVarNames(fieldDefs) {
|
||||
const names = new Set(BUILTIN_VAR_NAMES);
|
||||
for (const f of valueFieldDefs(fieldDefs)) names.add(String(f.label || f.key));
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 宽松取数:空值/空串 → null;布尔 → 0/1;数字串 → Number;其余 → null */
|
||||
function toNum(v) {
|
||||
if (v === null || v === undefined || v === '') return null;
|
||||
if (typeof v === 'boolean') return v ? 1 : 0;
|
||||
const n = typeof v === 'number' ? v : Number(String(v).trim());
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造某一行持仓的变量上下文
|
||||
* @param {object} opts
|
||||
* @param {object} opts.row strategy-positions 行(含 shares/avgPrice/lastTradePrice/values)
|
||||
* @param {object|null} opts.quote 行情快照
|
||||
* @param {object|null} opts.instrument 合约信息(涨停/跌停)
|
||||
* @param {Array} opts.fieldDefs 本策略字段定义
|
||||
* @returns {Object<string, number|null>}
|
||||
*/
|
||||
export function buildContext({ row, quote, instrument, fieldDefs } = {}) {
|
||||
const ctx = {};
|
||||
for (const g of VARIABLE_GROUPS) {
|
||||
let source = null;
|
||||
if (g.source === 'quote') source = quote ?? null;
|
||||
else if (g.source === 'instrument') source = instrument ?? null;
|
||||
else source = row ?? null;
|
||||
for (const it of g.items) {
|
||||
let v = source ? source[it.field] : null;
|
||||
// 昨收兜底:盘口快照缺 lastClose 时取合约信息 preClose
|
||||
if (it.name === '昨收' && (v === null || v === undefined)) v = instrument?.preClose ?? null;
|
||||
ctx[it.name] = toNum(v);
|
||||
}
|
||||
}
|
||||
for (const f of valueFieldDefs(fieldDefs)) {
|
||||
const label = String(f.label || f.key);
|
||||
const has = row && row.values && Object.prototype.hasOwnProperty.call(row.values, f.key);
|
||||
const raw = has ? row.values[f.key] : (f.def !== undefined ? f.def : null);
|
||||
ctx[label] = toNum(raw);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
+156
-148
@@ -15,6 +15,12 @@
|
||||
* - addStrategy 联动追加 tab(末尾,Q2);removeStrategy 联动删除对应 tab(D3)
|
||||
* - 策略行 name 由 getTabs join strategies(改名自动跟随,Q4)
|
||||
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
|
||||
*
|
||||
* R-027(迭代 22 阶段一)策略 tab 静态化:
|
||||
* - 策略集合与名称固定为内置两项(BUILTIN_STRATEGIES):不可新增/删除/重命名
|
||||
* - tab 列表由 内置 tab + 内置策略 tab 常量派生;存量 tabs 仅保留 visible/order 偏好
|
||||
* - 字段定义真相源迁到 strategyFields;strategies[] 收窄为身份表(仅兼容读取旧 configSchema)
|
||||
* - 退役 addStrategy/removeStrategy/renameStrategy/updateStrategies/append|removeStrategyTab
|
||||
*/
|
||||
|
||||
import z from '@deepseek-ai/schemastery';
|
||||
@@ -26,15 +32,31 @@ export const BUILTIN_TABS = [
|
||||
{ id: 'tab-watchlist', refKey: 'watchlist', name: '关注列表' },
|
||||
];
|
||||
|
||||
/** 默认 tab 列表(首次注册时的初始值;策略行由 DEFAULT_STRATEGIES 派生) */
|
||||
export const DEFAULT_TABS = BUILTIN_TABS.map((t, i) => ({
|
||||
id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name, visible: true, order: i,
|
||||
}));
|
||||
/** 内置策略(R-027 静态化:集合与名称固定,不可增删改名;字段定义另存 strategyFields) */
|
||||
export const BUILTIN_STRATEGIES = [
|
||||
{ id: 'grid-supermarket', name: '网格超市' },
|
||||
{ id: 'manual-t', name: '手动做T' },
|
||||
];
|
||||
|
||||
/** 默认策略(首次注册时的初始值) */
|
||||
export const DEFAULT_STRATEGIES = [
|
||||
{ id: 'grid-supermarket', name: '网格超市', configSchema: [] },
|
||||
{ id: 'manual-t', name: '手动做T', configSchema: [] },
|
||||
/** 内置策略 id 判定(字段定义写入 / 校验用) */
|
||||
export function isBuiltinStrategy(strategyId) {
|
||||
return BUILTIN_STRATEGIES.some((s) => s.id === strategyId);
|
||||
}
|
||||
|
||||
/** 未知策略 id 直接抛错(写路径守卫) */
|
||||
export function assertBuiltinStrategy(strategyId) {
|
||||
if (!isBuiltinStrategy(strategyId)) {
|
||||
throw Object.assign(new Error('未知策略: ' + strategyId), { code: 'strategy-not-found' });
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认 tab 列表(首次注册时的初始值;R-027:内置 tab + 内置策略 tab) */
|
||||
export const DEFAULT_TABS = [
|
||||
...BUILTIN_TABS.map((t, i) => ({ id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name, visible: true, order: i })),
|
||||
...BUILTIN_STRATEGIES.map((s, i) => ({
|
||||
id: 'tab-strategy-' + s.id, kind: 'strategy', refId: s.id, name: s.name,
|
||||
visible: true, order: BUILTIN_TABS.length + i,
|
||||
})),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,6 +78,28 @@ export const COLUMN_META = [
|
||||
{ key: 'high', label: '最高', fixed: false, defaultVisible: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* 策略自定义字段定义 schema(R-013 四类型 + R-026 计算类型)
|
||||
* - type='formula' 时使用 formula(中文变量表达式串)+ decimals(显示小数位),不使用 def
|
||||
*/
|
||||
const fieldSchema = 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'),
|
||||
z.const('formula'), // R-026:计算字段(值由公式实时算出,不落库)
|
||||
]).required(),
|
||||
enum: z.array(z.string()).default([]), // 仅 type=enum:预设选项
|
||||
def: z.any(), // 默认值(text=string / number=number / boolean=boolean / enum=枚举项)
|
||||
unit: z.string().default(''), // 单位(可为空;如 % / 元 / 手 —— 展示时拼在值后)
|
||||
formula: z.string().default(''), // 仅 type=formula:表达式串(中文变量)
|
||||
resultKind: z.union([z.const('number'), z.const('boolean')]).default('number'), // R-028:数字 / 判定
|
||||
decimals: z.number().default(2), // 仅 type=formula 且 resultKind=number:显示小数位(0-4)
|
||||
});
|
||||
|
||||
/** 策略设置 schema(schemastery 定义,settings.register 要求 schema 对象) */
|
||||
/** @type {import('@deepseek-ai/schemastery').Schema<any>} 策略设置 schema(显式注解:含 z.dict,规避 dts 推断 cosmokit Dict 引用) */
|
||||
export const strategySchema = z.object({
|
||||
@@ -65,20 +109,10 @@ export const strategySchema = z.object({
|
||||
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),
|
||||
configSchema: z.array(fieldSchema).default([]),
|
||||
})).default([]),
|
||||
// R-027:字段定义真相源(写路径唯一入口;strategies[].configSchema 仅兼容读取旧数据)
|
||||
strategyFields: z.dict(z.array(fieldSchema).default([])).default({}),
|
||||
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
|
||||
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
|
||||
tabs: z.union([
|
||||
@@ -237,126 +271,115 @@ export function registerSettings(ctx, config) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
/** 读取 settings 快照(容错:测试用桩 scope 无 get 时返回空对象) */
|
||||
function scopeValue(scope) {
|
||||
return (scope && typeof scope.get === 'function') ? (scope.get() ?? {}) : {};
|
||||
}
|
||||
|
||||
/** 字段类型白名单(含 R-026 计算类型) */
|
||||
const FIELD_TYPES = new Set(['text', 'number', 'boolean', 'enum', 'formula']);
|
||||
|
||||
/**
|
||||
* 读取策略定义表(R-011 收窄 + R-013 扩展:{id, name, configSchema})
|
||||
* 归一化:旧存量数据缺 configSchema(R-013 前)→ 补空数组(Q4 兼容)。
|
||||
* 字段定义归一化(R-013 + R-026):类型白名单、枚举数组、公式串、小数位夹取 0-4
|
||||
* @param {Array} fields configSchema 原始数组
|
||||
* @returns {Array} 归一化后的字段定义数组
|
||||
*/
|
||||
export function normalizeFields(fields) {
|
||||
return (Array.isArray(fields) ? fields : []).map((f) => {
|
||||
const type = FIELD_TYPES.has(f?.type) ? f.type : 'text';
|
||||
const out = {
|
||||
key: String(f?.key ?? ''),
|
||||
label: String(f?.label ?? ''),
|
||||
type,
|
||||
enum: Array.isArray(f?.enum) ? f.enum.map((x) => String(x)) : [],
|
||||
unit: String(f?.unit ?? ''),
|
||||
};
|
||||
if (type === 'formula') {
|
||||
out.formula = String(f?.formula ?? '').trim();
|
||||
out.resultKind = f?.resultKind === 'boolean' ? 'boolean' : 'number'; // R-028
|
||||
const n = Number(f?.decimals);
|
||||
out.decimals = Number.isFinite(n) ? Math.min(4, Math.max(0, Math.round(n))) : 2;
|
||||
} else if (f?.def !== undefined) {
|
||||
out.def = f.def;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取策略表(R-027 静态化:恒为内置两项;configSchema 取 strategyFields,缺失时兼容读取旧 strategies[])
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @returns {Array<{id: string, name: string, configSchema: Array}>}
|
||||
*/
|
||||
export function getStrategies(scope) {
|
||||
const value = scope?.get() ?? {};
|
||||
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) {
|
||||
const next = (Array.isArray(strategies) ? strategies : []).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
configSchema: Array.isArray(s.configSchema) ? s.configSchema : [],
|
||||
const value = scopeValue(scope);
|
||||
const legacy = new Map((Array.isArray(value.strategies) ? value.strategies : []).map((s) => [s.id, s.configSchema]));
|
||||
const store = (value.strategyFields && typeof value.strategyFields === 'object') ? value.strategyFields : {};
|
||||
return BUILTIN_STRATEGIES.map((s) => ({
|
||||
...s,
|
||||
configSchema: normalizeFields(
|
||||
Array.isArray(store[s.id]) ? store[s.id] : (Array.isArray(legacy.get(s.id)) ? legacy.get(s.id) : []),
|
||||
),
|
||||
}));
|
||||
await scope.update({ strategies: next });
|
||||
}
|
||||
|
||||
/** 读取某策略字段定义(未知名 → []) */
|
||||
export function getStrategyFields(scope, strategyId) {
|
||||
return getStrategies(scope).find((s) => s.id === strategyId)?.configSchema ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增策略(O3)
|
||||
* 写入某策略字段定义(R-027:单策略写入,唯一写路径;不触碰 strategies[] 与份额数据)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @param {string} name 策略名称
|
||||
* @returns {Promise<object>} 新策略对象
|
||||
* @param {string} strategyId 内置策略 id
|
||||
* @param {Array} configSchema 完整字段定义数组
|
||||
* @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(),
|
||||
configSchema: [], // R-013:新策略默认无自定义字段(定义在设置页配置)
|
||||
};
|
||||
await updateStrategies(scope, [...list, strategy]);
|
||||
// R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾)
|
||||
await appendStrategyTab(scope, 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;
|
||||
export async function updateStrategyFields(scope, strategyId, configSchema) {
|
||||
assertBuiltinStrategy(strategyId);
|
||||
const cur = scopeValue(scope);
|
||||
const store = (cur.strategyFields && typeof cur.strategyFields === 'object') ? cur.strategyFields : {};
|
||||
await scope.update({ ...cur, strategyFields: { ...store, [strategyId]: normalizeFields(configSchema) } });
|
||||
return getStrategies(scope).find((s) => s.id === strategyId);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除策略(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));
|
||||
// R-011:联动删除 tabs 列表中对应策略条目(D3)
|
||||
await removeStrategyTab(scope, id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 tabs 为有序数组(R-011 迁移:旧布尔对象 → 数组;幂等,不重复迁移)
|
||||
* 旧格式:tabs = { allPositions, tradeRecords, watchlist } + strategies 自带 visible/order
|
||||
* 新格式:tabs = [{ id, kind, refKey|refId, name, visible, order }]
|
||||
* 旧隐藏策略迁移后 visible=true(Q3,产品约束-009)。
|
||||
* 归一化 tabs(R-027 静态化):序列由常量派生(内置 tab + 内置策略 tab),
|
||||
* 存量 tabs 仅保留 visible/order 偏好;未知条目(自建策略 tab / 已退役 tab)丢弃,缺失条目补齐。
|
||||
* 旧布尔对象格式(迭代 02)继续兼容:仅取内置显隐。
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @returns {Array} 归一化后的 tabs 数组(按 order 排序)
|
||||
*/
|
||||
export function normalizeTabs(scope) {
|
||||
const value = scope?.get() ?? {};
|
||||
const raw = value.tabs;
|
||||
// 已归一化(数组)
|
||||
if (Array.isArray(raw)) return raw.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
const raw = scopeValue(scope).tabs;
|
||||
|
||||
// 旧布尔对象或缺失 → 生成内置条目(保留原显隐值)
|
||||
const legacy = (typeof raw === 'object' && raw !== null) ? raw : {};
|
||||
const tabs = BUILTIN_TABS.map((t, i) => ({
|
||||
id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name,
|
||||
visible: legacy[t.refKey] !== false, order: i,
|
||||
}));
|
||||
// 旧布尔对象(迭代 02):映射到内置 tab 显隐
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return DEFAULT_TABS.map((t) => (t.kind === 'builtin' && t.refKey in raw
|
||||
? { ...t, visible: raw[t.refKey] !== false }
|
||||
: t));
|
||||
}
|
||||
if (!Array.isArray(raw)) return DEFAULT_TABS.map((t) => ({ ...t }));
|
||||
|
||||
// 策略条目:按旧 order 接续追加;旧隐藏策略迁移后显示(Q3)
|
||||
const strategies = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
|
||||
const strategyRows = strategies.map((s, i) => ({
|
||||
id: 'tab-strategy-' + s.id, kind: 'strategy', refId: s.id, name: s.name,
|
||||
visible: true, order: tabs.length + i,
|
||||
}));
|
||||
return [...tabs, ...strategyRows];
|
||||
const byId = new Map(raw.map((t) => [t.id, t]));
|
||||
return DEFAULT_TABS
|
||||
.map((t) => {
|
||||
const hit = byId.get(t.id);
|
||||
return hit
|
||||
? { ...t, visible: hit.visible !== false, order: typeof hit.order === 'number' ? hit.order : t.order }
|
||||
: { ...t };
|
||||
})
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取统一 tab 列表(R-011):归一化 + 策略行 name join(Q4 自动跟随改名)
|
||||
* 读取统一 tab 列表(R-011/R-027):归一化结果(策略行 name 恒为内置名,无改名场景)
|
||||
* @param {ReturnType<typeof registerSettings>} scope
|
||||
* @returns {Array} tabs 数组(按 order 排序,策略行 name 已 join)
|
||||
* @returns {Array} tabs 数组(按 order 排序)
|
||||
*/
|
||||
export function getTabs(scope) {
|
||||
const tabs = normalizeTabs(scope);
|
||||
const nameMap = new Map(getStrategies(scope).map((s) => [s.id, s.name]));
|
||||
return tabs.map((t) => (t.kind === 'strategy'
|
||||
? { ...t, name: nameMap.get(t.refId) ?? t.name ?? t.refId }
|
||||
: t));
|
||||
return normalizeTabs(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -367,35 +390,21 @@ export function getTabs(scope) {
|
||||
*/
|
||||
export async function updateTabs(scope, tabs) {
|
||||
const current = scope?.get() ?? {};
|
||||
const normalized = (Array.isArray(tabs) ? tabs : []).map((t, i) => ({
|
||||
id: t.id,
|
||||
kind: t.kind === 'strategy' ? 'strategy' : 'builtin',
|
||||
refKey: t.refKey,
|
||||
refId: t.refId,
|
||||
name: t.name,
|
||||
visible: t.visible !== false,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
}));
|
||||
const known = new Set(DEFAULT_TABS.map((t) => t.id));
|
||||
const normalized = (Array.isArray(tabs) ? tabs : [])
|
||||
.filter((t) => known.has(t?.id)) // R-027:只接受内置条目(策略条目恒存在,不可增删)
|
||||
.map((t, i) => ({
|
||||
id: t.id,
|
||||
kind: t.kind === 'strategy' ? 'strategy' : 'builtin',
|
||||
refKey: t.refKey,
|
||||
refId: t.refId,
|
||||
name: t.name,
|
||||
visible: t.visible !== false,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
}));
|
||||
await scope.update({ ...current, tabs: normalized });
|
||||
}
|
||||
|
||||
/** 追加策略 tab 条目到末尾(addStrategy 联动;Q2 追加末尾) */
|
||||
export async function appendStrategyTab(scope, strategy) {
|
||||
const tabs = getTabs(scope);
|
||||
const next = [...tabs, {
|
||||
id: 'tab-strategy-' + strategy.id, kind: 'strategy', refId: strategy.id,
|
||||
name: strategy.name, visible: true,
|
||||
order: tabs.reduce((max, t) => Math.max(max, t.order ?? 0), -1) + 1,
|
||||
}];
|
||||
await updateTabs(scope, next);
|
||||
}
|
||||
|
||||
/** 删除策略 tab 条目(removeStrategy 联动;D3) */
|
||||
export async function removeStrategyTab(scope, strategyId) {
|
||||
const tabs = getTabs(scope);
|
||||
await updateTabs(scope, tabs.filter((t) => !(t.kind === 'strategy' && t.refId === strategyId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取通用 tab 显隐配置(R-011 迁移期兼容:返回布尔对象,供旧调用点使用)
|
||||
* @deprecated 由 getTabs 取代
|
||||
@@ -437,15 +446,14 @@ export async function updateTabConfig(scope, tabs) {
|
||||
* @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 value = scopeValue(scope);
|
||||
// R-027:字段定义真相源 = strategyFields(缺失时兼容读取旧 strategies[].configSchema)
|
||||
const configSchema = getStrategyFields(scope, strategyId);
|
||||
const raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
|
||||
|
||||
// 1. 默认全列(基础列默认序 + 字段列 configSchema 序;显隐缺省跟随 defaultVisible,R-015 行情列默认隐藏)
|
||||
const baseCols = COLUMN_META.map((c) => ({ key: c.key, label: c.label, fixed: false, kind: 'base', defaultVisible: c.defaultVisible !== false }));
|
||||
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field' }));
|
||||
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field', type: f.type }));
|
||||
const defaults = [...baseCols, ...fieldCols];
|
||||
|
||||
// 2. 应用覆盖:visible + 顺序(未覆盖的列保持默认,追加在默认序后)
|
||||
@@ -485,7 +493,7 @@ export function getStrategyColumns(scope, strategyId) {
|
||||
* @param {Array} columns 完整列数组 [{key, visible}](含不可见列——保留位置,visible 控制显示)
|
||||
*/
|
||||
export async function updateStrategyColumns(scope, strategyId, columns) {
|
||||
const current = scope?.get() ?? {};
|
||||
const current = scopeValue(scope);
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user