/** * 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 { 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 (
{/* 标题 + 策略过滤 + 时间段选择同一行(R-007/R-009) */}

交易记录

{/* R-009:策略过滤下拉(全部/各策略/未关联) */}
{attributionSaved && (
{attributionSaved}
)} {!orders?.length ? (
{range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''}
{isHistoryRange ? '该时间段暂无本地交易记录(数据自插件运行起逐日积累)' : '该时间段暂无委托记录'}
) : ( )}
■ 买 ■ 卖 点击委托行展开成交明细 · 点击表头排序 {isHistoryRange && · 历史数据 = 本地积累}
); } /** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) { return ( {rows.map((o) => ( toggleExpand(o.orderId)} attributionCandidates={attributionCandidates[o.code] ?? []} onLoadCandidates={() => loadCandidates(o.code)} setAttribution={setAttribution} /> ))}
toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')} toggleSort('code')} style={thClickStyle}>代码{arrow('code')} 名称 toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')} toggleSort('status')} style={thClickStyle}>状态{arrow('status')} toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')} toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')} toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')} 成交均价 toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')} 总费用 佣金 印花税 过户费 归属
); } /** 委托主行 + 展开明细(含 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 ( <> {hasTrades ? ( ) : ( · )} {fmtTime(order.insertTime)} {order.code} {order.name} {directionText(order.direction)} {ORDER_STATUS[order.status] ?? order.status ?? '—'} {fmtNum(order.orderVolume, 0)} {hasTrades ? fmtNum(order.aggVolume, 0) : '—'} {fmtNum(order.limitPrice)} {hasTrades ? fmtNum(order.aggPrice) : '—'} {hasTrades ? fmtNum(order.aggAmount) : '—'} {hasTrades ? fmtNum(order.aggFeeTotal, 2) : '—'} {hasTrades ? fmtNum(order.aggCommission, 2) : '—'} {hasTrades ? fmtNum(order.aggStampTax, 2) : '—'} {hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'} e.stopPropagation()}> {expanded && hasTrades && ( {order.trades.map((t) => ( ))}
成交时间 成交价 成交量 成交额 成交号
{fmtTime(t.tradeTime)} {fmtNum(t.price)} {fmtNum(t.volume, 0)} {fmtNum(t.amount)} {t.tradeId}
)} ); } /** 委托归属选择下拉(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 ( ); } 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' };