feat(UI): 交易记录归属分配改为 Modal 弹窗,行内只留精简态

This commit is contained in:
2026-09-08 11:13:55 +08:00
parent 1223e2e74b
commit 6a578cc598
+147 -85
View File
@@ -505,43 +505,23 @@ function OrderRows({ order, expanded, onToggle, targets, loadTargets, saveSegmen
} }
/** /**
* R-018 归属单元格:展示段 chips;点开进入份额分配器(AttributionEditor * R-018 归属单元格(行内精简态):只显示段 chips / 未关联 + 剩余提示 + 一个入口按钮
* - 已关联段:策略名 + 量(段 chip);未关联(余额 > 0 且无段)显示「未关联」 * 份额分配操作在 AttributionModal(弹窗)中完成 —— 不在表格行内堆大量控件。
* - 点「分配」→ 打开编辑器(加载 targets 惰性)
*/ */
function AttributionCell({ order, segments, totalSeg, targets, loadTargets, saveSegments, onNotice }) { 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 hasSeg = (segments ?? []).length > 0;
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0); const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
const leftover = Math.max(0, aggVolume - totalSeg); // 未分配余额(今日实时 aggVolume=已成交量;历史 aggVolume 由 fills 聚合并) const leftover = Math.max(0, aggVolume - totalSeg); // 未分配余额
const open = () => {
loadTargets();
setEditing(true);
};
if (editing) {
return (
<AttributionEditor
order={order}
segments={segments}
leftover={leftover}
targets={targets}
saveSegments={saveSegments}
onDone={() => setEditing(false)}
onNotice={onNotice}
/>
);
}
return ( 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 ? ( {hasSeg ? (
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}> <span style={{ display: 'inline-flex', flexWrap: 'wrap', gap: 2 }}>
{(segments ?? []).map((s) => ( {(segments ?? []).map((s) => (
<span <span
key={s.strategyId} key={s.strategyId}
title={s.strategyName || s.strategyId} title={(s.strategyName || s.strategyId) + ' · ' + fmtNum(s.volume, 0) + ' 股'}
style={{ style={{
fontSize: 11, padding: '1px 5px', borderRadius: 3, fontSize: 11, padding: '1px 5px', borderRadius: 3,
border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', 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))', 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>
))} ))}
</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> <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>未关联</span>
)} )}
{leftover > 1e-9 && ( {leftover > 1e-9 && (
<span style={{ fontSize: 11, color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}> <span
未分配剩余 {fmtNum(leftover, 0)} style={{ fontSize: 11, color: 'var(--dsw-alias-state-error-primary, #d32f2f)', whiteSpace: 'nowrap' }}
title={'未分配剩余 ' + fmtNum(leftover, 0) + ' 股'}
>
{fmtNum(leftover, 0)}
</span> </span>
)} )}
<button <button
onClick={open} onClick={() => { loadTargets(); setOpen(true); }}
style={{ 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)', border: '1px solid var(--dsw-alias-border-l2, #ccc)',
background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-label-primary, #333)', 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 ? '调整' : '分配'} {hasSeg ? '调整' : '分配'}
</button> </button>
{open && (
<AttributionModal
order={order}
segments={segments}
aggVolume={aggVolume}
totalSeg={totalSeg}
leftover={leftover}
targets={targets}
saveSegments={saveSegments}
onClose={() => setOpen(false)}
onNotice={onNotice}
/>
)}
</span> </span>
); );
} }
/** /**
* R-018 份额分配器(编辑器):策略下拉 + 段量输入 + 段列表(可撤) * R-018 归属分配弹窗(Modal):居中遮罩 + 面板
* - 余额 = 已成交量 已分配段和(默认输入 = 余额全量,可改小 * - 面板:委托摘要(code/名称/方向/已成交量+ 已分配段列表(每段可撤)+ 新增段(策略下拉 + 量输入,默认余额全量
* - 每步操作(加段/撤段)即时 saveSegments全量替换原子) * - 操作即时保存(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 list = (targets ?? []).length > 0 ? targets : null;
const [selStrategy, setSelStrategy] = useState(''); const [selStrategy, setSelStrategy] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
const segs = Array.isArray(segments) ? segments : []; const segs = Array.isArray(segments) ? segments : [];
const totalSeg = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0); const total = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
const remaining = Math.max(0, aggVolume - totalSeg); const remaining = Math.max(0, aggVolume - total);
const fullyAllocated = remaining <= 1e-9;
const amountHint = remaining > 0 ? String(remaining) : '';
const notice = (msg, isErr) => { const notice = (msg, isErr) => {
onNotice?.(msg); onNotice?.(msg);
if (isErr) setTimeout(() => onNotice?.(null), 3000); setTimeout(() => onNotice?.(null), isErr ? 3000 : 2000);
else setTimeout(() => onNotice?.(null), 2000);
}; };
const run = async (nextSegments) => { const run = async (nextSegments) => {
@@ -614,14 +610,10 @@ function AttributionEditor({ order, segments, leftover, targets, saveSegments, o
const v = Number(amount); const v = Number(amount);
if (!Number.isFinite(v) || v <= 0) { notice('请输入有效份额量', true); return; } if (!Number.isFinite(v) || v <= 0) { notice('请输入有效份额量', true); return; }
if (v > remaining + 1e-9) { notice('份额量超过未分配余额 ' + fmtNum(remaining, 0), 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 t = (targets ?? []).find((x) => x.strategyId === selStrategy);
const isSell = String(order.direction ?? '') === 'sell'; const isSell = String(order.direction ?? '') === 'sell';
if (isSell && t && !t.hasActiveHolding) { if (isSell && t && !t.hasActiveHolding) { notice('该策略无此持仓可减(关联失败)', true); return; }
notice('该策略无此持仓可减(关联失败)', true); // sell 且 activityShares < 输入量 → 自动截断为可容纳量(R4:自动截断续分)
return;
}
// sell 且 activityShares < amount → 自动截断为可容纳量(R4 老师拍板:自动截断续分)
let vol = v; let vol = v;
if (isSell && t && t.hasActiveHolding && Number(t.activityShares) + 1e-9 < v) { if (isSell && t && t.hasActiveHolding && Number(t.activityShares) + 1e-9 < v) {
vol = Number(t.activityShares); 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 }]; const merged = [...segs.filter((s) => s.strategyId !== selStrategy), { strategyId: selStrategy, volume: vol }];
await run(merged); await run(merged);
setAmount('');
}; };
const removeSegment = async (strategyId) => { const removeSegment = async (strategyId) => {
const merged = segs.filter((s) => s.strategyId !== strategyId); await run(segs.filter((s) => s.strategyId !== strategyId));
await run(merged);
}; };
const clearAll = async () => { const clearAll = async () => {
await run([]); await run([]);
}; };
const amountDefaultHint = remaining > 0 ? String(remaining) : '';
return ( return (
<span style={{ display: 'inline-flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start', minWidth: 190 }}> <div style={{
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #666)' }}> position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.4))',
已成交量 {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> 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> </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) => ( {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)' }}> <div key={s.strategyId} style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--dsw-alias-bg-layer-2, #fafafa)', borderRadius: 6, padding: '6px 10px' }}>
{s.strategyName || s.strategyId}·{fmtNum(s.volume, 0)} <span style={{ flex: 1, fontSize: 13 }}>{s.strategyName || s.strategyId}</span>
<span <span style={{ fontSize: 13, fontWeight: 'bold' }}>{fmtNum(s.volume, 0)} </span>
title="撤销该段(逆操作账本)" <button
onClick={() => !busy && removeSegment(s.strategyId)} onClick={() => !busy && removeSegment(s.strategyId)}
style={{ cursor: busy ? 'wait' : 'pointer', color: 'var(--dsw-alias-state-error-primary, #c62828)' }} disabled={busy}
></span> 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' }}
</span> title="撤销该段(逆操作账本,恢复对应持仓份额)"
>
撤除
</button>
</div>
))} ))}
</span> </div>
)} )}
{list && list.length > 0 && remaining > 1e-9 && ( </div>
<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> <div style={{ padding: '6px 18px 14px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
{list.map((t) => ( <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}> <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> </option>
))} ))}
</select> </select>
@@ -676,28 +721,45 @@ function AttributionEditor({ order, segments, leftover, targets, saveSegments, o
min="1" min="1"
step={100} step={100}
value={amount} value={amount}
placeholder={amountDefaultHint} placeholder={amountHint || '股数'}
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
style={{ width: 70, padding: '1px 3px', fontSize: 11 }} disabled={fullyAllocated || busy}
title="分配份额量(默认 = 未分配余额全量,可改小)" 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> </button>
</span> </div>
{list === null && (
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', marginTop: 6 }}>策略加载中</div>
)} )}
{remaining <= 1e-9 && ( </div>
<span style={{ fontSize: 11, color: 'var(--dsw-alias-state-success-primary, #1b5e20)' }}>已全部分配</span>
)} {/* 底部操作 */}
<span style={{ display: 'inline-flex', gap: 4 }}> <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '10px 18px 14px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
{segs.length > 0 && ( <button
<button onClick={() => !busy && clearAll()} disabled={busy} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}> 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>
)} <button
<button onClick={onDone} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer' }}>完成</button> onClick={() => !busy && onClose()}
</span> disabled={busy}
</span> 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>
); );
} }