From 70715bb0c8ea53c13e916af52371efabe1956ad3 Mon Sep 17 00:00:00 2001 From: ni ziyi <310925901@qq.com> Date: Thu, 25 Dec 2025 14:55:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=96=B0=E5=A2=9E):=20=E6=96=B0=E5=A2=9Eo?= =?UTF-8?q?cr=E8=8E=B7=E5=8F=96=E4=BF=A1=E6=81=AF=E5=90=8E=EF=BC=8C?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E7=9B=B4=E6=8E=A5=E5=85=A5=E5=BA=93=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/bill.js | 16 +- api/budget.js | 43 ++ components/BudgetPieChart/BudgetPieChart.vue | 193 +++++++ package-lock.json | 514 +------------------ package.json | 3 +- pages.json | 6 + pages/add/add.vue | 72 ++- pages/budget/budget.vue | 301 +++++++++++ pages/index/index.vue | 149 +++++- store/app.js | 89 ++++ utils/api-adapter.js | 396 ++++++++++++++ utils/db.js | 325 ++++++++++++ utils/sync.js | 373 ++++++++++++++ 13 files changed, 1962 insertions(+), 518 deletions(-) create mode 100644 api/budget.js create mode 100644 components/BudgetPieChart/BudgetPieChart.vue create mode 100644 pages/budget/budget.vue create mode 100644 store/app.js create mode 100644 utils/api-adapter.js create mode 100644 utils/db.js create mode 100644 utils/sync.js diff --git a/api/bill.js b/api/bill.js index 3886be7..be95820 100644 --- a/api/bill.js +++ b/api/bill.js @@ -15,6 +15,11 @@ export function createBill(data) { return post('/bills', data) } +// 批量创建账单 +export function createBillsBatch(data) { + return post('/bills/batch', data) +} + // 更新账单 export function updateBill(id, data) { return put(`/bills/${id}`, data) @@ -23,13 +28,4 @@ export function updateBill(id, data) { // 删除账单 export function deleteBill(id) { return del(`/bills/${id}`) -} - - - - - - - - - +} \ No newline at end of file diff --git a/api/budget.js b/api/budget.js new file mode 100644 index 0000000..8c2a1f3 --- /dev/null +++ b/api/budget.js @@ -0,0 +1,43 @@ +import { get, put } from '../utils/request' + +// 获取当前月份的预算信息(包含统计数据) +export async function getBudget() { + try { + const response = await get('/budgets') + return response + } catch (error) { + console.error('获取预算信息失败:', error) + throw error + } +} + +// 获取上月预算结算信息 +export async function getBudgetSettlement() { + try { + const response = await get('/budgets/settlement') + return response + } catch (error) { + console.error('获取预算结算信息失败:', error) + throw error + } +} + +// 设置预算 +export async function setBudget(data) { + try { + const now = new Date() + const requestData = { + year: data.year || now.getFullYear(), + month: data.month || (now.getMonth() + 1), + amount: data.amount + } + + const response = await put('/budgets', requestData) + return response + } catch (error) { + console.error('设置预算失败:', error) + throw error + } +} + + diff --git a/components/BudgetPieChart/BudgetPieChart.vue b/components/BudgetPieChart/BudgetPieChart.vue new file mode 100644 index 0000000..2337d76 --- /dev/null +++ b/components/BudgetPieChart/BudgetPieChart.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/package-lock.json b/package-lock.json index 79bbeea..b3807c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,13 @@ "dependencies": { "@dcloudio/uni-app": "^2.0.2-4080720251210002", "@dcloudio/uni-h5": "^2.0.2-4080720251210002", + "echarts": "^6.0.0", "pinia": "^2.3.1", "vue": "^3.5.25" }, "devDependencies": { "@types/node": "^24.10.2", - "@vitejs/plugin-vue": "^4.0.0", - "typescript": "^5.2.2", - "vite": "^4.5.14" + "typescript": "^5.2.2" } }, "node_modules/@babel/helper-string-parser": { @@ -83,358 +82,6 @@ "safe-area-insets": "^1.4.1" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -449,19 +96,6 @@ "undici-types": "~7.16.0" } }, - "node_modules/@vitejs/plugin-vue": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz", - "integrity": "sha512-e0X4jErIxAB5oLtDqbHvHpJe/uWNkdpYV83AOG2xo2tEVSzCzewgJMtREZM30wXnM5ls90hxiOtAuVU6H5JgbA==", - "dev": true, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.0.0", - "vue": "^3.2.25" - } - }, "node_modules/@vue/compiler-core": { "version": "3.5.25", "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.25.tgz", @@ -571,6 +205,15 @@ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", @@ -582,62 +225,11 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/intersection-observer": { "version": "0.7.0", "resolved": "https://registry.npmmirror.com/intersection-observer/-/intersection-observer-0.7.0.tgz", @@ -749,22 +341,6 @@ "node": ">=16" } }, - "node_modules/rollup": { - "version": "3.29.5", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-3.29.5.tgz", - "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", - "dev": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/safe-area-insets": { "version": "1.4.1", "resolved": "https://registry.npmmirror.com/safe-area-insets/-/safe-area-insets-1.4.1.tgz", @@ -778,6 +354,11 @@ "node": ">=0.10.0" } }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -802,61 +383,6 @@ "resolved": "https://registry.npmmirror.com/urijs/-/urijs-1.19.11.tgz", "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" }, - "node_modules/vite": { - "version": "4.5.14", - "resolved": "https://registry.npmmirror.com/vite/-/vite-4.5.14.tgz", - "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", - "dev": true, - "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - }, - "peerDependencies": { - "@types/node": ">= 14", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, "node_modules/vue": { "version": "3.5.25", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz", @@ -901,6 +427,14 @@ "optional": true } } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "dependencies": { + "tslib": "2.3.0" + } } } } diff --git a/package.json b/package.json index c8d1a05..5541b2b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@dcloudio/uni-app": "^2.0.2-4080720251210002", "@dcloudio/uni-h5": "^2.0.2-4080720251210002", + "echarts": "^6.0.0", "pinia": "^2.3.1", "vue": "^3.5.25" }, @@ -18,4 +19,4 @@ "@types/node": "^24.10.2", "typescript": "^5.2.2" } -} \ No newline at end of file +} diff --git a/pages.json b/pages.json index 8356fb4..79340c7 100644 --- a/pages.json +++ b/pages.json @@ -43,6 +43,12 @@ "style": { "navigationBarTitleText": "账户管理" } + }, + { + "path": "pages/budget/budget", + "style": { + "navigationBarTitleText": "预算管理" + } } ], "tabBar": { diff --git a/pages/add/add.vue b/pages/add/add.vue index 0471079..7278bdd 100644 --- a/pages/add/add.vue +++ b/pages/add/add.vue @@ -110,6 +110,9 @@ + + + 识别到 {{ ocrResultList.length }} 条账单记录 {{ resultListExpanded ? '▼' : '▶' }} @@ -154,7 +157,7 @@ + + + + diff --git a/pages/index/index.vue b/pages/index/index.vue index 97a336a..811970c 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -3,23 +3,51 @@ 我的账单 - - - - 本月支出 - ¥{{ monthlyExpense.toFixed(2) }} - + + + 本月收入 ¥{{ monthlyIncome.toFixed(2) }} + + 本月支出 + ¥{{ budget.usedAmount ? budget.usedAmount.toFixed(2) : '0.00' }} + + + + + + + + + + + 本月预算 + ¥{{ budget.amount ? budget.amount.toFixed(2) : '0.00' }} + + + 每日预算 + ¥{{ budget.remainingDaily ? budget.remainingDaily.toFixed(2) : '0.00' }} + + - + {{ date }} @@ -45,7 +73,7 @@ 加载中... - + @@ -53,13 +81,21 @@ import { ref, computed, onMounted } from 'vue' import { getBills } from '../../api/bill' import { getMonthlyStatistics } from '../../api/statistics' +import { getBudget } from '../../api/budget' import { formatDate } from '../../utils/date' import { useUserStore } from '../../store/user' +import BudgetPieChart from '../../components/BudgetPieChart/BudgetPieChart.vue' const userStore = useUserStore() const bills = ref([]) const monthlyIncome = ref(0) const monthlyExpense = ref(0) +const budget = ref({ + amount: 0, + usedAmount: 0, + remainingAmount: 0, + remainingDaily: 0 +}) const loading = ref(false) const groupedBills = computed(() => { @@ -108,6 +144,22 @@ const loadMonthlyStatistics = async () => { } } +const loadBudget = async () => { + try { + const data = await getBudget() + if (data) { + budget.value = { + amount: data.amount || 0, + usedAmount: data.usedAmount || 0, + remainingAmount: data.remainingAmount || 0, + remainingDaily: data.remainingDaily || 0 + } + } + } catch (error) { + console.error('加载预算失败', error) + } +} + const viewBill = (bill) => { // 跳转到账单编辑页面 uni.navigateTo({ @@ -122,9 +174,17 @@ const goToAccount = () => { }) } +const goToBudget = () => { + // 跳转到预算管理页面 + uni.navigateTo({ + url: '/pages/budget/budget' + }) +} + const onPullDownRefresh = () => { loadBills() loadMonthlyStatistics() + loadBudget() setTimeout(() => { uni.stopPullDownRefresh() }, 1000) @@ -138,15 +198,17 @@ onMounted(() => { }) return } - + loadBills() loadMonthlyStatistics() + loadBudget() }) // 监听刷新事件 uni.$on('refreshBills', () => { loadBills() loadMonthlyStatistics() + loadBudget() }) @@ -193,7 +255,12 @@ export default { display: block; } -.account-btn { +.header-actions { + display: flex; + gap: 15rpx; +} + +.action-btn { width: 60rpx; height: 60rpx; display: flex; @@ -205,22 +272,24 @@ export default { transition: all 0.3s; } -.account-btn:active { +.action-btn:active { background: rgba(255, 255, 255, 0.3); transform: scale(0.95); } -.account-icon { +.action-icon { font-size: 36rpx; } -.summary { +.summary-row { display: flex; justify-content: space-around; + margin-bottom: 20rpx; } .summary-item { text-align: center; + flex: 1; } .label { @@ -236,8 +305,60 @@ export default { font-weight: bold; } +.amount.income { + color: #51cf66; +} + +.amount.expense { + color: #ff6b6b; +} + +.budget-section { + background: rgba(255, 255, 255, 0.15); + border-radius: 12rpx; + padding: 30rpx 20rpx 20rpx; + backdrop-filter: blur(10px); +} + +.budget-chart-wrapper { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 25rpx; +} + +.budget-stats-row { + display: flex; + justify-content: space-around; + gap: 20rpx; +} + +.budget-stat-item { + flex: 1; + text-align: center; + background: rgba(255, 255, 255, 0.1); + padding: 20rpx 15rpx; + border-radius: 10rpx; +} + +.stat-label { + display: block; + font-size: 22rpx; + opacity: 0.85; + margin-bottom: 10rpx; + color: #fff; +} + +.stat-value { + display: block; + font-size: 30rpx; + font-weight: bold; + color: #fff; +} + .bill-list { padding: 20rpx; + height: calc(100vh - 520rpx - env(safe-area-inset-bottom)); } .bill-group { diff --git a/store/app.js b/store/app.js new file mode 100644 index 0000000..1c34cbf --- /dev/null +++ b/store/app.js @@ -0,0 +1,89 @@ +// 应用状态管理 +import { defineStore } from 'pinia' + +export const useAppStore = defineStore('app', { + state: () => ({ + // 登录状态 + isLoggedIn: !!uni.getStorageSync('token'), + + // 连接状态 + isOnline: true, + + // 同步状态 + syncStatus: 'synced', // synced, syncing, syncFailed + + // 离线模式 + isOfflineMode: false, + + // 最后同步时间 + lastSyncTime: uni.getStorageSync('lastSyncTime') || null + }), + + getters: { + // 检查是否为离线用户 + isOfflineUser: (state) => { + const userInfo = uni.getStorageSync('userInfo') || {} + return userInfo.isOffline === true || userInfo.userId === 'local_user' + }, + + // 检查OCR功能是否可用 + isOcrAvailable: (state) => { + return !state.isOfflineMode && state.isOnline + } + }, + + actions: { + // 设置登录状态 + setLoggedIn(status) { + this.isLoggedIn = status + }, + + // 设置连接状态 + setOnline(status) { + this.isOnline = status + }, + + // 设置同步状态 + setSyncStatus(status) { + this.syncStatus = status + }, + + // 设置离线模式 + setOfflineMode(status) { + this.isOfflineMode = status + }, + + // 设置最后同步时间 + setLastSyncTime(time) { + this.lastSyncTime = time + uni.setStorageSync('lastSyncTime', time) + }, + + // 初始化应用状态 + initAppStatus() { + // 检查登录状态 + this.isLoggedIn = !!uni.getStorageSync('token') + + // 检查是否为离线用户 + const userInfo = uni.getStorageSync('userInfo') || {} + this.isOfflineMode = userInfo.isOffline === true || userInfo.userId === 'local_user' + + // 检查网络状态 + uni.getNetworkType({ + success: (res) => { + this.isOnline = res.networkType !== 'none' + }, + fail: () => { + this.isOnline = false + } + }) + }, + + // 监听网络状态变化 + watchNetworkStatus() { + uni.onNetworkStatusChange((res) => { + this.isOnline = res.isConnected + }) + } + } +}) diff --git a/utils/api-adapter.js b/utils/api-adapter.js new file mode 100644 index 0000000..250e111 --- /dev/null +++ b/utils/api-adapter.js @@ -0,0 +1,396 @@ +// API适配层,用于路由请求到在线API或本地数据库 + +import { db, createOfflineUser } from './db' +import * as onlineAuthApi from '../api/auth' +import * as onlineBillApi from '../api/bill' +import * as onlineCategoryApi from '../api/category' +import * as onlineAccountApi from '../api/account' +import * as onlineBudgetApi from '../api/budget' +import * as onlineOcrApi from '../api/ocr' + +// 检查用户是否为离线用户 +function isOfflineUser() { + const userInfo = uni.getStorageSync('userInfo') || {} + return userInfo.isOffline === true || userInfo.userId === 'local_user' +} + +// 获取当前用户ID +function getCurrentUserId() { + const userInfo = uni.getStorageSync('userInfo') || {} + return userInfo.userId || 'local_user' +} + +// API适配层实现 +const apiAdapter = { + // 认证相关API + auth: { + // 登录 + login: async (data) => { + return onlineAuthApi.login(data) + }, + + // 注册 + register: async (data) => { + return onlineAuthApi.register(data) + }, + + // 离线登录 + offlineLogin: async () => { + try { + // 创建或获取离线用户 + const userId = await createOfflineUser() + + // 设置用户信息 + const userInfo = { + userId, + username: '离线用户', + nickname: '离线用户', + isOffline: true + } + + // 保存到本地存储 + uni.setStorageSync('userInfo', userInfo) + uni.setStorageSync('token', 'offline_token') + + return { + token: 'offline_token', + username: '离线用户', + nickname: '离线用户', + userId + } + } catch (error) { + console.error('离线登录失败:', error) + throw error + } + } + }, + + // 账单相关API + bill: { + // 获取账单列表 + getBills: async (params) => { + if (isOfflineUser()) { + // 离线模式:从本地数据库获取 + const userId = getCurrentUserId() + const sql = `SELECT * FROM bill WHERE user_id = ? ORDER BY bill_date DESC` + return db.query(sql, [userId]) + } else { + // 在线模式:从服务器获取 + return onlineBillApi.getBills(params) + } + }, + + // 创建账单 + createBill: async (data) => { + if (isOfflineUser()) { + // 离线模式:保存到本地数据库 + const userId = getCurrentUserId() + const sql = `INSERT INTO bill (user_id, category_id, amount, description, bill_date, type) + VALUES (?, ?, ?, ?, ?, ?)` + const result = await db.update(sql, [ + userId, + data.categoryId, + data.amount, + data.description || '', + data.billDate, + data.type + ]) + return { success: true, affectedRows: result } + } else { + // 在线模式:保存到服务器 + return onlineBillApi.createBill(data) + } + }, + + // 更新账单 + updateBill: async (id, data) => { + if (isOfflineUser()) { + // 离线模式:更新本地数据库 + const sql = `UPDATE bill SET category_id = ?, amount = ?, description = ?, bill_date = ?, type = ?, is_synced = 0 + WHERE id = ?` + const result = await db.update(sql, [ + data.categoryId, + data.amount, + data.description || '', + data.billDate, + data.type, + id + ]) + return { success: true, affectedRows: result } + } else { + // 在线模式:更新服务器 + return onlineBillApi.updateBill(id, data) + } + }, + + // 删除账单 + deleteBill: async (id) => { + if (isOfflineUser()) { + // 离线模式:从本地数据库删除 + const sql = `DELETE FROM bill WHERE id = ?` + const result = await db.update(sql, [id]) + return { success: true, affectedRows: result } + } else { + // 在线模式:从服务器删除 + return onlineBillApi.deleteBill(id) + } + } + }, + + // 分类相关API + category: { + // 获取分类列表 + getCategories: async (params) => { + if (isOfflineUser()) { + // 离线模式:从本地数据库获取 + const userId = getCurrentUserId() + const type = params.type || '' + let sql = `SELECT * FROM category WHERE user_id IS NULL OR user_id = ?` + const sqlParams = [userId] + + if (type) { + sql += ` AND type = ?` + sqlParams.push(type) + } + + sql += ` ORDER BY type, sort_order ASC` + return db.query(sql, sqlParams) + } else { + // 在线模式:从服务器获取 + return onlineCategoryApi.getCategories(params) + } + }, + + // 创建分类 + createCategory: async (data) => { + if (isOfflineUser()) { + // 离线模式:保存到本地数据库 + const userId = getCurrentUserId() + const sql = `INSERT INTO category (user_id, name, icon, type, sort_order) + VALUES (?, ?, ?, ?, ?)` + const result = await db.update(sql, [ + userId, + data.name, + data.icon || '📦', + data.type, + data.sortOrder || 0 + ]) + return { success: true, affectedRows: result } + } else { + // 在线模式:保存到服务器 + return onlineCategoryApi.createCategory(data) + } + }, + + // 更新分类 + updateCategory: async (id, data) => { + if (isOfflineUser()) { + // 离线模式:更新本地数据库 + const sql = `UPDATE category SET name = ?, icon = ?, type = ?, sort_order = ? + WHERE id = ?` + const result = await db.update(sql, [ + data.name, + data.icon, + data.type, + data.sortOrder || 0, + id + ]) + return { success: true, affectedRows: result } + } else { + // 在线模式:更新服务器 + return onlineCategoryApi.updateCategory(id, data) + } + }, + + // 删除分类 + deleteCategory: async (id) => { + if (isOfflineUser()) { + // 离线模式:从本地数据库删除 + const sql = `DELETE FROM category WHERE id = ? AND user_id = ?` + const result = await db.update(sql, [id, getCurrentUserId()]) + return { success: true, affectedRows: result } + } else { + // 在线模式:从服务器删除 + return onlineCategoryApi.deleteCategory(id) + } + } + }, + + // 账户相关API + account: { + // 获取账户信息 + getAccount: async (params) => { + if (isOfflineUser()) { + // 离线模式:从本地数据库获取 + const userId = getCurrentUserId() + const sql = `SELECT * FROM account WHERE user_id = ?` + const accounts = await db.query(sql, [userId]) + + if (accounts.length > 0) { + // 获取收支统计 + const incomeSql = `SELECT SUM(amount) as totalIncome FROM bill WHERE user_id = ? AND type = 2` + const expenseSql = `SELECT SUM(amount) as totalExpense FROM bill WHERE user_id = ? AND type = 1` + + const incomeResult = await db.query(incomeSql, [userId]) + const expenseResult = await db.query(expenseSql, [userId]) + + const totalIncome = incomeResult[0].totalIncome || 0 + const totalExpense = expenseResult[0].totalExpense || 0 + const initialBalance = accounts[0].initial_balance || 0 + const balance = initialBalance + totalIncome - totalExpense + + return { + ...accounts[0], + totalIncome, + totalExpense, + balance + } + } + + return null + } else { + // 在线模式:从服务器获取 + return onlineAccountApi.getAccount(params) + } + }, + + // 更新账户信息 + updateAccount: async (data) => { + if (isOfflineUser()) { + // 离线模式:更新本地数据库 + const userId = getCurrentUserId() + const sql = `UPDATE account SET name = ?, initial_balance = ? WHERE user_id = ?` + const result = await db.update(sql, [ + data.name, + data.initialBalance || 0, + userId + ]) + + // 返回更新后的账户信息 + return apiAdapter.account.getAccount() + } else { + // 在线模式:更新服务器 + return onlineAccountApi.updateAccount(data) + } + } + }, + + // 预算相关API + budget: { + // 获取预算信息 + getBudget: async (params) => { + if (isOfflineUser()) { + // 离线模式:从本地数据库获取 + const userId = getCurrentUserId() + const now = new Date() + const year = params.year || now.getFullYear() + const month = params.month || (now.getMonth() + 1) + + const sql = `SELECT * FROM budget WHERE user_id = ? AND year = ? AND month = ?` + const budgets = await db.query(sql, [userId, year, month]) + + if (budgets.length > 0) { + return budgets[0] + } + + return { + userId, + year, + month, + amount: 0.00 + } + } else { + // 在线模式:从服务器获取 + return onlineBudgetApi.getBudget(params) + } + }, + + // 设置预算 + setBudget: async (data) => { + if (isOfflineUser()) { + // 离线模式:保存到本地数据库 + const userId = getCurrentUserId() + const year = data.year + const month = data.month + const amount = data.amount || 0 + + // 检查是否已存在该月份预算 + const checkSql = `SELECT * FROM budget WHERE user_id = ? AND year = ? AND month = ?` + const budgets = await db.query(checkSql, [userId, year, month]) + + let result + if (budgets.length > 0) { + // 更新现有预算 + const sql = `UPDATE budget SET amount = ? WHERE user_id = ? AND year = ? AND month = ?` + result = await db.update(sql, [amount, userId, year, month]) + } else { + // 创建新预算 + const sql = `INSERT INTO budget (user_id, year, month, amount) VALUES (?, ?, ?, ?)` + result = await db.update(sql, [userId, year, month, amount]) + } + + return { success: true, affectedRows: result } + } else { + // 在线模式:保存到服务器 + return onlineBudgetApi.setBudget(data) + } + }, + + // 获取预算结算信息 + getBudgetSettlement: async (params) => { + if (isOfflineUser()) { + // 离线模式:从本地数据库计算 + const userId = getCurrentUserId() + const now = new Date() + let year = now.getFullYear() + let month = now.getMonth() + + // 如果当前是1月,上月是去年12月 + if (month === 0) { + year-- + month = 12 + } + + // 获取上月预算 + const budgetSql = `SELECT * FROM budget WHERE user_id = ? AND year = ? AND month = ?` + const budgets = await db.query(budgetSql, [userId, year, month]) + const budgetAmount = budgets[0]?.amount || 0 + + // 计算上月总支出 + const expenseSql = `SELECT SUM(amount) as totalExpense FROM bill + WHERE user_id = ? AND type = 1 + AND bill_date BETWEEN ? AND ?` + const startDate = `${year}-${month.toString().padStart(2, '0')}-01` + const endDate = new Date(year, month, 0).toISOString().split('T')[0] + + const expenseResult = await db.query(expenseSql, [userId, startDate, endDate]) + const totalExpense = expenseResult[0].totalExpense || 0 + + return { + year, + month, + budgetAmount, + totalExpense, + remainingAmount: budgetAmount - totalExpense + } + } else { + // 在线模式:从服务器获取 + return onlineBudgetApi.getBudgetSettlement(params) + } + } + }, + + // OCR相关API + ocr: { + // 识别图片 + recognizeImage: async (filePath) => { + // OCR功能仅在线用户可用 + if (isOfflineUser()) { + throw new Error('OCR功能仅在线用户可用') + } + return onlineOcrApi.recognizeImage(filePath) + } + } +} + +export default apiAdapter diff --git a/utils/db.js b/utils/db.js new file mode 100644 index 0000000..0dadcb5 --- /dev/null +++ b/utils/db.js @@ -0,0 +1,325 @@ +// 本地数据库管理模块 + +const DB_NAME = 'accounting.db' +const DB_VERSION = 1 +let database = null + +// 跨平台数据库兼容性处理 +function openDatabaseSync(name, version, displayName, estimatedSize) { + // #ifdef APP-PLUS + // App环境使用plus.sqlite + return plus.sqlite.openSQLiteSync({ + name: name, + path: `_doc/${name}` + }) + // #endif + + // #ifdef H5 + // H5环境使用Web SQL(如果支持) + if (window.openDatabase) { + return window.openDatabase(name, version, displayName, estimatedSize) + } else { + throw new Error('浏览器不支持Web SQL数据库,请使用支持的浏览器或App版本') + } + // #endif + + // #ifdef MP + // 小程序环境暂不支持本地SQLite数据库 + throw new Error('小程序环境暂不支持离线数据库功能') + // #endif +} + +// 初始化数据库 +export async function initDatabase() { + return new Promise((resolve, reject) => { + try { + // 打开数据库 + database = openDatabaseSync(DB_NAME, DB_VERSION, '记账数据库', 10 * 1024 * 1024) + + // 创建表结构 + createTables().then(() => { + // 插入预设分类数据 + insertDefaultCategories().then(() => { + resolve(true) + }).catch(err => { + console.error('插入预设分类失败:', err) + reject(err) + }) + }).catch(err => { + console.error('创建表结构失败:', err) + reject(err) + }) + } catch (error) { + console.error('初始化数据库失败:', error) + reject(error) + } + }) +} + +// 创建表结构 +async function createTables() { + return new Promise((resolve, reject) => { + try { + const createTableSqls = [ + // 用户表 + `CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + username TEXT, + nickname TEXT, + is_offline INTEGER DEFAULT 0, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // 分类表 + `CREATE TABLE IF NOT EXISTS category ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + name TEXT NOT NULL, + icon TEXT, + type INTEGER NOT NULL, + sort_order INTEGER DEFAULT 0, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // 账单表 + `CREATE TABLE IF NOT EXISTS bill ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + description TEXT, + bill_date DATE NOT NULL, + type INTEGER NOT NULL, + is_synced INTEGER DEFAULT 0, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // 账户表 + `CREATE TABLE IF NOT EXISTS account ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + initial_balance REAL NOT NULL DEFAULT 0.00, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, name) + )`, + + // 预算表 + `CREATE TABLE IF NOT EXISTS budget ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + amount REAL NOT NULL DEFAULT 0.00, + is_synced INTEGER DEFAULT 0, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, year, month) + )` + ] + + // 执行事务创建所有表 + database.transaction((tx) => { + for (const sql of createTableSqls) { + tx.executeSql(sql) + } + }) + + resolve(true) + } catch (error) { + console.error('创建表结构失败:', error) + reject(error) + } + }) +} + +// 插入预设分类数据 +async function insertDefaultCategories() { + return new Promise((resolve, reject) => { + try { + // 检查是否已存在预设分类 + const checkSql = 'SELECT COUNT(*) as count FROM category WHERE user_id IS NULL' + database.transaction((tx) => { + tx.executeSql(checkSql, [], (tx, result) => { + if (result.rows[0].count === 0) { + // 插入预设支出分类 + const expenseCategories = [ + { name: '餐饮', icon: '🍔', type: 1, sort_order: 1 }, + { name: '交通', icon: '🚗', type: 1, sort_order: 2 }, + { name: '购物', icon: '🛍️', type: 1, sort_order: 3 }, + { name: '娱乐', icon: '🎬', type: 1, sort_order: 4 }, + { name: '医疗', icon: '🏥', type: 1, sort_order: 5 }, + { name: '教育', icon: '📚', type: 1, sort_order: 6 }, + { name: '住房', icon: '🏠', type: 1, sort_order: 7 }, + { name: '水电', icon: '💡', type: 1, sort_order: 8 }, + { name: '通讯', icon: '📱', type: 1, sort_order: 9 }, + { name: '其他', icon: '📦', type: 1, sort_order: 10 } + ] + + // 插入预设收入分类 + const incomeCategories = [ + { name: '工资', icon: '💰', type: 2, sort_order: 1 }, + { name: '奖金', icon: '🎁', type: 2, sort_order: 2 }, + { name: '投资', icon: '📈', type: 2, sort_order: 3 }, + { name: '兼职', icon: '💼', type: 2, sort_order: 4 }, + { name: '其他', icon: '📦', type: 2, sort_order: 5 } + ] + + // 执行事务插入所有预设分类 + database.transaction((tx) => { + for (const cat of expenseCategories) { + const sql = `INSERT INTO category (user_id, name, icon, type, sort_order) VALUES (NULL, ?, ?, ?, ?)` + tx.executeSql(sql, [cat.name, cat.icon, cat.type, cat.sort_order]) + } + + for (const cat of incomeCategories) { + const sql = `INSERT INTO category (user_id, name, icon, type, sort_order) VALUES (NULL, ?, ?, ?, ?)` + tx.executeSql(sql, [cat.name, cat.icon, cat.type, cat.sort_order]) + } + }) + } + resolve(true) + }, (tx, error) => { + console.error('检查预设分类失败:', error) + reject(error) + }) + }) + } catch (error) { + console.error('插入预设分类失败:', error) + reject(error) + } + }) +} + +// 创建离线用户 +export async function createOfflineUser() { + return new Promise((resolve, reject) => { + try { + // 检查数据库是否已初始化 + if (!database) { + console.error('数据库未初始化,正在初始化...') + initDatabase().then(() => { + // 数据库初始化成功后,递归调用自己 + createOfflineUser().then(resolve).catch(reject) + }).catch(err => { + console.error('数据库初始化失败:', err) + reject(new Error('数据库初始化失败: ' + err.message)) + }) + return + } + + const userId = 'local_user' + const username = '离线用户' + const nickname = '离线用户' + + // 检查是否已存在离线用户 + const checkSql = 'SELECT * FROM user WHERE id = ?' + database.transaction((tx) => { + tx.executeSql(checkSql, [userId], (tx, result) => { + if (result.rows.length === 0) { + // 创建离线用户 + const insertSql = `INSERT INTO user (id, username, nickname, is_offline) VALUES (?, ?, ?, 1)` + tx.executeSql(insertSql, [userId, username, nickname], (tx, result) => { + // 创建默认账户 + const accountSql = `INSERT INTO account (user_id, name, initial_balance) VALUES (?, '默认账户', 0.00)` + tx.executeSql(accountSql, [userId], (tx, result) => { + resolve(userId) + }, (tx, error) => { + console.error('创建默认账户失败:', error) + reject(error) + }) + }, (tx, error) => { + console.error('创建离线用户失败:', error) + reject(error) + }) + } else { + resolve(userId) + } + }, (tx, error) => { + console.error('检查离线用户失败:', error) + reject(error) + }) + }) + } catch (error) { + console.error('创建离线用户失败:', error) + reject(error) + } + }) +} + +// 数据库操作封装 +const db = { + // 执行查询 + query: (sql, params = []) => { + return new Promise((resolve, reject) => { + try { + database.transaction((tx) => { + tx.executeSql(sql, params, (tx, result) => { + const rows = [] + for (let i = 0; i < result.rows.length; i++) { + rows.push(result.rows[i]) + } + resolve(rows) + }, (tx, error) => { + console.error('查询失败:', error) + reject(error) + }) + }) + } catch (error) { + console.error('查询失败:', error) + reject(error) + } + }) + }, + + // 执行更新(INSERT, UPDATE, DELETE) + update: (sql, params = []) => { + return new Promise((resolve, reject) => { + try { + database.transaction((tx) => { + tx.executeSql(sql, params, (tx, result) => { + resolve(result.rowsAffected) + }, (tx, error) => { + console.error('更新失败:', error) + reject(error) + }) + }) + } catch (error) { + console.error('更新失败:', error) + reject(error) + } + }) + }, + + // 执行事务 + transaction: (callback) => { + return new Promise((resolve, reject) => { + try { + database.transaction((tx) => { + callback(tx) + resolve(true) + }, (error) => { + console.error('事务执行失败:', error) + reject(error) + }) + } catch (error) { + console.error('事务执行失败:', error) + reject(error) + } + }) + }, + + // 获取数据库实例 + getInstance: () => { + if (!database) { + throw new Error('数据库未初始化') + } + return database + } +} + +export { db } diff --git a/utils/sync.js b/utils/sync.js new file mode 100644 index 0000000..af82188 --- /dev/null +++ b/utils/sync.js @@ -0,0 +1,373 @@ +// 数据同步功能实现 + +import { db } from './db' +import * as onlineBillApi from '../api/bill' +import * as onlineCategoryApi from '../api/category' +import * as onlineAccountApi from '../api/account' +import * as onlineBudgetApi from '../api/budget' + +// 获取当前用户ID +function getCurrentUserId() { + const userInfo = uni.getStorageSync('userInfo') || {} + return userInfo.userId || 'local_user' +} + +// 检查是否为离线用户 +function isOfflineUser() { + const userInfo = uni.getStorageSync('userInfo') || {} + return userInfo.isOffline === true || userInfo.userId === 'local_user' +} + +// 账单同步逻辑 +const syncBills = async () => { + if (isOfflineUser()) { + throw new Error('离线用户无法同步数据') + } + + const userId = getCurrentUserId() + + try { + // 1. 获取本地未同步的账单 + const localBillsSql = `SELECT * FROM bill WHERE user_id = ? AND is_synced = 0` + const localBills = await db.query(localBillsSql, [userId]) + + // 2. 获取服务器端账单 + const serverBills = await onlineBillApi.getBills({ userId }) + + // 3. 比较并同步数据 + for (const localBill of localBills) { + // 检查服务器是否已有相同账单 + const existingBill = serverBills.find(bill => { + return bill.amount === localBill.amount && + bill.type === localBill.type && + bill.bill_date === localBill.bill_date && + bill.description === localBill.description && + bill.category_id === localBill.category_id + }) + + if (existingBill) { + // 已有相同账单,标记为已同步 + const updateSql = `UPDATE bill SET is_synced = 1 WHERE id = ?` + await db.update(updateSql, [localBill.id]) + } else { + // 没有相同账单,上传到服务器 + await onlineBillApi.createBill({ + categoryId: localBill.category_id, + amount: localBill.amount, + description: localBill.description, + billDate: localBill.bill_date, + type: localBill.type + }) + + // 标记为已同步 + const updateSql = `UPDATE bill SET is_synced = 1 WHERE id = ?` + await db.update(updateSql, [localBill.id]) + } + } + + // 4. 将服务器端数据同步到本地 + for (const serverBill of serverBills) { + // 检查本地是否已有相同账单 + const checkSql = `SELECT * FROM bill WHERE user_id = ? AND amount = ? AND type = ? AND bill_date = ? AND description = ? AND category_id = ?` + const existingBills = await db.query(checkSql, [ + userId, + serverBill.amount, + serverBill.type, + serverBill.bill_date, + serverBill.description, + serverBill.category_id + ]) + + if (existingBills.length === 0) { + // 本地没有相同账单,添加到本地 + const insertSql = `INSERT INTO bill (user_id, category_id, amount, description, bill_date, type, is_synced) + VALUES (?, ?, ?, ?, ?, ?, 1)` + await db.update(insertSql, [ + userId, + serverBill.category_id, + serverBill.amount, + serverBill.description, + serverBill.bill_date, + serverBill.type + ]) + } + } + + return { success: true, message: '账单同步成功', syncedCount: localBills.length } + } catch (error) { + console.error('同步账单失败:', error) + throw error + } +} + +// 分类同步逻辑 +const syncCategories = async () => { + if (isOfflineUser()) { + throw new Error('离线用户无法同步数据') + } + + const userId = getCurrentUserId() + + try { + // 1. 获取本地用户自定义分类 + const localCategoriesSql = `SELECT * FROM category WHERE user_id = ?` + const localCategories = await db.query(localCategoriesSql, [userId]) + + // 2. 获取服务器端分类 + const serverCategories = await onlineCategoryApi.getCategories({ userId }) + + // 3. 比较并同步数据 + for (const localCategory of localCategories) { + // 检查服务器是否已有相同分类 + const existingCategory = serverCategories.find(category => { + return category.name === localCategory.name && + category.type === localCategory.type + }) + + if (!existingCategory) { + // 没有相同分类,上传到服务器 + await onlineCategoryApi.createCategory({ + name: localCategory.name, + icon: localCategory.icon, + type: localCategory.type, + sortOrder: localCategory.sort_order + }) + } + } + + // 4. 将服务器端分类同步到本地 + for (const serverCategory of serverCategories) { + // 检查本地是否已有相同分类 + const checkSql = `SELECT * FROM category WHERE user_id = ? AND name = ? AND type = ?` + const existingCategories = await db.query(checkSql, [ + userId, + serverCategory.name, + serverCategory.type + ]) + + if (existingCategories.length === 0) { + // 本地没有相同分类,添加到本地 + const insertSql = `INSERT INTO category (user_id, name, icon, type, sort_order) + VALUES (?, ?, ?, ?, ?)` + await db.update(insertSql, [ + userId, + serverCategory.name, + serverCategory.icon, + serverCategory.type, + serverCategory.sort_order + ]) + } + } + + return { success: true, message: '分类同步成功' } + } catch (error) { + console.error('同步分类失败:', error) + throw error + } +} + +// 账户同步逻辑 +const syncAccount = async () => { + if (isOfflineUser()) { + throw new Error('离线用户无法同步数据') + } + + const userId = getCurrentUserId() + + try { + // 1. 获取本地账户信息 + const localAccountSql = `SELECT * FROM account WHERE user_id = ?` + const localAccounts = await db.query(localAccountSql, [userId]) + + if (localAccounts.length === 0) { + return { success: true, message: '本地没有账户信息需要同步' } + } + + const localAccount = localAccounts[0] + + // 2. 获取服务器端账户信息 + const serverAccount = await onlineAccountApi.getAccount({ userId }) + + // 3. 比较并同步数据 + if (serverAccount) { + // 服务器有账户信息,比较并更新 + if (localAccount.name !== serverAccount.name || localAccount.initial_balance !== serverAccount.initial_balance) { + // 本地与服务器信息不一致,更新服务器端 + await onlineAccountApi.updateAccount({ + name: localAccount.name, + initialBalance: localAccount.initial_balance + }) + } + } else { + // 服务器没有账户信息,创建新账户 + await onlineAccountApi.updateAccount({ + name: localAccount.name, + initialBalance: localAccount.initial_balance + }) + } + + // 4. 将服务器端账户信息同步到本地 + const updatedServerAccount = await onlineAccountApi.getAccount({ userId }) + const updateSql = `UPDATE account SET name = ?, initial_balance = ? WHERE user_id = ?` + await db.update(updateSql, [ + updatedServerAccount.name, + updatedServerAccount.initial_balance, + userId + ]) + + return { success: true, message: '账户同步成功' } + } catch (error) { + console.error('同步账户失败:', error) + throw error + } +} + +// 预算同步逻辑 +const syncBudgets = async () => { + if (isOfflineUser()) { + throw new Error('离线用户无法同步数据') + } + + const userId = getCurrentUserId() + + try { + // 1. 获取本地预算 + const localBudgetsSql = `SELECT * FROM budget WHERE user_id = ?` + const localBudgets = await db.query(localBudgetsSql, [userId]) + + // 2. 获取服务器端预算 + const serverBudget = await onlineBudgetApi.getBudget({ userId }) + + // 3. 比较并同步数据 + for (const localBudget of localBudgets) { + // 检查服务器是否已有相同月份预算 + const checkSql = `SELECT * FROM budget WHERE user_id = ? AND year = ? AND month = ?` + const existingBudgets = await db.query(checkSql, [ + userId, + localBudget.year, + localBudget.month + ]) + + if (existingBudgets.length === 0) { + // 没有相同月份预算,上传到服务器 + await onlineBudgetApi.setBudget({ + userId, + year: localBudget.year, + month: localBudget.month, + amount: localBudget.amount + }) + } else if (existingBudgets[0].amount !== localBudget.amount) { + // 预算金额不一致,更新服务器 + await onlineBudgetApi.setBudget({ + userId, + year: localBudget.year, + month: localBudget.month, + amount: localBudget.amount + }) + } + } + + // 4. 将服务器端预算同步到本地 + if (serverBudget) { + // 检查本地是否已有相同月份预算 + const checkSql = `SELECT * FROM budget WHERE user_id = ? AND year = ? AND month = ?` + const existingBudgets = await db.query(checkSql, [ + userId, + serverBudget.year, + serverBudget.month + ]) + + if (existingBudgets.length === 0) { + // 本地没有相同月份预算,添加到本地 + const insertSql = `INSERT INTO budget (user_id, year, month, amount, is_synced) + VALUES (?, ?, ?, ?, 1)` + await db.update(insertSql, [ + userId, + serverBudget.year, + serverBudget.month, + serverBudget.amount + ]) + } else if (existingBudgets[0].amount !== serverBudget.amount) { + // 预算金额不一致,更新本地 + const updateSql = `UPDATE budget SET amount = ?, is_synced = 1 WHERE user_id = ? AND year = ? AND month = ?` + await db.update(updateSql, [ + serverBudget.amount, + userId, + serverBudget.year, + serverBudget.month + ]) + } + } + + return { success: true, message: '预算同步成功' } + } catch (error) { + console.error('同步预算失败:', error) + throw error + } +} + +// 全量同步数据 +const syncAllData = async () => { + if (isOfflineUser()) { + throw new Error('离线用户无法同步数据') + } + + try { + // 1. 同步账单 + const billResult = await syncBills() + + // 2. 同步分类 + const categoryResult = await syncCategories() + + // 3. 同步账户 + const accountResult = await syncAccount() + + // 4. 同步预算 + const budgetResult = await syncBudgets() + + return { + success: true, + message: '数据同步成功', + details: { + bills: billResult, + categories: categoryResult, + account: accountResult, + budgets: budgetResult + } + } + } catch (error) { + console.error('全量同步失败:', error) + throw error + } +} + +// 数据同步模块导出 +export const sync = { + // 全量同步 + syncAll: syncAllData, + + // 账单同步 + syncBills, + + // 分类同步 + syncCategories, + + // 账户同步 + syncAccount, + + // 预算同步 + syncBudgets, + + // 检查同步状态 + checkSyncStatus: () => { + const userInfo = uni.getStorageSync('userInfo') || {} + const lastSyncTime = uni.getStorageSync('lastSyncTime') || null + + return { + isOffline: userInfo.isOffline === true || userInfo.userId === 'local_user', + lastSyncTime + } + } +} + +export default sync