ed43ecae39
- 新增 src/client/views/ExpandChevron.jsx:14x14 chevron-right、success 绿、展开旋转 90°(源自策略 tab 表格行图标) - 设置页策略分组行首 ▸/▾ 文字箭头改用该组件,并修复箭头与策略名的水平对齐(基线 trick 改 flex 精确居中) - StrategyTab / TradeRecordsTab 两处重复内联 SVG 收敛为共享组件 - 去掉字段编辑器展开区冗余「收起」按钮(行首箭头/「收起字段」已可收起),清理 onClose prop - docs: 新增 UI约束-006(展开/收起箭头全站统一)
519 lines
24 KiB
React
519 lines
24 KiB
React
/**
|
||
* TradeRecordsTab —— 交易记录 tab(R-007 v3 + R-009 v5,2026-09-01)
|
||
*
|
||
* 单表合并视图:
|
||
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
|
||
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
|
||
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
|
||
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/本月快捷 + 手动起止日期)
|
||
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
|
||
*
|
||
* R-009(迭代 07):
|
||
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
|
||
* - 历史范围从「接口开发中」占位切换为查本地 SQLite(trades/history)
|
||
*
|
||
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||
import { useRpc } from './connection.jsx';
|
||
import { LoadState } from './LoadState.jsx';
|
||
import { ExpandChevron } from './ExpandChevron.jsx'; // 行展开图标共享组件
|
||
import { RangeSelector } from './RangeSelector.jsx';
|
||
|
||
const POLL_INTERVAL_MS = 4000; // 3-5s 轮询(取 4s)
|
||
|
||
/** 委托状态码 → 中文(官方 ORDER_STATUS 字典) */
|
||
const ORDER_STATUS = {
|
||
48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
||
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单',
|
||
};
|
||
|
||
/** HHMMSS → HH:MM:SS */
|
||
function fmtTime(t) {
|
||
if (!t || t.length !== 6) return t ?? '';
|
||
return t.slice(0, 2) + ':' + t.slice(2, 4) + ':' + t.slice(4, 6);
|
||
}
|
||
|
||
/** 数字格式化 */
|
||
function fmtNum(v, digits = 2) {
|
||
if (v == null || !isFinite(v)) return '—';
|
||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||
}
|
||
|
||
/** 方向样式:红买绿卖 */
|
||
function directionStyle(direction) {
|
||
if (direction === 'buy') return { color: 'var(--dsw-alias-state-error-primary, #d32f2f)', fontWeight: 'bold' };
|
||
if (direction === 'sell') return { color: 'var(--dsw-alias-state-success-primary, #2e7d32)', fontWeight: 'bold' };
|
||
return { color: 'var(--dsw-alias-label-tertiary, #999)' };
|
||
}
|
||
function directionText(direction) {
|
||
if (direction === 'buy') return '买入';
|
||
if (direction === 'sell') return '卖出';
|
||
return '—';
|
||
}
|
||
|
||
/** 费用计算:按整笔委托合并(同一委托分笔成交只收一次佣金,最低5元;2026-09-01 老师修正) */
|
||
function calcOrderFeesLocal(trades, commissionRateWan) {
|
||
const list = Array.isArray(trades) ? trades : [];
|
||
const totalAmount = list.reduce((s, t) => s + Math.max(0, +(t.amount ?? 0)), 0);
|
||
const direction = list[0]?.direction ?? '';
|
||
let commission = totalAmount * (commissionRateWan / 10000);
|
||
if (commission < 5 && commission > 0) commission = 5;
|
||
const stampTax = direction === 'sell' ? totalAmount * 0.0005 : 0;
|
||
const transferFee = totalAmount * 0.00001;
|
||
return {
|
||
commission: +commission.toFixed(4),
|
||
stampTax: +stampTax.toFixed(4),
|
||
transferFee: +transferFee.toFixed(4),
|
||
total: +(commission + stampTax + transferFee).toFixed(4),
|
||
};
|
||
}
|
||
|
||
/** 合并逻辑:按 m_strOrderSysID 把成交聚合进委托 */
|
||
function mergeOrdersAndTrades(orders, trades) {
|
||
const tradeByOrder = new Map();
|
||
for (const t of trades) {
|
||
if (!tradeByOrder.has(t.orderId)) tradeByOrder.set(t.orderId, []);
|
||
tradeByOrder.get(t.orderId).push(t);
|
||
}
|
||
return orders.map((o) => {
|
||
const ts = tradeByOrder.get(o.orderId) ?? [];
|
||
const totalVol = ts.reduce((s, t) => s + t.volume, 0);
|
||
const totalAmount = ts.reduce((s, t) => s + t.amount, 0);
|
||
const weightedPrice = totalVol > 0
|
||
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
|
||
: 0;
|
||
const rateWan = ts[0]?.commissionRateWan ?? 0;
|
||
const fees = calcOrderFeesLocal(ts, rateWan);
|
||
return {
|
||
...o,
|
||
trades: ts,
|
||
aggVolume: totalVol,
|
||
aggPrice: weightedPrice,
|
||
aggAmount: totalAmount,
|
||
aggCommission: fees.commission,
|
||
aggStampTax: fees.stampTax,
|
||
aggTransferFee: fees.transferFee,
|
||
aggFeeTotal: fees.total,
|
||
};
|
||
});
|
||
}
|
||
|
||
export function TradeRecordsTab() {
|
||
const [orders, setOrders] = useState(null);
|
||
const [trades, setTrades] = useState(null);
|
||
const [error, setError] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [range, setRange] = useState(null); // { start, end } YYYYMMDD
|
||
const [preset, setPreset] = useState('today'); // 快捷意图(单向):'today'|'thisWeek'|'thisMonth'|null
|
||
const [lastDate, setLastDate] = useState(null); // 最近交易日
|
||
// R-009:策略过滤(''=全部;'__unassigned__'=未关联;其他=strategyId)
|
||
const [strategies, setStrategies] = useState([]);
|
||
const [strategyFilter, setStrategyFilter] = useState('');
|
||
const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009)
|
||
const [attributionCandidates, setAttributionCandidates] = useState({}); // R-009:code → 归属候选列表缓存
|
||
const attributionCandidatesRef = useRef({}); // 同步缓存(loadCandidates 判重用)
|
||
// 同步 ref
|
||
useEffect(() => { attributionCandidatesRef.current = attributionCandidates; }, [attributionCandidates]);
|
||
|
||
const call = useRpc();
|
||
|
||
// 默认加载「今日」(preset='today',单向意图)
|
||
useEffect(() => {
|
||
if (lastDate && !range) {
|
||
setRange({ start: lastDate, end: lastDate });
|
||
}
|
||
}, [lastDate, range]);
|
||
const [expanded, setExpanded] = useState(new Set());
|
||
const [sortKey, setSortKey] = useState('insertTime');
|
||
const [sortDir, setSortDir] = useState('desc');
|
||
|
||
// 加载策略列表(过滤下拉用,R-009)
|
||
useEffect(() => {
|
||
call('one-divine-lot/strategies', { args: {} })
|
||
.then((res) => {
|
||
if (res?.ok && Array.isArray(res.value)) setStrategies(res.value);
|
||
})
|
||
.catch(() => {});
|
||
}, [call]);
|
||
|
||
// 是否「今日」意图(preset='today',单向)→ 轮询
|
||
const isTodayRange = preset === 'today';
|
||
// 是否历史范围(非今日意图)→ 查本地 SQLite(R-009)
|
||
const isHistoryRange = !!range && preset !== 'today';
|
||
|
||
const load = useCallback(async () => {
|
||
if (!range) return;
|
||
if (isHistoryRange) {
|
||
// R-009 历史范围:查本地 SQLite(trades/history),不再占位
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await call('one-divine-lot/trades/history', {
|
||
args: {
|
||
start: range.start,
|
||
end: range.end,
|
||
strategyId: strategyFilter || undefined, // '__unassigned__' 由服务端处理(未关联)
|
||
},
|
||
});
|
||
if (res?.ok && res.value) {
|
||
setOrders(res.value.orders ?? []);
|
||
setTrades(res.value.fills ?? []);
|
||
} else {
|
||
throw new Error((res && res.error && res.error.message) || '历史记录加载失败');
|
||
}
|
||
} catch (e) {
|
||
setError(e.message);
|
||
setOrders([]);
|
||
setTrades([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const [oRes, tRes] = await Promise.all([
|
||
call('one-divine-lot/orders', { args: { start: range.start, end: range.end } }),
|
||
call('one-divine-lot/trades', { args: { start: range.start, end: range.end } }),
|
||
]);
|
||
if (oRes?.ok && Array.isArray(oRes.value)) setOrders(oRes.value);
|
||
else throw new Error((oRes && oRes.error && oRes.error.message) || '委托加载失败');
|
||
if (tRes?.ok && Array.isArray(tRes.value)) setTrades(tRes.value);
|
||
else throw new Error((tRes && tRes.error && tRes.error.message) || '成交加载失败');
|
||
} catch (e) {
|
||
setError(e.message);
|
||
setOrders([]);
|
||
setTrades([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [call, range, isHistoryRange, strategyFilter]);
|
||
|
||
// 拉取某 code 的归属候选(该 code 当前持仓策略;Q3 全手动选)
|
||
const loadCandidates = useCallback(async (code) => {
|
||
if (!code) return;
|
||
// 已有缓存不重复请求
|
||
if (attributionCandidatesRef.current[code]) return;
|
||
try {
|
||
const res = await call('one-divine-lot/orders/attribution-candidates', { args: { code } });
|
||
if (res?.ok && Array.isArray(res.value)) {
|
||
setAttributionCandidates((prev) => ({ ...prev, [code]: res.value }));
|
||
}
|
||
} catch { /* ignore */ }
|
||
}, [call]);
|
||
|
||
// 手动设置归属(Q4:可随时改,以最终为准)
|
||
const setAttribution = useCallback(async (orderId, code, strategyId, holdingId) => {
|
||
try {
|
||
const res = await call('one-divine-lot/orders/set-attribution', {
|
||
args: { orderId, strategyId: strategyId || null, holdingId: holdingId || null },
|
||
});
|
||
if (res?.ok) {
|
||
setAttributionSaved('归属已保存: ' + orderId);
|
||
setTimeout(() => setAttributionSaved(null), 2000);
|
||
// 立即更新本地 state(避免竞态:不等 load() 重新拉,归属立刻生效)
|
||
setOrders((prev) => (prev ?? []).map((o) =>
|
||
o.orderId === orderId ? { ...o, strategyId, holdingId } : o
|
||
));
|
||
setTrades((prev) => (prev ?? []).map((t) =>
|
||
t.orderId === orderId ? { ...t, strategyId, holdingId } : t
|
||
));
|
||
load(); // 后台刷新兜底(同步 TradeSync 可能更新的数据)
|
||
} else {
|
||
setAttributionSaved('保存失败: ' + ((res && res.error && res.error.message) || 'unknown'));
|
||
setTimeout(() => setAttributionSaved(null), 3000);
|
||
}
|
||
} catch (e) {
|
||
setAttributionSaved('保存失败: ' + e.message);
|
||
setTimeout(() => setAttributionSaved(null), 3000);
|
||
}
|
||
}, [call, load]);
|
||
|
||
// 归属候选自动预载:数据加载后,为所有委托 code 预取候选(下拉选项齐全,避免只有「未关联」)
|
||
useEffect(() => {
|
||
const codes = [...new Set((orders ?? []).map((o) => o.code).filter(Boolean))];
|
||
if (codes.length === 0) return;
|
||
for (const code of codes) {
|
||
loadCandidates(code);
|
||
}
|
||
}, [orders, loadCandidates]);
|
||
|
||
// 时间段变化 → 重新加载(展开重置)
|
||
useEffect(() => {
|
||
setExpanded(new Set());
|
||
load();
|
||
}, [load, range]);
|
||
|
||
// 仅「今日」范围轮询
|
||
useEffect(() => {
|
||
if (!isTodayRange) return;
|
||
const timer = setInterval(() => {
|
||
Promise.all([
|
||
call('one-divine-lot/orders', { args: { start: range.start, end: range.end } }),
|
||
call('one-divine-lot/trades', { args: { start: range.start, end: range.end } }),
|
||
]).then(([o, t]) => {
|
||
if (o?.ok && Array.isArray(o.value)) setOrders(o.value);
|
||
if (t?.ok && Array.isArray(t.value)) setTrades(t.value);
|
||
}).catch(() => {});
|
||
}, POLL_INTERVAL_MS);
|
||
return () => clearInterval(timer);
|
||
}, [call, isTodayRange, range]);
|
||
|
||
// 合并 + 策略过滤(今日实时按归属过滤前端;历史已在服务端过滤)+ 排序
|
||
const rows = useMemo(() => {
|
||
let merged = mergeOrdersAndTrades(orders ?? [], trades ?? []);
|
||
if (strategyFilter) {
|
||
merged = merged.filter((o) => {
|
||
if (strategyFilter === '__unassigned__') return !o.strategyId;
|
||
return o.strategyId === strategyFilter;
|
||
});
|
||
}
|
||
const dir = sortDir === 'asc' ? 1 : -1;
|
||
return merged.slice().sort((a, b) => {
|
||
const va = a[sortKey];
|
||
const vb = b[sortKey];
|
||
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
|
||
return (va - vb) * dir;
|
||
});
|
||
}, [orders, trades, sortKey, sortDir, strategyFilter]);
|
||
|
||
const toggleSort = (key) => {
|
||
if (sortKey === key) {
|
||
setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'));
|
||
} else {
|
||
setSortKey(key);
|
||
setSortDir('desc');
|
||
}
|
||
};
|
||
|
||
const toggleExpand = (orderId) => {
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(orderId)) next.delete(orderId);
|
||
else next.add(orderId);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
|
||
|
||
return (
|
||
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||
{/* 标题 + 策略过滤 + 时间段选择同一行(R-007/R-009) */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}>
|
||
<h3 style={{ margin: 0 }}>交易记录</h3>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
{/* R-009:策略过滤下拉(全部/各策略/未关联) */}
|
||
<select
|
||
value={strategyFilter}
|
||
onChange={(e) => setStrategyFilter(e.target.value)}
|
||
style={{ fontSize: 12, padding: '2px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, color: 'var(--dsw-alias-label-secondary, #555)' }}
|
||
title="按策略过滤"
|
||
>
|
||
<option value="">全部策略</option>
|
||
<option value="__unassigned__">未关联</option>
|
||
{strategies.map((s) => (
|
||
<option key={s.id} value={s.id}>{s.name}</option>
|
||
))}
|
||
</select>
|
||
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} />
|
||
</div>
|
||
</div>
|
||
{attributionSaved && (
|
||
<div style={{ marginBottom: 8, fontSize: 12, color: attributionSaved.startsWith('保存失败') ? 'var(--dsw-alias-state-error-primary, #c62828)' : 'var(--dsw-alias-state-success-primary, #1b5e20)', background: attributionSaved.startsWith('保存失败') ? 'color-mix(in srgb, var(--dsw-alias-state-error-primary, #c62828) 10%, var(--dsw-alias-bg-layer-1, #fff))' : 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))', padding: '4px 8px', borderRadius: 4 }}>
|
||
{attributionSaved}
|
||
</div>
|
||
)}
|
||
|
||
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
|
||
{!orders?.length ? (
|
||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||
<div style={{ fontSize: 15, marginBottom: 8 }}>
|
||
{range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''}
|
||
</div>
|
||
<div style={{ fontSize: 13 }}>
|
||
{isHistoryRange ? '该时间段暂无本地交易记录(数据自插件运行起逐日积累)' : '该时间段暂无委托记录'}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
|
||
)}
|
||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||
<span style={{ color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}>■ 买</span>
|
||
<span style={{ marginLeft: 8, color: 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>■ 卖</span>
|
||
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
|
||
{isHistoryRange && <span style={{ marginLeft: 12 }}>· 历史数据 = 本地积累</span>}
|
||
</div>
|
||
</LoadState>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */
|
||
function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) {
|
||
return (
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||
<thead>
|
||
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
|
||
<th style={thStyle}> </th>
|
||
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
|
||
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
|
||
<th style={thStyle}>名称</th>
|
||
<th onClick={() => toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')}</th>
|
||
<th onClick={() => toggleSort('status')} style={thClickStyle}>状态{arrow('status')}</th>
|
||
<th onClick={() => toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')}</th>
|
||
<th onClick={() => toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')}</th>
|
||
<th onClick={() => toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')}</th>
|
||
<th align="right">成交均价</th>
|
||
<th onClick={() => toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')}</th>
|
||
<th align="right">总费用</th>
|
||
<th align="right">佣金</th>
|
||
<th align="right">印花税</th>
|
||
<th align="right">过户费</th>
|
||
<th style={thStyle}>归属</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((o) => (
|
||
<OrderRows
|
||
key={o.orderId}
|
||
order={o}
|
||
expanded={expanded.has(o.orderId)}
|
||
onToggle={() => toggleExpand(o.orderId)}
|
||
attributionCandidates={attributionCandidates[o.code] ?? []}
|
||
onLoadCandidates={() => loadCandidates(o.code)}
|
||
setAttribution={setAttribution}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
/** 委托主行 + 展开明细(含 R-009 手动归属下拉) */
|
||
function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCandidates, setAttribution }) {
|
||
const hasTrades = order.trades.length > 0;
|
||
const strategyName = (sid) => {
|
||
if (!sid) return '未关联';
|
||
return order.strategyName || sid;
|
||
};
|
||
return (
|
||
<>
|
||
<tr
|
||
onClick={onToggle}
|
||
style={{
|
||
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)',
|
||
cursor: hasTrades ? 'pointer' : 'default',
|
||
background: expanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent',
|
||
}}
|
||
>
|
||
<td style={{ ...tdStyle, textAlign: 'center', width: 28 }}>
|
||
{hasTrades ? (
|
||
<ExpandChevron expanded={expanded} style={{ cursor: 'pointer' }} />
|
||
) : (
|
||
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)', fontSize: 10 }}>·</span>
|
||
)}
|
||
</td>
|
||
<td style={tdStyle}>{fmtTime(order.insertTime)}</td>
|
||
<td style={tdStyle}>{order.code}</td>
|
||
<td style={tdStyle}>{order.name}</td>
|
||
<td style={{ ...tdStyle, ...directionStyle(order.direction) }} title={order.optName}>{directionText(order.direction)}</td>
|
||
<td style={tdStyle}>{ORDER_STATUS[order.status] ?? order.status ?? '—'}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(order.orderVolume, 0)}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggVolume, 0) : '—'}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(order.limitPrice)}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggPrice) : '—'}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggAmount) : '—'}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggFeeTotal, 2) : '—'}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggCommission, 2) : '—'}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
|
||
<td style={{ ...tdStyle }} onClick={(e) => e.stopPropagation()}>
|
||
<AttributionSelect
|
||
order={order}
|
||
candidates={attributionCandidates}
|
||
onLoadCandidates={onLoadCandidates}
|
||
setAttribution={setAttribution}
|
||
/>
|
||
</td>
|
||
</tr>
|
||
{expanded && hasTrades && (
|
||
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
|
||
<td colSpan={16} style={{ padding: '4px 8px 12px' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||
<th style={{ ...tdStyle, textAlign: 'left' }}>成交时间</th>
|
||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交价</th>
|
||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交量</th>
|
||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交额</th>
|
||
<th style={{ ...tdStyle, textAlign: 'right' }}>成交号</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{order.trades.map((t) => (
|
||
<tr key={t.tradeId} style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #f0f0f0)' }}>
|
||
<td style={tdStyle}>{fmtTime(t.tradeTime)}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.price)}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.volume, 0)}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.amount)}</td>
|
||
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--dsw-alias-label-tertiary, #999)' }}>{t.tradeId}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
|
||
* 候选 = 该 code 当前持仓策略(orders/attribution-candidates);
|
||
* 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。
|
||
*/
|
||
function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) {
|
||
const current = order.strategyId || '';
|
||
|
||
const handleSelect = (ev) => {
|
||
const val = ev.target.value;
|
||
if (val === current) return;
|
||
if (val === '__unassigned__') {
|
||
setAttribution(order.orderId, order.code, null, null);
|
||
return;
|
||
}
|
||
const cand = (candidates ?? []).find((c) => c.strategyId === val);
|
||
setAttribution(order.orderId, order.code, val, cand ? cand.holdingId : null);
|
||
};
|
||
|
||
return (
|
||
<select
|
||
value={current || '__unassigned__'}
|
||
onChange={handleSelect}
|
||
style={{
|
||
fontSize: 12,
|
||
padding: '2px 4px',
|
||
border: '1px solid ' + (current ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'),
|
||
borderRadius: 4,
|
||
background: current ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
|
||
color: current ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
|
||
maxWidth: 120,
|
||
}}
|
||
title="设置该委托的策略归属(可随时修改)"
|
||
>
|
||
<option value="__unassigned__">未关联</option>
|
||
{(candidates ?? []).map((c) => (
|
||
<option key={c.holdingId} value={c.strategyId}>{c.strategyName || c.strategyId}</option>
|
||
))}
|
||
|
||
</select>
|
||
);
|
||
}
|
||
|
||
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
|
||
const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
|
||
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }; |