const state = { rows: [], editingIndex: -1 } const LS_KEY = 'investment_ledger_rows_v1' const $ = id => document.getElementById(id) const setStatus = (msg, isError) => { const el = $('status') if (!el) return el.textContent = msg || '' el.style.color = isError ? '#dc2626' : '#1d4ed8' } const fmt = n => Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const esc = s => String(s ?? '').replace(/&/g, '&').replace(//g, '>') const numOrNull = v => { const n = Number(v) return Number.isFinite(n) ? n : null } const computeSummary = rows => { let buy = 0 let sell = 0 let pending = 0 for (const r of rows) { if (r.status === 'pending') pending += 1 if (r.event_type !== 'fill') continue const amt = Number(r.amount || 0) if (r.side === 'buy') buy += amt if (r.side === 'sell') sell += amt } return { total_events: rows.length, pending_orders: pending, filled_buy_amount: Number(buy.toFixed(2)), filled_sell_amount: Number(sell.toFixed(2)), net_cashflow: Number((sell - buy).toFixed(2)) } } const renderSummary = s => { const el = $('summary') if (!el) return const cards = [ { k: '事件总数', v: Number(s.total_events || 0).toLocaleString('zh-CN') }, { k: '待成交预设', v: Number(s.pending_orders || 0).toLocaleString('zh-CN') }, { k: '买入成交金额', v: fmt(s.filled_buy_amount) }, { k: '卖出成交金额', v: fmt(s.filled_sell_amount) }, { k: '净现金流', v: fmt(s.net_cashflow) } ] el.innerHTML = cards.map(c => `
${c.k}
${c.v}
`).join('') } const getHighlightMap = rows => { const multiMap = new Map() for (const r of rows) { const key = `${r.event_time || ''}|${r.strategy_id || ''}` if (!r.event_time || !r.strategy_id) continue multiMap.set(key, (multiMap.get(key) || 0) + 1) } return { multiMap } } const renderTable = rows => { const { multiMap } = getHighlightMap(rows) const tbody = $('tbl').querySelector('tbody') tbody.innerHTML = rows.map((r, idx) => { const price = r.fill_price || r.order_price || '' const merged = !!(r.parent_event_ids || String(r.chain_id || '').startsWith('M')) const multi = (multiMap.get(`${r.event_time || ''}|${r.strategy_id || ''}`) || 0) > 1 const cls = `${merged ? 'row-merged' : ''} ${multi ? 'row-multi' : ''}`.trim() return ` ${esc(r.event_id || '')} ${esc(r.event_time || '')} ${esc(r.symbol || '')} ${esc(r.event_type || '')} ${esc(r.side || '')} ${esc(price)} ${esc(r.qty || '')} ${esc(r.amount || '')} ${esc(r.pocket || '')} ${esc(r.strategy_id || '')} ${esc(r.chain_id || '')} ${esc(r.status || '')} ${esc(r.source_line_no || '')} ${esc(r.note || '')} ` }).join('') const legend = $('legend') if (legend) legend.innerHTML = '合并仓高亮同日多次规则高亮' } const toCsvCell = v => { const s = String(v ?? '') if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"` return s } const exportCsv = () => { if (!state.rows.length) return const cols = ['event_id', 'event_time', 'symbol', 'name', 'strategy_id', 'pocket', 'chain_id', 'event_type', 'side', 'order_price', 'fill_price', 'qty', 'amount', 'trigger_rule', 'status', 'parent_event_ids', 'source_line_no', 'note'] const lines = [cols.join(',')] for (const row of state.rows) lines.push(cols.map(c => toCsvCell(row[c])).join(',')) const bom = '\uFEFF' const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' }) const a = document.createElement('a') const now = new Date() const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}` a.href = URL.createObjectURL(blob) a.download = `investment_ledger_${stamp}.csv` document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(a.href) } const saveLocal = () => { localStorage.setItem(LS_KEY, JSON.stringify(state.rows)) } const refreshAll = () => { renderSummary(computeSummary(state.rows)) renderTable(state.rows) $('btnExport').disabled = state.rows.length === 0 saveLocal() } const emptyRow = () => ({ event_id: '', event_time: '', symbol: $('symbol').value.trim(), name: '', strategy_id: 'MANUAL_DIARY_v1', pocket: 'swing', chain_id: 'X', event_type: 'fill', side: 'buy', order_price: null, fill_price: null, qty: null, amount: null, trigger_rule: '', status: 'filled', parent_event_ids: '', source_line_no: '', note: '' }) const openModal = (idx = -1) => { state.editingIndex = idx const isEdit = idx >= 0 const row = isEdit ? state.rows[idx] : emptyRow() $('modalTitle').textContent = isEdit ? '修改事件' : '新增事件' $('m_event_time').value = row.event_time || '' $('m_symbol').value = row.symbol || '' $('m_event_type').value = row.event_type || 'fill' $('m_side').value = row.side || '' $('m_price').value = row.fill_price || row.order_price || '' $('m_qty').value = row.qty || '' $('m_amount').value = row.amount || '' $('m_pocket').value = row.pocket || '' $('m_strategy_id').value = row.strategy_id || '' $('m_chain_id').value = row.chain_id || '' $('m_status').value = row.status || '' $('m_parent_event_ids').value = row.parent_event_ids || '' $('m_note').value = row.note || '' $('modalWrap').classList.add('show') } const closeModal = () => { $('modalWrap').classList.remove('show') } const nextEventId = symbol => { const prefix = `EV${symbol || 'NA'}_` let max = 0 for (const r of state.rows) { const id = String(r.event_id || '') if (!id.startsWith(prefix)) continue const n = Number(id.slice(prefix.length)) if (Number.isFinite(n) && n > max) max = n } return `${prefix}${String(max + 1).padStart(4, '0')}` } const saveModal = () => { const symbol = $('m_symbol').value.trim() const price = numOrNull($('m_price').value) const row = { event_id: '', event_time: $('m_event_time').value.trim(), symbol, name: '', strategy_id: $('m_strategy_id').value.trim(), pocket: $('m_pocket').value.trim(), chain_id: $('m_chain_id').value.trim(), event_type: $('m_event_type').value.trim(), side: $('m_side').value.trim(), order_price: $('m_event_type').value.trim() === 'order_preset' ? price : null, fill_price: $('m_event_type').value.trim() === 'fill' ? price : null, qty: numOrNull($('m_qty').value), amount: numOrNull($('m_amount').value), trigger_rule: '', status: $('m_status').value.trim(), parent_event_ids: $('m_parent_event_ids').value.trim(), source_line_no: 'manual', note: $('m_note').value.trim() } if (!row.event_time || !row.symbol || !row.event_type) { setStatus('请至少填写 时间 / 标的 / 事件类型', true) return } if (state.editingIndex >= 0) { row.event_id = state.rows[state.editingIndex].event_id || nextEventId(symbol) state.rows[state.editingIndex] = row setStatus('修改成功') } else { row.event_id = nextEventId(symbol) state.rows.unshift(row) setStatus('新增成功') } closeModal() refreshAll() } const parseFile = async () => { try { setStatus('解析中...') $('btnParse').disabled = true const payload = { file_path: $('filePath').value.trim(), symbol: $('symbol').value.trim() } const resp = await fetch('/api/investment_ledger/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) const data = await resp.json() if (!resp.ok || data.ok === false) throw new Error(data.error || 'parse_failed') state.rows = Array.isArray(data.rows) ? data.rows : [] refreshAll() setStatus(`完成:识别 ${state.rows.length} 条事件,编码推断 ${data.encoding}`) } catch (e) { setStatus(`失败:${e.message || e}`, true) } finally { $('btnParse').disabled = false } } window.addEventListener('DOMContentLoaded', () => { $('btnParse').addEventListener('click', parseFile) $('btnAdd').addEventListener('click', () => openModal(-1)) $('btnExport').addEventListener('click', exportCsv) $('btnModalCancel').addEventListener('click', closeModal) $('btnModalSave').addEventListener('click', saveModal) $('modalWrap').addEventListener('click', e => { if (e.target && e.target.id === 'modalWrap') closeModal() }) $('tbl').addEventListener('click', e => { const t = e.target if (!(t instanceof HTMLElement)) return if (t.classList.contains('js-edit')) { const idx = Number(t.dataset.idx) if (Number.isFinite(idx)) openModal(idx) return } if (t.classList.contains('js-del')) { const idx = Number(t.dataset.idx) if (!Number.isFinite(idx)) return state.rows.splice(idx, 1) refreshAll() setStatus('删除成功') } }) const raw = localStorage.getItem(LS_KEY) if (raw) { try { const rows = JSON.parse(raw) if (Array.isArray(rows)) { state.rows = rows refreshAll() setStatus(`已加载本地数据 ${rows.length} 条`) } } catch {} } })