feat(UI): 交易记录归属分配改为 Modal 弹窗,行内只留精简态
This commit is contained in:
@@ -505,43 +505,23 @@ function OrderRows({ order, expanded, onToggle, targets, loadTargets, saveSegmen
|
||||
}
|
||||
|
||||
/**
|
||||
* R-018 归属单元格:展示段 chips;点开进入份额分配器(AttributionEditor)。
|
||||
* - 已关联段:策略名 + 量(段 chip);未关联(余额 > 0 且无段)显示「未关联」
|
||||
* - 点「分配」→ 打开编辑器(加载 targets 惰性)
|
||||
* R-018 归属单元格(行内精简态):只显示段 chips / 未关联 + 剩余提示 + 一个入口按钮。
|
||||
* 份额分配操作在 AttributionModal(弹窗)中完成 —— 不在表格行内堆大量控件。
|
||||
*/
|
||||
function AttributionCell({ order, segments, totalSeg, targets, loadTargets, saveSegments, onNotice }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasSeg = (segments ?? []).length > 0;
|
||||
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
|
||||
const leftover = Math.max(0, aggVolume - totalSeg); // 未分配余额(今日实时 aggVolume=已成交量;历史 aggVolume 由 fills 聚合并)
|
||||
|
||||
const open = () => {
|
||||
loadTargets();
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<AttributionEditor
|
||||
order={order}
|
||||
segments={segments}
|
||||
leftover={leftover}
|
||||
targets={targets}
|
||||
saveSegments={saveSegments}
|
||||
onDone={() => setEditing(false)}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const leftover = Math.max(0, aggVolume - totalSeg); // 未分配余额
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'flex-start', gap: 2, minWidth: 120 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, minWidth: 110 }}>
|
||||
{hasSeg ? (
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
<span style={{ display: 'inline-flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
{(segments ?? []).map((s) => (
|
||||
<span
|
||||
key={s.strategyId}
|
||||
title={s.strategyName || s.strategyId}
|
||||
title={(s.strategyName || s.strategyId) + ' · ' + fmtNum(s.volume, 0) + ' 股'}
|
||||
style={{
|
||||
fontSize: 11, padding: '1px 5px', borderRadius: 3,
|
||||
border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)',
|
||||
@@ -549,7 +529,7 @@ function AttributionCell({ order, segments, totalSeg, targets, loadTargets, save
|
||||
background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))',
|
||||
}}
|
||||
>
|
||||
{(s.strategyName || s.strategyId)}·{fmtNum(s.volume, 0)}
|
||||
{s.strategyName || s.strategyId}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
@@ -557,14 +537,17 @@ function AttributionCell({ order, segments, totalSeg, targets, loadTargets, save
|
||||
<span style={{ color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>未关联</span>
|
||||
)}
|
||||
{leftover > 1e-9 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}>
|
||||
未分配剩余 {fmtNum(leftover, 0)} 股
|
||||
<span
|
||||
style={{ fontSize: 11, color: 'var(--dsw-alias-state-error-primary, #d32f2f)', whiteSpace: 'nowrap' }}
|
||||
title={'未分配剩余 ' + fmtNum(leftover, 0) + ' 股'}
|
||||
>
|
||||
剩{fmtNum(leftover, 0)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={open}
|
||||
onClick={() => { loadTargets(); setOpen(true); }}
|
||||
style={{
|
||||
fontSize: 11, padding: '1px 8px', cursor: 'pointer', borderRadius: 3,
|
||||
fontSize: 11, padding: '1px 8px', cursor: 'pointer', borderRadius: 3, whiteSpace: 'nowrap',
|
||||
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
|
||||
background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-label-primary, #333)',
|
||||
}}
|
||||
@@ -572,29 +555,42 @@ function AttributionCell({ order, segments, totalSeg, targets, loadTargets, save
|
||||
>
|
||||
{hasSeg ? '调整' : '分配'}
|
||||
</button>
|
||||
{open && (
|
||||
<AttributionModal
|
||||
order={order}
|
||||
segments={segments}
|
||||
aggVolume={aggVolume}
|
||||
totalSeg={totalSeg}
|
||||
leftover={leftover}
|
||||
targets={targets}
|
||||
saveSegments={saveSegments}
|
||||
onClose={() => setOpen(false)}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* R-018 份额分配器(编辑器):策略下拉 + 段量输入 + 段列表(可撤)。
|
||||
* - 余额 = 已成交量 − 已分配段和(默认输入 = 余额全量,可改小)
|
||||
* - 每步操作(加段/撤段)即时 saveSegments(全量替换,原子)
|
||||
* R-018 归属分配弹窗(Modal):居中遮罩 + 面板。
|
||||
* - 面板:委托摘要(code/名称/方向/已成交量)+ 已分配段列表(每段可撤)+ 新增段(策略下拉 + 量输入,默认余额全量)
|
||||
* - 操作即时保存(setSegments 全量替换原子);顶部余额与状态实时反馈
|
||||
*/
|
||||
function AttributionEditor({ order, segments, leftover, targets, saveSegments, onDone, onNotice }) {
|
||||
function AttributionModal({ order, segments, aggVolume, totalSeg, leftover, targets, saveSegments, onClose, onNotice }) {
|
||||
const list = (targets ?? []).length > 0 ? targets : null;
|
||||
const [selStrategy, setSelStrategy] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
|
||||
const segs = Array.isArray(segments) ? segments : [];
|
||||
const totalSeg = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
|
||||
const remaining = Math.max(0, aggVolume - totalSeg);
|
||||
const total = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
|
||||
const remaining = Math.max(0, aggVolume - total);
|
||||
const fullyAllocated = remaining <= 1e-9;
|
||||
const amountHint = remaining > 0 ? String(remaining) : '';
|
||||
|
||||
const notice = (msg, isErr) => {
|
||||
onNotice?.(msg);
|
||||
if (isErr) setTimeout(() => onNotice?.(null), 3000);
|
||||
else setTimeout(() => onNotice?.(null), 2000);
|
||||
setTimeout(() => onNotice?.(null), isErr ? 3000 : 2000);
|
||||
};
|
||||
|
||||
const run = async (nextSegments) => {
|
||||
@@ -614,14 +610,10 @@ function AttributionEditor({ order, segments, leftover, targets, saveSegments, o
|
||||
const v = Number(amount);
|
||||
if (!Number.isFinite(v) || v <= 0) { notice('请输入有效份额量', true); return; }
|
||||
if (v > remaining + 1e-9) { notice('份额量超过未分配余额 ' + fmtNum(remaining, 0), true); return; }
|
||||
// 卖出场景:若该策略无活动仓(activityShares=0 且方向 sell)→ 服务端会拒绝;这里给前端提示
|
||||
const t = (targets ?? []).find((x) => x.strategyId === selStrategy);
|
||||
const isSell = String(order.direction ?? '') === 'sell';
|
||||
if (isSell && t && !t.hasActiveHolding) {
|
||||
notice('该策略无此持仓可减(关联失败)', true);
|
||||
return;
|
||||
}
|
||||
// sell 且 activityShares < amount → 自动截断为可容纳量(R4 老师拍板:自动截断续分)
|
||||
if (isSell && t && !t.hasActiveHolding) { notice('该策略无此持仓可减(关联失败)', true); return; }
|
||||
// sell 且 activityShares < 输入量 → 自动截断为可容纳量(R4:自动截断续分)
|
||||
let vol = v;
|
||||
if (isSell && t && t.hasActiveHolding && Number(t.activityShares) + 1e-9 < v) {
|
||||
vol = Number(t.activityShares);
|
||||
@@ -629,45 +621,98 @@ function AttributionEditor({ order, segments, leftover, targets, saveSegments, o
|
||||
}
|
||||
const merged = [...segs.filter((s) => s.strategyId !== selStrategy), { strategyId: selStrategy, volume: vol }];
|
||||
await run(merged);
|
||||
setAmount('');
|
||||
};
|
||||
|
||||
const removeSegment = async (strategyId) => {
|
||||
const merged = segs.filter((s) => s.strategyId !== strategyId);
|
||||
await run(merged);
|
||||
await run(segs.filter((s) => s.strategyId !== strategyId));
|
||||
};
|
||||
|
||||
const clearAll = async () => {
|
||||
await run([]);
|
||||
};
|
||||
|
||||
const amountDefaultHint = remaining > 0 ? String(remaining) : '';
|
||||
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start', minWidth: 190 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||
已成交量 {fmtNum(aggVolume, 0)} | 已分配 {fmtNum(totalSeg, 0)} | 余额 <span style={{ color: remaining > 1e-9 ? 'var(--dsw-alias-state-error-primary, #d32f2f)' : 'var(--dsw-alias-state-success-primary, #1b5e20)', fontWeight: 'bold' }}>{fmtNum(remaining, 0)}</span>
|
||||
<div style={{
|
||||
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,
|
||||
}} onClick={() => !busy && onClose()}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--dsw-alias-bg-layer-1, #fff)', borderRadius: 8,
|
||||
maxWidth: 480, width: '92%', maxHeight: '80vh', overflow: 'auto',
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.18)',
|
||||
}}
|
||||
>
|
||||
{/* 头部:委托摘要 + 关闭 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 15 }}>
|
||||
<span style={directionStyle(order.direction)}>{directionText(order.direction)}</span>
|
||||
{' '}{order.code} {order.name || ''}
|
||||
<span style={{ marginLeft: 8, color: 'var(--dsw-alias-label-secondary, #666)', fontWeight: 400, fontSize: 12 }}>
|
||||
委托 {order.orderId}
|
||||
</span>
|
||||
</h3>
|
||||
<button onClick={() => !busy && onClose()} disabled={busy} style={{ cursor: busy ? 'wait' : 'pointer', border: 'none', background: 'transparent', fontSize: 16, color: 'var(--dsw-alias-label-tertiary, #999)' }} title="关闭">✕</button>
|
||||
</div>
|
||||
{(segs.length > 0) && (
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
|
||||
|
||||
{/* 余额统计行 */}
|
||||
<div style={{ padding: '12px 18px 4px', display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 13 }}>
|
||||
<span>已成交量 <b>{fmtNum(aggVolume, 0)}</b></span>
|
||||
<span>已分配 <b>{fmtNum(total, 0)}</b></span>
|
||||
<span>
|
||||
待分配余额{' '}
|
||||
<b style={{ color: fullyAllocated ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-state-error-primary, #d32f2f)' }}>
|
||||
{fmtNum(remaining, 0)}
|
||||
</b>
|
||||
</span>
|
||||
{fullyAllocated && <span style={{ color: 'var(--dsw-alias-state-success-primary, #1b5e20)', fontSize: 12 }}>已全部分配</span>}
|
||||
</div>
|
||||
|
||||
{/* 已分配段列表 */}
|
||||
<div style={{ padding: '10px 18px' }}>
|
||||
{segs.length === 0 ? (
|
||||
<div style={{ color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 13, padding: '4px 0' }}>
|
||||
尚未关联任何策略(可在下方选择策略并分配份额;关联会同步调整对应策略持仓)
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{segs.map((s) => (
|
||||
<span key={s.strategyId} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, padding: '1px 5px', borderRadius: 3, border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-state-success-primary, #1b5e20)' }}>
|
||||
{s.strategyName || s.strategyId}·{fmtNum(s.volume, 0)}
|
||||
<span
|
||||
title="撤销该段(逆操作账本)"
|
||||
<div key={s.strategyId} style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--dsw-alias-bg-layer-2, #fafafa)', borderRadius: 6, padding: '6px 10px' }}>
|
||||
<span style={{ flex: 1, fontSize: 13 }}>{s.strategyName || s.strategyId}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 'bold' }}>{fmtNum(s.volume, 0)} 股</span>
|
||||
<button
|
||||
onClick={() => !busy && removeSegment(s.strategyId)}
|
||||
style={{ cursor: busy ? 'wait' : 'pointer', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
|
||||
>✕</span>
|
||||
</span>
|
||||
disabled={busy}
|
||||
style={{ cursor: busy ? 'wait' : 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)', fontSize: 11, padding: '2px 8px' }}
|
||||
title="撤销该段(逆操作账本,恢复对应持仓份额)"
|
||||
>
|
||||
撤除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{list && list.length > 0 && remaining > 1e-9 && (
|
||||
<span style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
|
||||
<select value={selStrategy} onChange={(e) => setSelStrategy(e.target.value)} style={{ fontSize: 11, padding: '1px 3px' }} title="选择归属策略">
|
||||
<option value="">策略…</option>
|
||||
{list.map((t) => (
|
||||
</div>
|
||||
|
||||
{/* 新增段 */}
|
||||
<div style={{ padding: '6px 18px 14px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', margin: '10px 0 6px' }}>
|
||||
分配给策略{fullyAllocated ? '(已全部分配,如需调整请先撤除某段)' : ''}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<select
|
||||
value={selStrategy}
|
||||
onChange={(e) => setSelStrategy(e.target.value)}
|
||||
style={{ fontSize: 13, padding: '4px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, flex: 1, minWidth: 140 }}
|
||||
disabled={fullyAllocated || busy}
|
||||
title="选择归属策略(候选 = 全部策略;卖出需该策略有对应持仓)"
|
||||
>
|
||||
<option value="">选择策略…</option>
|
||||
{(list ?? []).map((t) => (
|
||||
<option key={t.strategyId} value={t.strategyId}>
|
||||
{t.strategyName || t.strategyId}{t.hasActiveHolding ? '(仓 ' + fmtNum(t.activityShares, 0) + ')' : ''}
|
||||
{t.strategyName || t.strategyId}{t.hasActiveHolding ? '(当前仓 ' + fmtNum(t.activityShares, 0) + ')' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -676,28 +721,45 @@ function AttributionEditor({ order, segments, leftover, targets, saveSegments, o
|
||||
min="1"
|
||||
step={100}
|
||||
value={amount}
|
||||
placeholder={amountDefaultHint}
|
||||
placeholder={amountHint || '股数'}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ width: 70, padding: '1px 3px', fontSize: 11 }}
|
||||
title="分配份额量(默认 = 未分配余额全量,可改小)"
|
||||
disabled={fullyAllocated || busy}
|
||||
style={{ width: 110, padding: '4px 6px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4 }}
|
||||
title={'分配份额量(默认 = 待分配余额 ' + fmtNum(remaining, 0) + ',可改小)'}
|
||||
/>
|
||||
<button onClick={() => !busy && addSegment()} disabled={busy} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer' }}>
|
||||
<button
|
||||
onClick={addSegment}
|
||||
disabled={fullyAllocated || busy}
|
||||
style={{ cursor: busy ? 'wait' : 'pointer', padding: '4px 14px', fontSize: 13, border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary, #1565c0)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
|
||||
>
|
||||
分配
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{list === null && (
|
||||
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', marginTop: 6 }}>策略加载中…</div>
|
||||
)}
|
||||
{remaining <= 1e-9 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--dsw-alias-state-success-primary, #1b5e20)' }}>已全部分配</span>
|
||||
)}
|
||||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||||
{segs.length > 0 && (
|
||||
<button onClick={() => !busy && clearAll()} disabled={busy} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 18px 14px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
|
||||
<button
|
||||
onClick={() => !busy && clearAll()}
|
||||
disabled={busy || segs.length === 0}
|
||||
style={{ cursor: busy ? 'wait' : 'pointer', padding: '5px 12px', fontSize: 13, 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)' }}
|
||||
title="清除全部归属(逆操作:建仓买入段作废 / 卖出段恢复持仓)"
|
||||
>
|
||||
全部撤除
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDone} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer' }}>完成</button>
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => !busy && onClose()}
|
||||
disabled={busy}
|
||||
style={{ cursor: busy ? 'wait' : 'pointer', padding: '5px 16px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user