| <!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> |