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
+93
View File
@@ -0,0 +1,93 @@
/**
* R-013 列显隐配置回归(迭代 11 扩展,2026-09-02
* 覆盖(纯内存 mock scope,技术约束-011):
* 1. normalizeStrategyColumns 默认:基础列全显默认序 + configSchema 字段列全显
* 2. 覆盖生效:visible=false 隐藏、排序调整
* 3. configSchema 增删字段自动跟随(新增字段追加、删除字段移除)
* 4. updateStrategyColumns 整表覆盖 + getStrategyColumns 读回
*/
import {
normalizeStrategyColumns, getStrategyColumns, updateStrategyColumns,
} from '../src/settings.js';
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(" ✅ " + msg); }
else { fail++; console.log(" ❌ " + msg); }
}
function section(t) { console.log("\n[" + t + "]"); }
/** 内存 mock scope */
function makeScope(initial) {
const store = structuredClone(initial ?? {});
return {
get: () => structuredClone(store),
update: async (patch) => { Object.assign(store, structuredClone(patch)); },
_store: store,
};
}
const gridSchema = [
{ key: 'gridGap', label: '网格间距', type: 'text', def: '3%' },
{ key: 'riskLevel', label: '风险等级', type: 'enum', enum: ['低','中','高'], def: '中' },
];
// ---------- 1. 默认归一化 ----------
section('1. 默认列配置(无覆盖)');
const scope = makeScope({
strategies: [
{ id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema },
{ id: 'manual-t', name: '手动做T' },
],
});
const gridCols = normalizeStrategyColumns(scope, 'grid-supermarket');
const baseKeys = ['lastPrice','pctChange','lastClose','avgPrice','lastTradePrice','shares'];
assert(gridCols.length === 8, '默认 8 列(6 基础 + 2 字段)');
assert(baseKeys.every((k) => gridCols.some((c) => c.key === k)), '含全部基础列');
assert(gridCols[6].key === 'gridGap' && gridCols[7].key === 'riskLevel', '字段列按 configSchema 序追加末尾');
assert(gridCols.every((c) => c.visible !== false), '默认全部可见');
assert(gridCols[0].key === 'lastPrice', '默认序:现价在前');
const manualCols = normalizeStrategyColumns(scope, 'manual-t');
assert(manualCols.length === 6, '旧策略(无 configSchema)仅 6 基础列');
// ---------- 2. 覆盖生效 ----------
section('2. 覆盖(隐藏 + 排序)');
await updateStrategyColumns(scope, 'grid-supermarket', [
{ key: 'riskLevel', visible: true },
{ key: 'shares', visible: true },
{ key: 'gridGap', visible: false }, // 隐藏 gridGap
{ key: 'lastPrice', visible: false }, // 隐藏现价
{ key: 'pctChange', visible: true },
{ key: 'lastClose', visible: true },
{ key: 'avgPrice', visible: true },
{ key: 'lastTradePrice', visible: true },
]);
const after = getStrategyColumns(scope, 'grid-supermarket');
assert(after.length === 8, '读回 8 列(含不可见,保位)');
assert(after[0].key === 'riskLevel', '排序覆盖:riskLevel 提到最前');
assert(after.find((c) => c.key === 'gridGap').visible === false, 'gridGap 已隐藏');
assert(after.find((c) => c.key === 'lastPrice').visible === false, 'lastPrice 已隐藏');
assert(after[1].key === 'shares', '排序覆盖:shares 第二');
// ---------- 3. configSchema 增删跟随 ----------
section('3. configSchema 增删字段自动跟随');
await updateStrategyColumns(scope, 'grid-supermarket', after); // 已存覆盖
// 模拟新增字段 hasStop
scope._store.strategies = scope._store.strategies.map((s) => s.id === 'grid-supermarket'
? { ...s, configSchema: [...gridSchema, { key: 'hasStop', label: '设置止损', type: 'boolean', def: false }] } : s);
const afterAdd = normalizeStrategyColumns(scope, 'grid-supermarket');
assert(afterAdd.some((c) => c.key === 'hasStop'), '新增字段 hasStop 自动加入列配置');
assert(afterAdd.find((c) => c.key === 'hasStop').visible !== false, '新字段默认可见');
assert(afterAdd.find((c) => c.key === 'gridGap').visible === false, '原隐藏 gridGap 保留隐藏');
// 模拟删除字段 riskLevel
scope._store.strategies = scope._store.strategies.map((s) => s.id === 'grid-supermarket'
? { ...s, configSchema: gridSchema.filter((f) => f.key !== 'riskLevel') } : s);
const afterDel = normalizeStrategyColumns(scope, 'grid-supermarket');
assert(!afterDel.some((c) => c.key === 'riskLevel'), '删除字段 riskLevel 列自动移除');
assert(afterDel.some((c) => c.key === 'gridGap'), '其余列保留');
console.log("");
console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);