feat(R-013): 策略自定义字段完整实现 + 列显隐配置 + 单元格编辑

功能实现(迭代 11):
- 数据层: strategies.configSchema 自定义字段定义(text/number/boolean/enum + def + unit 单位可空)
  + strategy_holdings "values" JSON 列(幂等 ALTER)+ readValues/writeValues + 归一化
- API: holdings/values-update 端点(服务端按 configSchema 校验: number有限/enum在选项/boolean布尔)
- 设置页: 「策略分组」策略行展开配置字段(StrategyFieldsEditor 独立组件)
- 持仓表: 自定义字段列化 + 列显隐/排序(ColumnSettingsPopover,每策略独立 strategyColumns 配置)
- 单元格点击编辑(FieldCellEditor): 文本/数字输入框、布尔开关、枚举下拉 + ✓ 确认
- UI 细节: ✎ 可编辑标记(值后)、编辑列宽稳定、数字输入框固定 5 位宽

修复:
- SettingsSection Fragment/StrategyFieldsEditor 未导入导致的配置页白屏
- z.dict schema dts 推断 cosmokit 引用(显式类型注解)
- 自定义字段从展开交易明细移出(字段已列化)

文档: R-013/迭代11 技术方案/验收标准/迭代目标 同步(D6 演进 + unit + 列配置)
回归: test-r013-custom-fields 21/21 + test-r013-columns 16/16 + tabs 23/23
This commit is contained in:
2026-09-02 17:21:41 +08:00
parent 6f74ed4a79
commit aac16b904c
17 changed files with 1270 additions and 61 deletions
+124 -8
View File
@@ -33,16 +33,46 @@ export const DEFAULT_TABS = BUILTIN_TABS.map((t, i) => ({
/** 默认策略(首次注册时的初始值) */
export const DEFAULT_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
{ id: 'grid-supermarket', name: '网格超市', configSchema: [] },
{ id: 'manual-t', name: '手动做T', configSchema: [] },
];
/**
* 策略持仓表基础数据列目录(迭代 11 列显隐扩展)
* 可配置区基础列:key / label / 默认是否显示(data=固定数据列;自定义字段列由 configSchema 动态合成)
* 固定列(不在此目录,锁死):展开箭头 | 代码 | 名称(最前)| 操作(最后)
*/
export const COLUMN_META = [
{ key: 'lastPrice', label: '现价', fixed: false },
{ key: 'pctChange', label: '涨幅', fixed: false },
{ key: 'lastClose', label: '昨收', fixed: false },
{ key: 'avgPrice', label: '成本价', fixed: false },
{ key: 'lastTradePrice', label: '最后一笔成交价', fixed: false },
{ key: 'shares', label: '策略份额', fixed: false },
];
/** 策略设置 schemaschemastery 定义,settings.register 要求 schema 对象) */
/** @type {import('@deepseek-ai/schemastery').Schema<any>} 策略设置 schema(显式注解:含 z.dict,规避 dts 推断 cosmokit Dict 引用) */
export const strategySchema = z.object({
// 策略定义表(R-011 收窄:仅 id + namevisible/order 归 tabs 统一管理
// 策略定义表(R-011 收窄 + R-013 扩展 configSchemaid + name + 自定义字段定义
// R-013(技术约束-015):configSchema = 该策略需要的自定义字段定义(key/label/type/enum/def),
// 随策略定义存 settings 不落库;旧策略缺省 [](兼容,Q4)。
strategies: z.array(z.object({
id: z.string().required(),
name: z.string().required(),
configSchema: z.array(z.object({
key: z.string().required(), // 稳健标识(英文 slug,与字段值 JSON 的 key 对齐)
label: z.string(), // UI 展示名(可中文)
type: z.union([
z.const('text'),
z.const('number'),
z.const('boolean'),
z.const('enum'),
]).required(), // 字段类型
enum: z.array(z.string()).default([]), // 仅 type=enum:预设选项
def: z.any(), // 默认值(text=string / number=number / boolean=boolean / enum=枚举项)
unit: z.string().default(''), // 单位(可为空;如 % / 元 / 手 —— 展示时拼在值后)
})).default([]),
})).default(DEFAULT_STRATEGIES),
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
@@ -73,6 +103,14 @@ export const strategySchema = z.object({
activeId: z.string().default(''),
defaultId: z.string().default(''),
}).default({ list: [], activeId: '', defaultId: '' }),
// 策略持仓表列配置(迭代 11 扩展:每策略一套,仅存与默认不同的覆盖;读取归一化)
// strategyColumns: { [strategyId]: [{ key, visible }] } —— key 覆盖 COLUMN_META 基础列 + 该策略 configSchema 字段列
strategyColumns: z.dict(
z.array(z.object({
key: z.string().required(),
visible: z.boolean().default(true),
})).default([])
).default({}),
});
@@ -195,21 +233,29 @@ export function registerSettings(ctx, config) {
}
/**
* 读取策略定义表(R-011 收窄:仅 id + name,无 visible/order
* 读取策略定义表(R-011 收窄 + R-013 扩展:{id, name, configSchema}
* 归一化:旧存量数据缺 configSchemaR-013 前)→ 补空数组(Q4 兼容)。
* @param {ReturnType<typeof registerSettings>} scope
*/
export function getStrategies(scope) {
const value = scope?.get() ?? {};
return Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
return list.map((s) => ({ ...s, configSchema: Array.isArray(s.configSchema) ? s.configSchema : [] }));
}
/**
* 更新策略(整表更新)
* 更新策略(整表更新R-013configSchema 随整表携带
* 写入归一化:项缺 configSchema → 补 [](防客户端旧格式整表提交丢定义)。
* @param {ReturnType<typeof registerSettings>} scope
* @param {Array} strategies 完整策略列表
*/
export async function updateStrategies(scope, strategies) {
await scope.update({ strategies });
const next = (Array.isArray(strategies) ? strategies : []).map((s) => ({
id: s.id,
name: s.name,
configSchema: Array.isArray(s.configSchema) ? s.configSchema : [],
}));
await scope.update({ strategies: next });
}
/**
@@ -224,6 +270,7 @@ export async function addStrategy(scope, name) {
const strategy = {
id,
name: String(name ?? '').trim(),
configSchema: [], // R-013:新策略默认无自定义字段(定义在设置页配置)
};
await updateStrategies(scope, [...list, strategy]);
// R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾)
@@ -369,6 +416,75 @@ export async function updateTabConfig(scope, tabs) {
await updateTabs(scope, next);
}
/**
* ============================================================================
* 策略持仓表列配置(迭代 11 列显隐扩展;产品约束-011、技术约束-017 待落)
* 数据:settings.strategyColumns = { [strategyId]: [{key, visible}] }(仅存覆盖)
* 归一化:基础列(COLUMN_META 默认序全显)+ 该策略 configSchema 字段列(configSchema 序全显)
* → 应用 strategyColumns 覆盖(visible + 顺序)→ 返回有序列数组 [{key, label, fixed, visible}]
* 固定列不在此配置:展开箭头/代码/名称(最前)、操作(最后)。
*/
/**
* 归一化某策略的列配置(基础列 + 自定义字段列,应用覆盖)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} strategyId
* @returns {Array<{key: string, label: string, fixed: boolean, visible: boolean, kind: 'base'|'field'}>}
*/
export function normalizeStrategyColumns(scope, strategyId) {
const value = scope?.get() ?? {};
const strategies = Array.isArray(value.strategies) ? value.strategies : [];
const strategy = strategies.find((s) => s.id === strategyId);
const configSchema = Array.isArray(strategy?.configSchema) ? strategy.configSchema : [];
const raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
// 1. 默认全列(基础列全显默认序 + 字段列全显 configSchema 序)
const baseCols = COLUMN_META.map((c) => ({ key: c.key, label: c.label, fixed: false, kind: 'base' }));
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field' }));
const defaults = [...baseCols, ...fieldCols];
// 2. 应用覆盖:visible + 顺序(未覆盖的列保持默认,追加在默认序后)
const override = Array.isArray(raw) ? raw : [];
const visibleMap = new Map();
for (const o of override) visibleMap.set(o.key, o.visible !== false);
// 覆盖中的自定义字段列若 configSchema 已删除 → 忽略(自动跟随删除)
const activeKeys = new Set(defaults.map((c) => c.key));
const validOverride = override.filter((o) => activeKeys.has(o.key));
// 3. 组装:先按覆盖顺序排,未覆盖列追加默认序
const ordered = [];
const placed = new Set();
for (const o of validOverride) {
const d = defaults.find((c) => c.key === o.key);
if (d) { ordered.push({ ...d, visible: o.visible !== false }); placed.add(d.key); }
}
for (const d of defaults) {
if (!placed.has(d.key)) {
ordered.push({ ...d, visible: visibleMap.has(d.key) ? visibleMap.get(d.key) : true });
}
}
return ordered;
}
/** 读取某策略列配置(归一化结果) */
export function getStrategyColumns(scope, strategyId) {
return normalizeStrategyColumns(scope, strategyId);
}
/**
* 更新某策略列配置(整表覆盖该策略的 columns)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} strategyId
* @param {Array} columns 完整列数组 [{key, visible}](含不可见列——保留位置,visible 控制显示)
*/
export async function updateStrategyColumns(scope, strategyId, columns) {
const current = scope?.get() ?? {};
const store = current.strategyColumns && typeof current.strategyColumns === 'object' ? current.strategyColumns : {};
const next = { ...store, [strategyId]: (Array.isArray(columns) ? columns : []).map((c) => ({ key: c.key, visible: c.visible !== false })) };
await scope.update({ ...current, strategyColumns: next });
}
/**
* ============================================================================
* QMT 连接配置(R-004 已定稿;产品约束-005、技术约束-008)
@@ -517,4 +633,4 @@ export async function setDefaultQmtConnection(scope, id) {
const value = scope?.get() ?? {};
await scope.update({ ...value, qmtConnections: { ...state, defaultId: id } });
return { ...state, defaultId: id };
}
}