|
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>账户账单详情</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--bg-color: #ffffff;
|
|
--text-color: #111827;
|
|
--secondary-text-color: #6b7280;
|
|
--border-color: #e5e7eb;
|
|
--card-bg-color: #ffffff;
|
|
--accent-color: #000000;
|
|
--error-color: #ef4444;
|
|
--success-color: #10b981;
|
|
--info-color: #3b82f6;
|
|
--income-color: #22c55e;
|
|
--expense-color: #ef4444;
|
|
--summary-bar-bg: #f9fafb;
|
|
}
|
|
body {
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
margin: 0;
|
|
background-color: var(--summary-bar-bg);
|
|
color: var(--text-color);
|
|
line-height: 1.6;
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
}
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 32px 20px;
|
|
}
|
|
header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
padding-bottom: 24px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
header h1 {
|
|
margin: 0;
|
|
font-size: 28px;
|
|
color: var(--text-color);
|
|
font-weight: 700;
|
|
}
|
|
.header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.header-actions .back-link {
|
|
padding: 8px 16px;
|
|
background-color: var(--bg-color);
|
|
color: var(--secondary-text-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
|
}
|
|
.header-actions .back-link:hover {
|
|
background-color: #f3f4f6;
|
|
border-color: #d1d5db;
|
|
text-decoration: none;
|
|
}
|
|
|
|
#account-summary-bar {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
background-color: var(--card-bg-color);
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin-bottom: 30px;
|
|
border: 1px solid var(--border-color);
|
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
}
|
|
.summary-item {
|
|
text-align: left;
|
|
}
|
|
.summary-label {
|
|
display: block;
|
|
font-size: 0.875em;
|
|
color: var(--secondary-text-color);
|
|
margin-bottom: 4px;
|
|
font-weight: 500;
|
|
}
|
|
.summary-value {
|
|
display: block;
|
|
font-size: 1.5em;
|
|
color: var(--text-color);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.statement-card-item {
|
|
background-color: var(--card-bg-color);
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
margin-bottom: 12px;
|
|
cursor: pointer;
|
|
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
|
}
|
|
.statement-card-item:hover {
|
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
|
|
transform: translateY(-2px);
|
|
}
|
|
.statement-card-item .card-summary {
|
|
padding: 16px 20px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.statement-card-item .summary-left { display: flex; align-items: center; gap: 12px; }
|
|
.statement-card-item .change-type-icon {
|
|
width: 32px; height: 32px; border-radius: 50%; display: flex;
|
|
align-items: center; justify-content: center; font-weight: 600; color: white;
|
|
}
|
|
.statement-card-item .summary-info .description { font-weight: 500; color: var(--text-color); }
|
|
.statement-card-item .summary-info .timestamp { font-size: 0.875em; color: var(--secondary-text-color); }
|
|
.statement-card-item .summary-right .amount { font-weight: 600; font-size: 1.1em; }
|
|
.statement-card-item .amount.positive { color: var(--income-color); }
|
|
.statement-card-item .amount.negative { color: var(--expense-color); }
|
|
.statement-card-item .amount.neutral { color: var(--text-color); }
|
|
.statement-card-item .card-details {
|
|
padding: 0 20px 16px 20px;
|
|
border-top: 1px solid var(--border-color);
|
|
margin-top: 12px;
|
|
display: none;
|
|
}
|
|
.statement-card-item .details-grid {
|
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 12px 20px; margin-top: 12px;
|
|
}
|
|
.statement-card-item .detail-item { font-size: 0.9em; }
|
|
.statement-card-item .detail-item strong { color: var(--secondary-text-color); font-weight: 500; margin-right: 8px; display: inline-block; min-width: 80px;}
|
|
.statement-card-item .detail-item span { color: var(--text-color); word-break: break-all; }
|
|
.statement-card-item .metadata-title { font-weight: 600; margin-top: 16px; margin-bottom: 8px; font-size: 0.95em; color: var(--text-color); }
|
|
.statement-card-item .metadata-item { font-size: 0.85em; padding-left: 10px; border-left: 2px solid var(--border-color); margin-bottom: 4px; }
|
|
.statement-card-item .metadata-item strong { min-width: 70px; }
|
|
|
|
#statements-loading-indicator {
|
|
text-align: center; padding: 40px; font-size: 1em; color: var(--secondary-text-color); display: none; width: 100%;
|
|
}
|
|
#statements-loading-indicator::before {
|
|
content: ''; display: inline-block; width: 24px; height: 24px;
|
|
border: 3px solid rgba(0,0,0, 0.1); border-radius: 50%;
|
|
border-top-color: var(--text-color); animation: spin 0.8s linear infinite;
|
|
margin-right: 12px; position: relative; top: 4px;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
#pagination-controls {
|
|
margin-top: 20px;
|
|
text-align: center;
|
|
}
|
|
#pagination-controls button {
|
|
padding: 8px 16px;
|
|
margin: 0 5px;
|
|
border: 1px solid var(--border-color);
|
|
background-color: var(--card-bg-color);
|
|
color: var(--text-color);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
#pagination-controls button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
#pagination-controls button:hover:not(:disabled) {
|
|
border-color: var(--accent-color);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>账户账单详情</h1>
|
|
<div class="header-actions">
|
|
<a href="{{ url_for('dashboard') }}" class="back-link">← 返回仪表盘</a>
|
|
</div>
|
|
</header>
|
|
|
|
<div id="account-summary-bar">
|
|
<div class="summary-item">
|
|
<span class="summary-label">总收入</span>
|
|
<span class="summary-value" id="summary-total-income">--.-- USD</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">总支出</span>
|
|
<span class="summary-value" id="summary-total-expense">--.-- USD</span>
|
|
</div>
|
|
<div class="summary-item">
|
|
<span class="summary-label">总结余</span>
|
|
<span class="summary-value" id="summary-total-balance">--.-- USD</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="statements-loading-indicator">正在加载账单...</div>
|
|
<div id="statement-records-container">
|
|
</div>
|
|
<div id="pagination-controls">
|
|
<button id="prev-page-btn" disabled>上一页</button>
|
|
<span id="page-info">第 1 页</span>
|
|
<button id="next-page-btn" disabled>下一页</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const accountEmail = "{{ account_email }}";
|
|
const recordsContainer = document.getElementById('statement-records-container');
|
|
const loadingIndicator = document.getElementById('statements-loading-indicator');
|
|
const prevPageBtn = document.getElementById('prev-page-btn');
|
|
const nextPageBtn = document.getElementById('next-page-btn');
|
|
const pageInfoElem = document.getElementById('page-info');
|
|
|
|
const summaryTotalIncomeElem = document.getElementById('summary-total-income');
|
|
const summaryTotalExpenseElem = document.getElementById('summary-total-expense');
|
|
const summaryTotalBalanceElem = document.getElementById('summary-total-balance');
|
|
|
|
let currentPage = 1;
|
|
const pageSize = 20;
|
|
let totalRecords = 0;
|
|
|
|
function formatStatementTimestamp(unixTimestamp) {
|
|
if (!unixTimestamp) return 'N/A';
|
|
const date = new Date(unixTimestamp * 1000);
|
|
return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
|
}
|
|
|
|
function getChangeTypeDetails(type) {
|
|
const types = {
|
|
1: { text: "充值", icon: "➕", color: "#22c55e" }, 2: { text: "提现", icon: "➖", color: "#ef4444" },
|
|
3: { text: "接收", icon: "➡️", color: "#3b82f6" }, 4: { text: "发送", icon: "⬅️", color: "#f97316" },
|
|
5: { text: "消费", icon: "💳", color: "#ef4444" }, 6: { text: "收益", icon: "💰", color: "#10b981" },
|
|
7: { text: "提出", icon: "🔄", color: "#6366f1" }, 8: { text: "开卡", icon: "🚫", color: "#6b7280" },
|
|
14: { text: "红包", icon: "🧧", color: "#f43f5e" }
|
|
};
|
|
return types[type] || { text: `类型 ${type}`, icon: "❓", color: "#6b7280" };
|
|
}
|
|
|
|
function formatMetadata(metadata) {
|
|
if (typeof metadata !== 'object' || metadata === null || Object.keys(metadata).length === 0) {
|
|
return '<p class="detail-item"><strong>元数据:</strong> <span>无</span></p>';
|
|
}
|
|
let html = '<div class="metadata-title">元数据:</div>';
|
|
for (const key in metadata) {
|
|
html += `<div class="metadata-item"><strong>${key}:</strong> <span>${typeof metadata[key] === 'object' ? JSON.stringify(metadata[key]) : metadata[key]}</span></div>`;
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function renderStatementRecords(apiResponse) {
|
|
recordsContainer.innerHTML = '';
|
|
loadingIndicator.style.display = 'none';
|
|
|
|
if (!apiResponse || !apiResponse.data || !apiResponse.data.items) {
|
|
recordsContainer.innerHTML = '<p>无法加载账单数据或无记录。</p>';
|
|
updatePaginationControls(0);
|
|
return;
|
|
}
|
|
const items = apiResponse.data.items;
|
|
totalRecords = apiResponse.data.total || items.length;
|
|
|
|
if (items.length === 0) {
|
|
recordsContainer.innerHTML = '<p>该账户暂无账单记录。</p>';
|
|
updatePaginationControls(0);
|
|
return;
|
|
}
|
|
|
|
items.forEach(item => {
|
|
const statementItemCard = document.createElement('div');
|
|
statementItemCard.classList.add('statement-card-item');
|
|
const changeTypeDetails = getChangeTypeDetails(item.change_type);
|
|
const amountClass = item.change > 0 ? 'positive' : (item.change < 0 ? 'negative' : 'neutral');
|
|
const formattedAmount = `${item.change >= 0 ? '+' : ''}${parseFloat(item.change).toFixed(item.field === "RED_PACKET_BALANCE" || item.field === "AVAILABLE_BALANCE" ? 5 : 2)}`;
|
|
|
|
statementItemCard.innerHTML = `
|
|
<div class="card-summary">
|
|
<div class="summary-left">
|
|
<div class="change-type-icon" style="background-color: ${changeTypeDetails.color};">${changeTypeDetails.icon}</div>
|
|
<div class="summary-info">
|
|
<div class="description">${changeTypeDetails.text} - ${item.field === "AVAILABLE_BALANCE" ? "可用余额" : (item.field === "RED_PACKET_BALANCE" ? "红包余额" : item.field)}</div>
|
|
<div class="timestamp">${formatStatementTimestamp(item.created_at)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="summary-right"><span class="amount ${amountClass}">${formattedAmount}</span></div>
|
|
</div>
|
|
<div class="card-details">
|
|
<div class="details-grid">
|
|
<p class="detail-item"><strong>ID:</strong> <span>${item.id}</span></p>
|
|
<p class="detail-item"><strong>交易ID:</strong> <span>${item.tx_id}</span></p>
|
|
<p class="detail-item"><strong>字段:</strong> <span>${item.field}</span></p>
|
|
<p class="detail-item"><strong>变动类型:</strong> <span>${changeTypeDetails.text} (${item.change_type})</span></p>
|
|
<p class="detail-item"><strong>变动额:</strong> <span class="${amountClass}">${formattedAmount}</span></p>
|
|
<p class="detail-item"><strong>状态:</strong> <span>${item.status === 1 ? '成功' : '处理中/失败'}</span></p>
|
|
<p class="detail-item"><strong>变动前余额:</strong> <span>${parseFloat(item.pre_balance).toFixed(5)}</span></p>
|
|
<p class="detail-item"><strong>变动后余额:</strong> <span>${parseFloat(item.balance).toFixed(5)}</span></p>
|
|
</div>
|
|
${formatMetadata(item.metadata)}
|
|
</div>
|
|
`;
|
|
statementItemCard.querySelector('.card-summary').addEventListener('click', () => {
|
|
const details = statementItemCard.querySelector('.card-details');
|
|
details.style.display = details.style.display === 'none' || details.style.display === '' ? 'block' : 'none';
|
|
});
|
|
recordsContainer.appendChild(statementItemCard);
|
|
});
|
|
updatePaginationControls(totalRecords);
|
|
}
|
|
|
|
function updatePaginationControls(total) {
|
|
const totalPages = Math.ceil(total / pageSize);
|
|
pageInfoElem.textContent = `第 ${currentPage} / ${totalPages} 页 (共 ${total} 条)`;
|
|
prevPageBtn.disabled = currentPage === 1;
|
|
nextPageBtn.disabled = currentPage === totalPages || totalPages === 0;
|
|
}
|
|
|
|
async function fetchStatementsForPage(page) {
|
|
loadingIndicator.style.display = 'block';
|
|
recordsContainer.innerHTML = '';
|
|
try {
|
|
const response = await fetch(`/api/account_statements?email=${encodeURIComponent(accountEmail)}&page=${page}&size=${pageSize}`);
|
|
if (response.status === 401) { window.location.href = '/'; return; }
|
|
if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); }
|
|
|
|
const statementData = await response.json();
|
|
if (statementData.error) {
|
|
recordsContainer.innerHTML = `<p style="color: var(--error-color);">获取账单失败: ${statementData.error}</p>`;
|
|
} else {
|
|
renderStatementRecords(statementData);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error fetching statements for ${accountEmail} page ${page}:`, error);
|
|
recordsContainer.innerHTML = `<p style="color: var(--error-color);">获取账单时发生网络错误。</p>`;
|
|
}
|
|
}
|
|
|
|
prevPageBtn.addEventListener('click', () => {
|
|
if (currentPage > 1) {
|
|
currentPage--;
|
|
fetchStatementsForPage(currentPage);
|
|
}
|
|
});
|
|
|
|
nextPageBtn.addEventListener('click', () => {
|
|
const totalPages = Math.ceil(totalRecords / pageSize);
|
|
if (currentPage < totalPages) {
|
|
currentPage++;
|
|
fetchStatementsForPage(currentPage);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (accountEmail) {
|
|
fetchAccountStatistics();
|
|
fetchStatementsForPage(currentPage);
|
|
} else {
|
|
recordsContainer.innerHTML = "<p>未指定账户信息。</p>";
|
|
loadingIndicator.style.display = 'none';
|
|
summaryTotalIncomeElem.textContent = 'N/A';
|
|
summaryTotalExpenseElem.textContent = 'N/A';
|
|
summaryTotalBalanceElem.textContent = 'N/A';
|
|
}
|
|
});
|
|
|
|
async function fetchAccountStatistics() {
|
|
if (!accountEmail) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/account_summary_statistics?email=${encodeURIComponent(accountEmail)}`);
|
|
|
|
if (response.status === 401) {
|
|
console.warn('Unauthorized access to statistics API via backend. User might need to log in.');
|
|
summaryTotalIncomeElem.textContent = '认证失败';
|
|
summaryTotalExpenseElem.textContent = '认证失败';
|
|
summaryTotalBalanceElem.textContent = '认证失败';
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let errorMsg = `HTTP error! status: ${response.status}`;
|
|
try {
|
|
const errData = await response.json();
|
|
errorMsg = errData.error || errData.message || errorMsg;
|
|
} catch (e) { }
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
const statsData = await response.json();
|
|
|
|
if (statsData.error) {
|
|
console.error('Backend failed to get statistics:', statsData.error);
|
|
summaryTotalIncomeElem.textContent = '加载失败';
|
|
summaryTotalExpenseElem.textContent = '加载失败';
|
|
summaryTotalBalanceElem.textContent = '加载失败';
|
|
} else if (statsData.code === 0 && statsData.data) {
|
|
summaryTotalIncomeElem.textContent = `${parseFloat(statsData.data.income || 0).toFixed(2)} USD`;
|
|
summaryTotalExpenseElem.textContent = `${parseFloat(statsData.data.expense || 0).toFixed(2)} USD`;
|
|
summaryTotalBalanceElem.textContent = `${parseFloat(statsData.data.balance || 0).toFixed(2)} USD`;
|
|
} else {
|
|
console.error('Received unexpected statistics data structure from backend:', statsData);
|
|
summaryTotalIncomeElem.textContent = '数据格式错误';
|
|
summaryTotalExpenseElem.textContent = '数据格式错误';
|
|
summaryTotalBalanceElem.textContent = '数据格式错误';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching account statistics via backend:', error);
|
|
summaryTotalIncomeElem.textContent = '网络错误';
|
|
summaryTotalExpenseElem.textContent = '网络错误';
|
|
summaryTotalBalanceElem.textContent = '网络错误';
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |