Files
Toolbox/public/tools/investment_ledger/investment_ledger.js
T
yangxiangyuan a6f763ffd8 feat: 重构认证流程,升级工具功能并优化后端配置
- 修复token泄露风险:改用X-Local-Creds-Token请求头传递token,从URL Hash读取set_token避免日志记录
- 重构全站认证逻辑:提取公用工具函数统一各服务的认证校验流程
- 升级投资账本工具:支持本地上传CSV和粘贴文本,移除硬编码路径,适配新参数格式
- 新增403/404/500标准错误响应文件,统一API错误返回格式
- 优化后端配置:关闭ETag,重构CORS中间件,新增全局错误捕获,限制敏感文件访问
- 优化web zen box的Service Worker,支持音频分片请求与断点续传
- 清理冗余前端代码,调整页面格式与权限控制
2026-08-01 21:50:05 +08:00

303 lines
10 KiB
JavaScript

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 => `<div class="card"><div class="k">${c.k}</div><div class="v">${c.v}</div></div>`).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 `<tr class="${cls}">
<td>${esc(r.event_id || '')}</td>
<td>${esc(r.event_time || '')}</td>
<td>${esc(r.symbol || '')}</td>
<td>${esc(r.event_type || '')}</td>
<td>${esc(r.side || '')}</td>
<td>${esc(price)}</td>
<td>${esc(r.qty || '')}</td>
<td>${esc(r.amount || '')}</td>
<td>${esc(r.pocket || '')}</td>
<td>${esc(r.strategy_id || '')}</td>
<td>${esc(r.chain_id || '')}</td>
<td>${esc(r.status || '')}</td>
<td>${esc(r.source_line_no || '')}</td>
<td>${esc(r.note || '')}</td>
<td class="cell-op">
<button class="mini-btn js-edit" data-idx="${idx}">修改</button>
<button class="mini-btn del js-del" data-idx="${idx}">删除</button>
</td>
</tr>`
}).join('')
const legend = $('legend')
if (legend) legend.innerHTML = '<span class="chip merged">合并仓高亮</span><span class="chip multi">同日多次规则高亮</span>'
}
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 readSelectedFileText = async () => {
const input = $('fileInput')
const file = input && input.files && input.files[0]
if (!file) return ''
return file.text()
}
const parseFile = async () => {
try {
setStatus('解析中...')
$('btnParse').disabled = true
const fileText = await readSelectedFileText()
const rawText = String((fileText || $('rawText').value || '')).trim()
if (!rawText) throw new Error('请选择 CSV 文件,或粘贴 CSV 文本')
const payload = {
raw_text: rawText,
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 {}
}
})