diff --git a/.gitignore b/.gitignore index 691eb5d..b007628 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,9 @@ client/unpackage/ *.pem *.key +# Uploads (user-generated content) +server/uploads/ + # Playwright .playwright-mcp/ +server/backups/ diff --git a/CLAUDE.md b/CLAUDE.md index a517304..86eb176 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ - **运行时**: Node.js + Express - **数据库**: MySQL 8.0 (mysql2 连接池) - **认证**: HMAC-SHA256 签名 token,30 天过期 +- **文件上传**: multer (头像,最大 2MB) +- **备份**: Node.js mysql2 导出 (不依赖 mysqldump) - **CORS**: 已配置,支持 credentials ## Design System @@ -37,10 +39,17 @@ ### 关键原则 - 最小触摸目标 44×44px -- 禁止使用 emoji 作为图标,统一使用 SvgIcon 组件 +- 禁止使用 emoji 作为图标,统一使用 Icon 组件 - 尊重 prefers-reduced-motion - 文字对比度 ≥ 4.5:1 (WCAG AA) +### 小程序胶囊适配 +- 微信小程序右上角有胶囊按钮,页面顶部右侧元素(图标、按钮)需避开 +- 使用 `system.ts` 导出的 `capsuleRight` 计算安全距离 +- 导航栏添加动态 padding: `:style="{ paddingRight: capsuleRight + 'px' }"` +- 居中标题页面(统计、账单、我的)不需要处理 +- 有右侧元素的页面(首页 bell、编辑页保存按钮)必须处理 + ## Project Conventions ### 命名 @@ -58,41 +67,69 @@ client/src/ │ ├── add/ # 添加/编辑记录 │ ├── stats/ # 统计分析 │ ├── bills/ # 全部账单 (下拉刷新/上拉加载) -│ ├── profile/ # 个人设置 -│ └── category-manage/ # 分类管理 +│ ├── profile/ # 我的(身份切换入口) +│ ├── profile-edit/ # 编辑资料(昵称+头像) +│ ├── group-manage/ # 群组管理(创建/加入/退出/解散) +│ ├── category-manage/ # 分类管理 +│ └── budget/ # 预算设置 ├── components/ # 公共组件 │ ├── BudgetBar/ # 预算进度条 │ ├── CategoryIcon/ # 分类图标 (首字+颜色) +│ ├── Icon/ # 图标组件 (PNG 映射) │ ├── Numpad/ # 数字键盘 -│ ├── SvgIcon/ # SVG 图标 (base64 data URI) +│ ├── Skeleton/ # 骨架屏 │ └── TransactionItem/ # 交易列表项 ├── stores/ # Pinia stores -│ ├── transaction.ts # 交易 CRUD +│ ├── transaction.ts # 交易 CRUD (自动传递 group_id) │ ├── category.ts # 分类 CRUD │ ├── budget.ts # 预算管理 -│ └── stats.ts # 统计概览 +│ ├── stats.ts # 统计概览 (自动传递 group_id) +│ ├── user.ts # 用户信息 (昵称/头像) +│ └── group.ts # 群组管理 (身份切换) +├── api/ # API 封装 +│ ├── auth.ts # 登录接口 +│ ├── transaction.ts # 交易接口 +│ ├── stats.ts # 统计接口 +│ ├── user.ts # 用户信息接口 (含头像上传) +│ └── group.ts # 群组接口 ├── utils/ │ ├── format.ts # 金额/日期格式化 -│ ├── request.ts # HTTP 请求封装 (401 处理/自动重登录) -│ └── system.ts # 系统信息 (状态栏高度等) +│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试) +│ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight) +│ └── app-ready.ts # App 启动就绪机制 (waitForReady) +├── config.ts # 全局配置 (API_BASE) └── static/icons/ # tabBar 图标 (PNG) server/src/ -├── index.ts # Express 入口 (CORS, JSON 解析) -├── middleware/auth.ts # HMAC token 验证中间件 +├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册) +├── middleware/auth.ts # HMAC token 验证 (timingSafeEqual) ├── routes/ -│ ├── auth.ts # 登录 / token 签发 -│ ├── transaction.ts # 交易 CRUD (用户隔离) +│ ├── auth.ts # 登录 / token 签发 (返回用户信息) +│ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换) │ ├── category.ts # 分类 CRUD (默认+自定义) │ ├── budget.ts # 预算 CRUD (按月) -│ └── stats.ts # 统计查询 +│ ├── stats.ts # 统计查询 (支持 group_id) +│ ├── user.ts # 用户信息 + 头像上传 +│ ├── group.ts # 群组 CRUD (创建/加入/退出/解散) +│ └── backup.ts # 备份 API +├── utils/ +│ ├── backup.ts # Node.js 数据库备份 (gzip) +│ └── date.ts # 日期工具 └── db/ ├── connection.ts # MySQL 连接池 - ├── init.ts # 建表 + seed (multipleStatements) + ├── init.ts # 建表 + seed + 迁移 (幂等) ├── schema.sql # 表结构 └── seed.sql # 默认分类数据 ``` +### 数据库表 +- `users` — 用户 (openid, nickname, avatar_url) +- `categories` — 分类 (user_id=0 默认, 其他=自定义) +- `transactions` — 交易记录 (user_id, group_id, amount 分) +- `budgets` — 预算 (user_id, month, amount) +- `groups` — 群组 (name, invite_code, created_by) +- `group_members` — 群组成员 (group_id, user_id, role) + ### 金额处理 - 存储单位: 分 (整数,避免浮点精度问题) - 显示单位: 元 (保留两位小数) @@ -101,15 +138,28 @@ server/src/ - 使用 `formatAmount()` 带 ¥ 前缀,`formatAmountRaw()` 纯数字 ### 数据持久化 -- 前端本地存储 Key: `xc:token`, `xc:userId` +- 前端本地存储 Key: + - `xc:token` — 认证 token + - `xc:userId` — 用户 ID + - `xc:nickname` — 用户昵称 + - `xc:avatar_url` — 头像文件名 + - `xc:currentGroupId` — 当前选中群组 ID (null=个人模式) - 后端 MySQL 数据库 -- API 基础地址: `request.ts` 中的 `BASE_URL` +- API 基础地址: `config.ts` 中的 `API_BASE` ### 认证流程 -- H5: 页面加载时自动 login 获取 token +- H5: 页面加载时自动 demo-login 获取 token - 微信小程序: `App.vue` onLaunch 调用 `wx.login` 获取 code,换 token - Token: HMAC-SHA256 签名,30 天过期 -- 401 处理: 自动清除 token,小程序触发 reLogin,H5 刷新页面 +- 401 处理: 请求入队 → 自动重登录 → 队列中所有请求重试 +- 启动就绪: `app-ready.ts` 的 `waitForReady()` 确保页面在登录完成后才加载数据 + +### 一起记 (群组) +- 群组仅做数据聚合,记录始终属于记录者 (user_id) +- 个人视图: `WHERE user_id = 我` (所有记录) +- 群组视图: `WHERE group_id = X AND user_id IN (当前成员)` +- 退出群组: 该用户的 group_id 设为 NULL,记录回到个人视图 +- 身份切换: 「我的」页底部弹窗选择,全局生效 ### Git - 分支: `master` → `main` (注意远程) @@ -141,9 +191,10 @@ cd server && npm run db:init ## 部署 -- 服务器: `106.14.208.43:3000` +- 开发服务: `dev.xiaocai.j35.site:3000` +- 正式服务: `xiaocai.j35.site` - MySQL: 本地 3306 端口 -- 微信小程序上线需配置 HTTPS 域名 (替换 `request.ts` 中的 `BASE_URL`) +- 小程序开发版自动连接 dev 服务,正式版/H5 连接线上 (config.ts) ### 手动部署步骤 @@ -163,7 +214,7 @@ cd /var/www/xiaocai && npm install --production # 5. 重启服务 pm2 restart xiaocai-server # 或直接运行 -node dist/index.js +NODE_ENV=production node dist/index.js ``` ## 开发 diff --git a/DEV.md b/DEV.md index d9e0808..392ef01 100644 --- a/DEV.md +++ b/DEV.md @@ -26,10 +26,17 @@ - 不要为了注释而注释,避免冗余的废话(例如 `i++; // 自增 i`)。 - 多行 JSDoc/文档注释保持可读性,**不要压缩成单行**。 +## 小程序适配要点 + +- 页面顶部右侧有元素时,使用 `capsuleRight` 避开胶囊按钮 +- 新页面需要加载数据时,在 `onMounted` 中先 `await waitForReady()` 再请求 +- API 地址统一在 `config.ts` 中配置,不要在各文件中硬编码 +- 头像等用户文件 URL 只存文件名,前端拼接 `API_BASE + 路径 + 文件名` + ## Never 规则 - Never 修改 dist/ 目录 -- Never 使用内联样式,除非需要动态计算 +- Never 使用内联样式,除非需要动态计算(胶囊 padding 等除外) - Never 在渲染路径中执行耗时操作 - Never 在列表渲染中省略 key 属性 - Never 修改 lock 文件 @@ -39,4 +46,5 @@ ## Requirements -- 代码提交前检查代码,包括但不限于代码格式、注释、测试覆盖率等。 +- 代码提交前必须检查代码,包括但不限于代码格式、注释、测试覆盖率、文档更新等。 +- 影响到历史数据吧,必须要准备好数据迁移方案。 diff --git a/client/src/App.vue b/client/src/App.vue index 60fb3b0..0e94386 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,22 +1,28 @@ + + diff --git a/client/src/pages/index/index.vue b/client/src/pages/index/index.vue index ab11273..d211334 100644 --- a/client/src/pages/index/index.vue +++ b/client/src/pages/index/index.vue @@ -2,11 +2,12 @@ - + - + + - {{ greeting }},小菜 + {{ greeting }},{{ userStore.nickname }} @@ -117,9 +118,12 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app' import { useTransactionStore } from '@/stores/transaction' import { useStatsStore } from '@/stores/stats' import { useBudgetStore } from '@/stores/budget' +import { useUserStore } from '@/stores/user' +import { useGroupStore } from '@/stores/group' +import { waitForReady } from '@/utils/app-ready' import { getOverview } from '@/api/stats' import { formatAmount, formatAmountRaw } from '@/utils/format' -import { statusBarHeight } from '@/utils/system' +import { statusBarHeight, capsuleRight } from '@/utils/system' import BudgetBar from '@/components/BudgetBar/BudgetBar.vue' import TransactionItem from '@/components/TransactionItem/TransactionItem.vue' import Icon from '@/components/Icon/Icon.vue' @@ -128,11 +132,13 @@ import Skeleton from '@/components/Skeleton/Skeleton.vue' const txStore = useTransactionStore() const statsStore = useStatsStore() const budgetStore = useBudgetStore() +const userStore = useUserStore() +const groupStore = useGroupStore() const DEFAULT_BUDGET = 500000 // 5000元(分) -const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 }) -const budget = ref(null) +const overview = computed(() => statsStore.overview) +const budget = computed(() => budgetStore.budget) const recentTx = ref([]) const loading = ref(true) const loadError = ref(false) @@ -149,9 +155,10 @@ const greeting = computed(() => { return '晚上好' }) -async function loadData() { +async function loadData(silent = false) { + await waitForReady() try { - loading.value = true + if (!silent) loading.value = true loadError.value = false const now = new Date() const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` @@ -159,14 +166,13 @@ async function loadData() { statsStore.fetchOverview(), budgetStore.fetchBudget(), txStore.fetchTransactions({ page: 1 }), - fetchTodayData(today) + fetchTodayData(today), + userStore.fetchUserInfo() ]) - overview.value = statsStore.overview - budget.value = budgetStore.budget recentTx.value = txStore.transactions.slice(0, 5) } catch (e) { console.error('Load error:', e) - loadError.value = true + if (!silent) loadError.value = true } finally { loading.value = false } @@ -174,7 +180,7 @@ async function loadData() { async function fetchTodayData(today: string) { try { - const data = await getOverview({ startDate: today, endDate: today }) + const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId }) todayExpense.value = data.expense || 0 todayIncome.value = data.income || 0 todayCount.value = data.count || 0 @@ -192,7 +198,7 @@ onMounted(() => { // Refresh data when returning from other pages (e.g., after adding a transaction) onShow(() => { - if (initialLoaded.value && !loading.value) loadData() + if (initialLoaded.value && !loading.value) loadData(true) }) onPullDownRefresh(() => { @@ -249,6 +255,12 @@ function goBills() { display: flex; align-items: center; justify-content: center; + overflow: hidden; +} + +.avatar-img { + width: 100%; + height: 100%; } .greeting { diff --git a/client/src/pages/profile-edit/index.vue b/client/src/pages/profile-edit/index.vue new file mode 100644 index 0000000..a212af8 --- /dev/null +++ b/client/src/pages/profile-edit/index.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/client/src/pages/profile/index.vue b/client/src/pages/profile/index.vue index af1fbae..2fb630d 100644 --- a/client/src/pages/profile/index.vue +++ b/client/src/pages/profile/index.vue @@ -5,15 +5,27 @@ 我的 - + - + + - 小菜 + {{ userStore.nickname }} 记账小能手 累计 {{ txStore.total }} 笔记录 + + + + + + + + {{ groupStore.isGroupMode ? groupStore.currentGroup?.name : '个人账本' }} + {{ groupStore.isGroupMode ? groupStore.currentGroup?.member_count + ' 人共享' : '仅自己可见' }} + + @@ -64,14 +76,54 @@ + + + + + 切换账本 + + + + 个人账本 + + + + + + + {{ g.name }} + {{ g.member_count }} 人 + + + + + + + 创建群组 + + + 加入群组 + + + + + 管理群组 + + +