Compare commits
2 Commits
35f2ad1a23
...
f1e7e785a1
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e7e785a1 | |||
| 4ac8a29805 |
@@ -0,0 +1,61 @@
|
|||||||
|
# 计划:Tab 设置——统一管理所有会话 tab(内置 + 策略分组,显示/隐藏 + 拖动排序)(阶段航点)
|
||||||
|
|
||||||
|
> 编号:PLAN-010 | 粒度:阶段航点(大粒度) | 创建:2026-09-02 | 状态:**执行中**
|
||||||
|
> 派生自终极目标:目标-002(分仓管理工具)、目标-003(按策略监控市场)
|
||||||
|
> 依据需求:**R-011(已定稿,2026-09-02,Q1-Q5 确认)** —— 符合入范围门槛
|
||||||
|
> 设计约束:产品约束-009、UI约束-003、技术约束-014(R-011 新增沉淀)、技术约束-004/005/006(沿用)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将设置页「通用设置」升级为 **「Tab 设置」**,成为所有会话 tab(系统内置 + 策略分组)**唯一的顺序与显隐入口**:两类 tab 混排一张表,每行 = 拖动手柄 + 名称 + 显示/隐藏开关;**任何 tab 均不支持重命名与删除**;拖动落点立即持久化。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**:
|
||||||
|
1. **数据模型统一**(src/settings.js):settings.tabs 从布尔对象 → 有序数组 [{id, kind: builtin|strategy, refKey|refId, name, visible, order}];strategies 收窄为 {id, name};读取时旧格式静默归一化(旧 tabs 布尔对象 → 三个内置条目保留原显隐;旧策略按 order 接续追加,旧隐藏策略迁移后 visible=true);新增整表更新(顺序 + 显隐);DEFAULT_TABS 升级为默认数组;
|
||||||
|
2. **API 层**(src/api/strategies.js):tabs/update 语义改为整表更新(顺序 + 显隐);strategies/add 联动追加 tab 条目(末尾);strategies/remove 联动删除对应 tab 条目;废弃 strategies/move;strategies/update 仅剩重命名;
|
||||||
|
3. **客户端注册**(src/client/index.js):合并 registerGeneralTabs + registerStrategyTabs 为统一注册(读 tabs 数组按 order 排序、过滤 visible;builtin 走内置 render、strategy 走 StrategyTab),移除硬编码 order 间隔(10/11/12 与 13+);
|
||||||
|
4. **设置页 UI**(src/client/views/SettingsSection.jsx):
|
||||||
|
- 「Tab 设置」子 tab 取代「通用设置」:混排表格,内置行「内置」徽标、策略行「策略」徽标;每行 = 拖动手柄 + 名称 + 显隐开关;无任何重命名/删除按钮;
|
||||||
|
- 拖动:原生 HTML5 Drag & Drop,落点重排 → 立即调 tabs/update 持久化 → 提示「刷新页面后生效」;
|
||||||
|
- 「策略分组」子 tab 瘦身:只留 新增/重命名/删除,移除排序箭头与显隐开关,加提示「顺序与显示请在「Tab 设置」中调整」。
|
||||||
|
|
||||||
|
**不做**:
|
||||||
|
- 策略的命名/删除入口迁移(仍在「策略分组」子 tab);
|
||||||
|
- 内置 tab 的重命名/删除(禁);
|
||||||
|
- tab 注册热更新(沿用「刷新页面后生效」机制,无事件机制);
|
||||||
|
- 其他设置子 tab(QMT 连接配置)改动(仅 UI约束-002 文案「通用设置」→「Tab 设置」对齐)。
|
||||||
|
|
||||||
|
## 程序结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── settings.js # 改:tabs 有序数组 + 归一化迁移 + 整表更新;strategies 收窄
|
||||||
|
├── api/strategies.js # 改:tabs/update 整表;add/remove 联动 tabs;废弃 move
|
||||||
|
src/client/
|
||||||
|
├── index.js # 改:统一注册(读 tabs 数组)
|
||||||
|
└── views/SettingsSection.jsx # 改:「Tab 设置」子 tab + 拖动排序 + 策略分组瘦身
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. **文档骨架**:PLAN-010 + 迭代 09 文档(本步);
|
||||||
|
2. **settings.js**:tabs 有序数组 schema + DEFAULT_TABS + 归一化(getTabs 旧格式转新)+ updateTabs 整表 + strategies 收窄(去 visible/order 读写);
|
||||||
|
3. **api/strategies.js**:tabs/update 整表语义;strategies/add 追加 tab;strategies/remove 联动删 tab;废弃 move(API 表 + handler);
|
||||||
|
4. **client/index.js**:统一注册函数(读 tabs 数组),移除硬编码 order;
|
||||||
|
5. **SettingsSection.jsx**:「Tab 设置」子 tab(混排表格 + 徽标 + 显隐开关 + 拖动排序 + 立即持久化);策略分组子 tab 瘦身;
|
||||||
|
6. **构建测试**:pnpm run build + typecheck + 独立数据目录回归(技术约束-011,注意只读端点可直连、写操作走临时目录);
|
||||||
|
7. **验收 + 复盘**。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 「通用设置」子 tab 更名为「Tab 设置」,表格混排内置 + 策略全部 tab;
|
||||||
|
- 内置行带「内置」徽标、策略行带「策略」徽标;
|
||||||
|
- 每行可拖动排序,落点立即持久化(刷新页面后生效);
|
||||||
|
- 每行有显示/隐藏开关,切换立即持久化;
|
||||||
|
- 任何行均无重命名/删除按钮;
|
||||||
|
- 策略分组子 tab 只留 新增/重命名/删除,无排序箭头/显隐开关,有指引提示;
|
||||||
|
- 新增策略出现在 Tab 设置列表末尾;删除策略后对应 tab 条目消失;
|
||||||
|
- 老配置(布尔对象 tabs + 策略 order/visible)读取时自动归一化,无报错、无数据丢失;
|
||||||
|
- 会话 tab 栏按新顺序与显隐注册(内置与策略可交错);
|
||||||
|
- 现有功能不回归(全部持仓/交易记录/策略持仓/QMT 切换)。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 计划:UI 适配 DSH 主题(浅色 / 深色 / 跟随系统)(阶段航点)
|
||||||
|
|
||||||
|
> 编号:PLAN-011 | 粒度:阶段航点 | 创建:2026-09-02 | 状态:**执行中**
|
||||||
|
> 派生自终极目标:目标-001(神之一手工具集)
|
||||||
|
> 依据需求:**R-012(已定稿,2026-09-02,暂定跟随系统)** —— 符合入范围门槛
|
||||||
|
> 设计约束:UI约束-003(Tab 设置)、UI约束-002(QMT 卡片)沿用;本次新增色值 token 化约定
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
神之一手客户端 UI 全部硬编码色值替换为宿主 `--dsw-*` token,使各页面在 DSH 浅色 / 深色 / 跟随系统主题下均可读、协调,随主题切换自动适配,不自行维护主题偏好。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**:
|
||||||
|
1. 客户端 10 个文件 141 处硬编码颜色按语义映射表替换为 `var(--dsw-*)` 或 `color-mix`(随主题淡色底);
|
||||||
|
2. 确认 QmtConnectionChip 已用 token 与硬编码混杂处统一;
|
||||||
|
3. 涨跌色(红涨绿跌)→ 宿主 state-error/success;主按钮 / 徽标 / 提示底色 → 宿主语义 token + color-mix;
|
||||||
|
4. 构建 + typecheck + 深浅主题人工验收。
|
||||||
|
|
||||||
|
**不做**:
|
||||||
|
- 自定义 `--odl-*` 覆盖 token(D5 暂缓);
|
||||||
|
- 布局 / 间距 / 圆角改动;
|
||||||
|
- 服务端 / API / 数据改动;
|
||||||
|
- 插件自维护主题偏好(跟随宿主)。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
```
|
||||||
|
src/client/views/
|
||||||
|
├── SettingsSection.jsx # 55 处(最大)
|
||||||
|
├── StrategyTab.jsx # 24
|
||||||
|
├── TradeRecordsTab.jsx # 21
|
||||||
|
├── QmtConnectionChip.jsx # 13(部分已 token)
|
||||||
|
├── RangeSelector.jsx # 7
|
||||||
|
├── PriceCell.jsx # 5(涨跌色)
|
||||||
|
├── AllPositionsTab.jsx # 4
|
||||||
|
├── LoadState.jsx # 3
|
||||||
|
├── Toast.jsx # 2
|
||||||
|
└── PlaceholderTab.jsx # 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. **文档骨架**:PLAN-011 + 迭代 10(本步);
|
||||||
|
2. **批量替换**:按文件逐处替换(先小文件确认模式,再大文件);
|
||||||
|
3. **验证**:build + typecheck;人工切 DSH 浅/深主题检查各页;
|
||||||
|
4. **验收 + 复盘**。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 浅色主题下观感与现状基本一致(无突兀色差);
|
||||||
|
- 深色主题下所有页面可读(背景/文字/边框/按钮/涨跌/徽标/提示/下拉菜单均协调);
|
||||||
|
- 跟随系统切换实时生效;
|
||||||
|
- 无残留硬编码色(#xxx / rgb / rgba 全部清理,注释可留);
|
||||||
|
- 布局与交互不变(仅色值)。
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
| 编号 | 约束说明 | 添加日期 | 生效状态 | 失效日期 | 最后一次变更描述 |
|
| 编号 | 约束说明 | 添加日期 | 生效状态 | 失效日期 | 最后一次变更描述 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| UI约束-001 | 会话头部 QMT 连接切换 chip:紧凑形态 `QMT: <激活配置名> ▾`,与 PTC 模式标签并排;下拉菜单列出全部配置(当前激活项勾选标记),点选即激活;操作结果用顶部轻提示反馈(成功绿/失败红,约 3s 消失,沿用现有 Toast 体系) | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9/Q10 确认的头部控件形态 |
|
| UI约束-001 | 会话头部 QMT 连接切换 chip:紧凑形态 `QMT: <激活配置名> ▾`,与 PTC 模式标签并排;下拉菜单列出全部配置(当前激活项勾选标记),点选即激活;操作结果用顶部轻提示反馈(成功绿/失败红,约 3s 消失,沿用现有 Toast 体系) | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9/Q10 确认的头部控件形态 |
|
||||||
| UI约束-002 | 设置页「QMT 连接配置」子 tab:与「通用设置 / 策略分组」并列的第三个子 tab;配置以**卡片形式**展示(自适应网格 minmax(260px,1fr),激活卡片绿色描边):卡片含名称 + 激活/默认徽标 + HTTP 地址 + 行内操作(激活 / 设为默认 / 编辑 / 测试连接 / 删除)+ 新增/编辑表单(纵排两字段);删除沿用现有确认弹窗模式,删除最后一条时给出禁止提示;超时不设输入框(Q5);长文本(地址/错误信息)单行省略 + 悬停看全文,组件设 minWidth:0 防撑行 | 2026-08-29 | 生效 | - | 变更(2026-08-29 老师反馈):列表形式改卡片形式,约束组件宽度、长文本省略不换行;原定稿:设置页子 tab 形态沿用现有设置交互体系 |
|
| UI约束-002 | 设置页「QMT 连接配置」子 tab:与「Tab 设置 / 策略分组」并列的第三个子 tab;配置以**卡片形式**展示(自适应网格 minmax(260px,1fr),激活卡片绿色描边):卡片含名称 + 激活/默认徽标 + HTTP 地址 + 行内操作(激活 / 设为默认 / 编辑 / 测试连接 / 删除)+ 新增/编辑表单(纵排两字段);删除沿用现有确认弹窗模式,删除最后一条时给出禁止提示;超时不设输入框(Q5);长文本(地址/错误信息)单行省略 + 悬停看全文,组件设 minWidth:0 防撑行 | 2026-08-29 | 生效 | - | 变更(2026-08-29 老师反馈):列表形式改卡片形式,约束组件宽度、长文本省略不换行;原定稿:设置页子 tab 形态沿用现有设置交互体系;变更(2026-09-02 R-011 定稿):「通用设置」更名为「Tab 设置」 |
|
||||||
|
| UI约束-003 | 设置页「Tab 设置」子 tab:表格列出**全部会话 tab(系统内置 + 策略分组)混排**,内置行带「内置」徽标、策略行带「策略」徽标;每行 = 拖动手柄(原生 HTML5 Drag & Drop)+ 名称 + 显示/隐藏开关;**任何行均无重命名/删除按钮**;拖动落点确认后**立即持久化**(每次 drop 提交整表,沿用「刷新页面后生效」提示机制);「策略分组」子 tab 只保留 新增/重命名/删除,移除排序箭头与显隐开关,并提示「顺序与显示请在「Tab 设置」中调整」 | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1/Q4/Q5 确认):拖动排序 + 显隐开关,禁重命名/删除 |
|
||||||
|
|
||||||
|
| UI约束-004 | 神之一手 UI 适配 DSH 主题:所有颜色引用宿主 `--dsw-*` token(带 fallback `var(--dsw-alias-xxx, #原色)`),随 DSH 浅色 / 深色 / 跟随系统自动切换,不自行维护主题偏好(跟随宿主);语义映射:表面底→bg-layer-1、次级面→bg-layer-2、hover→interactive-bg-hover、主/次/弱文字→label-primary/secondary/tertiary、边框→border-l1..l4、红(涨/删/错)→state-error-primary、绿(跌/成/激活)→state-success-primary、蓝(信息/业务)→state-business-primary、实心按钮字→button-contrast-fill、遮罩→bg-mask-1、阴影→shadow-lv3;淡底用 color-mix(in srgb, var(--语义色) 10-12%, transparent);禁止硬编码色值(#hex/rgb)进运行代码 | 2026-09-02 | 生效 | - | R-012 定稿(2026-09-02,暂定跟随系统)+ 迭代 10 实施:141 处硬编码色 token 化,宿主机制实证(body[data-ds-dark-theme] + alias token 双值定义) |
|
||||||
|
|
||||||
<!-- 示例条目(确认格式后删除):
|
<!-- 示例条目(确认格式后删除):
|
||||||
| UI约束-001 | 示例:求签页面必须保持单屏完整,不出现滚动 | 2026-08-26 | 生效 | - | 讨论确认:移动端优先,避免滚动打断仪式感 |
|
| UI约束-001 | 示例:求签页面必须保持单屏完整,不出现滚动 | 2026-08-26 | 生效 | - | 讨论确认:移动端优先,避免滚动打断仪式感 |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
| 产品约束-006 | QMT 连接会话头部快捷切换:会话窗口顶栏(PTC 模式标签旁)常驻下拉控件(chip 显示 `QMT: <激活配置名>`),点开列出全部配置(激活项勾选),点选即激活并轻提示反馈;头部控件仅做切换,配置管理(增删改/测试连接/默认标记)仍在设置页子 tab | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9/Q10 确认(入口=会话头部 PTC 旁,菜单仅切换激活) |
|
| 产品约束-006 | QMT 连接会话头部快捷切换:会话窗口顶栏(PTC 模式标签旁)常驻下拉控件(chip 显示 `QMT: <激活配置名>`),点开列出全部配置(激活项勾选),点选即激活并轻提示反馈;头部控件仅做切换,配置管理(增删改/测试连接/默认标记)仍在设置页子 tab | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9/Q10 确认(入口=会话头部 PTC 旁,菜单仅切换激活) |
|
||||||
|
|
||||||
| 产品约束-008 | 交易记录支持**按策略过滤**:交易记录 tab 提供策略过滤下拉(全部 / 各策略 / 未关联),对今日(QMT 实时)与历史(本地 SQLite)均生效;策略归属 = 委托时间 join 持仓生命周期窗口推导(一码多策略取份额最大,未命中=未关联);历史范围展示本地积累数据(不再「接口开发中」占位) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):策略过滤 + 历史本地展示 |
|
| 产品约束-008 | 交易记录支持**按策略过滤**:交易记录 tab 提供策略过滤下拉(全部 / 各策略 / 未关联),对今日(QMT 实时)与历史(本地 SQLite)均生效;策略归属 = 委托时间 join 持仓生命周期窗口推导(一码多策略取份额最大,未命中=未关联);历史范围展示本地积累数据(不再「接口开发中」占位) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):策略过滤 + 历史本地展示 |
|
||||||
|
| 产品约束-009 | 会话 tab 统一由「Tab 设置」管理:设置页「Tab 设置」子 tab 是**所有会话 tab(系统内置 + 策略分组)的唯一顺序与显隐入口**,两类 tab 混排;每行 = 拖动排序 + 显示/隐藏开关;**任何 tab 均不支持重命名与删除**(内置 tab 名称只读,策略命名/删除仍在「策略分组」子 tab);策略改名后 Tab 设置中的名称自动跟随(只存引用);新增策略默认追加到列表末尾;删除策略联动删除 Tab 设置中对应条目;顺序与显隐唯一数据源 = settings.tabs 有序数组 | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1-Q5 确认):Tab 设置 = 显示/隐藏 + 拖动排序(落点立即持久化),全 tab 禁重命名/删除 |
|
||||||
<!-- 示例条目(确认格式后删除):
|
<!-- 示例条目(确认格式后删除):
|
||||||
| 产品约束-001 | 示例:求签功能必须保证抽取结果的不可预测性 | 2026-08-26 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 |
|
| 产品约束-001 | 示例:求签功能必须保证抽取结果的不可预测性 | 2026-08-26 | 生效 | - | 讨论确认:为保证公平性,抽取必须不可预测 |
|
||||||
-->
|
-->
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
| 技术约束-009 | 会话头部快捷切换控件挂载 DSH 开放 slot `conversation.session.header.actions`(多实例挂载点,按 order 排序多插件共存):客户端插件以独立 id 并排注册(DSH 内置 PTC 标签 order=-10,本控件 order=-9),不改动 DSH 宿主;控件经 ConnectionProvider 包装复用现有 RPC 通道与 /odl/api/* 端点 | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9 确认;宿主代码审查核实 slot 机制与内置插件注册方式 |
|
| 技术约束-009 | 会话头部快捷切换控件挂载 DSH 开放 slot `conversation.session.header.actions`(多实例挂载点,按 order 排序多插件共存):客户端插件以独立 id 并排注册(DSH 内置 PTC 标签 order=-10,本控件 order=-9),不改动 DSH 宿主;控件经 ConnectionProvider 包装复用现有 RPC 通道与 /odl/api/* 端点 | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9 确认;宿主代码审查核实 slot 机制与内置插件注册方式 |
|
||||||
|
|
||||||
| 技术约束-013 | 交易记录本地存储(R-009):QMT 当日交易数据(委托/成交)由服务端 TradeSync 定时同步落 SQLite(启动预热 + 60s 定时 + UPSERT 幂等,只同步当日);trade_orders(委托主行,order_id 主键 + insert_ts 派生时间列 + **strategy_id/holding_id 手动归属列**)+ trade_fills(成交明细,trade_id 主键、order_id 外键)两表;**委托归属由用户在交易记录 tab 手动设置**(候选 = 该 code 当前持仓策略 + 未关联,全手动选、可随时改、以最终为准);**UPSERT 不覆盖归属列**(手动指定为插件逻辑);本地历史查询走 trades/history 端点(策略过滤 = 用户设置的归属);今日实时仍走 QMT Bridge;QMT 委托/成交 code 无后缀、持仓带后缀 —— 数据源映射层统一 normalizeInstrumentCode 归一化;委托交易日 = insertDate(tradeDate 兜底) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):两表 + 定时同步 + 本地历史查询;变更 1(2026-09-01):+code 归一化 + tradeDate 兜底;变更 2(2026-09-01 老师二次定稿):归属改**手动设置**(trade_orders 冗余 strategy_id+holding_id,UPSERT 不覆盖归属列),弃算法推导 |
|
| 技术约束-013 | 交易记录本地存储(R-009):QMT 当日交易数据(委托/成交)由服务端 TradeSync 定时同步落 SQLite(启动预热 + 60s 定时 + UPSERT 幂等,只同步当日);trade_orders(委托主行,order_id 主键 + insert_ts 派生时间列 + **strategy_id/holding_id 手动归属列**)+ trade_fills(成交明细,trade_id 主键、order_id 外键)两表;**委托归属由用户在交易记录 tab 手动设置**(候选 = 该 code 当前持仓策略 + 未关联,全手动选、可随时改、以最终为准);**UPSERT 不覆盖归属列**(手动指定为插件逻辑);本地历史查询走 trades/history 端点(策略过滤 = 用户设置的归属);今日实时仍走 QMT Bridge;QMT 委托/成交 code 无后缀、持仓带后缀 —— 数据源映射层统一 normalizeInstrumentCode 归一化;委托交易日 = insertDate(tradeDate 兜底) | 2026-09-01 | 生效 | - | 新增(2026-09-01 R-009 定稿 + 迭代 07 实施):两表 + 定时同步 + 本地历史查询;变更 1(2026-09-01):+code 归一化 + tradeDate 兜底;变更 2(2026-09-01 老师二次定稿):归属改**手动设置**(trade_orders 冗余 strategy_id+holding_id,UPSERT 不覆盖归属列),弃算法推导 |
|
||||||
|
| 技术约束-014 | 会话 tab 注册与顺序显隐(R-011):客户端注册统一读 **settings.tabs 有序数组**(唯一顺序与显隐来源,内置条目 refKey + 策略条目 refId),按 order 排序、过滤 visible 后注册(builtin 走内置 render、strategy 走 StrategyTab),移除硬编码 order 间隔(原内置 10/11/12、策略 13+);settings.tabs 从布尔对象升级为有序数组,读取时对旧格式(布尔对象 + 策略自带 order/visible)静默归一化迁移(旧隐藏策略迁移后显示),写入即落库;策略定义表收窄为 {id,name}(去除 visible/order);tabs/update 语义改为整表更新(顺序 + 显隐),strategies/add 联动追加 tab 条目(末尾),strategies/remove 联动删除对应 tab 条目,废弃 strategies/move | 2026-09-02 | 生效 | - | R-011 定稿(2026-09-02 Q1-Q5 确认):统一 tabs 有序数组 + 自动迁移 + 联动增删 |
|
||||||
<!-- 示例条目(确认格式后删除):
|
<!-- 示例条目(确认格式后删除):
|
||||||
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
|
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
|
||||||
-->
|
-->
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 技术实现方案:09-Tab 设置统一管理
|
||||||
|
|
||||||
|
> 迭代编号:09 | 依据:PLAN-010、R-011、产品约束-009、UI约束-003、技术约束-014
|
||||||
|
|
||||||
|
## 1. 数据模型(src/settings.js)
|
||||||
|
|
||||||
|
### 1.1 tabs 有序数组(新 schema)
|
||||||
|
|
||||||
|
```js
|
||||||
|
tabs: z.array(z.object({
|
||||||
|
id: z.string().required(), // 唯一 id:'tab-all-positions' / 'tab-strategy-<id>'
|
||||||
|
kind: z.enum(['builtin', 'strategy']).required(),
|
||||||
|
refKey: z.string().optional(), // builtin 专用:'allPositions' | 'tradeRecords' | 'watchlist'
|
||||||
|
refId: z.string().optional(), // strategy 专用:策略 id
|
||||||
|
name: z.string().required(), // 展示名(策略行 join 时刷新)
|
||||||
|
visible: z.boolean().default(true),
|
||||||
|
order: z.number().default(0),
|
||||||
|
})).default(DEFAULT_TABS)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 归一化迁移(读取时)
|
||||||
|
|
||||||
|
getTabs(scope) 逻辑:
|
||||||
|
- 读取 settings.tabs:若为数组 → 直接返回(按 order 排序);
|
||||||
|
- 若为旧布尔对象(或缺失)→ 生成默认内置三条(全部持仓/交易记录/关注列表,保留原显隐值),策略按 strategies 旧 order 接续追加(旧 hidden → visible=true);
|
||||||
|
- 首次写入时落库归一化后的数组(幂等,不重复迁移)。
|
||||||
|
|
||||||
|
### 1.3 strategies 收窄
|
||||||
|
|
||||||
|
strategies schema 去掉 visible/order(仅 {id, name});getStrategies 不再排序/过滤 visible;策略展示名统一由 getTabs join strategies 得出(Q4 自动跟随改名)。
|
||||||
|
|
||||||
|
## 2. API 层(src/api/strategies.js)
|
||||||
|
|
||||||
|
| 端点 | 变更 |
|
||||||
|
|---|---|
|
||||||
|
| tabs | 返回合并后的完整 tabs 数组(含策略行 name join) |
|
||||||
|
| tabs/update | 整表更新 { tabs }(顺序 + 显隐);策略行 name 由服务端 join 刷新,客户端可只传 id 顺序 |
|
||||||
|
| strategies/add | 联动 append tab 条目(末尾,order = max+1) |
|
||||||
|
| strategies/remove | 联动删除对应 tab 条目(refId === 策略 id) |
|
||||||
|
| strategies/move | **废弃**(从 METHODS 移除) |
|
||||||
|
| strategies/update | 仅剩重命名(联动刷新 tabs 中策略行 name) |
|
||||||
|
|
||||||
|
## 3. 客户端注册(src/client/index.js)
|
||||||
|
|
||||||
|
合并 registerGeneralTabs + registerStrategyTabs → registerAllFromTabs(tabs):
|
||||||
|
```js
|
||||||
|
const sorted = tabs.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
|
for (const t of sorted) {
|
||||||
|
if (t.visible === false) continue;
|
||||||
|
const render = t.kind === 'builtin' ? GENERAL_RENDER[t.refKey] : (props) => createElement(StrategyTab, { ...props, strategyId: t.refId, strategyName: t.name });
|
||||||
|
// slots.register conversation.view, order: t.order
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- GENERAL_RENDER = { allPositions, tradeRecords, watchlist } 映射常量;
|
||||||
|
- 移除硬编码 order 10/11/12 与 13+ 间隔(直接用 t.order);
|
||||||
|
- fetchTabConfig → fetchTabs 返回数组。
|
||||||
|
|
||||||
|
## 4. 设置页 UI(src/client/views/SettingsSection.jsx)
|
||||||
|
|
||||||
|
### 4.1 「Tab 设置」子 tab
|
||||||
|
|
||||||
|
- 子 tab 导航:general → tabs(更名),标签「Tab 设置」;
|
||||||
|
- 表格列:拖动手柄(≡)| 名称(内置行带「内置」徽标、策略行带「策略」徽标)| 显示(Switch);
|
||||||
|
- **无重命名/删除按钮(任何行)**;
|
||||||
|
- 拖动(原生 DnD):
|
||||||
|
- tr draggable,onDragStart 记 id;onDragOver 阻止默认 + 计算插入位;onDrop 重排数组;
|
||||||
|
- 落点 → 立即调 tabs/update(整表提交)→ 提示「已保存,刷新页面后生效」;
|
||||||
|
- 显隐开关:切换 → 立即调 tabs/update → 提示。
|
||||||
|
|
||||||
|
### 4.2 策略分组瘦身
|
||||||
|
|
||||||
|
- 移除排序箭头列(↑↓)与显示列(Switch);
|
||||||
|
- 保留:新增输入框 + 新增按钮 / 重命名 / 删除(确认弹窗 + 份额回未分配沿用);
|
||||||
|
- 顶部加提示:顺序与显示请在「Tab 设置」中调整。
|
||||||
|
|
||||||
|
## 5. 兼容与风险
|
||||||
|
|
||||||
|
- **迁移**:读取归一化幂等;旧策略 visible=false 迁移后 visible=true(Q3,产品约束-009 一致);
|
||||||
|
- **名称 join**:tabs 存 refId,展示时 join strategies;策略改名后 tabs 行 name 自动跟随(Q4);
|
||||||
|
- **注册**:refresh = 刷新页面重载插件(无事件机制,沿用);
|
||||||
|
- **RPC**:strategies/move 废弃需同步移除客户端调用(settings UI 不再有箭头);
|
||||||
|
- **回归**:技术约束-011 —— 写操作测试用独立数据目录(ODL_TEST_DATA_DIR / dataDir 参数)。
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 迭代复盘:09-Tab 设置统一管理(内置 + 策略分组,显示/隐藏 + 拖动排序)
|
||||||
|
|
||||||
|
> 复盘日期:2026-09-02 | 迭代状态:**已完成(老师确认)**
|
||||||
|
> 关联需求:R-011(Tab 设置:统一管理所有 tab,已定稿 Q1-Q5)
|
||||||
|
> 关联计划:PLAN-010(计划-Tab设置统一管理)
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
迭代 09 达成:设置页「通用设置」升级为「Tab 设置」,成为所有会话 tab(系统内置 + 策略分组)**唯一的顺序与显隐入口**:两类 tab 混排一张表,每行 = 拖动手柄(原生 HTML5 DnD)+ 名称(内置带「内置」徽标、策略带「策略」徽标)+ 显示/隐藏开关;**任何 tab 均不支持重命名与删除**;拖动落点立即持久化;策略分组子 tab 瘦身为 新增/重命名/删除。
|
||||||
|
|
||||||
|
## 过程事实
|
||||||
|
|
||||||
|
1. **需求定稿(R-011)**:老师提出设置页默认 tab 与策略 tab 一起排序(内置标记、不可重命名/删除)→ AI 分析现状(两套数据、两套 order 空间,内置恒在策略前)→ 提出统一 tabs 有序数组方案 → 老师确认 D1-D7 + Q1-Q5(拖动落点立即持久化 / 新增追加末尾 / 旧隐藏策略迁移后显示 / 名称 join 自动跟随 / 全部 tab 禁重命名删除);
|
||||||
|
2. **数据层(settings.js)**:tabs 从布尔对象 → 统一有序数组(kind: builtin|strategy + refKey/refId + visible + order);strategies 收窄为 {id, name};旧格式读取时静默归一化迁移(schema union 兼容存量 + normalizeTabs 转换);addStrategy/removeStrategy 联动 tabs;
|
||||||
|
3. **API 层(strategies.js)**:tabs/update 语义改整表(顺序 + 显隐);strategies/add 联动追加 tab(末尾);strategies/remove 联动删除 tab 条目;废弃 strategies/move;
|
||||||
|
4. **客户端注册(client/index.js)**:合并 registerGeneralTabs + registerStrategyTabs 为统一注册(读 tabs 数组按 order 排序、过滤 visible,builtin 走内置 render、strategy 走 StrategyTab),移除硬编码 order 间隔(10/11/12 与 13+);
|
||||||
|
5. **设置页 UI(SettingsSection.jsx)**:「Tab 设置」子 tab(混排表格 + 徽标 + 显隐开关 + 拖动排序 + 落点立即持久化);策略分组瘦身(移除排序箭头与显隐开关,加指引提示);
|
||||||
|
6. **验证**:回归测试 35 项全通过(scripts/test-r011-tabs.mjs 数据层 23 项 + test-r011-api.mjs API 层 12 项)+ 真实 schema resolve 验证(旧配置归一化正确)+ 构建 + typecheck 通过。
|
||||||
|
|
||||||
|
## 经验教训(复盘沉淀)
|
||||||
|
|
||||||
|
### 1. schemastery 无 z.enum / .optional(),schema 兼容存量用 z.union 双分支
|
||||||
|
- schemastery 只有 z.union([z.const(...), ...]) 无 z.enum,可选字段不调 .required() 即可;
|
||||||
|
- **关键**:DSH settings 的 resolve 会用 schema 校验存量 user 层——新 schema 直接替换会导致旧配置(布尔对象 tabs)校验失败、插件启动报错;
|
||||||
|
- **沉淀**:settings schema 变更必须先验证存量兼容——用 z.union 双分支(旧格式 + 新格式),旧值通过校验、读取时再归一化;改动 settings schema 前跑 schema-resolve 测试。
|
||||||
|
|
||||||
|
### 2. 读取时归一化 + schema 双分支 = 无感迁移
|
||||||
|
- 本次没有写一次性迁移脚本,而是「schema 接受旧格式 + normalizeTabs 读取时转新」:存量用户升级零操作、零报错;
|
||||||
|
- 归一化结果在首次写操作(updateTabs/add/remove)时落库为数组,之后自然持久化;
|
||||||
|
- **沉淀**:settings 结构变更优先「schema 兼容 + 读取归一化」而非迁移脚本,符合 R-006/R-008 的迁移经验(幂等、无需用户操作)。
|
||||||
|
|
||||||
|
### 3. 前端字符串与 JSX 引号冲突
|
||||||
|
- 写 JSX 组件代码时,代码里同时有 JS 单双引号与 JSX 属性引号,用模板字符串包整段时内部反引号/插值冲突,多次报错;
|
||||||
|
- **沉淀**:批量生成代码用数组 join(每行独立字符串),避免模板字符串嵌套转义地狱。
|
||||||
|
|
||||||
|
## 遗留/后续
|
||||||
|
|
||||||
|
1. **Tab 设置拖动交互**:当前实现为整行 draggable(原生 DnD),落点插入到目标行位置;后续可优化为拖动手柄专属拖拽 + 拖拽中的视觉反馈(插入线);
|
||||||
|
2. **关注列表 tab**:仍为占位(PlaceholderTab),后续迭代实现;
|
||||||
|
3. **迁移落库时机**:旧配置首次读取不落库(纯读),首次写操作时才持久化新数组——如老师希望启动即落库可加一次性迁移(当前行为无感知差异,可接受);
|
||||||
|
4. **多 DSH 实例/多用户**:tabs 顺序为全局设置(非按会话),符合现状(strategies 亦为全局)。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 迭代目标:09-Tab 设置统一管理(内置 + 策略分组,显示/隐藏 + 拖动排序)
|
||||||
|
|
||||||
|
> 迭代编号:09 | 创建:2026-09-02 | 状态:进行中
|
||||||
|
> 依据计划:PLAN-010 | 需求:R-011(已定稿,2026-09-02,Q1-Q5 确认)
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
将设置页「通用设置」升级为「Tab 设置」,成为所有会话 tab(系统内置 + 策略分组)**唯一的顺序与显隐入口**:两类 tab 混排一张表,每行 = 拖动手柄 + 名称 + 显示/隐藏开关;任何 tab 均不支持重命名与删除;拖动落点立即持久化。
|
||||||
|
|
||||||
|
## 目标分解
|
||||||
|
|
||||||
|
1. **数据模型统一**(settings.js):tabs 布尔对象 → 有序数组,旧格式自动归一化;strategies 收窄为 {id, name};
|
||||||
|
2. **API 层**(api/strategies.js):tabs/update 整表(顺序 + 显隐);strategies/add 联动追加 tab;strategies/remove 联动删除 tab;废弃 strategies/move;
|
||||||
|
3. **客户端注册**(client/index.js):合并为统一注册,读 tabs 数组(order 排序 + visible 过滤),移除硬编码 order;
|
||||||
|
4. **设置页 UI**(SettingsSection.jsx):「Tab 设置」子 tab(混排 + 徽标 + 显隐开关 + 拖动排序)+ 策略分组瘦身(仅 新增/重命名/删除 + 指引提示)。
|
||||||
|
|
||||||
|
## 讨论过程
|
||||||
|
|
||||||
|
- 2026-09-02 老师提出需求:设置页默认几个 tab 与策略 tab 一起排序,内置标记、不可重命名/删除;
|
||||||
|
- 2026-09-02 AI 分析现状(两套数据/两套 order 空间),提出统一 tabs 有序数组方案;
|
||||||
|
- 2026-09-02 老师确认 D1-D7:Tab 设置只做 显示/隐藏 + 排序(拖动),策略命名/删除留策略分组;
|
||||||
|
- 2026-09-02 老师确认 Q1-Q5:拖动落点立即持久化 / 新增追加末尾 / 旧隐藏策略迁移后显示 / 名称 join 自动跟随 / 全部 tab 禁重命名删除,R-011 定稿。
|
||||||
|
|
||||||
|
## 对老师的配合需求
|
||||||
|
|
||||||
|
- 验收时验证:拖动排序落点立即生效(刷新后 tab 栏按新顺序);显隐开关切换后刷新生效;策略分组瘦身后的新增/重命名/删除仍正常;老配置自动迁移无报错。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 验收标准:09-Tab 设置统一管理
|
||||||
|
|
||||||
|
> 迭代编号:09 | 依据:PLAN-010 验收要点 + R-011 定稿
|
||||||
|
|
||||||
|
## 验收标准线
|
||||||
|
|
||||||
|
1. 「通用设置」子 tab 更名为「Tab 设置」,表格混排内置 + 策略全部 tab;
|
||||||
|
2. 内置行带「内置」徽标、策略行带「策略」徽标;
|
||||||
|
3. 每行可拖动排序,落点**立即持久化**(刷新后会话 tab 栏按新顺序,内置与策略可交错);
|
||||||
|
4. 每行有显示/隐藏开关,切换立即持久化(刷新后隐藏的 tab 消失/显示的出现);
|
||||||
|
5. **任何行均无重命名/删除按钮**;
|
||||||
|
6. 策略分组子 tab 只留 新增/重命名/删除,无排序箭头/显隐开关,有指引提示;
|
||||||
|
7. 新增策略出现在 Tab 设置列表末尾;删除策略后对应 tab 条目消失(份额回未分配沿用);
|
||||||
|
8. 老配置(布尔对象 tabs + 策略 order/visible)读取自动归一化,无报错、无数据丢失;
|
||||||
|
9. 会话 tab 栏按新顺序与显隐注册;
|
||||||
|
10. 现有功能不回归(全部持仓/交易记录/策略持仓/QMT 切换/删除策略份额回退)。
|
||||||
|
|
||||||
|
## 验收方法
|
||||||
|
|
||||||
|
- 构建:pnpm run build + typecheck 通过;
|
||||||
|
- 单元/回归脚本:独立数据目录跑策略 CRUD + tabs 读写(技术约束-011);
|
||||||
|
- 手动(需 DSH 运行 + 老师确认):设置页 Tab 设置拖动排序 / 显隐切换 / 策略分组瘦身 / 老配置迁移;会话 tab 栏顺序与显隐验证。
|
||||||
|
|
||||||
|
## 验收目标
|
||||||
|
|
||||||
|
- 全部 10 条验收标准线通过,迭代 09 标记「验收通过」,R-011 实现状态更新,归档流程走查。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 技术实现方案:10-UI 适配 DSH 主题
|
||||||
|
|
||||||
|
> 迭代编号:10 | 依据:PLAN-011、R-012
|
||||||
|
|
||||||
|
## 1. 主题机制(已查明)
|
||||||
|
|
||||||
|
- 宿主深色主题挂 `body[data-ds-dark-theme]`;
|
||||||
|
- 宿主注入 `--dsw-*` CSS 变量(定义在宿主 runtime,随 light/dark/system 切换);
|
||||||
|
- 插件内联 style 直接引用 `var(--dsw-alias-xxx)` 即可自动适配。
|
||||||
|
|
||||||
|
## 2. 语义映射(实施基准,来自 R-012)
|
||||||
|
|
||||||
|
```
|
||||||
|
背景: #fff(表面) → var(--dsw-alias-bg-layer-1)
|
||||||
|
遮罩: rgba(0,0,0,.4) → var(--dsw-alias-bg-mask-1)
|
||||||
|
hover面: #f5f5f5 → var(--dsw-alias-interactive-bg-hover)
|
||||||
|
激活绿底: #e8f5e9/#f1f8f2 → color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent)
|
||||||
|
错误红底: #fdecea → color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent)
|
||||||
|
信息蓝底: #e3f2fd → color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent)
|
||||||
|
主文字: #333/#222 → var(--dsw-alias-label-primary)
|
||||||
|
次文字: #555/#666 → var(--dsw-alias-label-secondary)
|
||||||
|
弱文字: #888/#999/#aaa → var(--dsw-alias-label-tertiary)
|
||||||
|
蓝字: #1565c0 → var(--dsw-alias-state-business-primary)
|
||||||
|
细线: #eee → var(--dsw-alias-border-l1)
|
||||||
|
描边: #ddd/#ccc → var(--dsw-alias-border-l2)
|
||||||
|
红(涨/错/删): #d32f2f/#c62828 → var(--dsw-alias-state-error-primary)
|
||||||
|
绿(跌/成/主): #2e7d32 → var(--dsw-alias-state-success-primary)
|
||||||
|
实心按钮字: #fff → var(--dsw-alias-button-contrast-fill)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 替换细则
|
||||||
|
|
||||||
|
- 替换范围:inline style 对象、模板字符串中的颜色字面量;
|
||||||
|
- `color-mix(in srgb, var(--xxx) 10%, transparent)` 用于需要「淡底 + 文字同色」的提示/激活态;
|
||||||
|
- 带透明度的原色(如 rgba(255,215,0,.5) 价格闪动高亮)保留动画语义,改 `color-mix(in srgb, var(--dsw-alias-state-warn-primary) 50%, transparent)`;
|
||||||
|
- 注释中色值仅作说明可保留(不影响运行,验收时以无运行色为准);
|
||||||
|
- 每文件替换后 `node --check` 校验 JSX 语法。
|
||||||
|
|
||||||
|
## 4. 风险
|
||||||
|
|
||||||
|
- **语义偏差**:个别颜色无法精确定位语义 → 保留原色并在验收清单标注,交老师确认;
|
||||||
|
- **color-mix 兼容性**:现代浏览器(Chrome 111+/Safari 16.2+)支持,DSH 目标为 Chromium 系,可接受;
|
||||||
|
- **fallback**:`var(--xxx, <原色>)` 提供兜底,宿主 token 缺失时视觉不变(更安全)。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 迭代复盘:10-UI 适配 DSH 主题(浅色 / 深色 / 跟随系统)
|
||||||
|
|
||||||
|
> 复盘日期:2026-09-02 | 迭代状态:**已完成(老师确认)**
|
||||||
|
> 关联需求:R-012(UI 适配 DSH 主题,已定稿:暂定跟随系统)
|
||||||
|
> 关联计划:PLAN-011(计划-UI主题适配)
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
迭代 10 达成:神之一手客户端 11 个文件共 141 处硬编码颜色全部替换为宿主 `--dsw-*` token(含 fallback),插件在 DSH 浅色 / 深色 / 跟随系统主题下自动适配;不自行维护主题偏好(跟随宿主);仅色值 token 化,布局与交互不变。
|
||||||
|
|
||||||
|
## 过程事实
|
||||||
|
|
||||||
|
1. **需求定稿(R-012)**:老师提出适配 DSH 浅/深/系统主题 → AI 查明宿主机制(body[data-ds-dark-theme] + --dsw-* token,深色挂 data-ds-dark-theme、alias token 双值定义)→ 老师确认「暂定跟随系统」(D1-D5 + 语义映射表);
|
||||||
|
2. **替换实施**:PriceCell / LoadState / Toast / PlaceholderTab / RangeSelector / AllPositionsTab / StrategyTab / TradeRecordsTab / QmtConnectionChip / SettingsSection 共 10 文件(+market 目录无颜色);
|
||||||
|
3. **语义映射执行**:白底→bg-layer-1、淡灰底→bg-layer-2、hover→interactive-bg-hover、主/次/弱文字→label-primary/secondary/tertiary、边框→border-l1/l2/l3/l4、红(涨/删/错)→state-error-primary、绿(跌/成/激活)→state-success-primary、蓝(信息/业务)→state-business-primary、实心按钮字→button-contrast-fill、遮罩→bg-mask-1、阴影→shadow-lv3;
|
||||||
|
4. **淡色底**:激活/提示底色用 color-mix(in srgb, var(--语义色) 10-12%, transparent),深浅主题自适应;
|
||||||
|
5. **验证**:typecheck + build 通过;headless Chrome 实证宿主 token 系统完整(238 处 dsw-alias 引用、浅/深双值定义、data-ds-dark-theme 选择器);残留硬编码色 = 0。
|
||||||
|
|
||||||
|
## 经验教训(复盘沉淀)
|
||||||
|
|
||||||
|
### 1. 宿主主题机制:body[data-ds-dark-theme] + --dsw-* token(已实证)
|
||||||
|
- DSH 主题不是 data-theme 属性切换,而是宿主在深色时给 body 挂 `data-ds-dark-theme`,token 以「alias 链 → static 值」双主题注入(light: neutral-bluish-00 白系;dark: neutral-bluish-875 深系);
|
||||||
|
- **沉淀**:插件适配宿主主题只须引用 `var(--dsw-alias-xxx, fallback)`,fallback 保证 token 缺失时浅色可用;不要自建主题偏好。
|
||||||
|
|
||||||
|
### 2. var() 带 fallback 是安全的迁移策略
|
||||||
|
- 每处替换写成 `var(--dsw-alias-xxx, #原色)`:宿主 token 定义齐全时自动适配;万一某 token 缺失(宿主版本差异),退回原浅色值不破相;
|
||||||
|
- **沉淀**:对宿主 token 的依赖一律带 fallback,兼容宿主版本演进。
|
||||||
|
|
||||||
|
### 3. 批量替换的 edit 冲突处理
|
||||||
|
- 多个相同 style 片段(如表头、输入框、删除按钮)导致 old_string 多处匹配:用 replace_all 处理真正相同的模式,或用带上下文的更精确 old_string;
|
||||||
|
- **沉淀**:批量替换前先 grep 去重确认唯一性,相同模式直接用 replace_all,不同上下文逐条处理。
|
||||||
|
|
||||||
|
## 遗留/后续
|
||||||
|
|
||||||
|
1. **语义色待老师验收**:深色下个别语义色(state-error 红 / state-success 绿在深色底的对比度、紫/蓝徽标)观感需老师切主题确认;若个别不满意可后续加 `--odl-*` 覆盖(D5 暂缓项);
|
||||||
|
2. **color-mix 兼容性**:现代 Chromium 支持;若遇旧内核浏览器个别淡底失效,fallback 无(color-mix 无 fallback 语法)——可后续降级处理;
|
||||||
|
3. **Toast 样式**:随宿主语义色变化,实心绿/红底 + 白字在深色下对比度已由 token 保证;
|
||||||
|
4. **shadows**:boxShadow 用了 shadow-lv3 token(宿主完整 shadow 值),个别较浅卡片阴影在深色下可能几乎不可见——可后续微调。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 迭代目标:10-UI 适配 DSH 主题(浅色 / 深色 / 跟随系统)
|
||||||
|
|
||||||
|
> 迭代编号:10 | 创建:2026-09-02 | 状态:进行中
|
||||||
|
> 依据计划:PLAN-011 | 需求:R-012(已定稿,2026-09-02,暂定跟随系统)
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
神之一手客户端 UI 全部硬编码色值替换为宿主 `--dsw-*` token,使插件在 DSH 浅色 / 深色 / 跟随系统主题下均可读、协调,随主题自动切换。
|
||||||
|
|
||||||
|
## 目标分解
|
||||||
|
|
||||||
|
1. 10 个文件 141 处硬编码色按语义映射替换为宿主 token / color-mix;
|
||||||
|
2. 涨跌红涨绿跌 → 宿主 state-error/success;主按钮/徽标/提示底色 → 宿主语义色;
|
||||||
|
3. 构建 + typecheck + 深浅主题人工验收。
|
||||||
|
|
||||||
|
## 对老师的配合需求
|
||||||
|
|
||||||
|
- 验收:DSH 设置切换 浅色/深色/跟随系统,检查各页面可读性与协调性。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 验收标准:10-UI 适配 DSH 主题
|
||||||
|
|
||||||
|
> 迭代编号:10 | 依据:PLAN-011 验收要点 + R-012
|
||||||
|
|
||||||
|
## 验收标准线
|
||||||
|
|
||||||
|
1. 浅色主题下插件各页面观感与现状基本一致(无突兀色差);
|
||||||
|
2. 深色主题下所有页面可读(背景/文字/边框/按钮/涨跌/徽标/提示/下拉菜单协调);
|
||||||
|
3. DSH 设置切换 浅色/深色/跟随系统 实时生效;
|
||||||
|
4. 涨跌色(红涨绿跌)在深浅两主题下均醒目可辨;
|
||||||
|
5. 运行代码无残留硬编码色(#xxx / rgb / rgba),注释可留;
|
||||||
|
6. 布局与交互不变(仅色值);
|
||||||
|
7. build + typecheck 通过。
|
||||||
|
|
||||||
|
## 验收方法
|
||||||
|
|
||||||
|
- build + typecheck;
|
||||||
|
- 老师切 DSH 浅/深主题人工检查:设置页(Tab 设置/策略分组/QMT 卡片)、全部持仓、交易记录、策略持仓、会话头部 QMT chip 下拉、Toast。
|
||||||
|
|
||||||
|
## 验收目标
|
||||||
|
|
||||||
|
- 7 条验收线通过,迭代 10 标记「验收通过」,R-012 更新实现状态。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# R-011 Tab 设置:统一管理所有 tab(内置 + 策略分组混排,显示/隐藏 + 拖动排序)
|
||||||
|
|
||||||
|
> 状态:**已定稿**(2026-09-02,老师确认 Q1-Q5)| 登记日期:2026-09-02
|
||||||
|
> 来源:老师指令(2026-09-02,设置页 tab 化扩展的二次演进)
|
||||||
|
> 优先级:P1(设置体验优化,无数据风险)
|
||||||
|
|
||||||
|
## 需求描述
|
||||||
|
|
||||||
|
设置页的「通用设置」子 tab 升级为 **「Tab 设置」**,成为**统一管理所有会话 tab(系统内置 + 策略分组)的唯一入口**:两类 tab 混排在一张表里,每行提供**显示/隐藏开关** + 通过**拖动**调整顺序;**任何 tab 都不支持重命名与删除**(策略的命名/删除仍在「策略分组」子 tab,内置 tab 名称只读)。
|
||||||
|
|
||||||
|
## 现状(代码审查 2026-09-02)
|
||||||
|
|
||||||
|
| 项 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| 内置 tab | client/index.js 的 GENERAL_TABS 硬编码 order 10/11/12(全部持仓/交易记录/关注列表),注册时排在策略之前,**不可与策略交错** |
|
||||||
|
| 策略 tab | settings.strategies 数组 {id,name,visible,order},注册时按 order 排序、从 order 13 起(永远在内置之后) |
|
||||||
|
| 显隐控制 | settings.tabs 布尔对象 {allPositions,tradeRecords,watchlist}(通用设置三个开关)+ 策略各自 visible |
|
||||||
|
| 排序交互 | 「策略分组」子 tab 用 ↑↓ 上下箭头移动策略 |
|
||||||
|
|
||||||
|
**结构性障碍**:两套数据、两套 order 空间(内置 10/11/12,策略 13+),内置永远在策略前,无法混合拖动。
|
||||||
|
|
||||||
|
## 讨论结论(2026-09-02 老师确认)
|
||||||
|
|
||||||
|
- **D1** 策略的「命名」和「删除」**不归 Tab 设置管**:仍在「策略分组」子 tab 操作;「策略分组」里原来的 ↑↓ 排序箭头与显隐开关移除(被 Tab 设置取代)。
|
||||||
|
- **D2** 子 tab 名称 = **「Tab 设置」**(不是「特指」);管理对象 = 系统内置 tab(标记「内置」)+ 策略分组 tab(标记「策略」),两类混排。
|
||||||
|
- **D3** 策略侧**同步变更**:顺序与显隐统一为**一份数据源**(tabs 有序数组);策略分组 tab 的展示与 Tab 设置完全一致;删除策略时**联动删除**统一列表中的对应条目。
|
||||||
|
- **D4** 顺序调整用**拖动**(原生 HTML5 Drag & Drop,零新依赖),不用上下箭头;**落点确认后立即持久化**(Q1)。
|
||||||
|
- **D5**(2026-09-02 Q5 修订,原「去掉显隐开关」作废)**Tab 设置只含「显示/隐藏 + 排序」**:所有 tab(内置 + 策略)均有显隐开关与拖动排序;**任何 tab 均不支持重命名与删除**(Q5)。
|
||||||
|
- **D6** 老配置**自动迁移**:现网 tabs 布尔对象 + 策略自带 order/visible → 启动时静默归一化为统一数组,无需用户操作。
|
||||||
|
- **D7** 隐藏边界:接受「没有任何神之一手 tab」的状态(全部隐藏时会话窗不显示任何神之一手 tab,不视为异常)。
|
||||||
|
|
||||||
|
## 目标数据模型(定稿)
|
||||||
|
|
||||||
|
统一 settings.tabs 为**有序数组**(唯一顺序与显隐来源;策略行名称来自 strategies join,自动跟随改名):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// settings.tabs(定稿)
|
||||||
|
tabs: [
|
||||||
|
{ id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
|
||||||
|
{ id: 'tab-trade-records', kind: 'builtin', refKey: 'tradeRecords', name: '交易记录', visible: true, order: 1 },
|
||||||
|
{ id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 2 },
|
||||||
|
{ id: 'tab-strategy-grid-supermarket', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 3 },
|
||||||
|
{ id: 'tab-strategy-manual-t', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 4 },
|
||||||
|
]
|
||||||
|
// strategies 收窄为定义表:{ id, name }(无 visible / order)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 内置行的 name 与常量对齐(防漂移),策略行的 name 来自 strategies join(改名自动跟随,因为只存 refId,Q4);
|
||||||
|
- **迁移规则**(D6 + Q3):旧 tabs 布尔对象 → 生成三个内置条目(**保留原显隐**);旧策略按各自 order 接续追加为策略条目,**旧隐藏的策略迁移后变为显示**(visible=true,Q3);
|
||||||
|
- 新增策略**追加到列表末尾**(Q2)。
|
||||||
|
|
||||||
|
## 涉及改动面(技术方案,定稿)
|
||||||
|
|
||||||
|
1. **src/settings.js**:tabs 从布尔对象 → 有序数组;新增读取归一化(旧格式自动转新)+ 整表更新(order + visible);strategies 相关函数收窄(去掉 visible/order 语义);DEFAULT_TABS 升级为默认顺序数组。
|
||||||
|
2. **src/api/strategies.js**:tabs/update 语义改为整表更新(顺序 + 显隐,拖动落点/开关切换各提交一次);strategies/add 联动追加 tab 条目(末尾);strategies/remove 联动删除对应 tab 条目(D3);废弃 strategies/move;strategies/update 仅剩重命名。
|
||||||
|
3. **src/client/index.js**:合并 registerGeneralTabs + registerStrategyTabs 为统一注册(读 tabs 数组按 order 排序、过滤 visible,builtin 走内置 render、strategy 走 StrategyTab),去掉 10/11/12 与 13+ 硬编码间隔。
|
||||||
|
4. **src/client/views/SettingsSection.jsx**:
|
||||||
|
- 「Tab 设置」子 tab 取代「通用设置」:表格列出全部 tab(内置行带「内置」徽标、策略行带「策略」徽标),每行 = 拖动手柄 + 名称 + **显隐开关**;**无重命名/删除按钮(任何行)**;
|
||||||
|
- 拖动:draggable + onDragStart/onDragOver/onDrop,落点重排 → 立即调 tabs/update 持久化(Q1)→ 刷新(沿用「刷新页面后生效」机制);
|
||||||
|
- 「策略分组」子 tab 瘦身:只留 新增策略 / 重命名 / 删除(删除确认弹窗 + 份额回未分配沿用),移除排序箭头与显隐开关,加提示「顺序与显示请在「Tab 设置」中调整」。
|
||||||
|
|
||||||
|
## 定稿记录(Q1-Q5,2026-09-02 老师确认)
|
||||||
|
|
||||||
|
- **Q1** 拖动落点确认后**立即持久化**(每次 drop 提交整表)→ 确认。
|
||||||
|
- **Q2** 新增策略默认**追加到列表末尾** → 确认。
|
||||||
|
- **Q3** 迁移时旧「隐藏」的策略**变为显示**(visible=true)→ 确认。
|
||||||
|
- **Q4** 策略改名后 Tab 设置中的名称**自动跟随**(join strategies);Tab 设置不支持改名,策略名称在「策略分组」改 → 确认。
|
||||||
|
- **Q5** Tab 设置中只有**显示/隐藏 + 排序**,**全部 tab 都不支持重命名与删除** → 确认(同时修订 D5:显隐开关保留)。
|
||||||
|
|
||||||
|
> 三要素满足(边界清楚 / 核心逻辑明确 / 老师确认 Q1-Q5),**已定稿**,可进入计划范围。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# R-012 UI 适配 DSH 主题(浅色 / 深色 / 跟随系统)
|
||||||
|
|
||||||
|
> 状态:**已定稿(暂定:跟随系统,语义色映射可再调)**(2026-09-02,老师确认方向「暂定跟随系统」)| 登记日期:2026-09-02
|
||||||
|
> 来源:老师指令(2026-09-02)
|
||||||
|
> 优先级:P1(UI 体验)
|
||||||
|
|
||||||
|
## 需求描述
|
||||||
|
|
||||||
|
神之一手插件 UI 适配 DSH 的浅色 / 深色 / 跟随系统主题:将客户端全部硬编码颜色替换为宿主主题 token(`--dsw-*` CSS 变量),插件各页面随 DSH 主题切换自动适配,不自行维护主题偏好(跟随宿主)。
|
||||||
|
|
||||||
|
## 现状(代码审查 2026-09-02)
|
||||||
|
|
||||||
|
- 客户端 10 个文件共 **141 处硬编码颜色**:SettingsSection.jsx 55 / StrategyTab.jsx 24 / TradeRecordsTab.jsx 21 / QmtConnectionChip.jsx 13 / RangeSelector.jsx 7 / PriceCell.jsx 5 / AllPositionsTab.jsx 4 / LoadState.jsx 3 / Toast.jsx 2 / PlaceholderTab.jsx 1;
|
||||||
|
- 宿主机制(已查明):深色主题时宿主挂 `body[data-ds-dark-theme]`,并注入 `--dsw-*` token(`--dsw-alias-bg-base/layer-1/layer-2`、`label-primary/secondary/tertiary/caption`、`border-l1..l4`、`state-success/error/business-primary`、`brand-primary`、`button-primary-fill/hover`、`button-contrast-fill`、`interactive-bg-hover/active`、`bg-mask-1`、`tooltip-bg` 等),随 light/dark/system 自动切换;
|
||||||
|
- 先例:QmtConnectionChip 部分样式已引用 `var(--dsw-alias-label-secondary, #666)`,token 可用。
|
||||||
|
|
||||||
|
## 决策(2026-09-02 老师确认:暂定跟随系统)
|
||||||
|
|
||||||
|
- **D1** 主题偏好跟随宿主(light/dark/system),插件不自行监听/维护;
|
||||||
|
- **D2** 全部颜色映射到宿主 `--dsw-*` token(语义映射表见下);涨跌色 A 股红涨绿跌 → 宿主语义:涨=红=`state-error-primary`、跌=绿=`state-success-primary`(宿主在深浅主题下保证可读);
|
||||||
|
- **D3** 实心主按钮 / 徽标 / 提示底色等语义色(success/error/business)跟随宿主对应 token;淡色底用 `color-mix(in srgb, <primary> 10%, transparent)` 跟随主题;
|
||||||
|
- **D4** 仅做色值 token 化(最小改动),不动布局 / 间距 / 圆角;
|
||||||
|
- **D5** 若宿主 token 观感不满意,后续可加插件级 `--odl-*` 覆盖变量(本迭代不做,暂定)。
|
||||||
|
|
||||||
|
## 语义映射表(实施基准)
|
||||||
|
|
||||||
|
| 原色 | 语义 | 替换 |
|
||||||
|
|---|---|---|
|
||||||
|
| #fff(表面底) | 表格/卡片/弹窗/输入框/菜单/按钮白底 | var(--dsw-alias-bg-layer-1) |
|
||||||
|
| rgba(0,0,0,.4)(遮罩) | 弹窗遮罩 | var(--dsw-alias-bg-mask-1) |
|
||||||
|
| #f5f5f5(hover/静态行底) | 次级面/hover | var(--dsw-alias-interactive-bg-hover) 或 bg-layer-2(静态) |
|
||||||
|
| #e8f5e9/#f1f8f2(激活浅绿底) | 选中/激活 | color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent) |
|
||||||
|
| #fdecea(错误浅红底) | 错误提示底 | color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent) |
|
||||||
|
| #e3f2fd(信息浅蓝底) | 信息提示底 | color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent) |
|
||||||
|
| #333/#222 | 主文字 | var(--dsw-alias-label-primary) |
|
||||||
|
| #555/#666 | 次文字 | var(--dsw-alias-label-secondary) |
|
||||||
|
| #888/#999/#aaa | 弱文字/caption | var(--dsw-alias-label-tertiary) |
|
||||||
|
| #1565c0(蓝字) | 业务/信息字 | var(--dsw-alias-state-business-primary) |
|
||||||
|
| #eee(细线) | 行分隔/表头细线 | var(--dsw-alias-border-l1) |
|
||||||
|
| #ddd/#ccc(描边/强调) | 控件描边/分隔 | var(--dsw-alias-border-l2) |
|
||||||
|
| #90caf9/#ce93d8(徽标淡描边) | 徽标描边 | var(--dsw-alias-border-l3)(描边中性化,文字保留语义色) |
|
||||||
|
| #d32f2f/#c62828(红字/实心红) | 涨/错误/删除/危险 | var(--dsw-alias-state-error-primary);实心底同上,文字 var(--dsw-alias-button-contrast-fill) |
|
||||||
|
| #2e7d32(绿字/实心绿) | 跌/成功/激活/主按钮 | var(--dsw-alias-state-success-primary);实心底同上,文字 var(--dsw-alias-button-contrast-fill) |
|
||||||
|
| #fff(实心按钮上文字) | 按钮文字 | var(--dsw-alias-button-contrast-fill) |
|
||||||
|
|
||||||
|
> 实施细则:仅 inline style / 变量赋值中的颜色字面量替换;注释中的色值可保留或同步(不影响运行);个别语义不确定处保留原色并在迭代记录中列出待老师验收。
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
| R-008 | 数据存储管理(JSON → SQLite) | 数据存储从 JSON data store 升级为 SQLite(node:sqlite):仅存储引擎替换 + strategies/allocation/market_quotes 三表 + 一次性迁移脚本(启动自动迁移)+ JSON 废弃;策略仍存 DSH settings;不建 trades 表(R-007 时再建)。**2026-09-01 定稿(T-008 转正,D1-D8 确认),2026-09-01 完成(迭代 06 验收通过),已归档至 已完成/R-008.md** | 老师指令(2026-09-01,T-008 转正) | P1 | 已定稿 | 2026-09-01 | 06-数据存储SQLite | **已实现(已归档)** |
|
| R-008 | 数据存储管理(JSON → SQLite) | 数据存储从 JSON data store 升级为 SQLite(node:sqlite):仅存储引擎替换 + strategies/allocation/market_quotes 三表 + 一次性迁移脚本(启动自动迁移)+ JSON 废弃;策略仍存 DSH settings;不建 trades 表(R-007 时再建)。**2026-09-01 定稿(T-008 转正,D1-D8 确认),2026-09-01 完成(迭代 06 验收通过),已归档至 已完成/R-008.md** | 老师指令(2026-09-01,T-008 转正) | P1 | 已定稿 | 2026-09-01 | 06-数据存储SQLite | **已实现(已归档)** |
|
||||||
| R-009 | 交易记录本地存储(SQLite)+ 策略关联 | 在 SQLite 新增交易记录表(trade_orders + trade_fills,委托/成交两表),QMT 当日交易数据本地持久化(跨日积累成历史库);**归属由用户在交易记录 tab 手动设置(trade_orders 冗余存 strategy_id + holding_id,UPSERT 不覆盖归属列,可随时改)**;本地历史查询端点(trades/history 策略过滤)+ 交易记录 tab 策略过滤与历史范围本地展示;QMT code 归一化 + tradeDate 兜底。**2026-09-01 定稿(Q1-Q8 + 二次定稿 Q1-Q4),2026-09-01 完成(迭代 07 验收通过),已归档至 已完成/R-009.md** | 老师指令(2026-09-01) | P1 | 已定稿 | 2026-09-01 | 07-交易记录本地存储SQLite与策略关联 | **已实现(已归档)** |
|
| R-009 | 交易记录本地存储(SQLite)+ 策略关联 | 在 SQLite 新增交易记录表(trade_orders + trade_fills,委托/成交两表),QMT 当日交易数据本地持久化(跨日积累成历史库);**归属由用户在交易记录 tab 手动设置(trade_orders 冗余存 strategy_id + holding_id,UPSERT 不覆盖归属列,可随时改)**;本地历史查询端点(trades/history 策略过滤)+ 交易记录 tab 策略过滤与历史范围本地展示;QMT code 归一化 + tradeDate 兜底。**2026-09-01 定稿(Q1-Q8 + 二次定稿 Q1-Q4),2026-09-01 完成(迭代 07 验收通过),已归档至 已完成/R-009.md** | 老师指令(2026-09-01) | P1 | 已定稿 | 2026-09-01 | 07-交易记录本地存储SQLite与策略关联 | **已实现(已归档)** |
|
||||||
| R-010 | 策略持仓行展开关联交易记录(Holding → 交易汇总) | 策略持仓 tab 每个持仓行(Holding)可展开,展开显示与该 holding 关联的交易记录(仅委托汇总,不分笔成交);strategy-positions 附加 holding_id + 新增 trades/by-holding 端点 + 持仓行展开 UI + 成本价/最后一笔成交价两列 + 神之一手 tab 隐藏输入框。**2026-09-02 定稿(Q1-Q4 确认),2026-09-02 完成(老师确认),已归档至 已完成/R-010.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 08-策略持仓行展开关联交易记录 | **已实现(已归档)** |
|
| R-010 | 策略持仓行展开关联交易记录(Holding → 交易汇总) | 策略持仓 tab 每个持仓行(Holding)可展开,展开显示与该 holding 关联的交易记录(仅委托汇总,不分笔成交);strategy-positions 附加 holding_id + 新增 trades/by-holding 端点 + 持仓行展开 UI + 成本价/最后一笔成交价两列 + 神之一手 tab 隐藏输入框。**2026-09-02 定稿(Q1-Q4 确认),2026-09-02 完成(老师确认),已归档至 已完成/R-010.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 08-策略持仓行展开关联交易记录 | **已实现(已归档)** |
|
||||||
|
| R-011 | Tab 设置:统一管理所有 tab(内置 + 策略分组混排,显示/隐藏 + 拖动排序) | 设置页「通用设置」升级为「Tab 设置」,统一管理所有会话 tab(系统内置 + 策略分组)的唯一入口:两类混排、**拖动排序**(原生 HTML5 DnD,落点立即持久化)+ **显隐开关**;**任何 tab 均不支持重命名/删除**(策略命名/删除仍在「策略分组」子 tab);顺序/显隐统一为一份数据源(tabs 有序数组,策略行名 join strategies 自动跟随改名),删除策略联动删除对应条目;新增策略追加末尾;老配置自动迁移(旧隐藏策略迁移后显示)。**2026-09-02 定稿(Q1-Q5 确认)并完成(迭代 09 验收通过)**,详见 docs/05-需求池/R-011.md | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 09-Tab设置统一管理 | **已实现(迭代 09 完结)** |
|
||||||
|
| R-012 | UI 适配 DSH 主题(浅色 / 深色 / 跟随系统) | 神之一手 UI 适配 DSH 浅色/深色/跟随系统主题:141 处硬编码色值替换为宿主 `--dsw-*` token,随主题自动切换;不自行维护主题偏好(**暂定跟随系统**);涨跌红涨绿跌 → 宿主 state-error/success;仅色值 token 化不动布局。**2026-09-02 定稿(暂定跟随系统)并完成(迭代 10)**,详见 docs/05-需求池/R-012.md | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 10-UI主题适配 | **已实现(迭代 10 完结)** |
|
||||||
|
|
||||||
## 渐进明细规划素材
|
## 渐进明细规划素材
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
* 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31)
|
* 服务端 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
|
* strategy-positions / unallocated / summary
|
||||||
* add-shares / move-all-shares / remove-shares
|
* add-shares / move-all-shares / remove-shares
|
||||||
* tabs / tabs/update
|
* tabs / tabs/update
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getStrategies, updateStrategies, addStrategy, removeStrategy, moveStrategy,
|
getStrategies, updateStrategies, addStrategy, removeStrategy,
|
||||||
getTabConfig, updateTabConfig,
|
getTabs, updateTabs,
|
||||||
} from '../settings.js';
|
} from '../settings.js';
|
||||||
|
|
||||||
/** 策略/份额/tabs 端点方法表 */
|
/** 策略/份额/tabs 端点方法表 */
|
||||||
@@ -19,7 +19,6 @@ export const STRATEGY_METHODS = new Set([
|
|||||||
'strategies/update',
|
'strategies/update',
|
||||||
'strategies/add',
|
'strategies/add',
|
||||||
'strategies/remove',
|
'strategies/remove',
|
||||||
'strategies/move',
|
|
||||||
'strategy-positions',
|
'strategy-positions',
|
||||||
'unallocated',
|
'unallocated',
|
||||||
'summary',
|
'summary',
|
||||||
@@ -50,13 +49,11 @@ export async function handleStrategy(method, args, { manager, settings }) {
|
|||||||
await removeStrategy(settings, args.strategyId);
|
await removeStrategy(settings, args.strategyId);
|
||||||
await manager.clearStrategyShares(args.strategyId);
|
await manager.clearStrategyShares(args.strategyId);
|
||||||
return getStrategies(settings);
|
return getStrategies(settings);
|
||||||
case 'strategies/move':
|
|
||||||
return await moveStrategy(settings, args.strategyId, args.dir);
|
|
||||||
case 'tabs':
|
case 'tabs':
|
||||||
return getTabConfig(settings);
|
return getTabs(settings);
|
||||||
case 'tabs/update':
|
case 'tabs/update':
|
||||||
await updateTabConfig(settings, args.tabs);
|
await updateTabs(settings, args.tabs);
|
||||||
return getTabConfig(settings);
|
return getTabs(settings);
|
||||||
case 'strategy-positions':
|
case 'strategy-positions':
|
||||||
return await manager.getStrategyPositions(args.strategyId);
|
return await manager.getStrategyPositions(args.strategyId);
|
||||||
case 'unallocated':
|
case 'unallocated':
|
||||||
|
|||||||
+31
-61
@@ -23,21 +23,8 @@ import { MarketDataProvider } from './market/MarketDataProvider.jsx';
|
|||||||
|
|
||||||
export const inject = ['slots', 'connection'];
|
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 {
|
try {
|
||||||
const res = await fetch('/odl/api/tabs', {
|
const res = await fetch('/odl/api/tabs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -45,29 +32,21 @@ async function fetchTabConfig() {
|
|||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data && data.ok) {
|
if (data && data.ok && Array.isArray(data.value)) return data.value;
|
||||||
return {
|
|
||||||
allPositions: data.value.allPositions !== false,
|
|
||||||
tradeRecords: data.value.tradeRecords !== false,
|
|
||||||
watchlist: data.value.watchlist !== false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
return { allPositions: true, tradeRecords: true, watchlist: true };
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function strategyTabId(strategyId) {
|
function strategyTabId(strategyId) {
|
||||||
return 'odl-strategy-' + strategyId;
|
return 'odl-strategy-' + strategyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENERAL_TABS = [
|
// R-011:内置 tab 渲染映射(refKey → render);order 由 settings.tabs 统一管理
|
||||||
{ key: 'allPositions', id: 'odl-all-positions', label: '全部持仓', order: 10,
|
const GENERAL_RENDER = {
|
||||||
render: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)) },
|
allPositions: (withConnection) => withConnection((props) => createElement(AllPositionsTab, props)),
|
||||||
{ key: 'tradeRecords', id: 'odl-trade-records', label: '交易记录', order: 11,
|
tradeRecords: (withConnection) => withConnection((props) => createElement(TradeRecordsTab, props)),
|
||||||
render: (withConnection) => withConnection((props) => createElement(TradeRecordsTab, props)) },
|
watchlist: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })),
|
||||||
{ key: 'watchlist', id: 'odl-watchlist', label: '关注列表', order: 12,
|
};
|
||||||
render: (withConnection) => withConnection((props) => createElement(PlaceholderTab, { ...props, title: '关注列表' })) },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function apply(ctx) {
|
export function apply(ctx) {
|
||||||
const connection = ctx.get('connection');
|
const connection = ctx.get('connection');
|
||||||
@@ -109,40 +88,31 @@ export function apply(ctx) {
|
|||||||
tabDisposers = [];
|
tabDisposers = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerGeneralTabs = (tabConfig) => {
|
// R-011:统一注册 —— 读 settings.tabs 有序数组,按 order 排序、过滤 visible
|
||||||
for (const tab of GENERAL_TABS) {
|
// builtin 走内置 render(GENERAL_RENDER),strategy 走 StrategyTab
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const registerAllTabs = async () => {
|
const registerAllTabs = async () => {
|
||||||
const [strategies, tabConfig] = await Promise.all([fetchStrategies(), fetchTabConfig()]);
|
const tabs = await fetchTabs();
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposeAllTabs();
|
disposeAllTabs();
|
||||||
registerGeneralTabs(tabConfig);
|
if (!tabs) return;
|
||||||
const n = registerStrategyTabs(strategies);
|
const sorted = tabs.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
ctx.logger?.info?.('[one-divine-lot] 已注册通用 tabs + ' + n + ' 个策略 tab');
|
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();
|
registerAllTabs();
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function AllPositionsTab(props) {
|
|||||||
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>现价</th>
|
||||||
@@ -56,14 +56,14 @@ export function AllPositionsTab(props) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(positions?.positions ?? []).map((p) => (
|
{(positions?.positions ?? []).map((p) => (
|
||||||
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={p.code} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.code}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.code}</td>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.name}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.name}</td>
|
||||||
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
|
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.totalVolume}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.totalVolume}</td>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.allocated}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px' }}>{p.allocated}</td>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? '#e65100' : '#2e7d32' }}>{p.unallocated}</td>
|
<td style={{ padding: '8px 6px', lineHeight: '24px', color: p.unallocated > 0 ? 'var(--dsw-alias-state-warn-primary, #e65100)' : 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>{p.unallocated}</td>
|
||||||
<td style={{ padding: '8px 6px', lineHeight: '24px', fontSize: 12, color: '#555' }}>
|
<td style={{ padding: '8px 6px', lineHeight: '24px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #555)' }}>
|
||||||
{Object.entries(p.shares).map(([sid, n]) => sid + ': ' + n).join(', ') || '—'}
|
{Object.entries(p.shares).map(([sid, n]) => sid + ': ' + n).join(', ') || '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -49,17 +49,17 @@ export function LoadState({ loading, error, onRetry, autoRetry = 2, children })
|
|||||||
}, [onRetry, autoRetry]);
|
}, [onRetry, autoRetry]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ padding: 16, color: '#666' }}>加载中...</div>;
|
return <div style={{ padding: 16, color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errMsg = error || manualError;
|
const errMsg = error || manualError;
|
||||||
if (errMsg) {
|
if (errMsg) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 16, color: '#c62828' }}>
|
<div style={{ padding: 16, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>
|
||||||
<div style={{ marginBottom: 8 }}>数据加载失败:{errMsg}</div>
|
<div style={{ marginBottom: 8 }}>数据加载失败:{errMsg}</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleManualRetry}
|
onClick={handleManualRetry}
|
||||||
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 4, background: '#fff', color: '#c62828' }}
|
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary, #c62828)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
export function PlaceholderTab({ title }) {
|
export function PlaceholderTab({ title }) {
|
||||||
return (
|
return (
|
||||||
<div className="odl-root" style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: '#999' }}>
|
<div className="odl-root" style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||||||
<div style={{ fontSize: 16, marginBottom: 8 }}>{title}</div>
|
<div style={{ fontSize: 16, marginBottom: 8 }}>{title}</div>
|
||||||
<div style={{ fontSize: 13 }}>功能开发中,敬请期待</div>
|
<div style={{ fontSize: 13 }}>功能开发中,敬请期待</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ function useFlash(price) {
|
|||||||
export function PriceCell({ price, lastClose }) {
|
export function PriceCell({ price, lastClose }) {
|
||||||
const flash = useFlash(price);
|
const flash = useFlash(price);
|
||||||
const hasPrice = price != null && isFinite(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 (hasPrice && lastClose != null && isFinite(lastClose)) {
|
||||||
if (price > lastClose) color = '#d32f2f';
|
// A股红涨绿跌;语义色跟随宿主深浅主题自动适配
|
||||||
else if (price < lastClose) color = '#2e7d32';
|
if (price > lastClose) color = 'var(--dsw-alias-state-error-primary, #d32f2f)';
|
||||||
else color = '#666';
|
else if (price < lastClose) color = 'var(--dsw-alias-state-success-primary, #2e7d32)';
|
||||||
|
else color = 'var(--dsw-alias-label-secondary, #666)';
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
@@ -53,7 +54,7 @@ export function PriceCell({ price, lastClose }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatPrice(price)}
|
{formatPrice(price)}
|
||||||
{flash > 0 && <style>{'@keyframes odl-price-flash{0%{background:rgba(255,215,0,.5)}100%{background:transparent}}'}</style>}
|
{flash > 0 && <style>{'@keyframes odl-price-flash{0%{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f9a825) 40%, transparent)}100%{background:transparent}}'}</style>}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,18 +21,18 @@ function MenuItem({ item, selected, onSelect }) {
|
|||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(item)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left',
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%', textAlign: 'left',
|
||||||
padding: '8px 12px', border: 'none', cursor: 'pointer', background: selected ? '#e8f5e9' : 'transparent',
|
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: '#333',
|
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'; }}
|
onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||||
>
|
>
|
||||||
<span style={{ width: 16, flex: 'none', color: '#2e7d32', fontWeight: 700 }}>{selected ? '✓' : ''}</span>
|
<span style={{ width: 16, flex: 'none', color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 700 }}>{selected ? '✓' : ''}</span>
|
||||||
<span style={{ flex: 1, minWidth: 0 }}>
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
<span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name}</span>
|
<span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name}</span>
|
||||||
<span style={{ display: 'block', fontSize: 11, color: '#999', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.baseUrl}</span>
|
<span style={{ display: 'block', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.baseUrl}</span>
|
||||||
</span>
|
</span>
|
||||||
{item.isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
{item.isDefault && <span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-state-business-primary, #1565c0)', border: '1px solid var(--dsw-alias-border-l3, #90caf9)', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ function QmtHealthIndicator() {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// 状态推导:null=灰(未知/加载中),healthy=true=绿,false=红
|
// 状态推导: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
|
const title = health === null
|
||||||
? 'QMT 健康状态获取中…(点击立即检测)'
|
? 'QMT 健康状态获取中…(点击立即检测)'
|
||||||
: (health.healthy
|
: (health.healthy
|
||||||
@@ -91,7 +91,7 @@ function QmtHealthIndicator() {
|
|||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
width: 10, height: 10, borderRadius: '50%', background: color, flex: 'none',
|
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',
|
cursor: 'pointer',
|
||||||
opacity: refreshing ? 0.5 : 1,
|
opacity: refreshing ? 0.5 : 1,
|
||||||
transition: 'opacity .2s',
|
transition: 'opacity .2s',
|
||||||
@@ -181,13 +181,13 @@ function ChipInner() {
|
|||||||
role="menu"
|
role="menu"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 10001,
|
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 10001,
|
||||||
minWidth: 220, maxWidth: 300, background: '#fff', border: '1px solid #ddd',
|
minWidth: 220, maxWidth: 300, background: 'var(--dsw-alias-bg-layer-1, #fff)', border: '1px solid var(--dsw-alias-border-l2, #ddd)',
|
||||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', padding: '4px 0',
|
borderRadius: 8, boxShadow: 'var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,0.15))', padding: '4px 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '4px 12px', fontSize: 11, color: '#999' }}>切换 QMT 连接</div>
|
<div style={{ padding: '4px 12px', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)' }}>切换 QMT 连接</div>
|
||||||
{state.list.length === 0 ? (
|
{state.list.length === 0 ? (
|
||||||
<div style={{ padding: '10px 12px', fontSize: 12, color: '#888' }}>
|
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)' }}>
|
||||||
暂无连接配置,请进 设置 → 神之一手 → QMT 连接配置 添加
|
暂无连接配置,请进 设置 → 神之一手 → QMT 连接配置 添加
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -200,7 +200,7 @@ function ChipInner() {
|
|||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<div style={{ borderTop: '1px solid #eee', marginTop: 4, padding: '6px 12px', fontSize: 11, color: '#999' }}>
|
<div style={{ borderTop: '1px solid var(--dsw-alias-border-l1, #eee)', marginTop: 4, padding: '6px 12px', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||||||
管理配置请进 设置 → 神之一手 → QMT 连接配置
|
管理配置请进 设置 → 神之一手 → QMT 连接配置
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD
|
|||||||
|
|
||||||
if (error || !lastDate) {
|
if (error || !lastDate) {
|
||||||
return (
|
return (
|
||||||
<div style={{ fontSize: 13, color: '#c62828' }}>
|
<div style={{ fontSize: 13, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>
|
||||||
{error || '交易日历加载中...'}
|
{error || '交易日历加载中...'}
|
||||||
<button onClick={load} style={{ marginLeft: 8, padding: '2px 10px', cursor: 'pointer' }}>重试</button>
|
<button onClick={load} style={{ marginLeft: 8, padding: '2px 10px', cursor: 'pointer' }}>重试</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,17 +170,17 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD
|
|||||||
padding: '2px 10px',
|
padding: '2px 10px',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
cursor: 'pointer',
|
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,
|
borderRadius: 4,
|
||||||
background: active ? '#e8f5e9' : '#fff',
|
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 ? '#1b5e20' : '#555',
|
color: active ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-secondary, #555)',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
});
|
});
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: '2px 4px',
|
padding: '2px 4px',
|
||||||
border: '1px solid #ccc',
|
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
width: 112,
|
width: 112,
|
||||||
};
|
};
|
||||||
@@ -190,9 +190,9 @@ export function RangeSelector({ preset, onPresetChange, range, onChange, onLastD
|
|||||||
<button style={btnStyle(activeToday)} onClick={() => applyPreset('today')} title="最近交易日">今日</button>
|
<button style={btnStyle(activeToday)} onClick={() => applyPreset('today')} title="最近交易日">今日</button>
|
||||||
<button style={btnStyle(activeThisWeek)} onClick={() => applyPreset('thisWeek')} title="本周一至本周五(不考虑周六日)">本周</button>
|
<button style={btnStyle(activeThisWeek)} onClick={() => applyPreset('thisWeek')} title="本周一至本周五(不考虑周六日)">本周</button>
|
||||||
<button style={btnStyle(activeThisMonth)} onClick={() => applyPreset('thisMonth')} title="本月1号至本月最后一天(完整自然月)">本月</button>
|
<button style={btnStyle(activeThisMonth)} onClick={() => applyPreset('thisMonth')} title="本月1号至本月最后一天(完整自然月)">本月</button>
|
||||||
<span style={{ color: '#999' }}>|</span>
|
<span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>|</span>
|
||||||
<input type="date" value={range ? fmt(range.start) : ''} onChange={onStartChange} style={inputStyle} title="起始日期" />
|
<input type="date" value={range ? fmt(range.start) : ''} onChange={onStartChange} style={inputStyle} title="起始日期" />
|
||||||
<span style={{ color: '#999' }}>~</span>
|
<span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>~</span>
|
||||||
<input type="date" value={range ? fmt(range.end) : ''} onChange={onEndChange} style={inputStyle} title="结束日期" />
|
<input type="date" value={range ? fmt(range.end) : ''} onChange={onEndChange} style={inputStyle} title="结束日期" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 神之一手设置 section(迭代 02:设置 tab 化扩展)
|
* 神之一手设置 section
|
||||||
*
|
*
|
||||||
* 结构:
|
* 结构(R-011 迭代 09):
|
||||||
* - 三个子 tab:通用设置 / 策略分组 / QMT 连接配置(迭代 03,R-004)
|
* - 三个子 tab:Tab 设置 / 策略分组 / QMT 连接配置
|
||||||
* - 通用设置:三个 switch(全部持仓 / 交易记录 / 关注列表),控制对应会话 tab 显隐,切换后提示「刷新页面后生效」
|
* - Tab 设置:统一管理所有会话 tab(内置 + 策略分组混排),拖动排序(落点立即持久化)+ 显示/隐藏开关;
|
||||||
* - 策略分组:策略 CRUD(新增/重命名/删除确认/排序/显隐)+ 就地提示
|
* 任何 tab 均不支持重命名/删除(产品约束-009、UI约束-003)
|
||||||
|
* - 策略分组:策略 CRUD(新增/重命名/删除确认)+ 提示「顺序与显示请在 Tab 设置中调整」
|
||||||
|
* - QMT 连接配置:R-004 卡片形式(迭代 03)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
@@ -15,17 +17,17 @@ import { ToastProvider, useToast } from './Toast.jsx';
|
|||||||
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
|
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
|
position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.4))',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ background: '#fff', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
|
<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 }}>{title}</h3>
|
<h3 style={{ margin: '0 0 12px', fontSize: 16 }}>{title}</h3>
|
||||||
<div style={{ fontSize: 14, color: '#555', marginBottom: 20 }}>{message}</div>
|
<div style={{ fontSize: 14, color: 'var(--dsw-alias-label-secondary, #555)', marginBottom: 20 }}>{message}</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
<button onClick={onCancel} style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff' }}>
|
<button onClick={onCancel} 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>
|
||||||
<button onClick={onConfirm} style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#c62828', color: '#fff' }}>
|
<button onClick={onConfirm} 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,30 +44,32 @@ function Switch({ checked, onChange, disabled }) {
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={{
|
style={{
|
||||||
width: 44, height: 24, borderRadius: 12, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
|
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,
|
opacity: disabled ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute', top: 2, left: checked ? 22 : 2, width: 20, height: 20,
|
position: 'absolute', top: 2, left: checked ? 22 : 2, width: 20, height: 20,
|
||||||
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
|
borderRadius: '50%', background: 'var(--dsw-alias-button-contrast-fill, #fff)', transition: 'left 0.2s',
|
||||||
}} />
|
}} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通用设置子 tab */
|
/** Tab 设置子 tab(R-011:统一管理所有会话 tab —— 内置 + 策略分组混排,拖动排序 + 显隐开关) */
|
||||||
function GeneralSettings({ onChanged }) {
|
function TabSettings() {
|
||||||
const [tabConfig, setTabConfig] = useState(null);
|
const [tabs, setTabs] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [dragId, setDragId] = useState(null);
|
||||||
|
const [overId, setOverId] = useState(null);
|
||||||
const call = useRpc();
|
const call = useRpc();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await call('one-divine-lot/tabs', {});
|
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) || '加载失败');
|
else setError((res && res.error && res.error.message) || '加载失败');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
@@ -74,48 +78,97 @@ function GeneralSettings({ onChanged }) {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const toggle = async (key, label) => {
|
// 整表持久化(拖动落点 / 显隐切换共用;Q1 落点立即持久化)
|
||||||
if (!tabConfig) return;
|
const persist = async (nextTabs, msg) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const next = { ...tabConfig, [key]: !tabConfig[key] };
|
const res = await call('one-divine-lot/tabs/update', { args: { tabs: nextTabs } });
|
||||||
const res = await call('one-divine-lot/tabs/update', { args: { tabs: next } });
|
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
setTabConfig(next);
|
setTabs(res.value);
|
||||||
toast.success(`「${label}」已${next[key] ? '显示' : '隐藏'}`);
|
toast.success(msg + '(刷新页面后生效)');
|
||||||
onChanged?.(label, next[key]);
|
|
||||||
} else {
|
} else {
|
||||||
toast.error((res && res.error && res.error.message) || '保存失败');
|
toast.error((res && res.error && res.error.message) || '保存失败');
|
||||||
|
await load();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e.message);
|
toast.error(e.message);
|
||||||
|
await load();
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = [
|
const toggleVisible = async (t) => {
|
||||||
{ key: 'allPositions', label: '全部持仓', desc: '会话窗中的「全部持仓」标签页' },
|
if (!tabs) return;
|
||||||
{ key: 'tradeRecords', label: '交易记录', desc: '会话窗中的「交易记录」标签页(开发中)' },
|
const next = tabs.map((x) => (x.id === t.id ? { ...x, visible: !x.visible } : x));
|
||||||
{ key: 'watchlist', label: '关注列表', desc: '会话窗中的「关注列表」标签页(开发中)' },
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{error && <div style={{ color: '#c62828', marginBottom: 12 }}>{error}</div>}
|
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
|
||||||
{!tabConfig && !error && <div style={{ color: '#666' }}>加载中...</div>}
|
拖动调整所有标签页顺序,开关控制显示/隐藏;内置与策略标签可任意混排。名称不支持在此修改(策略名称在「策略分组」中修改)。
|
||||||
{tabConfig && items.map((item) => (
|
</div>
|
||||||
<div key={item.key} style={{
|
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
{!tabs && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
|
||||||
padding: '12px 0', borderBottom: '1px solid #eee',
|
{tabs && (
|
||||||
}}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||||
<div>
|
<thead>
|
||||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{item.label}</div>
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
|
||||||
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{item.desc}</div>
|
<th style={{ width: 44, padding: '8px 6px' }}></th>
|
||||||
</div>
|
<th style={{ padding: '8px 6px' }}>标签页</th>
|
||||||
<Switch checked={tabConfig[item.key] !== false} onChange={() => toggle(item.key, item.label)} disabled={saving} />
|
<th style={{ width: 80, padding: '8px 6px' }}>显示</th>
|
||||||
</div>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<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); }}
|
||||||
|
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' }}>
|
||||||
|
<span style={badgeStyle(t.kind)}>{t.kind === 'builtin' ? '内置' : '策略'}</span>
|
||||||
|
{t.name}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px 6px' }}>
|
||||||
|
<Switch checked={t.visible !== false} onChange={() => toggleVisible(t)} disabled={saving} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{changedMsg && (
|
{changedMsg && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
|
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
|
||||||
background: '#e3f2fd', color: '#1565c0', fontSize: 13, border: '1px solid #90caf9',
|
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>
|
{changedMsg} —— <strong>刷新页面后生效</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div style={{ color: '#c62828', marginBottom: 12 }}>{error}</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' }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
@@ -265,44 +283,34 @@ function StrategyGroupSettings() {
|
|||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
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 }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!strategies && !error && <div style={{ color: '#666' }}>加载中...</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 && (
|
{strategies && (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
|
||||||
<th style={{ padding: '8px 6px' }}>排序</th>
|
|
||||||
<th style={{ padding: '8px 6px' }}>策略</th>
|
<th style={{ padding: '8px 6px' }}>策略</th>
|
||||||
<th style={{ padding: '8px 6px' }}>ID</th>
|
<th style={{ padding: '8px 6px' }}>ID</th>
|
||||||
<th style={{ padding: '8px 6px' }}>显示</th>
|
|
||||||
<th style={{ padding: '8px 6px' }}>操作</th>
|
<th style={{ padding: '8px 6px' }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{strategies.map((s, idx) => (
|
{strategies.map((s) => (
|
||||||
<tr key={s.id} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={s.id} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMove(s.id, 'up')}
|
|
||||||
disabled={saving || idx === 0}
|
|
||||||
style={{ padding: '2px 6px', cursor: idx === 0 ? 'not-allowed' : 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff', marginRight: 4 }}
|
|
||||||
>↑</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMove(s.id, 'down')}
|
|
||||||
disabled={saving || idx === strategies.length - 1}
|
|
||||||
style={{ padding: '2px 6px', cursor: idx === strategies.length - 1 ? 'not-allowed' : 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff' }}
|
|
||||||
>↓</button>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '8px 6px' }}>
|
<td style={{ padding: '8px 6px' }}>
|
||||||
{editingId === s.id ? (
|
{editingId === s.id ? (
|
||||||
<span>
|
<span>
|
||||||
@@ -320,18 +328,15 @@ function StrategyGroupSettings() {
|
|||||||
s.name
|
s.name
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 6px', color: '#666', fontSize: 12 }}>{s.id}</td>
|
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 12 }}>{s.id}</td>
|
||||||
<td style={{ padding: '8px 6px' }}>
|
|
||||||
<Switch checked={s.visible !== false} onChange={() => toggleVisible(s.id)} disabled={saving} />
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
|
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
|
||||||
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 3, background: '#fff', marginRight: 4 }}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
|
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
|
||||||
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #c62828', borderRadius: 3, background: '#fff', color: '#c62828' }}
|
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>
|
>删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -354,16 +359,16 @@ function StrategyGroupSettings() {
|
|||||||
|
|
||||||
/** 设置 section 主组件:三个子 tab */
|
/** 设置 section 主组件:三个子 tab */
|
||||||
function SettingsSectionInner(props) {
|
function SettingsSectionInner(props) {
|
||||||
const [activeTab, setActiveTab] = useState('general'); // general | strategies | qmt
|
const [activeTab, setActiveTab] = useState('tabs'); // tabs | strategies | qmt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
|
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
|
||||||
<h2 style={{ margin: '0 0 16px' }}>神之一手</h2>
|
<h2 style={{ margin: '0 0 16px' }}>神之一手</h2>
|
||||||
|
|
||||||
{/* 子 tab 导航 */}
|
{/* 子 tab 导航 */}
|
||||||
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid #ddd', marginBottom: 16 }}>
|
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', marginBottom: 16 }}>
|
||||||
{[
|
{[
|
||||||
{ key: 'general', label: '通用设置' },
|
{ key: 'tabs', label: 'Tab 设置' },
|
||||||
{ key: 'strategies', label: '策略分组' },
|
{ key: 'strategies', label: '策略分组' },
|
||||||
{ key: 'qmt', label: 'QMT 连接配置' },
|
{ key: 'qmt', label: 'QMT 连接配置' },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
@@ -371,8 +376,8 @@ function SettingsSectionInner(props) {
|
|||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px', cursor: 'pointer', border: 'none', borderBottom: activeTab === tab.key ? '2px solid #2e7d32' : '2px solid transparent',
|
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 ? '#2e7d32' : '#666', fontWeight: activeTab === tab.key ? 600 : 400,
|
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}
|
{tab.label}
|
||||||
@@ -380,9 +385,7 @@ function SettingsSectionInner(props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'general' && <GeneralSettings onChanged={(label, visible) => {
|
{activeTab === 'tabs' && <TabSettings />}
|
||||||
// 通用设置变更 → 就地提示(由 GeneralSettings 内 toast 提示,这里可额外提示)
|
|
||||||
}} />}
|
|
||||||
{activeTab === 'strategies' && <StrategyGroupSettings />}
|
{activeTab === 'strategies' && <StrategyGroupSettings />}
|
||||||
{activeTab === 'qmt' && <QmtConnectionsSettings />}
|
{activeTab === 'qmt' && <QmtConnectionsSettings />}
|
||||||
</div>
|
</div>
|
||||||
@@ -411,34 +414,34 @@ function EllipsisText({ text, maxWidth = 220, title, style }) {
|
|||||||
|
|
||||||
/** QMT 连接配置卡片(R-004;产品约束-005、UI约束-002;2026-08-29 老师反馈改卡片形式) */
|
/** QMT 连接配置卡片(R-004;产品约束-005、UI约束-002;2026-08-29 老师反馈改卡片形式) */
|
||||||
function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActivate, onSetDefault, onTest, onEdit, onDelete }) {
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
border: isActive ? '1px solid #2e7d32' : '1px solid #ddd',
|
border: isActive ? '1px solid var(--dsw-alias-state-success-primary, #2e7d32)' : '1px solid var(--dsw-alias-border-l2, #ddd)',
|
||||||
background: isActive ? '#f1f8f2' : '#fff',
|
background: isActive ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 8%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '12px 14px',
|
padding: '12px 14px',
|
||||||
display: 'flex', flexDirection: 'column', gap: 8,
|
display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
minWidth: 0, overflow: 'hidden',
|
minWidth: 0, overflow: 'hidden',
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
|
boxShadow: 'var(--dsw-shadow-lv3, 0 1px 2px rgba(0,0,0,0.04))',
|
||||||
}}>
|
}}>
|
||||||
{/* 标题行:名称 + 徽标 */}
|
{/* 标题行:名称 + 徽标 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||||
<EllipsisText text={conn.name} maxWidth={140} style={{ fontWeight: 600, fontSize: 14 }} />
|
<EllipsisText text={conn.name} maxWidth={140} style={{ fontWeight: 600, fontSize: 14 }} />
|
||||||
{isActive && <span style={{ flex: 'none', fontSize: 11, color: '#2e7d32', border: '1px solid #2e7d32', borderRadius: 8, padding: '0 6px' }}>生效中</span>}
|
{isActive && <span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-state-success-primary, #2e7d32)', border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', borderRadius: 8, padding: '0 6px' }}>生效中</span>}
|
||||||
{isDefault && <span style={{ flex: 'none', fontSize: 11, color: '#1565c0', border: '1px solid #90caf9', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
{isDefault && <span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-state-business-primary, #1565c0)', border: '1px solid var(--dsw-alias-border-l3, #90caf9)', borderRadius: 8, padding: '0 6px' }}>默认</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 地址行:省略+悬停 */}
|
{/* 地址行:省略+悬停 */}
|
||||||
<div style={{ fontSize: 12, color: '#666', minWidth: 0 }}>
|
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', minWidth: 0 }}>
|
||||||
<EllipsisText text={conn.baseUrl} maxWidth={240} />
|
<EllipsisText text={conn.baseUrl} maxWidth={240} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 测试结果行:可换行但受卡片约束;超长悬停看全文 */}
|
{/* 测试结果行:可换行但受卡片约束;超长悬停看全文 */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: 12, lineHeight: '16px', color: testResult.ok ? '#2e7d32' : '#c62828',
|
fontSize: 12, lineHeight: '16px', color: testResult.ok ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)',
|
||||||
background: testResult.ok ? '#e8f5e9' : '#fdecea',
|
background: testResult.ok ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))',
|
||||||
borderRadius: 4, padding: '4px 8px', minWidth: 0,
|
borderRadius: 4, padding: '4px 8px', minWidth: 0,
|
||||||
}}>
|
}}>
|
||||||
<EllipsisText text={testResult.text} maxWidth={9999} title={testResult.fullText || testResult.text} />
|
<EllipsisText text={testResult.text} maxWidth={9999} title={testResult.fullText || testResult.text} />
|
||||||
@@ -447,11 +450,11 @@ function QmtConnectionCard({ conn, isActive, isDefault, busy, testResult, onActi
|
|||||||
|
|
||||||
{/* 操作行:按钮自动换行排列,不挤压卡片 */}
|
{/* 操作行:按钮自动换行排列,不挤压卡片 */}
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 2 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 2 }}>
|
||||||
{!isActive && <button onClick={onActivate} disabled={busy} style={{ ...btn, color: '#2e7d32', borderColor: '#2e7d32' }}>激活</button>}
|
{!isActive && <button onClick={onActivate} disabled={busy} style={{ ...btn, color: 'var(--dsw-alias-state-success-primary, #2e7d32)', borderColor: 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>激活</button>}
|
||||||
{!isDefault && <button onClick={onSetDefault} disabled={busy} style={btn}>设为默认</button>}
|
{!isDefault && <button onClick={onSetDefault} disabled={busy} style={btn}>设为默认</button>}
|
||||||
<button onClick={onTest} disabled={busy}>{busy ? '测试中…' : '测试连接'}</button>
|
<button onClick={onTest} disabled={busy}>{busy ? '测试中…' : '测试连接'}</button>
|
||||||
<button onClick={onEdit} disabled={busy} style={btn}>编辑</button>
|
<button onClick={onEdit} disabled={busy} style={btn}>编辑</button>
|
||||||
<button onClick={onDelete} disabled={busy} style={{ ...btn, color: '#c62828', borderColor: '#c62828' }}>删除</button>
|
<button onClick={onDelete} disabled={busy} style={{ ...btn, color: 'var(--dsw-alias-state-error-primary, #c62828)', borderColor: 'var(--dsw-alias-state-error-primary, #c62828)' }}>删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -620,19 +623,19 @@ function QmtConnectionsSettings() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
<div style={{ fontSize: 13, color: '#666', minWidth: 0 }}>
|
<div style={{ fontSize: 13, color: 'var(--dsw-alias-label-secondary, #666)', minWidth: 0 }}>
|
||||||
激活配置为当前生效连接(切换立即生效);默认配置在插件启动时自动激活。
|
激活配置为当前生效连接(切换立即生效);默认配置在插件启动时自动激活。
|
||||||
</div>
|
</div>
|
||||||
<button onClick={openAdd} disabled={saving} style={{ flex: 'none', padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
|
<button onClick={openAdd} disabled={saving} style={{ flex: 'none', 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div style={{ color: '#c62828', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
|
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}><EllipsisText text={error} maxWidth={560} /></div>}
|
||||||
{!state && !error && <div style={{ color: '#666' }}>加载中...</div>}
|
{!state && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
|
||||||
|
|
||||||
{state && state.list.length === 0 && (
|
{state && state.list.length === 0 && (
|
||||||
<div style={{ padding: '24px 0', color: '#888', fontSize: 13 }}>
|
<div style={{ padding: '24px 0', color: 'var(--dsw-alias-label-secondary, #888)', fontSize: 13 }}>
|
||||||
暂无连接配置。点击「新增连接」录入第一个 QMT Bridge 地址(如 http://192.168.3.43:8610)。
|
暂无连接配置。点击「新增连接」录入第一个 QMT Bridge 地址(如 http://192.168.3.43:8610)。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -662,40 +665,40 @@ function QmtConnectionsSettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{formOpen && (
|
{formOpen && (
|
||||||
<div style={{ marginTop: 16, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa', maxWidth: 640 }}>
|
<div style={{ marginTop: 16, padding: 16, border: '1px solid var(--dsw-alias-border-l2, #ddd)', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2, #fafafa)', maxWidth: 640 }}>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>{editingId === null ? '新增连接配置' : '编辑连接配置'}</div>
|
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>{editingId === null ? '新增连接配置' : '编辑连接配置'}</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 10 }}>
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||||
连接名称
|
连接名称
|
||||||
<input type="text" placeholder="如:本机 QMT" value={formName}
|
<input type="text" placeholder="如:本机 QMT" value={formName}
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
|
style={{ padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, fontSize: 13 }} />
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#666' }}>
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||||
HTTP 地址
|
HTTP 地址
|
||||||
<input type="text" placeholder="如 http://192.168.3.43:8610" value={formUrl}
|
<input type="text" placeholder="如 http://192.168.3.43:8610" value={formUrl}
|
||||||
onChange={(e) => setFormUrl(e.target.value)}
|
onChange={(e) => setFormUrl(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||||
style={{ padding: '6px 10px', border: '1px solid #ccc', borderRadius: 4, fontSize: 13 }} />
|
style={{ padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, fontSize: 13 }} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
<button onClick={handleSave} disabled={saving}
|
<button onClick={handleSave} disabled={saving}
|
||||||
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: '#2e7d32', color: '#fff' }}>
|
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>
|
</button>
|
||||||
<button onClick={handleTestForm} disabled={testingId !== null}
|
<button onClick={handleTestForm} disabled={testingId !== null}
|
||||||
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
|
style={{ 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 }}>
|
||||||
{testingId === '__form__' ? '测试中…' : '测试连接'}
|
{testingId === '__form__' ? '测试中…' : '测试连接'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={closeForm} disabled={saving}
|
<button onClick={closeForm} disabled={saving}
|
||||||
style={{ padding: '3px 10px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#fff', fontSize: 12 }}>
|
style={{ 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 }}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
{testResult && testResult.key === '__form__' && (
|
{testResult && testResult.key === '__form__' && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 12, color: testResult.ok ? '#2e7d32' : '#c62828',
|
fontSize: 12, color: testResult.ok ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)',
|
||||||
background: testResult.ok ? '#e8f5e9' : '#fdecea', borderRadius: 4, padding: '3px 8px',
|
background: testResult.ok ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))', borderRadius: 4, padding: '3px 8px',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}>
|
}}>
|
||||||
<EllipsisText text={testResult.text} maxWidth={360} title={testResult.fullText || testResult.text} />
|
<EllipsisText text={testResult.text} maxWidth={360} title={testResult.fullText || testResult.text} />
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ function calcPctChange(snap) {
|
|||||||
|
|
||||||
/** 涨幅着色:涨红跌绿(A股习惯) */
|
/** 涨幅着色:涨红跌绿(A股习惯) */
|
||||||
function pctStyle(pct) {
|
function pctStyle(pct) {
|
||||||
if (pct == null) return { color: '#999' };
|
if (pct == null) return { color: 'var(--dsw-alias-label-tertiary, #999)' };
|
||||||
if (pct > 0) return { color: '#d32f2f', fontWeight: 'bold' };
|
if (pct > 0) return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' };
|
||||||
if (pct < 0) return { color: '#2e7d32', fontWeight: 'bold' };
|
if (pct < 0) return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' };
|
||||||
return { color: '#999' };
|
return { color: 'var(--dsw-alias-label-tertiary, #999)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 涨幅文本:+x.xx% / -x.xx% / — */
|
/** 涨幅文本:+x.xx% / -x.xx% / — */
|
||||||
@@ -60,10 +60,10 @@ const ADD_FORM_CSS = `
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--dsw-alias-border-l2, #ddd);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
background: #fafafa;
|
background: var(--dsw-alias-bg-layer-1, #fafafa);
|
||||||
}
|
}
|
||||||
.odl-add-form .odl-field {
|
.odl-add-form .odl-field {
|
||||||
flex: 1 1 260px;
|
flex: 1 1 260px;
|
||||||
@@ -94,8 +94,8 @@ const ADD_FORM_CSS = `
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #2e7d32;
|
background: var(--dsw-alias-state-success-primary, #2e7d32);
|
||||||
color: #fff;
|
color: var(--dsw-alias-button-contrast-fill, #fff);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -288,7 +288,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdd(!showAdd)}
|
onClick={() => setShowAdd(!showAdd)}
|
||||||
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid #ccc', borderRadius: 4, background: '#f5f5f5' }}
|
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' }}
|
||||||
>
|
>
|
||||||
{showAdd ? '取消' : '+ 添加持仓'}
|
{showAdd ? '取消' : '+ 添加持仓'}
|
||||||
</button>
|
</button>
|
||||||
@@ -329,13 +329,13 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{positions && positions.length === 0 ? (
|
{positions && positions.length === 0 ? (
|
||||||
<div style={{ padding: 24, textAlign: 'center', color: '#999' }}>
|
<div style={{ padding: 24, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||||||
暂无持仓,点击「+ 添加持仓」从全部持仓中添加
|
暂无持仓,点击「+ 添加持仓」从全部持仓中添加
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px', width: 28 }}> </th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px', width: 28 }}> </th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>代码</th>
|
||||||
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
<th style={{ padding: '8px 6px', lineHeight: '24px' }}>名称</th>
|
||||||
@@ -359,9 +359,9 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
<tr
|
<tr
|
||||||
onClick={() => toggleExpand(p.code, p.holdingId)}
|
onClick={() => toggleExpand(p.code, p.holdingId)}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid #eee',
|
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)',
|
||||||
cursor: hasHolding ? 'pointer' : 'default',
|
cursor: hasHolding ? 'pointer' : 'default',
|
||||||
background: isExpanded ? '#f5f5f5' : 'transparent',
|
background: isExpanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
|
<td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
|
||||||
@@ -374,7 +374,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
transition: 'transform .15s ease',
|
transition: 'transform .15s ease',
|
||||||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
color: '#2e7d32',
|
color: 'var(--dsw-alias-state-success-primary, #2e7d32)',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -388,7 +388,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#ccc', fontSize: 10 }}>·</span>
|
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)', fontSize: 10 }}>·</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 6px' }}>{p.code}</td>
|
<td style={{ padding: '8px 6px' }}>{p.code}</td>
|
||||||
@@ -421,7 +421,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
<span>
|
<span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleMoveAllIn(p.code)}
|
onClick={() => handleMoveAllIn(p.code)}
|
||||||
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid #2e7d32', borderRadius: 3, background: '#fff', color: '#2e7d32', marginRight: 4 }}
|
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-success-primary, #2e7d32)', marginRight: 4 }}
|
||||||
title="将该票全部未分配份额移入本策略"
|
title="将该票全部未分配份额移入本策略"
|
||||||
>
|
>
|
||||||
全部移入
|
全部移入
|
||||||
@@ -437,12 +437,12 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
|
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
|
||||||
<td colSpan={10} style={{ padding: '4px 8px 12px' }}>
|
<td colSpan={10} style={{ padding: '4px 8px 12px' }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ padding: 8, color: '#999', fontSize: 12 }}>交易记录加载中...</div>
|
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>交易记录加载中...</div>
|
||||||
) : !trades || trades.length === 0 ? (
|
) : !trades || trades.length === 0 ? (
|
||||||
<div style={{ padding: 8, color: '#999', fontSize: 12 }}>
|
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>
|
||||||
{hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id)'}
|
{hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id)'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -486,9 +486,9 @@ function dirTextH(d) {
|
|||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
function dirColorH(d) {
|
function dirColorH(d) {
|
||||||
if (d === 'buy') return { color: '#d32f2f', fontWeight: 'bold' };
|
if (d === 'buy') return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' };
|
||||||
if (d === 'sell') return { color: '#2e7d32', fontWeight: 'bold' };
|
if (d === 'sell') return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' };
|
||||||
return { color: '#999' };
|
return { color: 'var(--dsw-alias-label-tertiary, #999)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function HoldingTradesTable({ trades }) {
|
function HoldingTradesTable({ trades }) {
|
||||||
@@ -496,7 +496,7 @@ function HoldingTradesTable({ trades }) {
|
|||||||
return (
|
return (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
|
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||||
<th style={{ ...tdH, textAlign: 'left' }}>交易日</th>
|
<th style={{ ...tdH, textAlign: 'left' }}>交易日</th>
|
||||||
<th style={{ ...tdH, textAlign: 'left' }}>时间</th>
|
<th style={{ ...tdH, textAlign: 'left' }}>时间</th>
|
||||||
<th style={{ ...tdH, textAlign: 'left' }}>方向</th>
|
<th style={{ ...tdH, textAlign: 'left' }}>方向</th>
|
||||||
@@ -510,7 +510,7 @@ function HoldingTradesTable({ trades }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{list.map((o) => (
|
{list.map((o) => (
|
||||||
<tr key={o.orderId} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
<tr key={o.orderId} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #f0f0f0)' }}>
|
||||||
<td style={tdH}>{fmtDateH(o.tradeDate)}</td>
|
<td style={tdH}>{fmtDateH(o.tradeDate)}</td>
|
||||||
<td style={tdH}>{fmtTimeH(o.insertTime)}</td>
|
<td style={tdH}>{fmtTimeH(o.insertTime)}</td>
|
||||||
<td style={{ ...tdH, ...dirColorH(o.direction) }}>{dirTextH(o.direction)}</td>
|
<td style={{ ...tdH, ...dirColorH(o.direction) }}>{dirTextH(o.direction)}</td>
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ export function ToastHost() {
|
|||||||
style={{
|
style={{
|
||||||
padding: '10px 16px',
|
padding: '10px 16px',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
color: '#fff',
|
color: 'var(--dsw-alias-button-contrast-fill, #fff)',
|
||||||
background: t.type === 'success' ? '#2e7d32' : '#c62828',
|
background: t.type === 'success' ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-state-error-primary, #c62828)',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
boxShadow: 'var(--dsw-shadow-lv3, 0 2px 8px rgba(0,0,0,0.2))',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
maxWidth: 360,
|
maxWidth: 360,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ function fmtNum(v, digits = 2) {
|
|||||||
|
|
||||||
/** 方向样式:红买绿卖 */
|
/** 方向样式:红买绿卖 */
|
||||||
function directionStyle(direction) {
|
function directionStyle(direction) {
|
||||||
if (direction === 'buy') return { color: '#d32f2f', fontWeight: 'bold' };
|
if (direction === 'buy') return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' };
|
||||||
if (direction === 'sell') return { color: '#2e7d32', fontWeight: 'bold' };
|
if (direction === 'sell') return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' };
|
||||||
return { color: '#999' };
|
return { color: 'var(--dsw-alias-label-tertiary, #999)' };
|
||||||
}
|
}
|
||||||
function directionText(direction) {
|
function directionText(direction) {
|
||||||
if (direction === 'buy') return '买入';
|
if (direction === 'buy') return '买入';
|
||||||
@@ -309,7 +309,7 @@ export function TradeRecordsTab() {
|
|||||||
<select
|
<select
|
||||||
value={strategyFilter}
|
value={strategyFilter}
|
||||||
onChange={(e) => setStrategyFilter(e.target.value)}
|
onChange={(e) => setStrategyFilter(e.target.value)}
|
||||||
style={{ fontSize: 12, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, color: '#555' }}
|
style={{ fontSize: 12, padding: '2px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, color: 'var(--dsw-alias-label-secondary, #555)' }}
|
||||||
title="按策略过滤"
|
title="按策略过滤"
|
||||||
>
|
>
|
||||||
<option value="">全部策略</option>
|
<option value="">全部策略</option>
|
||||||
@@ -322,14 +322,14 @@ export function TradeRecordsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{attributionSaved && (
|
{attributionSaved && (
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: attributionSaved.startsWith('保存失败') ? '#c62828' : '#1b5e20', background: attributionSaved.startsWith('保存失败') ? '#fdecea' : '#e8f5e9', padding: '4px 8px', borderRadius: 4 }}>
|
<div style={{ marginBottom: 8, fontSize: 12, color: attributionSaved.startsWith('保存失败') ? 'var(--dsw-alias-state-error-primary, #c62828)' : 'var(--dsw-alias-state-success-primary, #1b5e20)', background: attributionSaved.startsWith('保存失败') ? 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))', padding: '4px 8px', borderRadius: 4 }}>
|
||||||
{attributionSaved}
|
{attributionSaved}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
|
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
|
||||||
{!orders?.length ? (
|
{!orders?.length ? (
|
||||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
<div style={{ padding: 32, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||||||
<div style={{ fontSize: 15, marginBottom: 8 }}>
|
<div style={{ fontSize: 15, marginBottom: 8 }}>
|
||||||
{range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''}
|
{range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -340,9 +340,9 @@ export function TradeRecordsTab() {
|
|||||||
) : (
|
) : (
|
||||||
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
|
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
|
||||||
)}
|
)}
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||||||
<span style={{ color: '#d32f2f' }}>■ 买</span>
|
<span style={{ color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}>■ 买</span>
|
||||||
<span style={{ marginLeft: 8, color: '#2e7d32' }}>■ 卖</span>
|
<span style={{ marginLeft: 8, color: 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>■ 卖</span>
|
||||||
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
|
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
|
||||||
{isHistoryRange && <span style={{ marginLeft: 12 }}>· 历史数据 = 本地积累</span>}
|
{isHistoryRange && <span style={{ marginLeft: 12 }}>· 历史数据 = 本地积累</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -356,7 +356,7 @@ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, e
|
|||||||
return (
|
return (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
|
||||||
<th style={thStyle}> </th>
|
<th style={thStyle}> </th>
|
||||||
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
|
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
|
||||||
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
|
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
|
||||||
@@ -404,9 +404,9 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
|
|||||||
<tr
|
<tr
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid #eee',
|
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)',
|
||||||
cursor: hasTrades ? 'pointer' : 'default',
|
cursor: hasTrades ? 'pointer' : 'default',
|
||||||
background: expanded ? '#f5f5f5' : 'transparent',
|
background: expanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ ...tdStyle, textAlign: 'center', width: 28 }}>
|
<td style={{ ...tdStyle, textAlign: 'center', width: 28 }}>
|
||||||
@@ -420,7 +420,7 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
|
|||||||
transition: 'transform .15s ease',
|
transition: 'transform .15s ease',
|
||||||
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: '#2e7d32',
|
color: 'var(--dsw-alias-state-success-primary, #2e7d32)',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -434,7 +434,7 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#ccc', fontSize: 10 }}>·</span>
|
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)', fontSize: 10 }}>·</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>{fmtTime(order.insertTime)}</td>
|
<td style={tdStyle}>{fmtTime(order.insertTime)}</td>
|
||||||
@@ -461,11 +461,11 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded && hasTrades && (
|
{expanded && hasTrades && (
|
||||||
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
|
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
|
||||||
<td colSpan={16} style={{ padding: '4px 8px 12px' }}>
|
<td colSpan={16} style={{ padding: '4px 8px 12px' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
|
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||||
<th style={{ ...tdStyle, textAlign: 'left' }}>成交时间</th>
|
<th style={{ ...tdStyle, textAlign: 'left' }}>成交时间</th>
|
||||||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交价</th>
|
<th style={{ ...tdStyle, textAlign: 'right' }}>成交价</th>
|
||||||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交量</th>
|
<th style={{ ...tdStyle, textAlign: 'right' }}>成交量</th>
|
||||||
@@ -475,12 +475,12 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{order.trades.map((t) => (
|
{order.trades.map((t) => (
|
||||||
<tr key={t.tradeId} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
<tr key={t.tradeId} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #f0f0f0)' }}>
|
||||||
<td style={tdStyle}>{fmtTime(t.tradeTime)}</td>
|
<td style={tdStyle}>{fmtTime(t.tradeTime)}</td>
|
||||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.price)}</td>
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.price)}</td>
|
||||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.volume, 0)}</td>
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.volume, 0)}</td>
|
||||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.amount)}</td>
|
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.amount)}</td>
|
||||||
<td style={{ ...tdStyle, textAlign: 'right', color: '#999' }}>{t.tradeId}</td>
|
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--dsw-alias-label-tertiary, #999)' }}>{t.tradeId}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -517,10 +517,10 @@ function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: '2px 4px',
|
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,
|
borderRadius: 4,
|
||||||
background: current ? '#e8f5e9' : '#fff',
|
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 ? '#1b5e20' : '#999',
|
color: current ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
|
||||||
maxWidth: 120,
|
maxWidth: 120,
|
||||||
}}
|
}}
|
||||||
title="设置该委托的策略归属(可随时修改)"
|
title="设置该委托的策略归属(可随时修改)"
|
||||||
|
|||||||
+144
-95
@@ -5,38 +5,63 @@
|
|||||||
* 通过 ctx.settings.register('one-divine-lot', schema) 注册策略配置 namespace。
|
* 通过 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:可删除,彻底自由)
|
* 预置:网格超市、手动做T(R-003 O3-5:可删除,彻底自由)
|
||||||
*
|
*
|
||||||
* 迭代 02 新增(R-003 O3 策略 CRUD):
|
* R-011 迭代 09(产品约束-009、技术约束-014):
|
||||||
* - generateStrategyId(name):中文/任意名 → 英文 slug id
|
* - settings.tabs 从布尔对象升级为统一有序数组(内置 + 策略混排)
|
||||||
* - addStrategy / renameStrategy / moveStrategy / removeStrategy
|
* - 旧格式读取时静默归一化迁移(旧隐藏策略迁移后显示,Q3)
|
||||||
|
* - addStrategy 联动追加 tab(末尾,Q2);removeStrategy 联动删除对应 tab(D3)
|
||||||
|
* - 策略行 name 由 getTabs join strategies(改名自动跟随,Q4)
|
||||||
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
|
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import z from '@deepseek-ai/schemastery';
|
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 对象) */
|
/** 策略设置 schema(schemastery 定义,settings.register 要求 schema 对象) */
|
||||||
export const strategySchema = z.object({
|
export const strategySchema = z.object({
|
||||||
|
// 策略定义表(R-011 收窄:仅 id + name,visible/order 归 tabs 统一管理)
|
||||||
strategies: z.array(z.object({
|
strategies: z.array(z.object({
|
||||||
id: z.string().required(),
|
id: z.string().required(),
|
||||||
name: z.string().required(),
|
name: z.string().required(),
|
||||||
visible: z.boolean().default(true),
|
})).default(DEFAULT_STRATEGIES),
|
||||||
order: z.number().default(0),
|
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
|
||||||
})).default([
|
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
|
||||||
{ id: 'grid-supermarket', name: '网格超市', visible: true, order: 0 },
|
tabs: z.union([
|
||||||
{ id: 'manual-t', name: '手动做T', visible: true, order: 1 },
|
z.object({
|
||||||
]),
|
allPositions: z.boolean().default(true),
|
||||||
// 通用 tab 显隐配置(设置 tab 化扩展,2026-08-28)
|
tradeRecords: z.boolean().default(true),
|
||||||
tabs: z.object({
|
watchlist: z.boolean().default(true),
|
||||||
allPositions: z.boolean().default(true), // 全部持仓
|
}),
|
||||||
tradeRecords: z.boolean().default(true), // 交易记录(未来 tab,本期空占位)
|
z.array(z.object({
|
||||||
watchlist: z.boolean().default(true), // 关注列表(未来 tab,本期空占位)
|
id: z.string().required(),
|
||||||
}).default({
|
kind: z.union([z.const('builtin'), z.const('strategy')]).required(),
|
||||||
allPositions: true,
|
refKey: z.string(), // builtin 专用:allPositions/tradeRecords/watchlist(可空)
|
||||||
tradeRecords: true,
|
refId: z.string(), // strategy 专用:策略 id(可空)
|
||||||
watchlist: true,
|
name: z.string(), // 展示名(策略行 join 时刷新,Q4 自动跟随改名;可空)
|
||||||
}),
|
visible: z.boolean().default(true),
|
||||||
|
order: z.number().default(0),
|
||||||
|
})),
|
||||||
|
]).default(DEFAULT_TABS),
|
||||||
// QMT 连接配置(R-004,技术约束-008):list + 激活态 + 默认态
|
// QMT 连接配置(R-004,技术约束-008):list + 激活态 + 默认态
|
||||||
qmtConnections: z.object({
|
qmtConnections: z.object({
|
||||||
list: z.array(z.object({
|
list: z.array(z.object({
|
||||||
@@ -50,18 +75,6 @@ export const strategySchema = z.object({
|
|||||||
}).default({ list: [], activeId: '', defaultId: '' }),
|
}).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 生成兜底) */
|
/** 简易拼音映射表(常用汉字 → 拼音,用于 slug 生成兜底) */
|
||||||
const PINYIN_MAP = {
|
const PINYIN_MAP = {
|
||||||
@@ -182,23 +195,12 @@ export function registerSettings(ctx, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取策略列表(含 visible 过滤)
|
* 读取策略定义表(R-011 收窄:仅 id + name,无 visible/order)
|
||||||
* @param {ReturnType<typeof registerSettings>} scope
|
* @param {ReturnType<typeof registerSettings>} scope
|
||||||
*/
|
*/
|
||||||
export function getStrategies(scope) {
|
export function getStrategies(scope) {
|
||||||
const value = scope?.get() ?? {};
|
const value = scope?.get() ?? {};
|
||||||
const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
|
return 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,10 +224,10 @@ export async function addStrategy(scope, name) {
|
|||||||
const strategy = {
|
const strategy = {
|
||||||
id,
|
id,
|
||||||
name: String(name ?? '').trim(),
|
name: String(name ?? '').trim(),
|
||||||
visible: true,
|
|
||||||
order: list.reduce((max, s) => Math.max(max, s.order ?? 0), -1) + 1,
|
|
||||||
};
|
};
|
||||||
await updateStrategies(scope, [...list, strategy]);
|
await updateStrategies(scope, [...list, strategy]);
|
||||||
|
// R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾)
|
||||||
|
await appendStrategyTab(scope, strategy);
|
||||||
return strategy;
|
return strategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,33 +247,6 @@ export async function renameStrategy(scope, id, name) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 移动策略(排序,上移/下移)
|
|
||||||
* @param {ReturnType<typeof registerSettings>} scope
|
|
||||||
* @param {string} id 策略 id
|
|
||||||
* @param {'up'|'down'} dir 方向
|
|
||||||
* @returns {Promise<boolean>} 是否成功
|
|
||||||
*/
|
|
||||||
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)
|
* 删除策略(O3;份额清理由上层编排 storage)
|
||||||
@@ -284,40 +259,114 @@ export async function removeStrategy(scope, id) {
|
|||||||
const target = list.find((s) => s.id === id);
|
const target = list.find((s) => s.id === id);
|
||||||
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
|
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
|
||||||
await updateStrategies(scope, list.filter((s) => s.id !== id));
|
await updateStrategies(scope, list.filter((s) => s.id !== id));
|
||||||
|
// R-011:联动删除 tabs 列表中对应策略条目(D3)
|
||||||
|
await removeStrategyTab(scope, id);
|
||||||
return true;
|
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<typeof registerSettings>} scope
|
* @param {ReturnType<typeof registerSettings>} 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 value = scope?.get() ?? {};
|
||||||
const tabs = value.tabs ?? {};
|
const raw = value.tabs;
|
||||||
return {
|
// 已归一化(数组)
|
||||||
allPositions: tabs.allPositions !== false,
|
if (Array.isArray(raw)) return raw.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||||
tradeRecords: tabs.tradeRecords !== false,
|
|
||||||
watchlist: tabs.watchlist !== false,
|
// 旧布尔对象或缺失 → 生成内置条目(保留原显隐值)
|
||||||
};
|
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<typeof registerSettings>} scope
|
* @param {ReturnType<typeof registerSettings>} 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<typeof registerSettings>} 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) {
|
export async function updateTabConfig(scope, tabs) {
|
||||||
const current = scope?.get() ?? {};
|
const currentTabs = getTabs(scope);
|
||||||
const next = {
|
const next = currentTabs.map((t) => (t.kind === 'builtin' && t.refKey in tabs
|
||||||
...current,
|
? { ...t, visible: tabs[t.refKey] !== false }
|
||||||
tabs: {
|
: t));
|
||||||
allPositions: tabs.allPositions !== false,
|
await updateTabs(scope, next);
|
||||||
tradeRecords: tabs.tradeRecords !== false,
|
|
||||||
watchlist: tabs.watchlist !== false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await scope.update(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user