Files

406 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fmtCN = n => n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const parseAmount = s => { const x = String(s || '').replace(/[,\s]/g, ''); const v = Number(x); if (!Number.isFinite(v)) return null; return Math.round(v * 100) }
const centsToText = c => fmtCN((c || 0) / 100)
const logBox = () => document.getElementById('fd-output')
const logLocal = msg => { const b = logBox(); if (!b) return; const line = `${(window.fmtCNDateTime ? fmtCNDateTime(new Date()) : new Date().toLocaleString('zh-CN'))} ${msg}`; b.textContent = (b.textContent || '') + (b.textContent ? '\n' : '') + line; b.scrollTop = b.scrollHeight }
let editingId = null
const attachmentCache = new Map()
const getEl = id => document.getElementById(id)
const clearFileInput = () => { const fi = getEl('new-attachments'); if (fi) fi.value = '' }
const loadFlags = async () => {
try {
const r = await fetch('/api/flags')
const d = await r.json()
if (d && d.debug && d.debug.ccb_private_funds) {
const sec = getEl('funds-debug')
if (sec) sec.style.display = ''
}
} catch {}
}
const getAttachments = async flowId => {
if (attachmentCache.has(flowId)) return attachmentCache.get(flowId)
const r = await fetch(`/api/ccb_private_funds/attachments/${flowId}`)
const d = await r.json()
const rows = (d && d.ok && Array.isArray(d.rows)) ? d.rows : []
attachmentCache.set(flowId, rows)
return rows
}
const invalidateAttachments = flowId => {
attachmentCache.delete(flowId)
}
const uploadFiles = async (flowId, files) => {
if (!files || files.length === 0) return []
const result = []
for (const f of files) {
const buf = await f.arrayBuffer()
const r = await fetch(`/api/ccb_private_funds/attachments/upload?flow_id=${encodeURIComponent(flowId)}&filename=${encodeURIComponent(f.name)}&mime=${encodeURIComponent(f.type || 'application/octet-stream')}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: buf
})
const d = await r.json()
if (!d || !d.ok) throw new Error(d && d.error ? d.error : 'upload_failed')
result.push(d)
}
invalidateAttachments(flowId)
return result
}
const renderAttachmentCell = async (flowId, td) => {
td.className = 'attachments-cell expandable'
td.innerHTML = ''
const content = document.createElement('div')
content.className = 'expandable-content'
content.textContent = '加载中...'
td.appendChild(content)
const toggle = document.createElement('span')
toggle.className = 'expandable-toggle'
toggle.textContent = '👇展开'
toggle.addEventListener('click', () => {
const expanded = td.classList.toggle('expanded')
toggle.textContent = expanded ? '👆收缩' : '👇展开'
})
td.appendChild(toggle)
try {
const atts = await getAttachments(flowId)
if (!atts.length) {
content.innerHTML = '<span class="att-none">无</span>'
toggle.style.display = 'none'
return
}
content.innerHTML = ''
const c = document.createElement('span')
c.className = 'att-count'
c.textContent = `${atts.length} 个`
content.appendChild(c)
const wrap = document.createElement('div')
wrap.className = 'att-links'
for (const a of atts) {
const link = document.createElement('a')
link.href = a.url
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.textContent = a.filename
wrap.appendChild(link)
}
content.appendChild(wrap)
toggle.style.display = atts.length <= 2 ? 'none' : ''
} catch {
content.innerHTML = '<span class="att-none">加载失败</span>'
toggle.style.display = 'none'
}
}
const renderEditAttachments = async flowId => {
const editor = getEl('attachments-editor')
const box = getEl('existing-attachments')
if (!editor || !box) return
if (!flowId) {
editor.style.display = 'none'
box.innerHTML = ''
return
}
editor.style.display = ''
box.innerHTML = '加载中...'
try {
const rows = await getAttachments(flowId)
if (!rows.length) {
box.innerHTML = '<div class="empty-attachments">暂无附件</div>'
return
}
box.innerHTML = ''
rows.forEach(a => {
const item = document.createElement('div')
item.className = 'attachment-item'
const left = document.createElement('a')
left.href = a.url
left.target = '_blank'
left.rel = 'noopener noreferrer'
left.textContent = `${a.filename} (${fmtCN((a.size_bytes || 0) / 1024)} KB)`
const del = document.createElement('button')
del.className = 'btn btn-danger'
del.textContent = '删除附件'
del.addEventListener('click', async () => {
const ok = window.confirm('确认删除此附件?')
if (!ok) return
const r = await fetch(`/api/ccb_private_funds/attachments/${a.id}`, { method: 'DELETE' })
const d = await r.json()
if (!d || !d.ok) {
alert('附件删除失败')
return
}
invalidateAttachments(flowId)
await renderEditAttachments(flowId)
await loadFlows()
})
item.appendChild(left)
item.appendChild(del)
box.appendChild(item)
})
} catch {
box.innerHTML = '<div class="empty-attachments">加载失败</div>'
}
}
const enterEditMode = async row => {
editingId = row.id
getEl('new-date').value = row.date
getEl('new-type').value = row.type
getEl('new-amount').value = (row.amount_cents / 100).toFixed(2)
getEl('new-bank').value = row.bank_status || ''
getEl('new-remark').value = row.remark || ''
const btnAdd = getEl('btn-add')
const btnCancel = getEl('btn-cancel-edit')
if (btnAdd) btnAdd.textContent = '保存修改'
if (btnCancel) btnCancel.style.display = ''
clearFileInput()
await renderEditAttachments(editingId)
document.querySelector('.add-bar').scrollIntoView({ behavior: 'smooth' })
}
const exitEditMode = () => {
editingId = null
const btnAdd = getEl('btn-add')
const btnCancel = getEl('btn-cancel-edit')
if (btnAdd) btnAdd.textContent = '新增'
if (btnCancel) btnCancel.style.display = 'none'
clearFileInput()
renderEditAttachments(0)
}
const renderRows = rows => {
const tb = getEl('flows-body')
if (!tb) return
tb.innerHTML = ''
let sumIn = 0
let sumOut = 0
rows.forEach(r => {
const tr = document.createElement('tr')
tr.className = r.type === 'in' ? 'in' : 'out'
const tdDate = document.createElement('td'); tdDate.textContent = r.date
const tdType = document.createElement('td'); tdType.textContent = r.type === 'in' ? '汇入' : '汇出'
const tdAmt = document.createElement('td'); tdAmt.className = 'amount'; tdAmt.textContent = centsToText(r.amount_cents)
const tdBank = document.createElement('td'); tdBank.textContent = r.bank_status || ''
const tdEnd = document.createElement('td'); tdEnd.className = 'ending'; tdEnd.textContent = centsToText(r.ending_cents)
const tdRemark = document.createElement('td'); tdRemark.textContent = r.remark || ''
const tdAtt = document.createElement('td'); tdAtt.className = 'attachments-cell'
const tdOps = document.createElement('td')
const btnEdit = document.createElement('button')
btnEdit.className = 'btn btn-light'
btnEdit.textContent = '修改'
btnEdit.style.marginRight = '6px'
btnEdit.addEventListener('click', async () => { await enterEditMode(r) })
tdOps.appendChild(btnEdit)
const btnDel = document.createElement('button')
btnDel.className = 'btn btn-danger'
btnDel.textContent = '删除'
btnDel.addEventListener('click', async () => {
const ok = window.confirm('确认删除该条流水及其全部附件?')
if (!ok) return
try { await fetch('/api/debug/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: 'ccb_private_funds', action: 'delete', id: r.id }) }) } catch {}
const resp = await fetch(`/api/ccb_private_funds/flows/${r.id}`, { method: 'DELETE' })
const data = await resp.json()
if (!data || !data.ok) {
alert('删除失败')
return
}
invalidateAttachments(r.id)
if (editingId === r.id) exitEditMode()
await loadFlows()
})
tdOps.appendChild(btnDel)
tr.appendChild(tdDate)
tr.appendChild(tdType)
tr.appendChild(tdAmt)
tr.appendChild(tdBank)
tr.appendChild(tdEnd)
tr.appendChild(tdRemark)
tr.appendChild(tdAtt)
tr.appendChild(tdOps)
tb.appendChild(tr)
renderAttachmentCell(r.id, tdAtt)
if (r.type === 'in') sumIn += r.amount_cents
else sumOut += r.amount_cents
})
const st = getEl('stats')
if (st) st.textContent = `总汇入 ${centsToText(sumIn)} · 总汇出 ${centsToText(Math.abs(sumOut))} · 区间净变动 ${centsToText(sumIn + sumOut)} · 期末余额 ${rows.length ? centsToText(rows[rows.length - 1].ending_cents) : centsToText(0)}`
}
const loadFlows = async () => {
const st = getEl('funds-status')
const btnQ = getEl('btn-load')
if (st) { st.style.display = ''; st.textContent = '正在查询...' }
if (btnQ) btnQ.disabled = true
const start = getEl('date-start').value || ''
const end = getEl('date-end').value || ''
const type = getEl('type-filter').value || 'all'
const kw = getEl('kw').value || ''
try {
const r = await fetch(`/api/ccb_private_funds/flows?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&type=${encodeURIComponent(type)}&kw=${encodeURIComponent(kw)}`)
const d = await r.json()
if (d.ok) renderRows(d.rows || [])
if (st) { st.style.display = ''; st.textContent = `查询完成(${(d.rows || []).length} 条)` }
} catch (e) {
if (st) { st.style.display = ''; st.textContent = `查询失败:${String(e.message || e)}` }
} finally {
if (btnQ) btnQ.disabled = false
setTimeout(() => { if (st) st.style.display = 'none' }, 1200)
}
}
const saveFlow = async () => {
const st = getEl('funds-status')
const btn = getEl('btn-add')
if (st) { st.style.display = ''; st.textContent = editingId ? '正在保存修改...' : '正在新增...' }
if (btn) btn.disabled = true
const date = getEl('new-date').value
const type = getEl('new-type').value
const amtText = getEl('new-amount').value
const bank = getEl('new-bank').value
const remark = getEl('new-remark').value
const files = Array.from((getEl('new-attachments').files || []))
if (!date || !type || !amtText || !bank || !remark) {
if (st) st.textContent = '请完整填写日期、类型、金额、银行方/状态、备注'
if (btn) btn.disabled = false
setTimeout(() => { if (st) st.style.display = 'none' }, 1800)
return
}
const cents = parseAmount(amtText)
if (cents === null) {
if (st) st.textContent = '金额格式错误'
if (btn) btn.disabled = false
setTimeout(() => { if (st) st.style.display = 'none' }, 1800)
return
}
const body = { date, type, amount_cents: type === 'out' ? -Math.abs(cents) : Math.abs(cents), bank_status: bank, remark }
try {
if (editingId) {
const r = await fetch(`/api/ccb_private_funds/flows/${editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const d = await r.json()
if (!d || !d.ok) throw new Error((d && d.error) || 'update_failed')
if (files.length) await uploadFiles(editingId, files)
invalidateAttachments(editingId)
await renderEditAttachments(editingId)
if (st) st.textContent = '修改成功'
logLocal(`ccb_private_funds.update id=${editingId}`)
} else {
const dupRes = await fetch(`/api/ccb_private_funds/flows?start=${encodeURIComponent(date)}&end=${encodeURIComponent(date)}&type=all`)
const dupData = await dupRes.json()
if (dupData.ok && Array.isArray(dupData.rows) && dupData.rows.length > 0) {
const ok = window.confirm(`该日期已有 ${dupData.rows.length} 条记录,是否继续新增?`)
if (!ok) {
if (btn) btn.disabled = false
if (st) st.style.display = 'none'
return
}
}
const r = await fetch('/api/ccb_private_funds/flows', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const d = await r.json()
if (!d || !d.ok || !d.id) throw new Error((d && d.error) || 'create_failed')
if (files.length) await uploadFiles(d.id, files)
invalidateAttachments(d.id)
if (st) st.textContent = '新增成功'
logLocal(`ccb_private_funds.add id=${d.id}`)
}
clearFileInput()
await loadFlows()
} catch (e) {
if (st) st.textContent = `${editingId ? '修改' : '新增'}失败:${String(e.message || e)}`
} finally {
if (btn) btn.disabled = false
setTimeout(() => { if (st) st.style.display = 'none' }, 1400)
}
}
const openSettings = async () => {
const st = getEl('funds-status')
try {
if (st) { st.style.display = ''; st.textContent = '正在读取设置...' }
const r = await fetch('/api/ccb_private_funds/config')
const d = await r.json()
if (!d || !d.ok || !d.config) throw new Error((d && d.error) || 'load_config_failed')
const cfg = d.config || {}
const currentBase = String(cfg.weekly_api_base || '')
const currentKey = String(cfg.weekly_key || '93220')
const currentSync = cfg.weekly_sync_enabled !== false ? 'on' : 'off'
const nextBaseRaw = window.prompt('周报同步地址 weekly_api_base', currentBase)
if (nextBaseRaw === null) {
if (st) st.textContent = '已取消设置'
setTimeout(() => { if (st) st.style.display = 'none' }, 1200)
return
}
const nextKeyRaw = window.prompt('周报同步密钥 weekly_key', currentKey)
if (nextKeyRaw === null) {
if (st) st.textContent = '已取消设置'
setTimeout(() => { if (st) st.style.display = 'none' }, 1200)
return
}
const nextSyncRaw = window.prompt('是否开启周报同步(on/off)', currentSync)
if (nextSyncRaw === null) {
if (st) st.textContent = '已取消设置'
setTimeout(() => { if (st) st.style.display = 'none' }, 1200)
return
}
const syncText = String(nextSyncRaw || '').trim().toLowerCase()
const weekly_sync_enabled = !(syncText === 'off' || syncText === '0' || syncText === 'false' || syncText === 'no')
const payload = {
weekly_api_base: String(nextBaseRaw || '').trim(),
weekly_key: String(nextKeyRaw || '').trim(),
weekly_sync_enabled
}
const wr = await fetch('/api/ccb_private_funds/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const wd = await wr.json()
if (!wd || !wd.ok) throw new Error((wd && wd.error) || 'save_config_failed')
if (st) st.textContent = '设置已保存'
logLocal('ccb_private_funds.config.saved')
} catch (e) {
if (st) st.textContent = `设置失败:${String(e.message || e)}`
} finally {
setTimeout(() => { if (st) st.style.display = 'none' }, 1500)
}
}
let pageInited = false
const initPage = () => {
if (pageInited) return
pageInited = true
loadFlags()
const y = new Date().getFullYear()
const d = new Date(y, 11, 31)
const endOfYear = window.cnDateStr ? cnDateStr(d) : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const ds = getEl('date-start'); if (ds) ds.value = '2024-01-01'
const de = getEl('date-end'); if (de) de.value = endOfYear
const nd = getEl('new-date'); if (nd) nd.value = window.cnDateStr ? cnDateStr(new Date()) : nd.value
const bl = getEl('btn-load'); if (bl) bl.addEventListener('click', loadFlows)
const ba = getEl('btn-add'); if (ba) ba.addEventListener('click', saveFlow)
const bc = getEl('btn-cancel-edit'); if (bc) bc.addEventListener('click', exitEditMode)
const be = getEl('btn-export'); if (be) be.addEventListener('click', async () => { const start = getEl('date-start').value || ''; const end = getEl('date-end').value || ''; location.href = `/api/ccb_private_funds/export.csv?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}` })
const bs = getEl('btn-settings'); if (bs) bs.addEventListener('click', openSettings)
const bcp = getEl('fd-btn-copy'); if (bcp) bcp.addEventListener('click', async () => { try { await navigator.clipboard.writeText((logBox() && logBox().textContent) || '') } catch {} })
const brf = getEl('fd-btn-refresh'); if (brf) brf.addEventListener('click', async () => { try { const r = await fetch('/api/debug/logs?limit=5000&channel=ccb_private_funds'); const d2 = await r.json(); if (d2.ok) { const b = logBox(); if (b) { b.textContent = (d2.lines || []).join('\n'); b.scrollTop = b.scrollHeight } } } catch {} })
const bcl = getEl('fd-btn-clear'); if (bcl) bcl.addEventListener('click', async () => { try { await fetch('/api/debug/clear?channel=ccb_private_funds', { method: 'POST' }) } catch {}; const b = logBox(); if (b) b.textContent = '' })
loadFlows()
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initPage)
else initPage()