feat(策略持仓): 持仓行展开关联交易 + 成本价/最后成交价 + 隐藏输入框(迭代 08,R-010)

- strategy-positions 附加 holding_id + avgPrice/lastTradePrice(无关联默认成本价)
- trades/by-holding 端点(按 holding 查委托汇总)
- StrategyTab 持仓行展开(懒加载 + 内嵌汇总表,含交易日/时间列)
- 神之一手 tab 激活时隐藏 AI 对话输入框(纯 CSS :has() 方案)
This commit is contained in:
2026-09-02 01:20:58 +08:00
parent b3526c7693
commit 2790b92378
4 changed files with 229 additions and 49 deletions
+13
View File
@@ -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 classchat/其他 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)));
+1 -1
View File
@@ -39,7 +39,7 @@ export function AllPositionsTab(props) {
}, [load]);
return (
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
<h3 style={{ margin: '0 0 12px' }}>全部持仓{positions ? positions.positions.length : 0} </h3>
<LoadState loading={loading} error={error} onRetry={load} autoRetry={2}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
+1 -1
View File
@@ -5,7 +5,7 @@
export function PlaceholderTab({ title }) {
return (
<div style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: '#999' }}>
<div className="odl-root" style={{ padding: 40, textAlign: 'center', fontFamily: 'system-ui', color: '#999' }}>
<div style={{ fontSize: 16, marginBottom: 8 }}>{title}</div>
<div style={{ fontSize: 13 }}>功能开发中敬请期待</div>
</div>
+173 -6
View File
@@ -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 (
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
<h3 style={{ margin: 0 }}>{name}{(positions ?? []).length} </h3>
<div style={{ display: 'flex', gap: 8 }}>
@@ -244,23 +283,68 @@ function StrategyTabInner({ strategyId, strategyName }) {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<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>
<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>
</tr>
</thead>
<tbody>
{(positions ?? []).map((p) => (
<tr key={p.code} style={{ borderBottom: '1px solid #eee' }}>
{(positions ?? []).map((p) => {
const isExpanded = expandedCode === p.code;
const trades = (holdingTrades ?? {})[p.code];
const isLoading = holdingTradesLoading === p.code;
const hasHolding = p.holdingId != null;
return (
<React.Fragment key={p.code}>
<tr
onClick={() => toggleExpand(p.code, p.holdingId)}
style={{
borderBottom: '1px solid #eee',
cursor: hasHolding ? 'pointer' : 'default',
background: isExpanded ? '#f5f5f5' : 'transparent',
}}
>
<td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
{hasHolding ? (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
style={{
display: 'inline-block',
transition: 'transform .15s ease',
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
color: '#2e7d32',
verticalAlign: 'middle',
}}
>
<path
d="M4.5 2.5 L9.5 7 L4.5 11.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<span style={{ color: '#ccc', fontSize: 10 }}>·</span>
)}
</td>
<td style={{ padding: '8px 6px' }}>{p.code}</td>
<td style={{ padding: '8px 6px' }}>{p.name}</td>
<PriceCell price={getPrice(p.code)?.lastPrice} lastClose={getPrice(p.code)?.lastClose} />
<td style={{ padding: '8px 6px' }}>{fmtPrice(p.avgPrice)}</td>
<td style={{ padding: '8px 6px' }}>{fmtPrice(p.lastTradePrice)}</td>
<td style={{ padding: '8px 6px', fontWeight: 'bold' }}>{p.shares}</td>
<td style={{ padding: '8px 6px' }}>{p.volume}</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }} onClick={(e) => e.stopPropagation()}>
{removeTarget && removeTarget.code === p.code ? (
<span>
<input
@@ -297,7 +381,24 @@ function StrategyTabInner({ strategyId, strategyName }) {
)}
</td>
</tr>
))}
{isExpanded && (
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
<td colSpan={9} style={{ padding: '4px 8px 12px' }}>
{isLoading ? (
<div style={{ padding: 8, color: '#999', fontSize: 12 }}>交易记录加载中...</div>
) : !trades || trades.length === 0 ? (
<div style={{ padding: 8, color: '#999', fontSize: 12 }}>
{hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id'}
</div>
) : (
<HoldingTradesTable trades={trades} />
)}
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
)}
@@ -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 (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
<thead>
<tr style={{ borderBottom: '1px solid #ddd', color: '#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: 'right' }}>委托量</th>
<th style={{ ...tdH, textAlign: 'right' }}>成交量</th>
<th style={{ ...tdH, textAlign: 'right' }}>委托价</th>
<th style={{ ...tdH, textAlign: 'right' }}>成交均价</th>
<th style={{ ...tdH, textAlign: 'right' }}>成交额</th>
</tr>
</thead>
<tbody>
{list.map((o) => (
<tr key={o.orderId} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={tdH}>{fmtDateH(o.tradeDate)}</td>
<td style={tdH}>{fmtTimeH(o.insertTime)}</td>
<td style={{ ...tdH, ...dirColorH(o.direction) }}>{dirTextH(o.direction)}</td>
<td style={tdH}>{ORDER_STATUS_MAP[o.status] ?? o.status ?? '—'}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{fmtNumH(o.orderVolume, 0)}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{fmtNumH(o.tradedVolume, 0)}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{fmtNumH(o.limitPrice)}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{o.tradedVolume > 0 ? fmtNumH(o.amount / o.tradedVolume) : '—'}</td>
<td style={{ ...tdH, textAlign: 'right' }}>{fmtNumH(o.amount)}</td>
</tr>
))}
</tbody>
</table>
);
}
const tdH = { padding: '6px 6px', lineHeight: '20px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
/** 导出组件(包 ToastProvider */
export function StrategyTab(props) {
return (