Files
kyugao ec02a791ee 迭代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 通过
2026-09-10 14:05:06 +08:00

650 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 设置管理:策略(标签)配置
*
* 设计约束:产品约束-002/003/004(标签体系、标签自定义 + 显示开关、删除份额回未分配)
* 通过 ctx.settings.register('one-divine-lot', schema) 注册策略配置 namespace。
*
* 数据结构:
* strategies: [{ id, name }]R-011 收窄:visible/order 归 tabs 统一管理)
* tabs: [{ id, kind: builtin|strategy, refKey|refId, name, visible, order }]
* 预置:网格超市、手动做T(R-003 O3-5:可删除,彻底自由)
*
* R-011 迭代 09(产品约束-009、技术约束-014):
* - settings.tabs 从布尔对象升级为统一有序数组(内置 + 策略混排)
* - 旧格式读取时静默归一化迁移(旧隐藏策略迁移后显示,Q3)
* - addStrategy 联动追加 tab(末尾,Q2);removeStrategy 联动删除对应 tabD3
* - 策略行 name 由 getTabs join strategies(改名自动跟随,Q4
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
*
* R-027(迭代 22 阶段一)策略 tab 静态化:
* - 策略集合与名称固定为内置两项(BUILTIN_STRATEGIES):不可新增/删除/重命名
* - tab 列表由 内置 tab + 内置策略 tab 常量派生;存量 tabs 仅保留 visible/order 偏好
* - 字段定义真相源迁到 strategyFieldsstrategies[] 收窄为身份表(仅兼容读取旧 configSchema
* - 退役 addStrategy/removeStrategy/renameStrategy/updateStrategies/append|removeStrategyTab
*/
import z from '@deepseek-ai/schemastery';
/** 内置 tab 元数据(常量,顺序即默认顺序;R-011) */
export const BUILTIN_TABS = [
{ id: 'tab-all-positions', refKey: 'allPositions', name: '全部持仓' },
{ id: 'tab-trade-records', refKey: 'tradeRecords', name: '交易记录' },
{ id: 'tab-watchlist', refKey: 'watchlist', name: '关注列表' },
];
/** 内置策略(R-027 静态化:集合与名称固定,不可增删改名;字段定义另存 strategyFields */
export const BUILTIN_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
];
/** 内置策略 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,
})),
];
/**
* 策略持仓表基础数据列目录(迭代 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 },
// R-015/迭代 13:行情列(盘口快照 + 合约信息;defaultVisible=false 默认隐藏,列设置勾选开启)
{ key: 'upStopPrice', label: '涨停价', fixed: false, defaultVisible: false },
{ key: 'downStopPrice', label: '跌停价', fixed: false, defaultVisible: false },
{ key: 'open', label: '今开', fixed: false, defaultVisible: false },
{ key: 'high', label: '最高', fixed: false, defaultVisible: false },
];
/**
* 策略自定义字段定义 schemaR-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
});
/** 策略设置 schemaschemastery 定义,settings.register 要求 schema 对象) */
/** @type {import('@deepseek-ai/schemastery').Schema<any>} 策略设置 schema(显式注解:含 z.dict,规避 dts 推断 cosmokit Dict 引用) */
export const strategySchema = z.object({
// 策略定义表(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(fieldSchema).default([]),
})).default([]),
// R-027:字段定义真相源(写路径唯一入口;strategies[].configSchema 仅兼容读取旧数据)
strategyFields: z.dict(z.array(fieldSchema).default([])).default({}),
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
tabs: z.union([
z.object({
allPositions: z.boolean().default(true),
tradeRecords: z.boolean().default(true),
watchlist: z.boolean().default(true),
}),
z.array(z.object({
id: z.string().required(),
kind: z.union([z.const('builtin'), z.const('strategy')]).required(),
refKey: z.string(), // builtin 专用:allPositions/tradeRecords/watchlist(可空)
refId: z.string(), // strategy 专用:策略 id(可空)
name: z.string(), // 展示名(策略行 join 时刷新,Q4 自动跟随改名;可空)
visible: z.boolean().default(true),
order: z.number().default(0),
})),
]).default(DEFAULT_TABS),
// QMT 连接配置(R-004,技术约束-008):list + 激活态 + 默认态
qmtConnections: z.object({
list: z.array(z.object({
id: z.string().required(),
name: z.string().required(),
baseUrl: z.string().required(),
order: z.number().default(0),
})).default([]),
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({}),
});
/** 简易拼音映射表(常用汉字 → 拼音,用于 slug 生成兜底) */
const PINYIN_MAP = {
// 完整短语映射(优先级最高)
'网格超市': 'grid-supermarket',
'手动做T': 'manual-t',
'长线持有': 'long-term-hold',
'短线交易': 'short-term-trade',
'波段操作': 'swing-trade',
'价值投资': 'value-invest',
'趋势跟踪': 'trend-follow',
'新股申购': 'ipo-subscribe',
'打板': 'board',
'低吸': 'dip-buy',
'高抛': 'high-sell',
'做T': 't',
// 词映射
'长线': 'long-term',
'短线': 'short-term',
'持有': 'hold',
'波段': 'swing',
'价值': 'value',
'网格': 'grid',
'策略': 'strategy',
'趋势': 'trend',
'新股': 'new-stock',
'打新': 'ipo',
// 单字映射
'超': 'super',
'市': 'market',
'场': 'market',
'手': 'manual',
'动': 'action',
'长': 'long',
'短': 'short',
'持': 'hold',
'波': 'swing',
'值': 'value',
'格': 'grid',
'略': 'strategy',
'趋': 'trend',
'势': 'trend',
};
/**
* 生成策略英文 idslug
* 规则:常见中文策略名映射拼音 → 去空格/转小写/连字符 → 非 ASCII 字符用序号兜底
* @param {string} name 策略名称
* @param {string[]} existingIds 已存在的 id(用于去重)
* @returns {string} 唯一英文 id
*/
export function generateStrategyId(name, existingIds = []) {
const raw = String(name ?? '').trim();
if (!raw) return '';
// 1. 整名映射(常见策略名直接映射,优先级最高)
const whole = PINYIN_MAP[raw];
if (whole) return dedupe(whole, existingIds);
// 2. 最长子串贪婪匹配(把名字切成已映射的词/字片段)
let slug = '';
let i = 0;
while (i < raw.length) {
let matched = false;
for (let len = Math.min(4, raw.length - i); len >= 1; len--) {
const seg = raw.slice(i, i + len);
const p = PINYIN_MAP[seg];
if (p) {
slug += p + '-';
i += len;
matched = true;
break;
}
}
if (matched) continue;
const ch = raw[i];
if (/[a-zA-Z0-9]/.test(ch)) {
slug += ch.toLowerCase() + '-';
}
i++;
}
let base = slug.replace(/-+$/, '').replace(/^-+/, '') || 'strategy';
// 3. 规范化(去空格、连字符规整、移除非法字符)
base = base
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
|| 'strategy';
return dedupe(base, existingIds);
}
/** 去重:若 id 已存在,追加序号 */
function dedupe(base, existingIds) {
let candidate = base;
let i = 2;
while (existingIds.includes(candidate)) {
candidate = `${base}-${i}`;
i++;
}
return candidate;
}
/**
* 注册策略设置 namespace,返回 SettingsScope
* @param {import('@deepseek-ai/cordis').Context} ctx
*/
export function registerSettings(ctx, config) {
const ns = config.settingsNamespace ?? 'one-divine-lot';
const scope = ctx.settings.register(ns, strategySchema, {
// 组合 base(如需从 composition 注入默认值)
base: config.strategies ? { strategies: config.strategies } : undefined,
});
ctx.logger?.info(`[one-divine-lot] 设置已注册: ${ns}`);
return scope;
}
/** 读取 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-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 = 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) : []),
),
}));
}
/** 读取某策略字段定义(未知名 → []) */
export function getStrategyFields(scope, strategyId) {
return getStrategies(scope).find((s) => s.id === strategyId)?.configSchema ?? [];
}
/**
* 写入某策略字段定义(R-027:单策略写入,唯一写路径;不触碰 strategies[] 与份额数据)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} strategyId 内置策略 id
* @param {Array} configSchema 完整字段定义数组
* @returns {Promise<object>} 更新后的策略对象
*/
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);
}
/**
* 归一化 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 raw = scopeValue(scope).tabs;
// 旧布尔对象(迭代 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 }));
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/R-027):归一化结果(策略行 name 恒为内置名,无改名场景)
* @param {ReturnType<typeof registerSettings>} scope
* @returns {Array} tabs 数组(按 order 排序)
*/
export function getTabs(scope) {
return normalizeTabs(scope);
}
/**
* 更新统一 tab 列表(整表更新:顺序 + 显隐;R-011)
* 策略行 name 由服务端 join 刷新,客户端可只传 id/order/visible 的数组。
* @param {ReturnType<typeof registerSettings>} scope
* @param {Array} tabs 完整 tabs 数组
*/
export async function updateTabs(scope, tabs) {
const current = scope?.get() ?? {};
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 显隐配置(R-011 迁移期兼容:返回布尔对象,供旧调用点使用)
* @deprecated 由 getTabs 取代
*/
export function getTabConfig(scope) {
const tabs = getTabs(scope);
const cfg = { allPositions: true, tradeRecords: true, watchlist: true };
for (const t of tabs) {
if (t.kind === 'builtin' && t.refKey in cfg) cfg[t.refKey] = t.visible !== false;
}
return cfg;
}
/**
* 更新通用 tab 显隐配置(R-011 迁移期兼容:写回统一数组)
* @deprecated 由 updateTabs 取代
*/
export async function updateTabConfig(scope, tabs) {
const currentTabs = getTabs(scope);
const next = currentTabs.map((t) => (t.kind === 'builtin' && t.refKey in tabs
? { ...t, visible: tabs[t.refKey] !== false }
: t));
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 = scopeValue(scope);
// R-027:字段定义真相源 = strategyFields(缺失时兼容读取旧 strategies[].configSchema
const configSchema = getStrategyFields(scope, strategyId);
const raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
// 1. 默认全列(基础列默认序 + 字段列 configSchema 序;显隐缺省跟随 defaultVisibleR-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', type: f.type }));
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)) {
const dv = visibleMap.has(d.key) ? visibleMap.get(d.key) : d.defaultVisible !== false;
ordered.push({ ...d, visible: dv });
}
}
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 = 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 });
}
/**
* ============================================================================
* QMT 连接配置(R-004 已定稿;产品约束-005、技术约束-008)
*
* 数据结构:qmtConnections: { list: [{id,name,baseUrl,order}], activeId, defaultId }
* - 激活:一次只能激活一个(activeId),激活配置为当前生效连接,切换立即生效(Q2)
* - 默认:启动时自动激活默认配置(Q1),重启回到默认;运行中手动激活立即生效
* - 启动兜底:列表为空时回退 cordis 注入的 qmtBaseUrlQ4 不迁移)
* - 删除边界(Q6):删激活→自动切默认;删默认→默认转移第一条并激活之;禁删最后一条
*/
/** 归一化并校验 HTTP 地址:trim + 去尾斜杠 + 校验 http(s):// 前缀 */
export function normalizeBaseUrl(raw) {
const url = String(raw ?? '').trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(url) || url.length <= 8) {
throw Object.assign(new Error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'), { code: 'qmt-invalid-url' });
}
return url;
}
/** 生成连接配置英文 id(复用策略拼音 slug;兜底前缀 qmt-conn */
export function generateQmtConnectionId(name, existingIds = []) {
const base = generateStrategyId(name, []);
const slug = base && base !== 'strategy' ? base : 'qmt-conn';
return dedupe(slug, existingIds);
}
/** 读取 QMT 连接配置(list 按 order 排序) */
export function getQmtConnections(scope) {
const value = scope?.get() ?? {};
const qc = value.qmtConnections ?? {};
const list = Array.isArray(qc.list) ? qc.list : [];
return {
list: list.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
activeId: typeof qc.activeId === 'string' ? qc.activeId : '',
defaultId: typeof qc.defaultId === 'string' ? qc.defaultId : '',
};
}
/** 解析当前生效连接:activeId → defaultId → order 第一条 → null */
export function getActiveQmtConnection(state) {
const byId = (id) => (id ? state.list.find((c) => c.id === id) ?? null : null);
return byId(state.activeId) ?? byId(state.defaultId) ?? state.list[0] ?? null;
}
/**
* 启动激活连接解析(Q1/Q4):默认 → 上次激活 → 第一条;列表为空回退 cordis qmtBaseUrl。
* 解析结果与 activeId 不一致时回写(保证运行期状态一致)。
* @returns {Promise<{connection: object|null, baseUrl: string|null, source: string}>}
*/
export async function resolveStartupConnection(scope, config) {
const state = getQmtConnections(scope);
if (!state.list.length) {
const fallback = String(config?.qmtBaseUrl ?? '').trim().replace(/\/+$/, '');
return { connection: null, baseUrl: fallback || null, source: fallback ? 'cordis-fallback' : 'none' };
}
const byId = (id) => (id ? state.list.find((c) => c.id === id) ?? null : null);
const defaultConn = byId(state.defaultId);
const activeConn = byId(state.activeId);
const chosen = defaultConn
? { conn: defaultConn, why: 'default' }
: activeConn
? { conn: activeConn, why: 'last-active' }
: { conn: state.list[0], why: 'first' };
if (chosen.conn.id !== state.activeId) {
await activateQmtConnection(scope, chosen.conn.id);
}
return { connection: chosen.conn, baseUrl: chosen.conn.baseUrl, source: 'settings:' + chosen.why };
}
/** 新增连接配置 */
export async function addQmtConnection(scope, { name, baseUrl } = {}) {
const trimmedName = String(name ?? '').trim();
if (!trimmedName) throw Object.assign(new Error('连接名称不能为空'), { code: 'qmt-name-required' });
const normalizedUrl = normalizeBaseUrl(baseUrl);
const state = getQmtConnections(scope);
const id = generateQmtConnectionId(trimmedName, state.list.map((c) => c.id));
const conn = {
id,
name: trimmedName,
baseUrl: normalizedUrl,
order: state.list.reduce((max, c) => Math.max(max, c.order ?? 0), -1) + 1,
};
const value = scope?.get() ?? {};
await scope.update({ ...value, qmtConnections: { ...state, list: [...state.list, conn] } });
return conn;
}
/** 编辑连接配置(名称 / 地址);返回更新后的配置对象 */
export async function updateQmtConnection(scope, id, patch = {}) {
const state = getQmtConnections(scope);
const target = state.list.find((c) => c.id === id);
if (!target) throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
const nextName = patch.name !== undefined ? String(patch.name).trim() : target.name;
if (!nextName) throw Object.assign(new Error('连接名称不能为空'), { code: 'qmt-name-required' });
const nextUrl = patch.baseUrl !== undefined ? normalizeBaseUrl(patch.baseUrl) : target.baseUrl;
const updated = { ...target, name: nextName, baseUrl: nextUrl };
const value = scope?.get() ?? {};
await scope.update({
...value,
qmtConnections: { ...state, list: state.list.map((c) => (c.id === id ? updated : c)) },
});
return updated;
}
/** 删除连接配置(含 Q6 删除边界处理);返回删除后的状态与边界信息 */
export async function removeQmtConnection(scope, id) {
const state = getQmtConnections(scope);
if (!state.list.some((c) => c.id === id)) {
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
}
if (state.list.length <= 1) {
throw Object.assign(new Error('不能删除最后一个连接配置'), { code: 'qmt-last-connection' });
}
const removedWasActive = state.activeId === id;
const removedWasDefault = state.defaultId === id;
const list = state.list.filter((c) => c.id !== id);
// 删默认 → 默认标记转移到列表第一条(产品约束-005)
let defaultId = removedWasDefault ? (list[0]?.id ?? '') : state.defaultId;
// 删激活 → 自动切换到默认(若默认仍有效),否则第一条
let activeId = removedWasActive
? (defaultId && list.some((c) => c.id === defaultId) ? defaultId : (list[0]?.id ?? ''))
: state.activeId;
const value = scope?.get() ?? {};
await scope.update({ ...value, qmtConnections: { list, activeId, defaultId } });
return { list, activeId, defaultId, removedWasActive, removedWasDefault };
}
/** 激活连接配置(写 activeId;热切换由 api 层编排 setBaseUrl */
export async function activateQmtConnection(scope, id) {
const state = getQmtConnections(scope);
if (!state.list.some((c) => c.id === id)) {
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
}
const value = scope?.get() ?? {};
await scope.update({ ...value, qmtConnections: { ...state, activeId: id } });
return { ...state, activeId: id };
}
/** 设为默认配置(写 defaultId */
export async function setDefaultQmtConnection(scope, id) {
const state = getQmtConnections(scope);
if (!state.list.some((c) => c.id === id)) {
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
}
const value = scope?.get() ?? {};
await scope.update({ ...value, qmtConnections: { ...state, defaultId: id } });
return { ...state, defaultId: id };
}