@@ -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 (