From 2790b92378489921f12d50853581f57b9df3186e Mon Sep 17 00:00:00 2001 From: AdamGao Date: Wed, 2 Sep 2026 01:20:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=AD=96=E7=95=A5=E6=8C=81=E4=BB=93):=20?= =?UTF-8?q?=E6=8C=81=E4=BB=93=E8=A1=8C=E5=B1=95=E5=BC=80=E5=85=B3=E8=81=94?= =?UTF-8?q?=E4=BA=A4=E6=98=93=20+=20=E6=88=90=E6=9C=AC=E4=BB=B7/=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E6=88=90=E4=BA=A4=E4=BB=B7=20+=20=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=EF=BC=88=E8=BF=AD=E4=BB=A3=2008?= =?UTF-8?q?=EF=BC=8CR-010=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - strategy-positions 附加 holding_id + avgPrice/lastTradePrice(无关联默认成本价) - trades/by-holding 端点(按 holding 查委托汇总) - StrategyTab 持仓行展开(懒加载 + 内嵌汇总表,含交易日/时间列) - 神之一手 tab 激活时隐藏 AI 对话输入框(纯 CSS :has() 方案) --- src/client/index.js | 13 ++ src/client/views/AllPositionsTab.jsx | 2 +- src/client/views/PlaceholderTab.jsx | 4 +- src/client/views/StrategyTab.jsx | 259 ++++++++++++++++++++++----- 4 files changed, 229 insertions(+), 49 deletions(-) diff --git a/src/client/index.js b/src/client/index.js index a77beb8..fc8019d 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -75,6 +75,19 @@ export function apply(ctx) { ctx.logger?.warn?.('[one-divine-lot] connection 服务不可用'); } + // 神之一手 tab 激活时隐藏会话输入框(composer) + // 借鉴 dsh-context 的纯 CSS 方案::has(.odl-root) 命中 → 隐藏 [data-composer-seat] + // 我们的 tab 根元素带 odl-root class;chat/其他 tab 无此 class → 输入框正常显示 + const ODL_HIDE_COMPOSER_CSS = ` +[data-conversation-scroll]:has(.odl-root)>[data-composer-seat]{display:none} +`; + if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="one-divine-lot/hide-composer"]')) { + const tag = document.createElement('style'); + tag.dataset.pluginCss = 'one-divine-lot/hide-composer'; + tag.textContent = ODL_HIDE_COMPOSER_CSS; + document.head.appendChild(tag); + } + const withConnection = (render) => (props) => createElement(ConnectionProvider, { connection }, createElement(MarketDataProvider, null, render(props))); diff --git a/src/client/views/AllPositionsTab.jsx b/src/client/views/AllPositionsTab.jsx index 664ca0f..d18acaf 100644 --- a/src/client/views/AllPositionsTab.jsx +++ b/src/client/views/AllPositionsTab.jsx @@ -39,7 +39,7 @@ export function AllPositionsTab(props) { }, [load]); return ( -
+

全部持仓({positions ? positions.positions.length : 0} 只)

diff --git a/src/client/views/PlaceholderTab.jsx b/src/client/views/PlaceholderTab.jsx index 36ed785..85f4854 100644 --- a/src/client/views/PlaceholderTab.jsx +++ b/src/client/views/PlaceholderTab.jsx @@ -5,9 +5,9 @@ export function PlaceholderTab({ title }) { return ( -
+
{title}
功能开发中,敬请期待
); -} +} \ No newline at end of file diff --git a/src/client/views/StrategyTab.jsx b/src/client/views/StrategyTab.jsx index d6f3ac5..033ec41 100644 --- a/src/client/views/StrategyTab.jsx +++ b/src/client/views/StrategyTab.jsx @@ -15,13 +15,19 @@ * - 窄屏(<480px):每元素一行,两端对齐 */ -import { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useRpc } from './connection.jsx'; import { LoadState } from './LoadState.jsx'; import { ToastProvider, useToast } from './Toast.jsx'; import { useMarket } from '../market/MarketDataProvider.jsx'; import { PriceCell } from './PriceCell.jsx'; +/** 价格格式化(2 位小数) */ +function fmtPrice(v) { + if (v == null || !isFinite(v)) return '—'; + return Number(v).toFixed(2); +} + /** 添加持仓区域响应式样式(内联注入,含媒体查询) */ const ADD_FORM_CSS = ` .odl-add-form { @@ -110,6 +116,10 @@ function StrategyTabInner({ strategyId, strategyName }) { const [selectedCode, setSelectedCode] = useState(''); const [addShares, setAddShares] = useState(''); const [removeTarget, setRemoveTarget] = useState(null); // { code, shares } + // R-010:持仓行展开看关联交易(Holding → 委托汇总) + const [expandedCode, setExpandedCode] = useState(null); // 当前展开的 code + const [holdingTrades, setHoldingTrades] = useState(null); // { code: [委托汇总] } + const [holdingTradesLoading, setHoldingTradesLoading] = useState(null); // 展开加载中的 code const call = useRpc(); const toast = useToast(); const { getPrice, registerCodes } = useMarket(); @@ -187,10 +197,39 @@ function StrategyTabInner({ strategyId, strategyName }) { } }; + /** R-010:展开某持仓行 → 懒加载该 holding 的委托汇总 */ + const toggleExpand = async (code, holdingId) => { + if (expandedCode === code) { + // 折叠 + setExpandedCode(null); + return; + } + setExpandedCode(code); + if (holdingId == null) { + setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] })); + return; + } + // 懒加载:有缓存直接用,否则请求 + if (holdingTrades && holdingTrades[code]) return; + setHoldingTradesLoading(code); + try { + const res = await call('one-divine-lot/trades/by-holding', { args: { holdingId } }); + if (res?.ok && Array.isArray(res.value)) { + setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: res.value })); + } else { + setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] })); + } + } catch { + setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] })); + } finally { + setHoldingTradesLoading(null); + } + }; + const name = strategyName || (strategyId === 'grid-supermarket' ? '网格策略持仓' : strategyId === 'manual-t' ? '做T持仓' : strategyId); return ( -
+

{name}({(positions ?? []).length} 只)

@@ -244,60 +283,122 @@ function StrategyTabInner({ strategyId, strategyName }) {
+ + + - {(positions ?? []).map((p) => ( - - - - - - - toggleExpand(p.code, p.holdingId)} + style={{ + borderBottom: '1px solid #eee', + cursor: hasHolding ? 'pointer' : 'default', + background: isExpanded ? '#f5f5f5' : 'transparent', + }} + > + + + + + + + + + + + {isExpanded && ( + + + )} - - - ))} + + ); + })}
代码 名称 现价成本价最后一笔成交价 策略份额 总持仓 操作
{p.code}{p.name}{p.shares}{p.volume} - {removeTarget && removeTarget.code === p.code ? ( - - setRemoveTarget({ code: p.code, shares: e.target.value })} - style={{ padding: 2, width: 70, marginRight: 6 }} - /> - - - - ) : ( - - - - + {(positions ?? []).map((p) => { + const isExpanded = expandedCode === p.code; + const trades = (holdingTrades ?? {})[p.code]; + const isLoading = holdingTradesLoading === p.code; + const hasHolding = p.holdingId != null; + return ( + +
+ {hasHolding ? ( + + + + ) : ( + · + )} + {p.code}{p.name}{fmtPrice(p.avgPrice)}{fmtPrice(p.lastTradePrice)}{p.shares}{p.volume} e.stopPropagation()}> + {removeTarget && removeTarget.code === p.code ? ( + + setRemoveTarget({ code: p.code, shares: e.target.value })} + style={{ padding: 2, width: 70, marginRight: 6 }} + /> + + + + ) : ( + + + + + )} +
+ {isLoading ? ( +
交易记录加载中...
+ ) : !trades || trades.length === 0 ? ( +
+ {hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id)'} +
+ ) : ( + + )} +
)} @@ -306,6 +407,72 @@ function StrategyTabInner({ strategyId, strategyName }) { ); } +/** R-010:持仓关联的委托汇总表(只显示委托汇总,不分笔成交) */ +const ORDER_STATUS_MAP = { + 48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤', + 53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单', +}; + +function fmtDateH(d) { + if (!d || d.length !== 8) return d ?? '—'; + return d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8); +} +function fmtTimeH(t) { + if (!t || t.length !== 6) return t ?? ''; + return t.slice(0, 2) + ':' + t.slice(2, 4) + ':' + t.slice(4, 6); +} +function fmtNumH(v, digits = 2) { + if (v == null || !isFinite(v)) return '—'; + return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +} +function dirTextH(d) { + if (d === 'buy') return '买入'; + if (d === 'sell') return '卖出'; + return '—'; +} +function dirColorH(d) { + if (d === 'buy') return { color: '#d32f2f', fontWeight: 'bold' }; + if (d === 'sell') return { color: '#2e7d32', fontWeight: 'bold' }; + return { color: '#999' }; +} + +function HoldingTradesTable({ trades }) { + const list = Array.isArray(trades) ? trades : []; + return ( + + + + + + + + + + + + + + + + {list.map((o) => ( + + + + + + + + + + + + ))} + +
交易日时间方向状态委托量成交量委托价成交均价成交额
{fmtDateH(o.tradeDate)}{fmtTimeH(o.insertTime)}{dirTextH(o.direction)}{ORDER_STATUS_MAP[o.status] ?? o.status ?? '—'}{fmtNumH(o.orderVolume, 0)}{fmtNumH(o.tradedVolume, 0)}{fmtNumH(o.limitPrice)}{o.tradedVolume > 0 ? fmtNumH(o.amount / o.tradedVolume) : '—'}{fmtNumH(o.amount)}
+ ); +} +const tdH = { padding: '6px 6px', lineHeight: '20px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }; + /** 导出组件(包 ToastProvider) */ export function StrategyTab(props) { return (