diff --git a/scripts/test-r011-api.mjs b/scripts/test-r011-api.mjs new file mode 100644 index 0000000..f8841e1 --- /dev/null +++ b/scripts/test-r011-api.mjs @@ -0,0 +1,85 @@ +/** + * R-011 API 层集成测试:handleStrategy tabs 域(联动链路) + * 独立内存 mock settings scope + mock manager,验证: + * 1. tabs 端点:返回归一化后的统一数组 + * 2. tabs/update:整表更新(顺序 + 显隐) + * 3. strategies/add:联动追加 tab 条目(末尾) + * 4. strategies/remove:联动删除 tab 条目 + 清份额(manager) + * 5. strategies/update:重命名(tabs 行 name join 跟随) + * 纯内存,不触真实数据(技术约束-011) + */ +import { handleStrategy, STRATEGY_METHODS } from '../src/api/strategies.js'; + +let pass = 0, fail = 0; +function assert(cond, msg) { + if (cond) { pass++; console.log(" ✅ " + msg); } + else { fail++; console.log(" ❌ " + msg); } +} + +/** 内存 mock scope */ +function makeScope(initial) { + const store = structuredClone(initial ?? {}); + return { + get: () => structuredClone(store), + update: async (patch) => { Object.assign(store, structuredClone(patch)); }, + }; +} + +const scope = makeScope({ + strategies: [{ id: "grid-supermarket", name: "网格超市" }], + tabs: [ + { id: "tab-all", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 0 }, + { id: "tab-g", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: true, order: 1 }, + ], +}); +const cleared = []; +const manager = { + async clearStrategyShares(id) { cleared.push(id); }, +}; +const runtime = { manager, settings: scope }; + +// 1. tabs 端点 +console.log("\n[1] tabs 端点"); +const tabs1 = await handleStrategy("tabs", {}, runtime); +assert(Array.isArray(tabs1) && tabs1.length === 2, "tabs 返回统一数组"); +assert(tabs1[0].kind === "builtin" && tabs1[1].kind === "strategy", "内置 + 策略混排"); + +// 2. tabs/update 整表更新 +console.log("\n[2] tabs/update 整表更新"); +const afterUpdate = await handleStrategy("tabs/update", { tabs: [ + { id: "tab-g", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: false, order: 0 }, + { id: "tab-all", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 1 }, +] }, runtime); +assert(afterUpdate[0].id === "tab-g" && afterUpdate[0].visible === false, "策略 tab 排到前面且隐藏"); +assert(afterUpdate[1].id === "tab-all", "内置 tab 排后"); + +// 3. strategies/add 联动追加 tab +console.log("\n[3] strategies/add 联动追加 tab"); +const added = await handleStrategy("strategies/add", { name: "长线持有" }, runtime); +const tabsAfterAdd = await handleStrategy("tabs", {}, runtime); +assert(tabsAfterAdd.length === 3, "add 后 tabs 联动新增一条"); +assert(tabsAfterAdd[2].kind === "strategy" && tabsAfterAdd[2].refId === added.id, "新策略 tab 追加末尾"); +assert(tabsAfterAdd[2].name === "长线持有", "新策略 tab name 正确"); + +// 4. strategies/remove 联动删除 tab + 清份额 +console.log("\n[4] strategies/remove 联动删除 tab"); +await handleStrategy("strategies/remove", { strategyId: "grid-supermarket" }, runtime); +const tabsAfterRm = await handleStrategy("tabs", {}, runtime); +assert(!tabsAfterRm.some((t) => t.refId === "grid-supermarket"), "删除策略后对应 tab 条目消失"); +assert(tabsAfterRm.some((t) => t.refId === added.id), "其余策略 tab 保留"); +assert(cleared.includes("grid-supermarket"), "manager.clearStrategyShares 被调用(份额回未分配)"); + +// 5. strategies/update 重命名(tabs 行 join 跟随) +console.log("\n[5] strategies/update 重命名跟随"); +await handleStrategy("strategies/update", { strategies: [ + { id: added.id, name: "长线持有v2" }, +] }, runtime); +const tabsAfterRename = await handleStrategy("tabs", {}, runtime); +assert(tabsAfterRename.find((t) => t.refId === added.id).name === "长线持有v2", "重命名后 tabs 行 name join 跟随(Q4)"); + +// 6. 端点表不含 strategies/move +console.log("\n[6] strategies/move 已废弃"); +assert(!STRATEGY_METHODS.has("strategies/move"), "STRATEGY_METHODS 不含 strategies/move"); + +console.log("\n结果: " + pass + " 通过 / " + fail + " 失败"); +if (fail > 0) process.exit(1); diff --git a/scripts/test-r011-tabs.mjs b/scripts/test-r011-tabs.mjs new file mode 100644 index 0000000..b8b2af9 --- /dev/null +++ b/scripts/test-r011-tabs.mjs @@ -0,0 +1,139 @@ +/** + * R-011 回归测试:tabs 统一管理数据层 + * 独立内存 mock scope(settings 读写),验证: + * 1. 归一化迁移:旧布尔对象 tabs → 有序数组(内置保留原显隐,策略接续追加) + * 2. 迁移:旧隐藏策略 → visible=true(Q3) + * 3. getTabs:策略行 name join strategies(Q4 自动跟随改名) + * 4. updateTabs:整表更新(顺序 + 显隐) + * 5. addStrategy:联动追加 tab 条目(末尾,Q2) + * 6. removeStrategy:联动删除对应 tab 条目(D3) + * 纯内存,不触真实数据(技术约束-011) + */ +import { + getTabs, updateTabs, normalizeTabs, + addStrategy, removeStrategy, renameStrategy, getStrategies, +} from '../src/settings.js'; + +let pass = 0, fail = 0; +function assert(cond, msg) { + if (cond) { pass++; console.log(" ✅ " + msg); } + else { fail++; console.log(" ❌ " + msg); } +} + +/** 内存 mock scope:get/update 读改写对象 */ +function makeScope(initial) { + const store = structuredClone(initial ?? {}); + return { + get: () => structuredClone(store), + update: async (patch) => { Object.assign(store, structuredClone(patch)); }, + _store: store, + }; +} + +// ---------- 1. 旧格式归一化(布尔对象 tabs + 策略带 visible/order) ---------- +console.log("\n[1] 旧格式归一化迁移"); +const oldScope = makeScope({ + strategies: [ + { id: 'grid-supermarket', name: '网格超市', visible: true, order: 5 }, + { id: 'manual-t', name: '手动做T', visible: false, order: 9 }, + ], + tabs: { allPositions: true, tradeRecords: false, watchlist: true }, +}); +const oldTabs = normalizeTabs(oldScope); +assert(oldTabs.length === 5, "归一化后共 5 个条目(3 内置 + 2 策略)"); +assert(oldTabs[0].kind === 'builtin' && oldTabs[0].refKey === 'allPositions', '内置 allPositions 在前'); +assert(oldTabs[1].refKey === 'tradeRecords' && oldTabs[1].visible === false, '内置 tradeRecords 保留旧显隐=false'); +assert(oldTabs[2].refKey === 'watchlist' && oldTabs[2].visible === true, '内置 watchlist 保留旧显隐=true'); +const manualTab = oldTabs.find((t) => t.kind === 'strategy' && t.refId === 'manual-t'); +assert(manualTab && manualTab.visible === true, "旧隐藏策略 manual-t 迁移后 visible=true(Q3)"); +assert(manualTab && manualTab.name === '手动做T', '策略行 name 来自 strategies'); +assert(oldTabs[3].order === 3 && oldTabs[4].order === 4, '策略按 order 接续(order 连续)'); + +// ---------- 2. 已归一化数组直接返回 ---------- +console.log("\n[2] 已归一化数组直接返回"); +const arrScope = makeScope({ tabs: [ + { id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 }, + { id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 0 }, +] }); +const arrTabs = normalizeTabs(arrScope); +assert(arrTabs[0].id === 'tab-b' && arrTabs[1].id === 'tab-a', '数组按 order 排序'); + +// ---------- 3. 策略行 name join(改名自动跟随,Q4) ---------- +console.log("\n[3] getTabs 策略名 join"); +const joinScope = makeScope({ + strategies: [{ id: 'manual-t', name: '手动做T新名' }], + tabs: [ + { id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, + { id: 'tab-s', kind: 'strategy', refId: 'manual-t', name: '旧名', visible: true, order: 1 }, + ], +}); +const joined = getTabs(joinScope); +assert(joined.find((t) => t.id === 'tab-s').name === '手动做T新名', '策略改名后 tabs 行 name 自动跟随(Q4)'); + +// ---------- 4. updateTabs 整表更新 ---------- +console.log("\n[4] updateTabs 整表更新"); +const updScope = makeScope({ tabs: [ + { id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, + { id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 1 }, +] }); +await updateTabs(updScope, [ + { id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: false, order: 0 }, + { id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 }, +]); +const updTabs = getTabs(updScope); +assert(updTabs[0].id === 'tab-b' && updTabs[0].visible === false, '整表更新:顺序与显隐生效'); +assert(updTabs.length === 2, '整表更新后条目数不变'); + +// ---------- 5. addStrategy 联动追加 tab(末尾,Q2) ---------- +console.log("\n[5] addStrategy 联动追加 tab"); +const addScope = makeScope({ + strategies: [{ id: 'grid-supermarket', name: '网格超市' }], + tabs: [ + { id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, + { id: 'tab-s', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 1 }, + ], +}); +const added = await addStrategy(addScope, '长线持有'); +assert(getStrategies(addScope).length === 2, '策略定义表新增'); +const addTabs = getTabs(addScope); +assert(addTabs.length === 3, 'tabs 联动新增一条'); +assert(addTabs[2].kind === 'strategy' && addTabs[2].refId === added.id, '新策略 tab 追加到末尾(Q2)'); +assert(addTabs[2].name === '长线持有', '新策略 tab name 正确'); +assert(addTabs[2].visible === true, '新策略 tab 默认显示'); +assert(addTabs[2].order === 2, 'order 连续'); + +// ---------- 6. removeStrategy 联动删除 tab(D3) ---------- +console.log("\n[6] removeStrategy 联动删除 tab"); +const rmScope = makeScope({ + strategies: [ + { id: 'grid-supermarket', name: '网格超市' }, + { id: 'manual-t', name: '手动做T' }, + ], + tabs: [ + { id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 }, + { id: 'tab-g', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 1 }, + { id: 'tab-m', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 2 }, + ], +}); +await removeStrategy(rmScope, 'grid-supermarket'); +const rmTabs = getTabs(rmScope); +assert(rmTabs.length === 2, '删除策略后 tabs 联动删除对应条目'); +assert(!rmTabs.some((t) => t.refId === 'grid-supermarket'), 'grid-supermarket tab 条目已删除'); +assert(rmTabs.some((t) => t.refId === 'manual-t'), 'manual-t tab 条目保留'); +assert(getStrategies(rmScope).length === 1, '策略定义表删除'); + +// ---------- 7. renameStrategy 只改名(tabs 行 join 跟随,不影响定义) ---------- +console.log("\n[7] renameStrategy"); +const rnScope = makeScope({ + strategies: [{ id: 'manual-t', name: '手动做T' }], + tabs: [ + { id: 'tab-m', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 0 }, + ], +}); +await renameStrategy(rnScope, 'manual-t', '手动T+'); +assert(getStrategies(rnScope)[0].name === '手动T+', '策略定义名更新'); +assert(getTabs(rnScope)[0].name === '手动T+', 'tabs 行 name join 跟随(Q4)'); + +// ---------- 汇总 ---------- +console.log("\n结果: " + pass + " 通过 / " + fail + " 失败"); +if (fail > 0) process.exit(1); diff --git a/src/api/strategies.js b/src/api/strategies.js index bda355f..181bc35 100644 --- a/src/api/strategies.js +++ b/src/api/strategies.js @@ -2,15 +2,15 @@ * 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31) * * 端点: - * strategies / strategies/update / strategies/add / strategies/remove / strategies/move + * strategies / strategies/update / strategies/add / strategies/remove * strategy-positions / unallocated / summary * add-shares / move-all-shares / remove-shares * tabs / tabs/update */ import { - getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy, - getTabConfig, updateTabConfig, + getStrategies, updateStrategies, addStrategy, removeStrategy, + getTabs, updateTabs, } from '../settings.js'; /** 策略/份额/tabs 端点方法表 */ @@ -19,7 +19,6 @@ export const STRATEGY_METHODS = new Set([ 'strategies/update', 'strategies/add', 'strategies/remove', - 'strategies/move', 'strategy-positions', 'unallocated', 'summary', @@ -50,13 +49,11 @@ export async function handleStrategy(method, args, { manager, settings }) { await removeStrategy(settings, args.strategyId); await manager.clearStrategyShares(args.strategyId); return getStrategies(settings); - case 'strategies/move': - return await moveStrategy(settings, args.strategyId, args.dir); case 'tabs': - return getTabConfig(settings); + return getTabs(settings); case 'tabs/update': - await updateTabConfig(settings, args.tabs); - return getTabConfig(settings); + await updateTabs(settings, args.tabs); + return getTabs(settings); case 'strategy-positions': return await manager.getStrategyPositions(args.strategyId); case 'unallocated': diff --git a/src/client/index.js b/src/client/index.js index fc8019d..6e83e1b 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -23,21 +23,8 @@ import { MarketDataProvider } from './market/MarketDataProvider.jsx'; export const inject = ['slots', 'connection']; -async function fetchStrategies() { - try { - const res = await fetch('/odl/api/strategies', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - const data = await res.json(); - return data && data.ok ? data.value : []; - } catch (e) { - return []; - } -} -async function fetchTabConfig() { +async function fetchTabs() { try { const res = await fetch('/odl/api/tabs', { method: 'POST', @@ -45,29 +32,21 @@ async function fetchTabConfig() { body: JSON.stringify({}), }); const data = await res.json(); - if (data && data.ok) { - return { - allPositions: data.value.allPositions !== false, - tradeRecords: data.value.tradeRecords !== false, - watchlist: data.value.watchlist !== false, - }; - } + if (data && data.ok && Array.isArray(data.value)) return data.value; } catch (e) { /* ignore */ } - return { allPositions: true, tradeRecords: true, watchlist: true }; + return null; } function strategyTabId(strategyId) { return 'odl-strategy-' + strategyId; } -const GENERAL_TABS = [ - { key: 'allPositions', id: 'odl-all-positions', label: '全部持仓', order: 10, - render: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)) }, - { key: 'tradeRecords', id: 'odl-trade-records', label: '交易记录', order: 11, - render: (withConnection) => withConnection((props) => createElement(TradeRecordsTab, props)) }, - { key: 'watchlist', id: 'odl-watchlist', label: '关注列表', order: 12, - render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })) }, -]; +// R-011:内置 tab 渲染映射(refKey → render);order 由 settings.tabs 统一管理 +const GENERAL_RENDER = { + allPositions: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)), + tradeRecords: (withConnection) => withConnection((props) => createElement(TradeRecordsTab, props)), + watchlist: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })), +}; export function apply(ctx) { const connection = ctx.get('connection'); @@ -109,40 +88,31 @@ export function apply(ctx) { tabDisposers = []; }; - const registerGeneralTabs = (tabConfig) => { - for (const tab of GENERAL_TABS) { - if (tabConfig[tab.key] === false) continue; - const dispose = ctx.slots.register({ - name: 'conversation.view', - id: tab.id, - order: tab.order, - label: () => tab.label, - }, tab.render(withConnection)); - tabDisposers.push(dispose); - } - }; - - const registerStrategyTabs = (strategies) => { - const visible = strategies.filter((s) => s.visible !== false); - visible.forEach((s, idx) => { - const dispose = ctx.slots.register({ - name: 'conversation.view', - id: strategyTabId(s.id), - order: 13 + idx, - label: () => s.name, - }, withConnection((props) => createElement(StrategyTab, { ...props, strategyId: s.id, strategyName: s.name }))); - tabDisposers.push(dispose); - }); - return visible.length; - }; - + // R-011:统一注册 —— 读 settings.tabs 有序数组,按 order 排序、过滤 visible + // builtin 走内置 render(GENERAL_RENDER),strategy 走 StrategyTab const registerAllTabs = async () => { - const [strategies, tabConfig] = await Promise.all([fetchStrategies(), fetchTabConfig()]); + const tabs = await fetchTabs(); if (disposed) return; disposeAllTabs(); - registerGeneralTabs(tabConfig); - const n = registerStrategyTabs(strategies); - ctx.logger?.info?.('[one-divine-lot] 已注册通用 tabs + ' + n + ' 个策略 tab'); + if (!tabs) return; + const sorted = tabs.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + let n = 0; + for (const t of sorted) { + if (t.visible === false) continue; + const render = t.kind === 'builtin' + ? GENERAL_RENDER[t.refKey] + : (withConnection) => withConnection((props) => createElement(StrategyTab, { ...props, strategyId: t.refId, strategyName: t.name })); + if (!render) continue; + const dispose = ctx.slots.register({ + name: 'conversation.view', + id: t.kind === 'builtin' ? t.id : strategyTabId(t.refId), + order: t.order ?? 0, + label: () => t.name ?? t.id, + }, render(withConnection)); + tabDisposers.push(dispose); + n++; + } + ctx.logger?.info?.('[one-divine-lot] 已注册 ' + n + ' 个会话 tab'); }; registerAllTabs(); diff --git a/src/client/views/AllPositionsTab.jsx b/src/client/views/AllPositionsTab.jsx index d18acaf..807dc63 100644 --- a/src/client/views/AllPositionsTab.jsx +++ b/src/client/views/AllPositionsTab.jsx @@ -44,7 +44,7 @@ export function AllPositionsTab(props) { - + @@ -56,14 +56,14 @@ export function AllPositionsTab(props) { {(positions?.positions ?? []).map((p) => ( - + - - + diff --git a/src/client/views/LoadState.jsx b/src/client/views/LoadState.jsx index a984430..c3c36fe 100644 --- a/src/client/views/LoadState.jsx +++ b/src/client/views/LoadState.jsx @@ -49,17 +49,17 @@ export function LoadState({ loading, error, onRetry, autoRetry = 2, children }) }, [onRetry, autoRetry]); if (loading) { - return
加载中...
; + return
加载中...
; } const errMsg = error || manualError; if (errMsg) { return ( -
+
数据加载失败:{errMsg}
diff --git a/src/client/views/PlaceholderTab.jsx b/src/client/views/PlaceholderTab.jsx index 85f4854..6592bf4 100644 --- a/src/client/views/PlaceholderTab.jsx +++ b/src/client/views/PlaceholderTab.jsx @@ -5,7 +5,7 @@ export function PlaceholderTab({ title }) { return ( -
+
{title}
功能开发中,敬请期待
diff --git a/src/client/views/PriceCell.jsx b/src/client/views/PriceCell.jsx index 375b5d3..31c026f 100644 --- a/src/client/views/PriceCell.jsx +++ b/src/client/views/PriceCell.jsx @@ -35,11 +35,12 @@ function useFlash(price) { export function PriceCell({ price, lastClose }) { const flash = useFlash(price); const hasPrice = price != null && isFinite(price); - let color = '#999'; + let color = 'var(--dsw-alias-label-tertiary, #999)'; if (hasPrice && lastClose != null && isFinite(lastClose)) { - if (price > lastClose) color = '#d32f2f'; - else if (price < lastClose) color = '#2e7d32'; - else color = '#666'; + // A股红涨绿跌;语义色跟随宿主深浅主题自动适配 + if (price > lastClose) color = 'var(--dsw-alias-state-error-primary, #d32f2f)'; + else if (price < lastClose) color = 'var(--dsw-alias-state-success-primary, #2e7d32)'; + else color = 'var(--dsw-alias-label-secondary, #666)'; } return (
); } diff --git a/src/client/views/QmtConnectionChip.jsx b/src/client/views/QmtConnectionChip.jsx index 98d13c4..455d062 100644 --- a/src/client/views/QmtConnectionChip.jsx +++ b/src/client/views/QmtConnectionChip.jsx @@ -21,18 +21,18 @@ function MenuItem({ item, selected, onSelect }) { onClick={() => onSelect(item)} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left', - padding: '8px 12px', border: 'none', cursor: 'pointer', background: selected ? '#e8f5e9' : 'transparent', - fontSize: 13, color: '#333', + padding: '8px 12px', border: 'none', cursor: 'pointer', background: selected ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'transparent', + fontSize: 13, color: 'var(--dsw-alias-label-primary, #333)', }} - onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = '#f5f5f5'; }} + onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)'; }} onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }} > - {selected ? '✓' : ''} + {selected ? '✓' : ''} {item.name} - {item.baseUrl} + {item.baseUrl} - {item.isDefault && 默认} + {item.isDefault && 默认} ); } @@ -75,7 +75,7 @@ function QmtHealthIndicator() { }, [load]); // 状态推导:null=灰(未知/加载中),healthy=true=绿,false=红 - const color = health === null ? '#bbb' : (health.healthy ? '#2e7d32' : '#d32f2f'); + const color = health === null ? 'var(--dsw-alias-label-tertiary, #bbb)' : (health.healthy ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #d32f2f)'); const title = health === null ? 'QMT 健康状态获取中…(点击立即检测)' : (health.healthy @@ -91,7 +91,7 @@ function QmtHealthIndicator() { style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 10, height: 10, borderRadius: '50%', background: color, flex: 'none', - boxShadow: color !== '#bbb' ? '0 0 4px ' + color : 'none', + boxShadow: health === null ? 'none' : '0 0 4px ' + color, cursor: 'pointer', opacity: refreshing ? 0.5 : 1, transition: 'opacity .2s', @@ -181,13 +181,13 @@ function ChipInner() { role="menu" style={{ position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 10001, - minWidth: 220, maxWidth: 300, background: '#fff', border: '1px solid #ddd', - borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', padding: '4px 0', + minWidth: 220, maxWidth: 300, 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 16px rgba(0,0,0,0.15))', padding: '4px 0', }} > -
切换 QMT 连接
+
切换 QMT 连接
{state.list.length === 0 ? ( -
+
暂无连接配置,请进 设置 → 神之一手 → QMT 连接配置 添加
) : ( @@ -200,7 +200,7 @@ function ChipInner() { /> )) )} -
+
管理配置请进 设置 → 神之一手 → QMT 连接配置
diff --git a/src/client/views/RangeSelector.jsx b/src/client/views/RangeSelector.jsx index 4355fe7..3473935 100644 --- a/src/client/views/RangeSelector.jsx +++ b/src/client/views/RangeSelector.jsx @@ -154,7 +154,7 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD if (error || !lastDate) { return ( -
+
{error || '交易日历加载中...'}
@@ -170,17 +170,17 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD padding: '2px 10px', fontSize: 12, cursor: 'pointer', - border: active ? '1px solid #2e7d32' : '1px solid #ccc', + border: active ? '1px solid var(--dsw-alias-state-success-primary, #2e7d32)' : '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, - background: active ? '#e8f5e9' : '#fff', - color: active ? '#1b5e20' : '#555', + background: active ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)', + color: active ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-secondary, #555)', whiteSpace: 'nowrap', }); const inputStyle = { fontSize: 12, padding: '2px 4px', - border: '1px solid #ccc', + border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, width: 112, }; @@ -190,9 +190,9 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD - | + | - ~ + ~
); diff --git a/src/client/views/SettingsSection.jsx b/src/client/views/SettingsSection.jsx index 32369fe..0921dea 100644 --- a/src/client/views/SettingsSection.jsx +++ b/src/client/views/SettingsSection.jsx @@ -1,10 +1,12 @@ /** - * 神之一手设置 section(迭代 02:设置 tab 化扩展) + * 神之一手设置 section * - * 结构: - * - 三个子 tab:通用设置 / 策略分组 / QMT 连接配置(迭代 03,R-004) - * - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」 - * - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示 + * 结构(R-011 迭代 09): + * - 三个子 tab:Tab 设置 / 策略分组 / QMT 连接配置 + * - Tab 设置:统一管理所有会话 tab(内置 + 策略分组混排),拖动排序(落点立即持久化)+ 显示/隐藏开关; + * 任何 tab 均不支持重命名/删除(产品约束-009、UI约束-003) + * - 策略分组:策略 CRUD(新增/重命名/删除确认)+ 提示「顺序与显示请在 Tab 设置中调整」 + * - QMT 连接配置:R-004 卡片形式(迭代 03) */ import { useState, useEffect, useCallback } from 'react'; @@ -15,17 +17,17 @@ import { ToastProvider, useToast } from './Toast.jsx'; function ConfirmDialog({ title, message, onConfirm, onCancel }) { return (
-
+

{title}

-
{message}
+
{message}
- -
@@ -42,30 +44,32 @@ function Switch({ checked, onChange, disabled }) { disabled={disabled} style={{ width: 44, height: 24, borderRadius: 12, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer', - background: checked ? '#2e7d32' : '#9e9e9e', position: 'relative', padding: 0, transition: 'background 0.2s', + background: checked ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l4, #9e9e9e)', position: 'relative', padding: 0, transition: 'background 0.2s', opacity: disabled ? 0.6 : 1, }} > ); } -/** 通用设置子 tab */ -function GeneralSettings({ onChanged }) { - const [tabConfig, setTabConfig] = useState(null); +/** 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); const call = useRpc(); const toast = useToast(); const load = useCallback(async () => { try { const res = await call('one-divine-lot/tabs', {}); - if (res && res.ok) setTabConfig(res.value); + if (res && res.ok && Array.isArray(res.value)) setTabs(res.value); else setError((res && res.error && res.error.message) || '加载失败'); } catch (e) { setError(e.message); @@ -74,48 +78,97 @@ function GeneralSettings({ onChanged }) { useEffect(() => { load(); }, [load]); - const toggle = async (key, label) => { - if (!tabConfig) return; + // 整表持久化(拖动落点 / 显隐切换共用;Q1 落点立即持久化) + const persist = async (nextTabs, msg) => { setSaving(true); try { - const next = { ...tabConfig, [key]: !tabConfig[key] }; - const res = await call('one-divine-lot/tabs/update', { args: { tabs: next } }); + const res = await call('one-divine-lot/tabs/update', { args: { tabs: nextTabs } }); if (res && res.ok) { - setTabConfig(next); - toast.success(`「${label}」已${next[key] ? '显示' : '隐藏'}`); - onChanged?.(label, next[key]); + setTabs(res.value); + toast.success(msg + '(刷新页面后生效)'); } else { toast.error((res && res.error && res.error.message) || '保存失败'); + await load(); } } catch (e) { toast.error(e.message); + await load(); } finally { setSaving(false); } }; - const items = [ - { key: 'allPositions', label: '全部持仓', desc: '会话窗中的「全部持仓」标签页' }, - { key: 'tradeRecords', label: '交易记录', desc: '会话窗中的「交易记录」标签页(开发中)' }, - { key: 'watchlist', label: '关注列表', desc: '会话窗中的「关注列表」标签页(开发中)' }, - ]; + const toggleVisible = async (t) => { + if (!tabs) return; + const next = tabs.map((x) => (x.id === t.id ? { ...x, visible: !x.visible } : x)); + 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 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; } + const next = tabs.slice(); + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + // 重写 order(整表连续) + const ordered = next.map((x, i) => ({ ...x, order: i })); + setDragId(null); + setOverId(null); + await persist(ordered, '「' + moved.name + '」已移动'); + }; + + const badgeStyle = (kind) => ({ + fontSize: 11, borderRadius: 8, padding: '0 6px', marginRight: 6, flex: 'none', + color: kind === 'builtin' ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-state-warn-primary, #6a1b9a)', + border: kind === 'builtin' ? '1px solid var(--dsw-alias-border-l3, #90caf9)' : '1px solid var(--dsw-alias-border-l3, #ce93d8)', + }); return (
- {error &&
{error}
} - {!tabConfig && !error &&
加载中...
} - {tabConfig && items.map((item) => ( -
-
-
{item.label}
-
{item.desc}
-
- toggle(item.key, item.label)} disabled={saving} /> -
- ))} +
+ 拖动调整所有标签页顺序,开关控制显示/隐藏;内置与策略标签可任意混排。名称不支持在此修改(策略名称在「策略分组」中修改)。 +
+ {error &&
{error}
} + {!tabs && !error &&
加载中...
} + {tabs && ( +
代码 名称 现价
{p.code} {p.name} {p.totalVolume} {p.allocated} 0 ? '#e65100' : '#2e7d32' }}>{p.unallocated} + 0 ? 'var(--dsw-alias-state-warn-primary, #e65100)' : 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>{p.unallocated} {Object.entries(p.shares).map(([sid, n]) => sid + ': ' + n).join(', ') || '—'}
{formatPrice(price)} - {flash > 0 && } + {flash > 0 && }
+ + + + + + + + + {tabs.map((t) => ( + { 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); }} + 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', + }} + > + + + + + ))} + +
标签页显示
+ {t.kind === 'builtin' ? '内置' : '策略'} + {t.name} + + toggleVisible(t)} disabled={saving} /> +
+ )} ); } @@ -210,53 +263,18 @@ function StrategyGroupSettings() { } }; - const handleMove = async (id, dir) => { - setSaving(true); - try { - const res = await call('one-divine-lot/strategies/move', { args: { strategyId: id, dir } }); - if (res && res.ok) { - await load(); - } else { - toast.error((res && res.error && res.error.message) || '排序失败'); - } - } catch (e) { - toast.error(e.message); - } finally { - setSaving(false); - } - }; - - const toggleVisible = async (id) => { - if (!strategies) return; - setSaving(true); - try { - const next = strategies.map((s) => (s.id === id ? { ...s, visible: !s.visible } : s)); - const res = await call('one-divine-lot/strategies/update', { args: { strategies: next } }); - if (res && res.ok) { - setStrategies(next); - handleChanged('显示设置已更新'); - } else { - toast.error((res && res.error && res.error.message) || '保存失败'); - } - } catch (e) { - toast.error(e.message); - } finally { - setSaving(false); - } - }; - return (
{changedMsg && (
{changedMsg} —— 刷新页面后生效
)} - {error &&
{error}
} + {error &&
{error}
}
setNewName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAdd()} - style={{ flex: 1, padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4 }} + style={{ flex: 1, padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4 }} />
- {!strategies && !error &&
加载中...
} +
+ 顺序与显示请在「Tab 设置」中调整。 +
+ + {!strategies && !error &&
加载中...
} {strategies && ( - - + - - {strategies.map((s, idx) => ( - - + {strategies.map((s) => ( + - - + @@ -354,16 +359,16 @@ function StrategyGroupSettings() { /** 设置 section 主组件:三个子 tab */ function SettingsSectionInner(props) { - const [activeTab, setActiveTab] = useState('general'); // general | strategies | qmt + const [activeTab, setActiveTab] = useState('tabs'); // tabs | strategies | qmt return (

神之一手

{/* 子 tab 导航 */} -
+
{[ - { key: 'general', label: '通用设置' }, + { key: 'tabs', label: 'Tab 设置' }, { key: 'strategies', label: '策略分组' }, { key: 'qmt', label: 'QMT 连接配置' }, ].map((tab) => ( @@ -371,8 +376,8 @@ function SettingsSectionInner(props) { key={tab.key} onClick={() => setActiveTab(tab.key)} style={{ - padding: '8px 16px', cursor: 'pointer', border: 'none', borderBottom: activeTab === tab.key ? '2px solid #2e7d32' : '2px solid transparent', - background: 'none', fontSize: 14, color: activeTab === tab.key ? '#2e7d32' : '#666', fontWeight: activeTab === tab.key ? 600 : 400, + padding: '8px 16px', cursor: 'pointer', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--dsw-alias-state-success-primary, #2e7d32)' : '2px solid transparent', + background: 'none', fontSize: 14, color: activeTab === tab.key ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-label-secondary, #666)', fontWeight: activeTab === tab.key ? 600 : 400, }} > {tab.label} @@ -380,9 +385,7 @@ function SettingsSectionInner(props) { ))}
- {activeTab === 'general' && { - // 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示) - }} />} + {activeTab === 'tabs' && } {activeTab === 'strategies' && } {activeTab === 'qmt' && }
@@ -411,34 +414,34 @@ function EllipsisText({ text, maxWidth = 220, title, style }) { /** QMT 连接配置卡片(R-004;产品约束-005、UI约束-002;2026-08-29 老师反馈改卡片形式) */ function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActivate, onSetDefault, onTest, onEdit, onDelete }) { - const btn = { padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }; + const btn = { padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }; return (
{/* 标题行:名称 + 徽标 */}
- {isActive && 生效中} - {isDefault && 默认} + {isActive && 生效中} + {isDefault && 默认}
{/* 地址行:省略+悬停 */} -
+
{/* 测试结果行:可换行但受卡片约束;超长悬停看全文 */} {testResult && (
@@ -447,11 +450,11 @@ function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActi {/* 操作行:按钮自动换行排列,不挤压卡片 */}
- {!isActive && } + {!isActive && } {!isDefault && } - +
); @@ -620,19 +623,19 @@ function QmtConnectionsSettings() { return (
-
+
激活配置为当前生效连接(切换立即生效);默认配置在插件启动时自动激活。
-
- {error &&
} - {!state && !error &&
加载中...
} + {error &&
} + {!state && !error &&
加载中...
} {state && state.list.length === 0 && ( -
+
暂无连接配置。点击「新增连接」录入第一个 QMT Bridge 地址(如 http://192.168.3.43:8610)。
)} @@ -662,40 +665,40 @@ function QmtConnectionsSettings() { )} {formOpen && ( -
+
{editingId === null ? '新增连接配置' : '编辑连接配置'}
-
{testResult && testResult.key === '__form__' && ( diff --git a/src/client/views/StrategyTab.jsx b/src/client/views/StrategyTab.jsx index 3b24679..a9d2b81 100644 --- a/src/client/views/StrategyTab.jsx +++ b/src/client/views/StrategyTab.jsx @@ -39,10 +39,10 @@ function calcPctChange(snap) { /** 涨幅着色:涨红跌绿(A股习惯) */ function pctStyle(pct) { - if (pct == null) return { color: '#999' }; - if (pct > 0) return { color: '#d32f2f', fontWeight: 'bold' }; - if (pct < 0) return { color: '#2e7d32', fontWeight: 'bold' }; - return { color: '#999' }; + if (pct == null) return { color: 'var(--dsw-alias-label-tertiary, #999)' }; + if (pct > 0) return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' }; + if (pct < 0) return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' }; + return { color: 'var(--dsw-alias-label-tertiary, #999)' }; } /** 涨幅文本:+x.xx% / -x.xx% / — */ @@ -60,10 +60,10 @@ const ADD_FORM_CSS = ` align-items: center; gap: 10px; padding: 12px; - border: 1px solid #ddd; + border: 1px solid var(--dsw-alias-border-l2, #ddd); border-radius: 6px; margin-bottom: 12px; - background: #fafafa; + background: var(--dsw-alias-bg-layer-1, #fafafa); } .odl-add-form .odl-field { flex: 1 1 260px; @@ -94,8 +94,8 @@ const ADD_FORM_CSS = ` margin-left: auto; padding: 6px 16px; cursor: pointer; - background: #2e7d32; - color: #fff; + background: var(--dsw-alias-state-success-primary, #2e7d32); + color: var(--dsw-alias-button-contrast-fill, #fff); border: none; border-radius: 4px; } @@ -288,7 +288,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
@@ -329,13 +329,13 @@ function StrategyTabInner({ strategyId, strategyName }) { )} {positions && positions.length === 0 ? ( -
+
暂无持仓,点击「+ 添加持仓」从全部持仓中添加
) : (
排序
策略 ID显示 操作
- - -
{editingId === s.id ? ( @@ -320,18 +328,15 @@ function StrategyGroupSettings() { s.name )} {s.id} - toggleVisible(s.id)} disabled={saving} /> - {s.id}
- + @@ -359,9 +359,9 @@ function StrategyTabInner({ strategyId, strategyName }) { toggleExpand(p.code, p.holdingId)} style={{ - borderBottom: '1px solid #eee', + borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', cursor: hasHolding ? 'pointer' : 'default', - background: isExpanded ? '#f5f5f5' : 'transparent', + background: isExpanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent', }} > @@ -421,7 +421,7 @@ function StrategyTabInner({ strategyId, strategyName }) { {isExpanded && ( - +
代码 名称
@@ -374,7 +374,7 @@ function StrategyTabInner({ strategyId, strategyName }) { display: 'inline-block', transition: 'transform .15s ease', transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)', - color: '#2e7d32', + color: 'var(--dsw-alias-state-success-primary, #2e7d32)', verticalAlign: 'middle', }} > @@ -388,7 +388,7 @@ function StrategyTabInner({ strategyId, strategyName }) { /> ) : ( - · + · )} {p.code}
{isLoading ? ( -
交易记录加载中...
+
交易记录加载中...
) : !trades || trades.length === 0 ? ( -
+
{hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id)'}
) : ( @@ -486,9 +486,9 @@ function dirTextH(d) { return '—'; } function dirColorH(d) { - if (d === 'buy') return { color: '#d32f2f', fontWeight: 'bold' }; - if (d === 'sell') return { color: '#2e7d32', fontWeight: 'bold' }; - return { color: '#999' }; + if (d === 'buy') return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' }; + if (d === 'sell') return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' }; + return { color: 'var(--dsw-alias-label-tertiary, #999)' }; } function HoldingTradesTable({ trades }) { @@ -496,7 +496,7 @@ function HoldingTradesTable({ trades }) { return ( - + @@ -510,7 +510,7 @@ function HoldingTradesTable({ trades }) { {list.map((o) => ( - + diff --git a/src/client/views/Toast.jsx b/src/client/views/Toast.jsx index 81d1fcd..2d5917f 100644 --- a/src/client/views/Toast.jsx +++ b/src/client/views/Toast.jsx @@ -44,9 +44,9 @@ export function ToastHost() { style={{ padding: '10px 16px', borderRadius: 6, - color: '#fff', - background: t.type === 'success' ? '#2e7d32' : '#c62828', - boxShadow: '0 2px 8px rgba(0,0,0,0.2)', + color: 'var(--dsw-alias-button-contrast-fill, #fff)', + background: t.type === 'success' ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)', + boxShadow: 'var(--dsw-shadow-lv3, 0 2px 8px rgba(0,0,0,0.2))', fontSize: 13, maxWidth: 360, }} diff --git a/src/client/views/TradeRecordsTab.jsx b/src/client/views/TradeRecordsTab.jsx index dd42b34..81c77d4 100644 --- a/src/client/views/TradeRecordsTab.jsx +++ b/src/client/views/TradeRecordsTab.jsx @@ -42,9 +42,9 @@ function fmtNum(v, digits = 2) { /** 方向样式:红买绿卖 */ function directionStyle(direction) { - if (direction === 'buy') return { color: '#d32f2f', fontWeight: 'bold' }; - if (direction === 'sell') return { color: '#2e7d32', fontWeight: 'bold' }; - return { color: '#999' }; + if (direction === 'buy') return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' }; + if (direction === 'sell') return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' }; + return { color: 'var(--dsw-alias-label-tertiary, #999)' }; } function directionText(direction) { if (direction === 'buy') return '买入'; @@ -309,7 +309,7 @@ export function TradeRecordsTab() {
交易日 时间 方向
{fmtDateH(o.tradeDate)} {fmtTimeH(o.insertTime)} {dirTextH(o.direction)}
- + @@ -404,9 +404,9 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan @@ -461,11 +461,11 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan {expanded && hasTrades && ( - +
toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')} toggleSort('code')} style={thClickStyle}>代码{arrow('code')}
@@ -420,7 +420,7 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan transition: 'transform .15s ease', transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', cursor: 'pointer', - color: '#2e7d32', + color: 'var(--dsw-alias-state-success-primary, #2e7d32)', verticalAlign: 'middle', }} > @@ -434,7 +434,7 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan /> ) : ( - · + · )} {fmtTime(order.insertTime)}
- + @@ -475,12 +475,12 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan {order.trades.map((t) => ( - + - + ))} @@ -517,10 +517,10 @@ function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution style={{ fontSize: 12, padding: '2px 4px', - border: '1px solid ' + (current ? '#2e7d32' : '#ccc'), + border: '1px solid ' + (current ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'), borderRadius: 4, - background: current ? '#e8f5e9' : '#fff', - color: current ? '#1b5e20' : '#999', + background: current ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)', + color: current ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)', maxWidth: 120, }} title="设置该委托的策略归属(可随时修改)" diff --git a/src/settings.js b/src/settings.js index fff0d3a..e10f69f 100644 --- a/src/settings.js +++ b/src/settings.js @@ -5,38 +5,63 @@ * 通过 ctx.settings.register('one-divine-lot', schema) 注册策略配置 namespace。 * * 数据结构: - * strategies: [{ id, name, visible, order }] + * strategies: [{ id, name }](R-011 收窄:visible/order 归 tabs 统一管理) + * tabs: [{ id, kind: builtin|strategy, refKey|refId, name, visible, order }] * 预置:网格超市、手动做T(R-003 O3-5:可删除,彻底自由) * - * 迭代 02 新增(R-003 O3 策略 CRUD): - * - generateStrategyId(name):中文/任意名 → 英文 slug id - * - addStrategy / renameStrategy / moveStrategy / removeStrategy + * R-011 迭代 09(产品约束-009、技术约束-014): + * - settings.tabs 从布尔对象升级为统一有序数组(内置 + 策略混排) + * - 旧格式读取时静默归一化迁移(旧隐藏策略迁移后显示,Q3) + * - addStrategy 联动追加 tab(末尾,Q2);removeStrategy 联动删除对应 tab(D3) + * - 策略行 name 由 getTabs join strategies(改名自动跟随,Q4) * - removeStrategy 的份额清理由上层(api.js 编排 storage)完成 */ 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: '关注列表' }, +]; + +/** 默认 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, +})); + +/** 默认策略(首次注册时的初始值) */ +export const DEFAULT_STRATEGIES = [ + { id: 'grid-supermarket', name: '网格超市' }, + { id: 'manual-t', name: '手动做T' }, +]; + /** 策略设置 schema(schemastery 定义,settings.register 要求 schema 对象) */ export const strategySchema = z.object({ + // 策略定义表(R-011 收窄:仅 id + name,visible/order 归 tabs 统一管理) strategies: z.array(z.object({ id: z.string().required(), name: z.string().required(), - visible: z.boolean().default(true), - order: z.number().default(0), - })).default([ - { id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 }, - { id: 'manual-t', name: '手动做T', visible: true, order: 1 }, - ]), - // 通用 tab 显隐配置(设置 tab 化扩展,2026-08-28) - tabs: z.object({ - allPositions: z.boolean().default(true), // 全部持仓 - tradeRecords: z.boolean().default(true), // 交易记录(未来 tab,本期空占位) - watchlist: z.boolean().default(true), // 关注列表(未来 tab,本期空占位) - }).default({ - allPositions: true, - tradeRecords: true, - watchlist: true, - }), + })).default(DEFAULT_STRATEGIES), + // 统一 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({ @@ -50,18 +75,6 @@ export const strategySchema = z.object({ }).default({ list: [], activeId: '', defaultId: '' }), }); -/** 默认通用 tab 配置 */ -export const DEFAULT_TABS = { - allPositions: true, - tradeRecords: true, - watchlist: true, -}; - -/** 默认策略(首次注册时的初始值) */ -export const DEFAULT_STRATEGIES = [ - { id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 }, - { id: 'manual-t', name: '手动做T', visible: true, order: 1 }, -]; /** 简易拼音映射表(常用汉字 → 拼音,用于 slug 生成兜底) */ const PINYIN_MAP = { @@ -182,23 +195,12 @@ export function registerSettings(ctx, config) { } /** - * 读取策略列表(含 visible 过滤) + * 读取策略定义表(R-011 收窄:仅 id + name,无 visible/order) * @param {ReturnType} scope */ export function getStrategies(scope) { const value = scope?.get() ?? {}; - const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES; - // 统一按 order 排序(设置页 / tab 栏 / 逻辑层共用同一顺序) - return list.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); -} - -/** - * 获取可见策略列表(visible === true,按 order 排序) - */ -export function getVisibleStrategies(scope) { - return getStrategies(scope) - .filter((s) => s.visible !== false) - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + return Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES; } /** @@ -222,10 +224,10 @@ export async function addStrategy(scope, name) { const strategy = { id, name: String(name ?? '').trim(), - visible: true, - order: list.reduce((max, s) => Math.max(max, s.order ?? 0), -1) + 1, }; await updateStrategies(scope, [...list, strategy]); + // R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾) + await appendStrategyTab(scope, strategy); return strategy; } @@ -245,33 +247,6 @@ export async function renameStrategy(scope, id, name) { return true; } -/** - * 移动策略(排序,上移/下移) - * @param {ReturnType} scope - * @param {string} id 策略 id - * @param {'up'|'down'} dir 方向 - * @returns {Promise} 是否成功 - */ -export async function moveStrategy(scope, id, dir) { - const list = getStrategies(scope) - .slice() - .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); - const idx = list.findIndex((s) => s.id === id); - if (idx < 0) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' }); - const swapIdx = dir === 'up' ? idx - 1 : idx + 1; - if (swapIdx < 0 || swapIdx >= list.length) return false; // 已在边界 - const a = list[idx]; - const b = list[swapIdx]; - const orderA = a.order ?? 0; - const orderB = b.order ?? 0; - const next = list.map((s) => { - if (s.id === a.id) return { ...s, order: orderB }; - if (s.id === b.id) return { ...s, order: orderA }; - return s; - }); - await updateStrategies(scope, next); - return true; -} /** * 删除策略(O3;份额清理由上层编排 storage) @@ -284,40 +259,114 @@ export async function removeStrategy(scope, id) { 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; } /** - * 读取通用 tab 显隐配置(设置 tab 化扩展) + * 归一化 tabs 为有序数组(R-011 迁移:旧布尔对象 → 数组;幂等,不重复迁移) + * 旧格式:tabs = { allPositions, tradeRecords, watchlist } + strategies 自带 visible/order + * 新格式:tabs = [{ id, kind, refKey|refId, name, visible, order }] + * 旧隐藏策略迁移后 visible=true(Q3,产品约束-009)。 * @param {ReturnType} scope - * @returns {{ allPositions: boolean, tradeRecords: boolean, watchlist: boolean }} + * @returns {Array} 归一化后的 tabs 数组(按 order 排序) */ -export function getTabConfig(scope) { +export function normalizeTabs(scope) { const value = scope?.get() ?? {}; - const tabs = value.tabs ?? {}; - return { - allPositions: tabs.allPositions !== false, - tradeRecords: tabs.tradeRecords !== false, - watchlist: tabs.watchlist !== false, - }; + const raw = value.tabs; + // 已归一化(数组) + if (Array.isArray(raw)) return raw.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + // 旧布尔对象或缺失 → 生成内置条目(保留原显隐值) + 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, + })); + + // 策略条目:按旧 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]; } /** - * 更新通用 tab 显隐配置(整表更新) + * 读取统一 tab 列表(R-011):归一化 + 策略行 name join(Q4 自动跟随改名) * @param {ReturnType} scope - * @param {object} tabs { allPositions?, tradeRecords?, watchlist? } + * @returns {Array} tabs 数组(按 order 排序,策略行 name 已 join) + */ +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)); +} + +/** + * 更新统一 tab 列表(整表更新:顺序 + 显隐;R-011) + * 策略行 name 由服务端 join 刷新,客户端可只传 id/order/visible 的数组。 + * @param {ReturnType} scope + * @param {Array} tabs 完整 tabs 数组 + */ +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, + })); + 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 取代 + */ +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 current = scope?.get() ?? {}; - const next = { - ...current, - tabs: { - allPositions: tabs.allPositions !== false, - tradeRecords: tabs.tradeRecords !== false, - watchlist: tabs.watchlist !== false, - }, - }; - await scope.update(next); + 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); } /**
成交时间 成交价 成交量
{fmtTime(t.tradeTime)} {fmtNum(t.price)} {fmtNum(t.volume, 0)} {fmtNum(t.amount)}{t.tradeId}{t.tradeId}