| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>个人记账系统</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://cdn.jsdelivr.net/npm/sql.js@1.8.0/dist/sql-wasm.js"></script> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
| <style> |
| .fade-enter-active, .fade-leave-active { |
| transition: opacity 0.3s; |
| } |
| .fade-enter, .fade-leave-to { |
| opacity: 0; |
| } |
| .chart-container { |
| height: 300px; |
| } |
| .scrollbar-hide::-webkit-scrollbar { |
| display: none; |
| } |
| .scrollbar-hide { |
| -ms-overflow-style: none; |
| scrollbar-width: none; |
| } |
| </style> |
| </head> |
| <body class="bg-gray-50 font-sans"> |
| <div class="min-h-screen flex flex-col" id="app"> |
| |
| <header class="bg-blue-600 text-white shadow-md"> |
| <div class="container mx-auto px-4 py-3 flex justify-between items-center"> |
| <div class="flex items-center space-x-2"> |
| <i class="fas fa-wallet text-2xl"></i> |
| <h1 class="text-xl font-bold">个人记账系统</h1> |
| </div> |
| <div class="flex items-center space-x-4"> |
| <button @click="showAddModal = true" class="bg-white text-blue-600 px-4 py-2 rounded-full font-medium hover:bg-blue-50 transition"> |
| <i class="fas fa-plus mr-1"></i> 添加记录 |
| </button> |
| <div class="relative"> |
| <button @click="userMenuOpen = !userMenuOpen" class="flex items-center space-x-2 focus:outline-none"> |
| <div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center"> |
| <i class="fas fa-user"></i> |
| </div> |
| </button> |
| <div v-if="userMenuOpen" @click.away="userMenuOpen = false" class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50"> |
| <a href="#" class="block px-4 py-2 text-gray-800 hover:bg-blue-50">个人资料</a> |
| <a href="#" class="block px-4 py-2 text-gray-800 hover:bg-blue-50">设置</a> |
| <div class="border-t border-gray-200"></div> |
| <a href="#" class="block px-4 py-2 text-gray-800 hover:bg-blue-50">退出</a> |
| </div> |
| </div> |
| </div> |
| </div> |
| </header> |
|
|
| |
| <main class="flex-1 container mx-auto px-4 py-6"> |
| |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> |
| <div class="bg-white rounded-lg shadow p-6"> |
| <div class="flex items-center justify-between"> |
| <div> |
| <p class="text-gray-500">总收入</p> |
| <p class="text-2xl font-bold text-green-500">¥ {{ formatCurrency(totalIncome) }}</p> |
| </div> |
| <div class="bg-green-100 p-3 rounded-full"> |
| <i class="fas fa-arrow-up text-green-600"></i> |
| </div> |
| </div> |
| </div> |
| <div class="bg-white rounded-lg shadow p-6"> |
| <div class="flex items-center justify-between"> |
| <div> |
| <p class="text-gray-500">总支出</p> |
| <p class="text-2xl font-bold text-red-500">¥ {{ formatCurrency(totalExpense) }}</p> |
| </div> |
| <div class="bg-red-100 p-3 rounded-full"> |
| <i class="fas fa-arrow-down text-red-600"></i> |
| </div> |
| </div> |
| </div> |
| <div class="bg-white rounded-lg shadow p-6"> |
| <div class="flex items-center justify-between"> |
| <div> |
| <p class="text-gray-500">结余</p> |
| <p class="text-2xl font-bold text-blue-500">¥ {{ formatCurrency(totalIncome - totalExpense) }}</p> |
| </div> |
| <div class="bg-blue-100 p-3 rounded-full"> |
| <i class="fas fa-balance-scale text-blue-600"></i> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
| |
| <div class="lg:col-span-2 bg-white rounded-lg shadow p-6"> |
| <div class="flex justify-between items-center mb-4"> |
| <h2 class="text-lg font-semibold">收支分析</h2> |
| <div class="flex space-x-2"> |
| <button @click="chartType = 'bar'" :class="{'bg-blue-100 text-blue-600': chartType === 'bar'}" class="px-3 py-1 rounded text-sm">柱状图</button> |
| <button @click="chartType = 'pie'" :class="{'bg-blue-100 text-blue-600': chartType === 'pie'}" class="px-3 py-1 rounded text-sm">饼图</button> |
| </div> |
| </div> |
| <div class="chart-container"> |
| <canvas id="financeChart"></canvas> |
| </div> |
| </div> |
|
|
| |
| <div class="bg-white rounded-lg shadow p-6"> |
| <h2 class="text-lg font-semibold mb-4">分类统计</h2> |
| <div class="space-y-4"> |
| <div v-for="category in expenseCategories" :key="category.name" class="flex items-center"> |
| <div class="w-8 h-8 rounded-full flex items-center justify-center" :class="category.color"> |
| <i :class="category.icon"></i> |
| </div> |
| <div class="ml-3 flex-1"> |
| <div class="flex justify-between text-sm"> |
| <span>{{ category.name }}</span> |
| <span class="font-medium">¥ {{ formatCurrency(category.amount) }}</span> |
| </div> |
| <div class="w-full bg-gray-200 rounded-full h-1.5 mt-1"> |
| <div class="bg-blue-600 h-1.5 rounded-full" :style="`width: ${(category.amount / totalExpense) * 100}%`"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="mt-8 bg-white rounded-lg shadow overflow-hidden"> |
| <div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center"> |
| <h2 class="text-lg font-semibold">交易记录</h2> |
| <div class="flex space-x-2"> |
| <div class="relative"> |
| <select v-model="filterType" class="appearance-none bg-gray-100 border-0 py-1 pl-3 pr-8 rounded-full text-sm focus:outline-none"> |
| <option value="all">所有类型</option> |
| <option value="income">收入</option> |
| <option value="expense">支出</option> |
| </select> |
| <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"> |
| <i class="fas fa-chevron-down text-xs"></i> |
| </div> |
| </div> |
| <div class="relative"> |
| <select v-model="filterCategory" class="appearance-none bg-gray-100 border-0 py-1 pl-3 pr-8 rounded-full text-sm focus:outline-none"> |
| <option value="all">所有分类</option> |
| <option v-for="cat in allCategories" :value="cat.name">{{ cat.name }}</option> |
| </select> |
| <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"> |
| <i class="fas fa-chevron-down text-xs"></i> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div class="overflow-x-auto scrollbar-hide"> |
| <table class="min-w-full divide-y divide-gray-200"> |
| <thead class="bg-gray-50"> |
| <tr> |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日期</th> |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th> |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">分类</th> |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th> |
| <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">备注</th> |
| <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th> |
| </tr> |
| </thead> |
| <tbody class="bg-white divide-y divide-gray-200"> |
| <tr v-for="record in filteredRecords" :key="record.id"> |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(record.date) }}</td> |
| <td class="px-6 py-4 whitespace-nowrap"> |
| <span :class="record.type === 'income' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"> |
| {{ record.type === 'income' ? '收入' : '支出' }} |
| </span> |
| </td> |
| <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> |
| <div class="flex items-center"> |
| <div class="w-5 h-5 rounded-full flex items-center justify-center mr-2" :class="getCategoryColor(record.category)"> |
| <i :class="getCategoryIcon(record.category)" class="text-xs"></i> |
| </div> |
| {{ record.category }} |
| </div> |
| </td> |
| <td class="px-6 py-4 whitespace-nowrap text-sm" :class="record.type === 'income' ? 'text-green-600 font-medium' : 'text-red-600 font-medium'"> |
| ¥ {{ formatCurrency(record.amount) }} |
| </td> |
| <td class="px-6 py-4 text-sm text-gray-500">{{ record.note || '-' }}</td> |
| <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> |
| <button @click="editRecord(record)" class="text-blue-600 hover:text-blue-900 mr-3"><i class="fas fa-edit"></i></button> |
| <button @click="deleteRecord(record.id)" class="text-red-600 hover:text-red-900"><i class="fas fa-trash-alt"></i></button> |
| </td> |
| </tr> |
| <tr v-if="filteredRecords.length === 0"> |
| <td colspan="6" class="px-6 py-4 text-center text-sm text-gray-500">暂无记录</td> |
| </tr> |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </main> |
|
|
| |
| <div v-if="showAddModal" class="fixed inset-0 overflow-y-auto z-50"> |
| <div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> |
| <transition name="fade"> |
| <div class="fixed inset-0 transition-opacity" aria-hidden="true"> |
| <div class="absolute inset-0 bg-gray-500 opacity-75"></div> |
| </div> |
| </transition> |
|
|
| <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span> |
|
|
| <div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> |
| <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> |
| <div class="sm:flex sm:items-start"> |
| <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"> |
| <h3 class="text-lg leading-6 font-medium text-gray-900 mb-4"> |
| {{ editingRecord ? '编辑记录' : '添加新记录' }} |
| </h3> |
| <div class="mt-2 space-y-4"> |
| <div> |
| <label class="block text-sm font-medium text-gray-700 mb-1">类型</label> |
| <div class="flex space-x-4"> |
| <label class="inline-flex items-center"> |
| <input type="radio" v-model="newRecord.type" value="income" class="form-radio h-4 w-4 text-green-600"> |
| <span class="ml-2">收入</span> |
| </label> |
| <label class="inline-flex items-center"> |
| <input type="radio" v-model="newRecord.type" value="expense" class="form-radio h-4 w-4 text-red-600"> |
| <span class="ml-2">支出</span> |
| </label> |
| </div> |
| </div> |
| <div> |
| <label for="category" class="block text-sm font-medium text-gray-700 mb-1">分类</label> |
| <select id="category" v-model="newRecord.category" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"> |
| <option v-for="cat in (newRecord.type === 'income' ? incomeCategories : expenseCategories)" :value="cat.name">{{ cat.name }}</option> |
| </select> |
| </div> |
| <div> |
| <label for="amount" class="block text-sm font-medium text-gray-700 mb-1">金额</label> |
| <div class="mt-1 relative rounded-md shadow-sm"> |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> |
| <span class="text-gray-500 sm:text-sm">¥</span> |
| </div> |
| <input type="number" id="amount" v-model="newRecord.amount" class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-7 pr-12 sm:text-sm border-gray-300 rounded-md" placeholder="0.00"> |
| </div> |
| </div> |
| <div> |
| <label for="date" class="block text-sm font-medium text-gray-700 mb-1">日期</label> |
| <input type="date" id="date" v-model="newRecord.date" class="mt-1 focus:ring-blue-500 focus:border-blue-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> |
| </div> |
| <div> |
| <label for="note" class="block text-sm font-medium text-gray-700 mb-1">备注</label> |
| <textarea id="note" v-model="newRecord.note" rows="2" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border border-gray-300 rounded-md"></textarea> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> |
| <button @click="saveRecord" type="button" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm"> |
| 保存 |
| </button> |
| <button @click="showAddModal = false" type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"> |
| 取消 |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script> |
| <script> |
| |
| const config = { |
| locateFile: filename => `https://cdn.jsdelivr.net/npm/sql.js@1.8.0/dist/${filename}` |
| }; |
| |
| |
| document.addEventListener('DOMContentLoaded', async () => { |
| try { |
| |
| const SQL = await initSqlJs(config); |
| |
| |
| const db = new SQL.Database(); |
| |
| |
| db.run(` |
| CREATE TABLE IF NOT EXISTS records ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| type TEXT NOT NULL, |
| category TEXT NOT NULL, |
| amount REAL NOT NULL, |
| date TEXT NOT NULL, |
| note TEXT |
| ); |
| `); |
| |
| |
| new Vue({ |
| el: '#app', |
| data: { |
| db: db, |
| records: [], |
| newRecord: { |
| type: 'expense', |
| category: '餐饮', |
| amount: '', |
| date: new Date().toISOString().split('T')[0], |
| note: '' |
| }, |
| editingRecord: null, |
| showAddModal: false, |
| userMenuOpen: false, |
| filterType: 'all', |
| filterCategory: 'all', |
| chartType: 'bar', |
| chart: null, |
| incomeCategories: [ |
| { name: '工资', icon: 'fas fa-money-bill-wave', color: 'bg-green-100 text-green-600' }, |
| { name: '奖金', icon: 'fas fa-gift', color: 'bg-blue-100 text-blue-600' }, |
| { name: '投资', icon: 'fas fa-chart-line', color: 'bg-purple-100 text-purple-600' }, |
| { name: '其他收入', icon: 'fas fa-coins', color: 'bg-yellow-100 text-yellow-600' } |
| ], |
| expenseCategories: [ |
| { name: '餐饮', icon: 'fas fa-utensils', color: 'bg-red-100 text-red-600' }, |
| { name: '购物', icon: 'fas fa-shopping-bag', color: 'bg-pink-100 text-pink-600' }, |
| { name: '交通', icon: 'fas fa-bus', color: 'bg-indigo-100 text-indigo-600' }, |
| { name: '娱乐', icon: 'fas fa-gamepad', color: 'bg-purple-100 text-purple-600' }, |
| { name: '住房', icon: 'fas fa-home', color: 'bg-blue-100 text-blue-600' }, |
| { name: '医疗', icon: 'fas fa-heartbeat', color: 'bg-green-100 text-green-600' }, |
| { name: '教育', icon: 'fas fa-book', color: 'bg-yellow-100 text-yellow-600' }, |
| { name: '其他支出', icon: 'fas fa-ellipsis-h', color: 'bg-gray-100 text-gray-600' } |
| ] |
| }, |
| computed: { |
| allCategories() { |
| return [...this.incomeCategories, ...this.expenseCategories]; |
| }, |
| filteredRecords() { |
| let filtered = this.records; |
| |
| if (this.filterType !== 'all') { |
| filtered = filtered.filter(r => r.type === this.filterType); |
| } |
| |
| if (this.filterCategory !== 'all') { |
| filtered = filtered.filter(r => r.category === this.filterCategory); |
| } |
| |
| return filtered.sort((a, b) => new Date(b.date) - new Date(a.date)); |
| }, |
| totalIncome() { |
| return this.records |
| .filter(r => r.type === 'income') |
| .reduce((sum, r) => sum + r.amount, 0); |
| }, |
| totalExpense() { |
| return this.records |
| .filter(r => r.type === 'expense') |
| .reduce((sum, r) => sum + r.amount, 0); |
| }, |
| categorizedExpenses() { |
| const result = {}; |
| this.expenseCategories.forEach(cat => { |
| result[cat.name] = 0; |
| }); |
| |
| this.records |
| .filter(r => r.type === 'expense') |
| .forEach(r => { |
| if (result[r.category] !== undefined) { |
| result[r.category] += r.amount; |
| } else { |
| result['其他支出'] += r.amount; |
| } |
| }); |
| |
| return this.expenseCategories.map(cat => ({ |
| ...cat, |
| amount: result[cat.name] |
| })); |
| } |
| }, |
| methods: { |
| loadRecords() { |
| const stmt = this.db.prepare('SELECT * FROM records ORDER BY date DESC'); |
| const records = []; |
| while (stmt.step()) { |
| records.push(stmt.getAsObject()); |
| } |
| stmt.free(); |
| this.records = records; |
| this.updateChart(); |
| }, |
| saveRecord() { |
| if (!this.newRecord.amount || isNaN(this.newRecord.amount)) { |
| alert('请输入有效的金额'); |
| return; |
| } |
| |
| const amount = parseFloat(this.newRecord.amount); |
| if (amount <= 0) { |
| alert('金额必须大于0'); |
| return; |
| } |
| |
| if (!this.newRecord.date) { |
| alert('请选择日期'); |
| return; |
| } |
| |
| if (this.editingRecord) { |
| |
| const stmt = this.db.prepare( |
| 'UPDATE records SET type = ?, category = ?, amount = ?, date = ?, note = ? WHERE id = ?' |
| ); |
| stmt.bind([ |
| this.newRecord.type, |
| this.newRecord.category, |
| amount, |
| this.newRecord.date, |
| this.newRecord.note || null, |
| this.editingRecord.id |
| ]); |
| stmt.step(); |
| stmt.free(); |
| } else { |
| |
| const stmt = this.db.prepare( |
| 'INSERT INTO records (type, category, amount, date, note) VALUES (?, ?, ?, ?, ?)' |
| ); |
| stmt.bind([ |
| this.newRecord.type, |
| this.newRecord.category, |
| amount, |
| this.newRecord.date, |
| this.newRecord.note || null |
| ]); |
| stmt.step(); |
| stmt.free(); |
| } |
| |
| this.loadRecords(); |
| this.resetForm(); |
| this.showAddModal = false; |
| }, |
| editRecord(record) { |
| this.editingRecord = record; |
| this.newRecord = { |
| type: record.type, |
| category: record.category, |
| amount: record.amount, |
| date: record.date, |
| note: record.note || '' |
| }; |
| this.showAddModal = true; |
| }, |
| deleteRecord(id) { |
| if (confirm('确定要删除这条记录吗?')) { |
| const stmt = this.db.prepare('DELETE FROM records WHERE id = ?'); |
| stmt.bind([id]); |
| stmt.step(); |
| stmt.free(); |
| this.loadRecords(); |
| } |
| }, |
| resetForm() { |
| this.newRecord = { |
| type: 'expense', |
| category: '餐饮', |
| amount: '', |
| date: new Date().toISOString().split('T')[0], |
| note: '' |
| }; |
| this.editingRecord = null; |
| }, |
| formatCurrency(value) { |
| return value.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,'); |
| }, |
| formatDate(dateStr) { |
| const date = new Date(dateStr); |
| return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`; |
| }, |
| getCategoryIcon(categoryName) { |
| const category = this.allCategories.find(c => c.name === categoryName); |
| return category ? category.icon : 'fas fa-question-circle'; |
| }, |
| getCategoryColor(categoryName) { |
| const category = this.allCategories.find(c => c.name === categoryName); |
| return category ? category.color : 'bg-gray-100 text-gray-600'; |
| }, |
| updateChart() { |
| if (!window.Chart) { |
| |
| setTimeout(this.updateChart, 100); |
| return; |
| } |
| |
| if (this.chart) { |
| this.chart.destroy(); |
| } |
| |
| const ctx = document.getElementById('financeChart').getContext('2d'); |
| |
| if (this.chartType === 'bar') { |
| |
| const last7Days = this.getLastNDays(7); |
| const incomeData = last7Days.map(day => { |
| return this.records |
| .filter(r => r.type === 'income' && r.date === day) |
| .reduce((sum, r) => sum + r.amount, 0); |
| }); |
| const expenseData = last7Days.map(day => { |
| return this.records |
| .filter(r => r.type === 'expense' && r.date === day) |
| .reduce((sum, r) => sum + r.amount, 0); |
| }); |
| |
| this.chart = new window.Chart(ctx, { |
| type: 'bar', |
| data: { |
| labels: last7Days.map(day => day.split('-').slice(1).join('-')), |
| datasets: [ |
| { |
| label: '收入', |
| data: incomeData, |
| backgroundColor: 'rgba(16, 185, 129, 0.7)', |
| borderColor: 'rgba(16, 185, 129, 1)', |
| borderWidth: 1 |
| }, |
| { |
| label: '支出', |
| data: expenseData, |
| backgroundColor: 'rgba(239, 68, 68, 0.7)', |
| borderColor: 'rgba(239, 68, 68, 1)', |
| borderWidth: 1 |
| } |
| ] |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| scales: { |
| y: { |
| beginAtZero: true, |
| ticks: { |
| callback: value => '¥' + value |
| } |
| } |
| }, |
| plugins: { |
| tooltip: { |
| callbacks: { |
| label: context => { |
| return context.dataset.label + ': ¥' + context.raw.toFixed(2); |
| } |
| } |
| } |
| } |
| } |
| }); |
| } else { |
| |
| const data = this.categorizedExpenses |
| .filter(cat => cat.amount > 0) |
| .map(cat => ({ |
| name: cat.name, |
| value: cat.amount, |
| color: cat.color.split(' ')[1].replace('text-', '') |
| })); |
| |
| this.chart = new window |
| </html> |