feat: 用户信息完善 + 一起记功能

- users 表添加 nickname、avatar_url 字段
- 用户信息 API(GET/PUT /me)+ 头像上传(multer)
- 头像通过 API 路由获取(公开,不经过 auth)
- 登录接口返回用户昵称和头像
- 用户信息 Store + 个人资料编辑页面
- 我的页面和首页显示真实用户信息
- 群组表(groups、group_members)+ transactions 添加 group_id
- 群组 API(创建/加入/退出/解散)
- 交易和统计 API 支持 group_id 视图切换
- 群组客户端 Store + 身份切换
- 群组管理页面
- App 初始化群组 Store
- UPLOAD_DIR 配置化
- Node.js 备份替代 mysqldump
- 统一前端 BASE_URL(config.ts)
- uploads 加入 .gitignore
- 修复 trust proxy 警告
This commit is contained in:
2026-06-03 17:04:22 +08:00
parent 9162b2c87c
commit 21f4d81959
45 changed files with 3145 additions and 348 deletions

4
.gitignore vendored
View File

@@ -34,5 +34,9 @@ client/unpackage/
*.pem
*.key
# Uploads (user-generated content)
server/uploads/
# Playwright
.playwright-mcp/
server/backups/

View File

@@ -16,6 +16,8 @@
- **运行时**: Node.js + Express
- **数据库**: MySQL 8.0 (mysql2 连接池)
- **认证**: HMAC-SHA256 签名 token30 天过期
- **文件上传**: 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小程序触发 reLoginH5 刷新页面
- 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
```
## 开发

12
DEV.md
View File

@@ -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
- 代码提交前检查代码,包括但不限于代码格式、注释、测试覆盖率等。
- 代码提交前必须检查代码,包括但不限于代码格式、注释、测试覆盖率、文档更新等。
- 影响到历史数据吧,必须要准备好数据迁移方案。

View File

@@ -1,22 +1,28 @@
<script setup lang="ts">
import { onLaunch } from '@dcloudio/uni-app'
import { wxLogin, demoLogin } from '@/api/auth'
import { useGroupStore } from '@/stores/group'
import { saveLoginResult } from '@/utils/request'
import { markReady } from '@/utils/app-ready'
onLaunch(() => {
const groupStore = useGroupStore()
groupStore.init()
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: async (loginRes) => {
if (loginRes.code) {
try {
const data = await wxLogin(loginRes.code)
uni.setStorageSync('xc:token', data.token)
uni.setStorageSync('xc:userId', data.userId)
saveLoginResult(await wxLogin(loginRes.code))
groupStore.fetchGroups()
console.log('[Auth] Login success')
} catch (e) {
console.error('[Auth] Login failed:', e)
}
}
markReady()
}
})
// #endif
@@ -24,15 +30,16 @@ onLaunch(() => {
// #ifdef H5
const token = uni.getStorageSync('xc:token')
if (!token) {
// 同步等待 token避免页面首次请求因无 token 而 401
demoLogin().then((data) => {
uni.setStorageSync('xc:token', data.token)
uni.setStorageSync('xc:userId', data.userId)
saveLoginResult(data)
groupStore.fetchGroups()
console.log('[Auth] H5 demo login success')
}).catch((e) => {
console.error('[Auth] H5 demo login failed:', e)
})
}).finally(() => { markReady() })
} else {
groupStore.fetchGroups()
markReady()
console.log('[Auth] H5 token exists')
}
// #endif

View File

@@ -4,6 +4,8 @@ import { request } from '@/utils/request'
export interface LoginResult {
token: string
userId: number
nickname: string
avatar_url: string
}
/** H5 演示登录 */

View File

@@ -8,8 +8,8 @@ export interface Budget {
}
/** 获取预算 */
export function getBudget(month?: string) {
return request<Budget | null>({ url: '/budget', data: month ? { month } : {} })
export function getBudget(month?: string, group_id?: number | null) {
return request<Budget | null>({ url: '/budget', data: { month, group_id } })
}
/** 设置预算 */

58
client/src/api/group.ts Normal file
View File

@@ -0,0 +1,58 @@
import { request } from '@/utils/request'
/** 群组信息 */
export interface Group {
id: number
name: string
invite_code: string
created_by: number
created_at: string
role: 'owner' | 'member'
my_nickname: string
member_count: number
}
/** 群组详情(含成员) */
export interface GroupDetail extends Group {
members: GroupMember[]
}
/** 群组成员 */
export interface GroupMember {
user_id: number
role: 'owner' | 'member'
nickname: string
user_nickname: string
avatar_url: string
joined_at: string
}
/** 创建群组 */
export function createGroup(data: { name: string }) {
return request<{ id: number; invite_code: string }>({ url: '/groups', method: 'POST', data })
}
/** 获取群组列表 */
export function getGroups() {
return request<Group[]>({ url: '/groups' })
}
/** 获取群组详情 */
export function getGroupDetail(id: number) {
return request<GroupDetail>({ url: `/groups/${id}` })
}
/** 加入群组 */
export function joinGroup(invite_code: string) {
return request<{ group_id: number; name: string }>({ url: '/groups/join', method: 'POST', data: { invite_code } })
}
/** 退出群组 */
export function leaveGroup(id: number) {
return request({ url: `/groups/${id}/leave`, method: 'POST' })
}
/** 解散群组 */
export function deleteGroup(id: number) {
return request({ url: `/groups/${id}`, method: 'DELETE' })
}

View File

@@ -25,16 +25,16 @@ export interface TrendPoint {
}
/** 获取概览数据 */
export function getOverview(params?: { month?: string; startDate?: string; endDate?: string }) {
export function getOverview(params?: { month?: string; startDate?: string; endDate?: string; group_id?: number | null }) {
return request<Overview>({ url: '/stats/overview', data: params })
}
/** 获取分类统计 */
export function getCategoryStats(month?: string, type: string = 'expense') {
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type } })
export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null) {
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type, group_id } })
}
/** 获取趋势数据 */
export function getTrend(month?: string, type: string = 'expense') {
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type } })
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
}

View File

@@ -11,6 +11,8 @@ export interface Transaction {
category_name?: string
category_icon?: string
category_color?: string
group_id?: number | null
creator_nickname?: string
}
/** 交易列表响应 */
@@ -29,6 +31,7 @@ export interface TransactionParams {
startDate?: string
endDate?: string
sortBy?: string
group_id?: number | null
}
/** 获取交易列表 */
@@ -48,6 +51,7 @@ export function createTransaction(data: {
category_id?: number
note?: string
date: string
group_id?: number | null
}) {
return request<{ id: number }>({ url: '/transactions', method: 'POST', data })
}

46
client/src/api/user.ts Normal file
View File

@@ -0,0 +1,46 @@
import { request } from '@/utils/request'
import { API_BASE } from '@/config'
/** 用户信息 */
export interface UserInfo {
id: number
nickname: string
avatar_url: string
created_at: string
}
/** 获取当前用户信息 */
export function getUserInfo() {
return request<UserInfo>({ url: '/user/me' })
}
/** 更新昵称 */
export function updateNickname(nickname: string) {
return request({ url: '/user/me', method: 'PUT', data: { nickname } })
}
/** 上传头像(返回新 URL */
export function uploadAvatar(filePath: string): Promise<{ avatar_url: string }> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('xc:token')
uni.uploadFile({
url: `${API_BASE}/user/avatar`,
filePath,
name: 'file',
header: { Authorization: `Bearer ${token}` },
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.code === 0) {
resolve(data.data)
} else {
reject(new Error(data.message || '上传失败'))
}
} catch {
reject(new Error('上传响应解析失败'))
}
},
fail: () => reject(new Error('网络错误'))
})
})
}

View File

@@ -4,7 +4,10 @@
<text class="key-text">{{ key }}</text>
</view>
<view class="key fn" @tap="onDelete">
<Icon name="trash" :size="36" color="#FF8C69" />
<view class="backspace-icon">
<view class="backspace-arrow"></view>
<view class="backspace-line"></view>
</view>
</view>
<view class="key eq" @tap="onConfirm">
<Icon name="check" :size="36" color="#FFFFFF" />
@@ -13,26 +16,71 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '@/components/Icon/Icon.vue'
const props = withDefaults(defineProps<{
/** 金额字符串v-model */
modelValue?: string
/** 最大金额(元) */
max?: number
}>(), {
modelValue: '',
max: 9999999.99
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'confirm'): void
}>()
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
const emit = defineEmits(['input', 'delete', 'confirm'])
const amountStr = computed(() => props.modelValue)
function vibrate() {
try {
uni.vibrateShort({ type: 'light' })
} catch {}
try { uni.vibrateShort({ type: 'light' }) } catch {}
}
function emitValue(val: string) {
emit('update:modelValue', val)
}
function onKeyTap(key: string) {
vibrate()
emit('input', key)
const current = amountStr.value
if (key === '.') {
if (current.includes('.')) return
if (!current) { emitValue('0.'); return }
}
let next: string
if (!current || current === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = current + key
}
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
}
if (next.length > 10) return
const num = parseFloat(next)
if (!isNaN(num) && num > props.max) return
emitValue(next)
}
function onDelete() {
vibrate()
emit('delete')
const current = amountStr.value
if (current.length > 1) {
emitValue(current.slice(0, -1))
} else {
emitValue('')
}
}
function onConfirm() {
@@ -57,9 +105,7 @@ function onConfirm() {
align-items: center;
justify-content: center;
&:active {
background: #F0E0D6;
}
&:active { background: #F0E0D6; }
}
.key-text {
@@ -71,18 +117,41 @@ function onConfirm() {
.key.fn {
background: #FFE8E0;
&:active {
background: #FFD0C0;
}
&:active { background: #FFD0C0; }
}
.key.eq {
background: linear-gradient(135deg, #FF8C69, #E67355);
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
&:active { background: #E67355; }
}
&:active {
background: #E67355;
}
// 自绘 backspace 图标
.backspace-icon {
width: 40rpx;
height: 28rpx;
position: relative;
}
.backspace-arrow {
position: absolute;
left: 0;
top: 50%;
width: 16rpx;
height: 16rpx;
border-left: 4rpx solid #FF8C69;
border-bottom: 4rpx solid #FF8C69;
transform: translateY(-50%) rotate(45deg);
}
.backspace-line {
position: absolute;
left: 12rpx;
top: 50%;
width: 26rpx;
height: 4rpx;
background: #FF8C69;
transform: translateY(-50%);
border-radius: 2rpx;
}
</style>

26
client/src/config.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* 全局配置 — API 基础地址
*
* 小程序开发版 → dev.xiaocai.j35.site
* 小程序正式版 / H5 → 线上服务
*/
declare const wx: any
function resolveApiBase(): string {
// H5 环境:同源相对路径
if (typeof window !== 'undefined') return '/api'
// 小程序环境:根据版本号判断
try {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const info = (wx as any).getAccountInfoSync?.()?.miniProgram
if (info?.envVersion === 'develop') {
return 'https://dev.xiaocai.j35.site/api'
}
} catch { /* ignore */ }
return 'https://xiaocai.j35.site/api'
}
export const API_BASE = resolveApiBase()

View File

@@ -51,6 +51,20 @@
"navigationStyle": "custom",
"navigationBarTitleText": "预算设置"
}
},
{
"path": "pages/profile-edit/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "编辑资料"
}
},
{
"path": "pages/group-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "一起记"
}
}
],
"globalStyle": {

View File

@@ -22,6 +22,7 @@
<view class="amount-display">
<text class="currency">¥</text>
<text class="digits">{{ displayAmount }}</text>
<text class="cursor">|</text>
</view>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
@@ -46,7 +47,7 @@
</view>
</view>
<Numpad @input="onInput" @delete="onDelete" @confirm="save" />
<Numpad v-model="amountStr" @confirm="save" />
<SaveSuccess v-model:visible="showSuccess" :text="editId ? '修改成功' : '记账成功'" />
</view>
@@ -86,8 +87,8 @@ const dateLabel = computed(() => {
})
const displayAmount = computed(() => {
const num = parseFloat(amountStr.value) || 0
return num.toFixed(2)
if (!amountStr.value) return ''
return amountStr.value
})
const currentCategories = computed(() => catStore.getByType(type.value))
@@ -151,33 +152,6 @@ function onDateChange(e: any) {
selectedDate.value = e.detail.value
}
function onInput(key: string) {
if (key === '.' && amountStr.value.includes('.')) return
// When current value is '0', replace with new key (but '0' and '00' should stay as '0')
let next: string
if (amountStr.value === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = amountStr.value + key
}
const num = parseFloat(next)
if (!isNaN(num) && num > 9999999.99) return
// Check decimal limit on the resulting value (handles "00" adding 2 chars)
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
}
amountStr.value = next
}
function onDelete() {
if (amountStr.value.length > 1) {
amountStr.value = amountStr.value.slice(0, -1)
} else {
amountStr.value = '0'
}
}
async function save() {
if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value) * 100)
@@ -329,6 +303,18 @@ function goBack() {
font-variant-numeric: tabular-nums;
}
.cursor {
font-family: 'Fredoka', monospace;
font-size: 88rpx;
font-weight: 300;
color: #FF8C69;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.category-scroll {
max-height: 320rpx;
}

View File

@@ -71,8 +71,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
@@ -106,7 +107,17 @@ const groupedTx = computed(() => {
return Object.values(groups).sort((a, b) => b.date.localeCompare(a.date))
})
onMounted(() => loadTx(true))
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
loadTx(true).finally(() => { initialLoaded.value = true })
})
// 静默刷新(切换群组/返回时)
onShow(() => {
if (initialLoaded.value) loadTx(true)
})
function switchFilter(type: string) {
filterType.value = type

View File

@@ -49,18 +49,13 @@
<view class="section-title">自定义金额</view>
<view class="custom-input">
<text class="input-prefix">¥</text>
<text class="input-value" :class="{ placeholder: !amountStr }">
{{ amountStr || '0.00' }}
</text>
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text>
<text class="input-cursor">|</text>
<text class="input-unit"></text>
</view>
<!-- Numpad -->
<Numpad
@input="onInput"
@delete="onDelete"
@confirm="onConfirm"
/>
<Numpad v-model="amountStr" @confirm="onConfirm" />
</view>
</view>
</template>
@@ -113,32 +108,6 @@ function selectPreset(val: number) {
amountStr.value = String(val)
}
function onInput(key: string) {
if (key === '.' && amountStr.value.includes('.')) return
if (key === '.' && !amountStr.value) {
amountStr.value = '0.'
return
}
let next: string
if (amountStr.value === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = amountStr.value + key
}
const parts = next.split('.')
if (parts[1] && parts[1].length > 2) return
if (next.length > 10) return
amountStr.value = next
}
function onDelete() {
if (amountStr.value.length > 1) {
amountStr.value = amountStr.value.slice(0, -1)
} else {
amountStr.value = '0'
}
}
async function onConfirm() {
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) {
@@ -153,6 +122,7 @@ async function onConfirm() {
await budgetStore.setBudget(amount, currentMonth)
budget.value = budgetStore.budget
uni.showToast({ title: '预算已设置', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
@@ -348,4 +318,16 @@ function goBack() {
font-size: 28rpx;
color: #8B7E7E;
}
.input-cursor {
font-family: 'Fredoka', monospace;
font-size: 56rpx;
font-weight: 300;
color: #FF8C69;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
</style>

View File

@@ -0,0 +1,380 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">一起记</text>
<view style="width: 72rpx;"></view>
</view>
</view>
<!-- 创建群组 -->
<view class="action-card">
<text class="card-title">创建新群组</text>
<view class="action-row">
<input class="action-input" v-model="newGroupName" placeholder="群组名称" maxlength="100" />
<view class="action-btn" @tap="handleCreate">
<text class="action-btn-text">创建</text>
</view>
</view>
</view>
<!-- 加入群组 -->
<view class="action-card">
<text class="card-title">加入群组</text>
<view class="action-row">
<input class="action-input" v-model="inviteCode" placeholder="输入邀请码" maxlength="6" />
<view class="action-btn" @tap="handleJoin">
<text class="action-btn-text">加入</text>
</view>
</view>
</view>
<!-- 我的群组列表 -->
<view class="section-header">
<text class="section-title">我的群组</text>
</view>
<view v-if="groupStore.loading" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="groupStore.groups.length === 0" class="empty-box">
<Icon name="user" :size="48" color="#BFB3B3" />
<text class="empty-text">还没有加入任何群组</text>
</view>
<view v-else class="group-list">
<view v-for="group in groupStore.groups" :key="group.id" class="group-item">
<view class="group-icon">
<Icon name="user" :size="32" color="#FF8C69" />
</view>
<view class="group-info">
<text class="group-name">{{ group.name }}</text>
<text class="group-meta">{{ group.member_count }} · {{ group.role === 'owner' ? '群主' : '成员' }}</text>
<view class="invite-row" @tap="copyInviteCode(group.invite_code)">
<text class="invite-label">邀请码</text>
<text class="invite-code">{{ group.invite_code }}</text>
<text class="invite-copy">复制</text>
</view>
</view>
<view class="group-actions">
<view v-if="group.role === 'owner'" class="group-btn dissolve" @tap="handleDissolve(group)">
<text class="group-btn-text dissolve-text">解散</text>
</view>
<view v-else class="group-btn leave" @tap="handleLeave(group)">
<text class="group-btn-text leave-text">退出</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useGroupStore } from '@/stores/group'
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
const groupStore = useGroupStore()
const newGroupName = ref('')
const inviteCode = ref('')
onMounted(() => {
groupStore.fetchGroups()
})
function goBack() {
uni.navigateBack()
}
function copyInviteCode(code: string) {
uni.setClipboardData({
data: code,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
async function handleCreate() {
const name = newGroupName.value.trim()
if (!name) {
uni.showToast({ title: '请输入群组名称', icon: 'none' })
return
}
try {
const result = await groupStore.createGroup(name)
newGroupName.value = ''
uni.setClipboardData({
data: result.invite_code,
success: () => {
uni.showModal({
title: '创建成功',
content: `邀请码 ${result.invite_code} 已复制,分享给好友即可加入`,
showCancel: false
})
}
})
} catch (e: any) {
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
}
}
async function handleJoin() {
const code = inviteCode.value.trim().toUpperCase()
if (!code) {
uni.showToast({ title: '请输入邀请码', icon: 'none' })
return
}
try {
const result = await groupStore.joinGroup(code)
inviteCode.value = ''
uni.showToast({ title: `已加入「${result.name}`, icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
}
}
function handleLeave(group: any) {
uni.showModal({
title: '退出群组',
content: `确定退出「${group.name}」?你在该群组的记录将回到个人账本。`,
success: async (res) => {
if (res.confirm) {
try {
await groupStore.leaveGroup(group.id)
uni.showToast({ title: '已退出', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '退出失败', icon: 'none' })
}
}
}
})
}
function handleDissolve(group: any) {
uni.showModal({
title: '解散群组',
content: `确定解散「${group.name}」?群组记录将回到各成员的个人账本。`,
success: async (res) => {
if (res.confirm) {
try {
await groupStore.deleteGroup(group.id)
uni.showToast({ title: '已解散', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '解散失败', icon: 'none' })
}
}
}
})
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar { background: #FFF8F0; }
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 40rpx 24rpx;
}
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
}
.action-card {
margin: 24rpx 40rpx 0;
padding: 32rpx 40rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
}
.card-title {
font-size: 28rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
margin-bottom: 20rpx;
}
.action-row {
display: flex;
gap: 16rpx;
}
.action-input {
flex: 1;
height: 80rpx;
padding: 0 24rpx;
background: #FFF8F0;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
font-size: 28rpx;
color: #2D1B1B;
}
.action-btn {
height: 80rpx;
padding: 0 32rpx;
background: linear-gradient(135deg, #FF8C69, #E67355);
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn-text {
font-size: 28rpx;
font-weight: 600;
color: #FFFFFF;
white-space: nowrap;
}
.section-header {
padding: 40rpx 40rpx 16rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 500;
color: #8B7E7E;
}
.loading-box, .empty-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 80rpx 0;
}
.loading-text, .empty-text {
font-size: 28rpx;
color: #BFB3B3;
}
.group-list {
padding: 0 40rpx;
}
.group-item {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
margin-bottom: 16rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
gap: 24rpx;
}
.group-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #FFF0E6;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.group-info {
flex: 1;
min-width: 0;
}
.group-name {
font-size: 28rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
}
.group-meta {
font-size: 24rpx;
color: #BFB3B3;
display: block;
margin-top: 4rpx;
}
.invite-row {
display: flex;
align-items: center;
margin-top: 8rpx;
gap: 4rpx;
}
.invite-label {
font-size: 24rpx;
color: #8B7E7E;
}
.invite-code {
font-size: 24rpx;
font-weight: 600;
color: #FF8C69;
font-family: 'Fredoka', monospace;
}
.invite-copy {
font-size: 22rpx;
color: #FF8C69;
margin-left: 8rpx;
text-decoration: underline;
}
.group-actions {
flex-shrink: 0;
}
.group-btn {
padding: 12rpx 24rpx;
border-radius: 16rpx;
&.leave { background: #FFF0E6; }
&.dissolve { background: #FFE8E8; }
}
.group-btn-text {
font-size: 24rpx;
font-weight: 500;
&.leave-text { color: #FF8C69; }
&.dissolve-text { color: #FF6B6B; }
}
</style>

View File

@@ -2,11 +2,12 @@
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="header" :style="{ paddingRight: capsuleRight + 'px' }">
<view class="avatar-wrap">
<Icon name="user" :size="36" color="#8B7E7E" />
<image v-if="userStore.avatarUrl" :src="userStore.avatarUrl" class="avatar-img" mode="aspectFill" />
<Icon v-else name="user" :size="36" color="#8B7E7E" />
</view>
<text class="greeting">{{ greeting }}小菜</text>
<text class="greeting">{{ greeting }}{{ userStore.nickname }}</text>
<view class="bell" @tap="showNotification">
<Icon name="bell" :size="40" color="#8B7E7E" />
</view>
@@ -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<any>(null)
const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget)
const recentTx = ref<any[]>([])
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 {

View File

@@ -0,0 +1,250 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar" :style="{ paddingRight: capsuleRight + 'px' }">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">编辑资料</text>
<view class="nav-save" @tap="saveProfile">
<text class="save-text">保存</text>
</view>
</view>
</view>
<view class="avatar-section">
<view class="avatar-wrap" @tap="chooseAvatar">
<image v-if="previewUrl" :src="previewUrl" class="avatar-img" mode="aspectFill" />
<Icon v-else name="user" :size="64" color="#FFFFFF" />
<view class="avatar-badge">
<Icon name="edit" :size="24" color="#FFFFFF" />
</view>
</view>
<text class="avatar-hint">点击更换头像</text>
</view>
<view class="form-card">
<view class="form-item">
<text class="form-label">昵称</text>
<input class="form-input" v-model="formNickname" placeholder="请输入昵称" maxlength="50" />
</view>
</view>
<view class="tips-card">
<text class="tips-title">提示</text>
<text class="tips-text"> 昵称最长 50 个字符</text>
<text class="tips-text"> 头像支持 JPGPNG 格式最大 2MB</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
const userStore = useUserStore()
const formNickname = ref('')
const previewUrl = ref('')
const uploading = ref(false)
const saving = ref(false)
onMounted(() => {
formNickname.value = userStore.nickname
previewUrl.value = userStore.avatarUrl
})
function goBack() {
uni.navigateBack()
}
function chooseAvatar() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFilePaths[0]
previewUrl.value = tempPath
uploading.value = true
try {
await userStore.uploadAvatar(tempPath)
uni.showToast({ title: '头像已更新', icon: 'success' })
} catch {
uni.showToast({ title: '头像上传失败', icon: 'none' })
previewUrl.value = userStore.avatarUrl
} finally {
uploading.value = false
}
}
})
}
async function saveProfile() {
if (saving.value || uploading.value) return
const nickname = formNickname.value.trim()
if (!nickname) {
uni.showToast({ title: '请输入昵称', icon: 'none' })
return
}
saving.value = true
try {
await userStore.updateProfile(nickname)
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar { background: #FFF8F0; }
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 40rpx 24rpx;
}
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
}
.nav-save {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.save-text {
font-size: 28rpx;
font-weight: 600;
color: #FF8C69;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0 32rpx;
}
.avatar-wrap {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.5);
background: linear-gradient(135deg, #FF8C69, #FFB89A);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.avatar-img { width: 100%; height: 100%; }
.avatar-badge {
position: absolute;
bottom: 0;
right: 0;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-hint {
font-size: 24rpx;
color: #BFB3B3;
margin-top: 16rpx;
}
.form-card {
margin: 32rpx 40rpx 0;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
overflow: hidden;
}
.form-item {
display: flex;
align-items: center;
padding: 32rpx 40rpx;
}
.form-label {
width: 120rpx;
font-size: 28rpx;
font-weight: 500;
color: #2D1B1B;
flex-shrink: 0;
}
.form-input {
flex: 1;
font-size: 28rpx;
color: #2D1B1B;
}
.tips-card {
margin: 32rpx 40rpx 0;
padding: 32rpx 40rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
}
.tips-title {
font-size: 28rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
margin-bottom: 16rpx;
}
.tips-text {
font-size: 24rpx;
color: #8B7E7E;
display: block;
margin-bottom: 8rpx;
}
</style>

View File

@@ -5,15 +5,27 @@
<text class="page-title">我的</text>
</view>
<view class="profile-card">
<view class="profile-card" @tap="goProfileEdit">
<view class="p-avatar">
<Icon name="user" :size="64" color="#FFFFFF" />
<image v-if="userStore.avatarUrl" :src="userStore.avatarUrl" class="p-avatar-img" mode="aspectFill" />
<Icon v-else name="user" :size="64" color="#FFFFFF" />
</view>
<view class="p-info">
<text class="p-name">小菜</text>
<text class="p-name">{{ userStore.nickname }}</text>
<text class="p-tagline">记账小能手</text>
<text class="p-streak">累计 {{ txStore.total }} 笔记录</text>
</view>
<Icon name="chevronRight" :size="28" color="rgba(255,255,255,0.6)" />
</view>
<!-- 身份切换 -->
<view class="identity-card" @tap="showIdentityPicker">
<Icon name="user" :size="32" color="#FF8C69" />
<view class="id-info">
<text class="id-name">{{ groupStore.isGroupMode ? groupStore.currentGroup?.name : '个人账本' }}</text>
<text class="id-desc">{{ groupStore.isGroupMode ? groupStore.currentGroup?.member_count + ' 人共享' : '仅自己可见' }}</text>
</view>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="quick-stats">
@@ -64,14 +76,54 @@
</view>
</view>
</view>
<!-- 身份选择弹窗 -->
<view class="modal-mask" v-if="showIdentity" @tap="showIdentity = false">
<view class="identity-modal" @tap.stop>
<text class="modal-title">切换账本</text>
<view class="id-option" :class="{ active: !groupStore.isGroupMode }" @tap="switchToPersonal">
<Icon name="user" :size="36" :color="!groupStore.isGroupMode ? '#FF8C69' : '#8B7E7E'" />
<text class="id-option-name">个人账本</text>
<Icon v-if="!groupStore.isGroupMode" name="check" :size="28" color="#FF8C69" />
</view>
<view v-for="g in groupStore.groups" :key="g.id"
class="id-option" :class="{ active: groupStore.currentGroupId === g.id }"
@tap="switchToGroup(g.id)">
<Icon name="user" :size="36" :color="groupStore.currentGroupId === g.id ? '#FF8C69' : '#8B7E7E'" />
<view class="id-option-info">
<text class="id-option-name">{{ g.name }}</text>
<text class="id-option-desc">{{ g.member_count }} </text>
</view>
<Icon v-if="groupStore.currentGroupId === g.id" name="check" :size="28" color="#FF8C69" />
</view>
<view class="id-actions">
<view class="id-action-btn" @tap="promptCreateGroup">
<text class="id-action-text">创建群组</text>
</view>
<view class="id-action-btn" @tap="promptJoinGroup">
<text class="id-action-text">加入群组</text>
</view>
</view>
<view class="id-manage" @tap="goGroupManage">
<text class="id-manage-text">管理群组</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
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 { getTransactions } from '@/api/transaction'
import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
@@ -80,22 +132,26 @@ import Icon from '@/components/Icon/Icon.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const budget = ref<any>(null)
const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget)
const loading = ref(true)
const showAbout = ref(false)
const showIdentity = ref(false)
onMounted(async () => {
await waitForReady()
loading.value = true
try {
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 })
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
userStore.fetchUserInfo(),
groupStore.fetchGroups()
])
overview.value = statsStore.overview
budget.value = budgetStore.budget
} catch (e) {
console.error('Profile load error:', e)
} finally {
@@ -111,6 +167,94 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
function goProfileEdit() {
uni.navigateTo({ url: '/pages/profile-edit/index' })
}
function showIdentityPicker() {
showIdentity.value = true
}
function switchToPersonal() {
groupStore.switchToPersonal()
showIdentity.value = false
}
function switchToGroup(id: number) {
groupStore.switchToGroup(id)
showIdentity.value = false
}
function goGroupManage() {
showIdentity.value = false
uni.navigateTo({ url: '/pages/group-manage/index' })
}
function promptCreateGroup() {
// #ifdef MP-WEIXIN
// 小程序不支持 prompt使用输入框页面
uni.showModal({
title: '创建群组',
editable: true,
placeholderText: '请输入群组名称',
success: async (res) => {
if (res.confirm && res.content) {
try {
const result = await groupStore.createGroup(res.content.trim())
uni.setClipboardData({
data: result.invite_code,
success: () => uni.showToast({ title: '邀请码已复制', icon: 'success' })
})
} catch (e: any) {
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
}
}
}
})
// #endif
// #ifdef H5
const name = window.prompt('请输入群组名称')
if (name) {
groupStore.createGroup(name.trim()).then((result) => {
navigator.clipboard?.writeText(result.invite_code)
uni.showToast({ title: `邀请码 ${result.invite_code} 已复制`, icon: 'success' })
}).catch((e: any) => {
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
})
}
// #endif
}
function promptJoinGroup() {
// #ifdef MP-WEIXIN
uni.showModal({
title: '加入群组',
editable: true,
placeholderText: '请输入邀请码',
success: async (res) => {
if (res.confirm && res.content) {
try {
const result = await groupStore.joinGroup(res.content.trim())
uni.showToast({ title: `已加入「${result.name}`, icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
}
}
}
})
// #endif
// #ifdef H5
const code = window.prompt('请输入邀请码')
if (code) {
groupStore.joinGroup(code.trim()).then((result) => {
uni.showToast({ title: `已加入「${result.name}`, icon: 'success' })
}).catch((e: any) => {
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
})
}
// #endif
}
function getLocalDateStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
@@ -119,15 +263,21 @@ function getLocalDateStr(): string {
async function exportData() {
uni.showLoading({ title: '导出中...' })
try {
const data = await getTransactions({ page: 1, pageSize: 9999 })
const list = data.list
if (!list || list.length === 0) {
// 分页获取全部数据(服务端 pageSize 上限 100
const allList: any[] = []
let page = 1
let total = 0
do {
const data = await getTransactions({ page, pageSize: 100, group_id: groupStore.currentGroupId })
allList.push(...data.list)
total = data.total
page++
} while (allList.length < total)
if (allList.length === 0) {
uni.showToast({ title: '暂无数据', icon: 'none' })
return
}
if (data.total > list.length) {
uni.showToast({ title: `${data.total}条,仅导出前${list.length}`, icon: 'none' })
}
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
@@ -136,7 +286,7 @@ async function exportData() {
return val
}
const header = '日期,类型,分类,金额(元),备注\n'
const rows = list.map(t => {
const rows = allList.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
@@ -379,4 +529,129 @@ async function exportData() {
color: #FFFFFF;
}
}
.p-avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.identity-card {
margin: 24rpx 40rpx 0;
padding: 28rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
display: flex;
align-items: center;
gap: 20rpx;
}
.id-info {
flex: 1;
display: flex;
flex-direction: column;
}
.id-name {
font-size: 28rpx;
font-weight: 600;
color: #2D1B1B;
}
.id-desc {
font-size: 24rpx;
color: #BFB3B3;
margin-top: 4rpx;
}
.identity-modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #FFFFFF;
border-radius: 40rpx 40rpx 0 0;
padding: 40rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
max-height: 70vh;
overflow-y: auto;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.id-option {
display: flex;
align-items: center;
gap: 20rpx;
padding: 28rpx 24rpx;
border-radius: 20rpx;
margin-bottom: 12rpx;
&.active { background: #FFF0E6; }
&:active { background: #FFF8F0; }
}
.id-option-info {
flex: 1;
display: flex;
flex-direction: column;
}
.id-option-name {
font-size: 28rpx;
font-weight: 500;
color: #2D1B1B;
}
.id-option-desc {
font-size: 24rpx;
color: #BFB3B3;
margin-top: 2rpx;
}
.id-actions {
display: flex;
gap: 24rpx;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #F0E0D6;
}
.id-action-btn {
flex: 1;
height: 80rpx;
border-radius: 20rpx;
background: #FFF0E6;
display: flex;
align-items: center;
justify-content: center;
&:active { background: #FFE8D6; }
}
.id-action-text {
font-size: 28rpx;
font-weight: 500;
color: #FF8C69;
}
.id-manage {
margin-top: 16rpx;
padding: 20rpx;
text-align: center;
}
.id-manage-text {
font-size: 26rpx;
color: #8B7E7E;
}
</style>

View File

@@ -119,8 +119,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import { getOverview } from '@/api/stats'
import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format'
@@ -133,9 +134,9 @@ const statsStore = useStatsStore()
const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense')
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const categoryStats = ref<any[]>([])
const trendData = ref<any[]>([])
const overview = computed(() => statsStore.overview)
const categoryStats = computed(() => statsStore.categoryStats)
const trendData = computed(() => statsStore.trendData)
const loading = ref(true)
const loadError = ref(false)
const maxSingle = ref(0)
@@ -221,15 +222,24 @@ const monthCompareText = computed(() => {
return '持平'
})
onMounted(() => loadData())
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
loadData().finally(() => { initialLoaded.value = true })
})
onShow(() => {
if (initialLoaded.value) loadData(true)
})
// Only re-fetch category/trend on tab switch; re-fetch all on month change
watch(currentMonth, () => loadData())
watch(tabType, () => loadTabData())
async function loadData() {
async function loadData(silent = false) {
try {
loading.value = true
if (!silent) loading.value = true
loadError.value = false
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
@@ -238,9 +248,6 @@ async function loadData() {
fetchMaxSingle(),
fetchPrevMonthData()
])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
console.error('Stats load error:', e)
loadError.value = true
@@ -259,9 +266,6 @@ async function loadTabData() {
fetchMaxSingle(),
fetchPrevMonthData()
])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
console.error('Stats tab load error:', e)
}

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api/budget'
import { getCurrentMonth } from '@/utils/format'
import { useGroupStore } from '@/stores/group'
// 重新导出类型以保持向后兼容
export type Budget = api.Budget
@@ -13,7 +14,8 @@ export const useBudgetStore = defineStore('budget', () => {
async function fetchBudget(month?: string) {
loading.value = true
try {
budget.value = await api.getBudget(month || getCurrentMonth())
const groupStore = useGroupStore()
budget.value = await api.getBudget(month || getCurrentMonth(), groupStore.currentGroupId)
} catch {
budget.value = null
} finally {

113
client/src/stores/group.ts Normal file
View File

@@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as api from '@/api/group'
import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget'
import { useTransactionStore } from '@/stores/transaction'
export const useGroupStore = defineStore('group', () => {
const groups = ref<api.Group[]>([])
const loading = ref(false)
/** 当前选中的群组 IDnull 表示个人模式 */
const currentGroupId = ref<number | null>(null)
/** 当前群组信息 */
const currentGroup = computed(() => {
if (currentGroupId.value === null) return null
return groups.value.find(g => g.id === currentGroupId.value) || null
})
/** 是否处于群组模式 */
const isGroupMode = computed(() => currentGroupId.value !== null)
/** 切换到个人模式 */
function switchToPersonal() {
currentGroupId.value = null
uni.removeStorageSync('xc:currentGroupId')
refreshAll()
}
/** 切换到群组模式 */
function switchToGroup(groupId: number) {
currentGroupId.value = groupId
uni.setStorageSync('xc:currentGroupId', groupId)
refreshAll()
}
/** 切换身份后全局刷新所有数据 */
function refreshAll() {
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const txStore = useTransactionStore()
statsStore.fetchOverview()
budgetStore.fetchBudget()
txStore.fetchTransactions({ page: 1 })
}
/** 获取群组列表 */
async function fetchGroups() {
loading.value = true
try {
groups.value = await api.getGroups()
} catch {
groups.value = []
} finally {
loading.value = false
}
// 验证 currentGroupId 是否仍有效(群组可能已被解散或退出)
if (currentGroupId.value !== null) {
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
if (!stillValid) {
switchToPersonal()
}
}
}
/** 创建群组 */
async function createGroup(name: string) {
const result = await api.createGroup({ name })
await fetchGroups()
return result
}
/** 加入群组 */
async function joinGroup(inviteCode: string) {
const result = await api.joinGroup(inviteCode)
await fetchGroups()
return result
}
/** 退出群组 */
async function leaveGroup(groupId: number) {
await api.leaveGroup(groupId)
if (currentGroupId.value === groupId) {
switchToPersonal()
}
await fetchGroups()
}
/** 解散群组 */
async function deleteGroup(groupId: number) {
await api.deleteGroup(groupId)
if (currentGroupId.value === groupId) {
switchToPersonal()
}
await fetchGroups()
}
/** 初始化:从本地存储恢复选中状态 */
function init() {
const saved = uni.getStorageSync('xc:currentGroupId')
if (saved) {
currentGroupId.value = saved
}
}
return {
groups, loading, currentGroupId, currentGroup, isGroupMode,
switchToPersonal, switchToGroup, refreshAll,
fetchGroups, createGroup, joinGroup, leaveGroup, deleteGroup, init
}
})

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api/stats'
import { getCurrentMonth } from '@/utils/format'
import { useGroupStore } from '@/stores/group'
// 重新导出类型以保持向后兼容
export type Overview = api.Overview
@@ -15,8 +16,11 @@ export const useStatsStore = defineStore('stats', () => {
async function fetchOverview(month?: string) {
try {
const data = await api.getOverview({ month: month || getCurrentMonth() })
// 确保数据字段是数字类型
const groupStore = useGroupStore()
const data = await api.getOverview({
month: month || getCurrentMonth(),
group_id: groupStore.currentGroupId
})
overview.value = {
expense: Number(data?.expense) || 0,
income: Number(data?.income) || 0,
@@ -30,8 +34,8 @@ export const useStatsStore = defineStore('stats', () => {
async function fetchCategoryStats(month?: string, type: string = 'expense') {
try {
const data = await api.getCategoryStats(month || getCurrentMonth(), type)
// 确保返回的是数组,且金额是数字
const groupStore = useGroupStore()
const data = await api.getCategoryStats(month || getCurrentMonth(), type, groupStore.currentGroupId)
categoryStats.value = Array.isArray(data) ? data.map(item => ({
...item,
amount: Number(item.amount) || 0,
@@ -44,8 +48,8 @@ export const useStatsStore = defineStore('stats', () => {
async function fetchTrend(month?: string, type: string = 'expense') {
try {
const data = await api.getTrend(month || getCurrentMonth(), type)
// 确保返回的是数组,且金额是数字
const groupStore = useGroupStore()
const data = await api.getTrend(month || getCurrentMonth(), type, groupStore.currentGroupId)
trendData.value = Array.isArray(data) ? data.map(item => ({
...item,
amount: Number(item.amount) || 0

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api/transaction'
import { useGroupStore } from '@/stores/group'
export const useTransactionStore = defineStore('transaction', () => {
const transactions = ref<api.Transaction[]>([])
@@ -11,7 +12,12 @@ export const useTransactionStore = defineStore('transaction', () => {
async function fetchTransactions(params?: api.TransactionParams) {
loading.value = true
try {
const data = await api.getTransactions({ page: 1, ...params })
const groupStore = useGroupStore()
const data = await api.getTransactions({
page: 1,
...params,
group_id: groupStore.currentGroupId
})
transactions.value = data.list
total.value = data.total
currentPage.value = data.page
@@ -30,7 +36,11 @@ export const useTransactionStore = defineStore('transaction', () => {
note?: string
date: string
}) {
const result = await api.createTransaction(data)
const groupStore = useGroupStore()
const result = await api.createTransaction({
...data,
group_id: groupStore.currentGroupId
})
return result.id
}

52
client/src/stores/user.ts Normal file
View File

@@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as api from '@/api/user'
import { API_BASE } from '@/config'
export const useUserStore = defineStore('user', () => {
const userInfo = ref<api.UserInfo | null>(null)
const loading = ref(false)
const nickname = computed(() => {
return userInfo.value?.nickname || uni.getStorageSync('xc:nickname') || '小菜'
})
const avatarUrl = computed(() => {
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
if (!filename) return ''
// 已是完整 URLhttp/blob直接返回
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
// 拼接API_BASE + /user/avatar/ + 文件名
return `${API_BASE}/user/avatar/${filename}`
})
async function fetchUserInfo() {
loading.value = true
try {
userInfo.value = await api.getUserInfo()
if (userInfo.value) {
uni.setStorageSync('xc:nickname', userInfo.value.nickname)
uni.setStorageSync('xc:avatar_url', userInfo.value.avatar_url)
}
} catch (e) {
console.error('[UserStore] fetchUserInfo error:', e)
} finally {
loading.value = false
}
}
async function updateProfile(nickname: string) {
await api.updateNickname(nickname)
if (userInfo.value) userInfo.value.nickname = nickname
uni.setStorageSync('xc:nickname', nickname)
}
async function uploadAvatar(filePath: string) {
const result = await api.uploadAvatar(filePath)
if (userInfo.value) userInfo.value.avatar_url = result.avatar_url
uni.setStorageSync('xc:avatar_url', result.avatar_url)
return avatarUrl.value
}
return { userInfo, loading, nickname, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
})

View File

@@ -0,0 +1,20 @@
/**
* App 启动就绪状态
*
* App.vue onLaunch 中调用 markReady()
* 页面 onMounted 中调用 waitForReady() 等待登录完成
*/
let _resolve: () => void
const _ready = new Promise<void>(resolve => { _resolve = resolve })
/** 标记 App 启动完成(登录成功) */
export function markReady() { _resolve() }
/** 等待 App 就绪,最多等 3 秒超时 */
export function waitForReady(): Promise<void> {
return Promise.race([
_ready,
new Promise<void>(resolve => setTimeout(resolve, 3000))
])
}

View File

@@ -1,5 +1,4 @@
// API 基础地址
const BASE_URL = 'https://xiaocai.j35.site/api'
import { API_BASE } from '@/config'
interface RequestOptions {
url: string
@@ -7,13 +6,49 @@ interface RequestOptions {
data?: any
}
interface LoginResult {
token: string
userId: number
nickname: string
avatar_url: string
}
let isRedirectingToLogin = false
/** 重登录期间挂起的请求,登录成功后统一重试 */
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
/** 保存登录结果到本地存储 */
export function saveLoginResult(data: LoginResult) {
uni.setStorageSync('xc:token', data.token)
uni.setStorageSync('xc:userId', data.userId)
uni.setStorageSync('xc:nickname', data.nickname)
uni.setStorageSync('xc:avatar_url', data.avatar_url)
}
/** 执行重登录 */
async function reLogin(): Promise<void> {
// H5 环境demo 登录
if (typeof window !== 'undefined') {
const data = await request<LoginResult>({ url: '/auth/demo-login', method: 'POST' })
saveLoginResult(data)
return
}
// 小程序环境wx.login
const loginRes = await new Promise<UniApp.LoginRes>((resolve, reject) => {
uni.login({ provider: 'weixin', success: resolve, fail: reject })
})
if (!loginRes.code) throw new Error('wx.login failed')
const data = await request<LoginResult>({ url: '/auth/login', method: 'POST', data: { code: loginRes.code } })
saveLoginResult(data)
}
export function request<T = any>(options: RequestOptions): Promise<T> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('xc:token')
uni.request({
url: BASE_URL + options.url,
url: API_BASE + options.url,
method: options.method || 'GET',
data: options.data,
header: {
@@ -23,45 +58,36 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
success: (res: any) => {
const { statusCode, data } = res
// HTTP 401 - token invalid or expired
if (statusCode === 401) {
uni.removeStorageSync('xc:token')
uni.removeStorageSync('xc:userId')
// 加入重试队列
pendingRetries.push({ resolve, reject, options })
if (!isRedirectingToLogin) {
isRedirectingToLogin = true
// H5 环境自动获取 demo token 并重试
if (typeof window !== 'undefined') {
request<{ token: string; userId: number }>({
url: '/auth/demo-login',
method: 'POST'
}).then((data) => {
uni.setStorageSync('xc:token', data.token)
uni.setStorageSync('xc:userId', data.userId)
// 重试原请求
request(options).then(resolve).catch(reject)
}).catch(() => {
const retries = [...pendingRetries]
pendingRetries = []
reLogin()
.then(() => retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject)))
.catch(() => {
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
reject({ code: 40100, message: '未登录' })
}).finally(() => { isRedirectingToLogin = false })
} else {
// 小程序环境自动重新登录
reLogin().then(() => {
request(options).then(resolve).catch(reject)
}).catch(() => {
reject({ code: 40100, message: '未登录' })
}).finally(() => { isRedirectingToLogin = false })
}
} else {
reject({ code: 40100, message: '未登录' })
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
})
.finally(() => { isRedirectingToLogin = false })
}
return
}
// Business logic success
if (data.code === 0) {
resolve(data.data)
} else {
// "用户不存在" 不弹 toast首次登录竞态
if (data.code !== 40400 || data.message !== '用户不存在') {
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
}
reject(data)
}
},
@@ -72,40 +98,3 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
})
})
}
// 微信小程序自动重新登录
function reLogin(): Promise<void> {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: async (loginRes) => {
if (loginRes.code) {
try {
const data = await request<{ token: string; userId: number }>({
url: '/auth/login',
method: 'POST',
data: { code: loginRes.code }
})
uni.setStorageSync('xc:token', data.token)
uni.setStorageSync('xc:userId', data.userId)
resolve()
} catch (e) {
console.error('[Auth] Re-login failed:', e)
reject(e)
}
} else {
reject(new Error('wx.login failed'))
}
},
fail: (err) => {
console.error('[Auth] wx.login failed:', err)
reject(err)
}
})
// #endif
// #ifndef MP-WEIXIN
resolve()
// #endif
})
}

View File

@@ -1,10 +1,18 @@
let statusBarHeight = 0
/** 胶囊按钮右侧到屏幕右边的距离(含边距) */
let capsuleRight = 0
try {
const info = uni.getSystemInfoSync()
statusBarHeight = info.statusBarHeight || 0
// 小程序环境:获取胶囊位置
const menuRect = (uni as any).getMenuButtonBoundingClientRect?.()
if (menuRect) {
capsuleRight = info.screenWidth - menuRect.left + 8
}
} catch {
statusBarHeight = 20
}
export { statusBarHeight }
export { statusBarHeight, capsuleRight }

View File

@@ -0,0 +1,589 @@
# 用户信息完善 & 一起记功能 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 两个功能:① 用户可编辑昵称和上传头像,替换全局硬编码的"小菜";② 用户可创建/加入群组,在「我的」页切换身份,群组仅作为数据聚合视图。
**Architecture:**
- 用户信息:扩展 `users` 表 → 服务端 multer 上传头像 → `/api/user` 路由 → 前端 `userStore` + 编辑页
- 一起记:新建 `groups`/`group_members` 表 → `transactions``group_id` 标签 → 「我的」页身份切换 → 群组仅做数据聚合
**Tech Stack:** Vue 3 Composition API (uni-app), Express + mysql2 + multer, SCSS Claymorphism
---
## 一、核心数据模型
### 所有权 vs 聚合
```
transactions 表:
user_id = 谁记的(数据所有权,永远不变)
group_id = 群组标签(仅用于聚合视图,可变)
```
**个人视图:** `WHERE user_id = 我`
- 看到我的**所有记录**,不管 group_id 是什么
- 记录始终属于我
**群组视图:** `WHERE group_id = X AND user_id IN (当前成员)`
- 只看到当前成员在该群组下记的记录
- 离开的成员的记录自动消失
**加入群组:** 新记的账可选择记到群组group_id = X仅自己可见的旧记录不动
**退出群组:** 该用户所有 `group_id = X` 的记录,`group_id` 设为 NULL
- 记录回到个人视图
- 群组其他人看不到
**解散群组:** CASCADE 删除 groups → group_members 自动清理transactions.group_id 设为 NULL
### 举例
```
用户 A 创建群组 G用户 B 加入
A 在群组模式下记了午餐 ¥30
→ { user_id: A, group_id: G, amount: 3000 }
B 在群组模式下记了打车 ¥20
→ { user_id: B, group_id: G, amount: 2000 }
A 的个人视图:看到自己所有记录(包括这笔 ¥30
B 的个人视图:看到自己所有记录(包括这笔 ¥20
群组 G 视图: A 和 B 都看到 ¥30 + ¥20 = ¥50
B 退出群组:
→ B 的记录 group_id 设为 NULL
→ B 的个人视图:仍然看到 ¥20记录属于 B
→ A 看群组 G只看到 ¥30B 的记录已不在)
```
---
## 二、数据库设计
### 现有表(不变)
```
users(id, openid, session_key, created_at, updated_at)
categories(id, user_id, name, icon, color, type, sort_order, is_custom, created_at)
transactions(id, user_id, amount, type, category_id, note, date, created_at, updated_at)
budgets(id, user_id, amount, month, created_at, updated_at)
```
### 变更
```
users 表新增:
nickname VARCHAR(50) DEFAULT '小菜'
avatar_url VARCHAR(500) DEFAULT '' -- 服务端相对路径
新建 groups 表:
id INT AUTO_INCREMENT PK
name VARCHAR(100) NOT NULL
invite_code VARCHAR(10) UNIQUE NOT NULL -- 6位邀请码
created_by INT NOT NULL FK→users.id
created_at TIMESTAMP
updated_at TIMESTAMP
新建 group_members 表:
id INT AUTO_INCREMENT PK
group_id INT NOT NULL FK→groups.id ON DELETE CASCADE
user_id INT NOT NULL FK→users.id ON DELETE CASCADE
role ENUM('owner','member') DEFAULT 'member'
nickname VARCHAR(50) DEFAULT '' -- 群内昵称(预留)
created_at TIMESTAMP
UNIQUE KEY (group_id, user_id)
transactions 表新增:
group_id INT DEFAULT NULL -- 群组标签NULL=纯个人记录
INDEX idx_group_date (group_id, date)
-- 注意:不要 FK ON DELETE CASCADE因为退出群组时需要保留记录
```
---
## 三、API 设计
### 新增接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/user/me` | 获取当前用户信息 |
| PUT | `/api/user/me` | 更新昵称 |
| POST | `/api/user/avatar` | 上传头像 |
| POST | `/api/groups` | 创建群组 |
| GET | `/api/groups` | 获取用户的群组列表 |
| GET | `/api/groups/:id` | 获取群组详情+成员 |
| POST | `/api/groups/join` | 通过邀请码加入群组 |
| POST | `/api/groups/:id/leave` | 退出群组(清除该用户的 group_id 标记) |
| DELETE | `/api/groups/:id` | 解散群组(仅 owner |
### 变更接口
**交易接口(`/api/transactions`**
| 参数 | 说明 |
|------|------|
| `group_id` 不传 | 个人视图:`WHERE user_id = ?`(所有记录) |
| `group_id = 数字` | 群组视图:`WHERE group_id = ? AND user_id IN (当前成员)` |
**统计接口(`/api/stats`** 同上逻辑
**退出群组(`POST /api/groups/:id/leave`)关键逻辑:**
```sql
-- 1. 清除该用户在该群组的记录标签
UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?
-- 2. 删除成员关系
DELETE FROM group_members WHERE group_id = ? AND user_id = ?
```
---
## 四、前端架构
### 新增文件
```
client/src/
├── api/user.ts -- 用户信息 API含头像上传
├── api/group.ts -- 群组 API
├── stores/user.ts -- 用户信息 Store
├── stores/group.ts -- 群组 Store身份切换状态
├── pages/profile-edit/index.vue -- 编辑资料页
└── pages/group-manage/index.vue -- 群组管理页(创建/加入/列表/退出/解散)
```
### 修改文件
```
server/src/
├── db/schema.sql, db/init.ts -- 迁移
├── index.ts -- 注册路由 + 静态文件
├── routes/auth.ts -- 登录返回用户信息
├── routes/user.ts -- [新建]
├── routes/group.ts -- [新建]
├── routes/transaction.ts -- 查询逻辑改造
└── routes/stats.ts -- 统计逻辑改造
client/src/
├── api/auth.ts, api/transaction.ts, api/stats.ts -- 类型扩展
├── stores/transaction.ts, stores/stats.ts -- 自动传 group_id
├── App.vue -- 初始化
├── pages.json -- 注册页面
├── pages/index/index.vue -- 显示真实昵称
└── pages/profile/index.vue -- 身份切换 + 头像/昵称
```
### 身份切换交互(「我的」页)
```
┌─────────────────────────┐
│ profile-card (点击编辑) │
│ [头像] 昵称 > │
├─────────────────────────┤
│ identity-card (点击切换) │
│ 👤 个人账本 仅自己 > │
├─────────────────────────┤
│ quick-stats │
│ menu-card │
└─────────────────────────┘
点击 identity-card → 底部弹出 ActionSheet
┌─────────────────────────┐
│ 切换账本 │
│ ✓ 个人账本 │
│ 家庭账本 3人 │
│ 情侣账本 2人 │
│ ───────────────────── │
创建群组 │
加入群组 │
└─────────────────────────┘
```
**创建群组流程:**
1. 点击「创建群组」→ 弹出输入框输入名称 → 确认
2. 创建成功 → 显示邀请码 → 自动复制到剪贴板
3. 群组自动出现在身份列表中
**加入群组流程:**
1. 点击「加入群组」→ 弹出输入框输入邀请码 → 确认
2. 加入成功 → 群组自动出现在身份列表中
**退出/解散(群组管理页):**
- 点击群组卡片进入详情 → 显示成员列表、邀请码
- 成员可见「退出群组」按钮
- 群主可见「解散群组」按钮
---
## 五、任务清单
### Part 1: 用户信息完善Task 1-7
#### Task 1: 数据库 — users 表扩展
**Files:** `server/src/db/schema.sql`, `server/src/db/init.ts`
- [ ] **Step 1: 更新 schema.sql users 表**
```sql
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(100) UNIQUE,
session_key VARCHAR(100),
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
- [ ] **Step 2: init.ts runMigrations 添加迁移**
```typescript
const hasNickname = await columnExists(conn, 'users', 'nickname')
if (!hasNickname) {
await conn.query("ALTER TABLE users ADD COLUMN nickname VARCHAR(50) DEFAULT '小菜'")
}
const hasAvatarUrl = await columnExists(conn, 'users', 'avatar_url')
if (!hasAvatarUrl) {
await conn.query("ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) DEFAULT ''")
}
```
- [ ] **Step 3: 验证** `cd server && npm run db:init`
- [ ] **Step 4: Commit** `feat: users 表添加 nickname、avatar_url`
---
#### Task 2: 用户信息 API + 头像上传
**Files:** Create `server/src/routes/user.ts`, Modify `server/src/index.ts`
- [ ] **Step 1: 安装 multer** `cd server && npm install multer && npm install -D @types/multer`
- [ ] **Step 2: 创建 user 路由**
三个接口:
- `GET /me` — 查询用户信息
- `PUT /me` — 更新昵称验证非空、≤50字符
- `POST /avatar` — multer 接收 `file` 字段,存到 `uploads/avatars/`,更新 `users.avatar_url`,删除旧文件
- [ ] **Step 3: index.ts 注册路由 + 静态文件**
```typescript
import userRoutes from './routes/user'
import path from 'path'
app.use('/uploads', express.static(path.join(__dirname, '../uploads')))
app.use('/api/user', apiLimiter, userRoutes)
```
- [ ] **Step 4: Commit** `feat: 用户信息 API + 头像上传`
---
#### Task 3: 登录返回用户信息
**Files:** `server/src/routes/auth.ts`, `client/src/api/auth.ts`, `client/src/App.vue`
- [ ] **Step 1: auth.ts 两个登录路由返回 `nickname`, `avatar_url`**
- [ ] **Step 2: auth.ts LoginResult 类型扩展**
- [ ] **Step 3: App.vue 登录后存储 `xc:nickname`, `xc:avatar_url`**
- [ ] **Step 4: Commit** `feat: 登录返回用户信息`
---
#### Task 4: 用户信息 Store
**Files:** Create `client/src/api/user.ts`, Create `client/src/stores/user.ts`
- [ ] **Step 1: api/user.ts**`getUserInfo`, `updateNickname`, `uploadAvatar`(用 `uni.uploadFile`
- [ ] **Step 2: stores/user.ts**`nickname`, `avatarUrl` computed`fetchUserInfo`, `updateProfile`, `uploadAvatar`
- [ ] **Step 3: Commit** `feat: 用户信息 Store`
---
#### Task 5: 个人资料编辑页面
**Files:** Create `client/src/pages/profile-edit/index.vue`, Modify `client/src/pages.json`
- [ ] **Step 1: pages.json 注册页面**
- [ ] **Step 2: 创建编辑页** — 头像点击选择+上传,昵称输入框,保存按钮
- [ ] **Step 3: Commit** `feat: 个人资料编辑页面`
---
#### Task 6: 「我的」页面显示真实用户信息
**Files:** `client/src/pages/profile/index.vue`
- [ ] **Step 1: 引入 userStore替换硬编码头像和昵称**
- [ ] **Step 2: 点击 profile-card 跳转编辑页**
- [ ] **Step 3: Commit** `feat: 我的页面显示真实头像和昵称`
---
#### Task 7: 首页显示真实昵称
**Files:** `client/src/pages/index/index.vue`
- [ ] **Step 1: 引入 userStore替换 `小菜` 为 `userStore.nickname`**
- [ ] **Step 2: Commit** `feat: 首页问候语显示真实昵称`
---
### Part 2: 一起记功能Task 8-14
#### Task 8: 数据库 — 群组表
**Files:** `server/src/db/schema.sql`, `server/src/db/init.ts`
- [ ] **Step 1: schema.sql 添加 groups, group_members 表transactions 添加 group_id**
注意:`transactions.group_id` **不加外键约束**因为退出群组时需要保留记录group_id 设为 NULL
- [ ] **Step 2: init.ts runMigrations 添加群组迁移**
```typescript
const hasGroupId = await columnExists(conn, 'transactions', 'group_id')
if (!hasGroupId) {
// 创建 groups 表
// 创建 group_members 表
// ALTER TABLE transactions ADD COLUMN group_id INT DEFAULT NULL
// ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)
}
```
- [ ] **Step 3: 验证** `cd server && npm run db:init`
- [ ] **Step 4: Commit** `feat: 群组表创建transactions 添加 group_id`
---
#### Task 9: 群组 API
**Files:** Create `server/src/routes/group.ts`, Modify `server/src/index.ts`
- [ ] **Step 1: 创建 group 路由**
| 接口 | 关键逻辑 |
|------|---------|
| `POST /` | 生成邀请码,事务插入 groups + group_members(role=owner) |
| `GET /` | JOIN group_members含 member_count |
| `GET /:id` | 验证成员身份,返回群组信息 + 成员列表(含 nickname, avatar_url |
| `POST /join` | 验证邀请码,检查非成员,插入 group_members(role=member) |
| `POST /:id/leave` | **关键:先 `UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?`,再 `DELETE FROM group_members`** |
| `DELETE /:id` | 仅 owner`DELETE FROM groups`CASCADE 清理 memberstransactions.group_id 因无外键需手动清理) |
**解散群组时手动清理 transactions**
```sql
UPDATE transactions SET group_id = NULL WHERE group_id = ?
DELETE FROM groups WHERE id = ?
```
- [ ] **Step 2: index.ts 注册路由**
- [ ] **Step 3: Commit** `feat: 群组 API`
---
#### Task 10: 交易和统计 API 支持群组视图
**Files:** `server/src/routes/transaction.ts`, `server/src/routes/stats.ts`
**核心改造:查询逻辑根据 `group_id` 参数切换视图模式。**
- [ ] **Step 1: transaction.ts GET / 改造**
```typescript
const { group_id } = req.query
if (group_id && group_id !== 'null') {
// 群组视图:群组下所有成员的记录
// 先验证当前用户是群组成员
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group_id, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
params = [group_id, group_id]
} else {
// 个人视图:我的所有记录(不管 group_id
where = 'WHERE t.user_id = ?'
params = [req.userId]
}
```
- [ ] **Step 2: transaction.ts POST / 改造**
创建记录时接受可选 `group_id` 参数,写入数据库。
- [ ] **Step 3: transaction.ts PUT/DELETE 改造**
编辑/删除权限:只能操作自己 `user_id` 的记录(不管 group_id
- [ ] **Step 4: stats.ts 三个路由改造**
与 transaction 相同的视图切换逻辑。
- [ ] **Step 5: Commit** `feat: 交易和统计 API 支持群组视图`
---
#### Task 11: 群组客户端 Store
**Files:** Create `client/src/api/group.ts`, Create `client/src/stores/group.ts`, Modify `client/src/stores/transaction.ts`, Modify `client/src/stores/stats.ts`
- [ ] **Step 1: api/group.ts** — 封装群组 API
- [ ] **Step 2: stores/group.ts**
```typescript
// 核心状态
const currentGroupId = ref<number | null>(null) // null = 个人模式
const isGroupMode = computed(() => currentGroupId.value !== null)
// 切换方法
function switchToPersonal() {
currentGroupId.value = null
uni.removeStorageSync('xc:currentGroupId')
}
function switchToGroup(id: number) {
currentGroupId.value = id
uni.setStorageSync('xc:currentGroupId', id)
}
```
- [ ] **Step 3: 更新 transaction/stats Store**
每个 fetch 方法自动读取 `useGroupStore().currentGroupId` 并传给 API。
- [ ] **Step 4: Commit** `feat: 群组 Store + 数据层自动跟随`
---
#### Task 12: 「我的」页身份切换
**Files:** `client/src/pages/profile/index.vue`
- [ ] **Step 1: 在 profile-card 和 quick-stats 之间添加身份切换卡片**
```vue
<view class="identity-card" @tap="showIdentityPicker">
<Icon :name="groupStore.isGroupMode ? 'users' : 'user'" :size="32" color="#FF8C69" />
<view class="id-info">
<text class="id-name">{{ groupStore.isGroupMode ? groupStore.currentGroup?.name : '个人账本' }}</text>
<text class="id-desc">{{ groupStore.isGroupMode ? groupStore.currentGroup?.member_count + ' 人共享' : '仅自己可见' }}</text>
</view>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
```
- [ ] **Step 2: 底部弹出身份选择面板**
```vue
<view class="identity-modal" v-if="showIdentity" @tap="showIdentity = false">
<view class="modal-content" @tap.stop>
<text class="modal-title">切换账本</text>
<!-- 个人账本 -->
<view class="id-option" :class="{ active: !groupStore.isGroupMode }" @tap="switchToPersonal">
<Icon name="user" :size="36" ... />
<text>个人账本</text>
<Icon v-if="!groupStore.isGroupMode" name="check" ... />
</view>
<!-- 群组列表 -->
<view v-for="g in groupStore.groups" ... @tap="switchToGroup(g.id)">
...
</view>
<!-- 操作按钮 -->
<view class="id-actions">
<view @tap="promptCreateGroup">创建群组</view>
<view @tap="promptJoinGroup">加入群组</view>
</view>
</view>
</view>
```
- [ ] **Step 3: 创建群组交互**
点击「创建群组」→ `uni.showModal` 输入名称 → 调用 `groupStore.createGroup()` → 成功后 `uni.setClipboardData` 复制邀请码 + Toast
- [ ] **Step 4: 加入群组交互**
点击「加入群组」→ `uni.showModal` 输入邀请码 → 调用 `groupStore.joinGroup()` → 成功后 Toast
- [ ] **Step 5: 切换后刷新数据**
切换身份后调用 `refreshData()` 重新加载当前页的 stats 和 transactions。
- [ ] **Step 6: Commit** `feat: 我的页身份切换`
---
#### Task 13: 群组管理页面
**Files:** Create `client/src/pages/group-manage/index.vue`, Modify `client/src/pages.json`
- [ ] **Step 1: pages.json 注册页面**
- [ ] **Step 2: 创建群组管理页面**
页面功能:
- 群组列表(卡片形式,显示名称、人数、邀请码)
- 点击邀请码可复制
- 成员列表
- 「退出群组」按钮(普通成员)— 二次确认:"退出后你在该群组的记录将回到个人账本"
- 「解散群组」按钮(群主)— 二次确认:"所有群组记录将被清除"
- [ ] **Step 3: Commit** `feat: 群组管理页面`
---
#### Task 14: App.vue 初始化 + 最终集成
**Files:** `client/src/App.vue`, `client/src/pages/index/index.vue`
- [ ] **Step 1: App.vue 初始化 groupStore**
登录成功后 `groupStore.init()` + `groupStore.fetchGroups()`
- [ ] **Step 2: 首页 fetchTodayData 传 group_id**
```typescript
const groupStore = useGroupStore()
const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId })
```
- [ ] **Step 3: 验证完整流程**
- [ ] **Step 4: Commit** `feat: 集成完成`
---
## 六、验证清单
### 用户信息
- [ ] 「我的」页显示真实头像和昵称
- [ ] 点击头像区域跳转编辑页,可上传头像(存到服务端)、修改昵称
- [ ] 保存后首页/我的页同步更新
### 一起记 — 创建/加入
- [ ] 身份切换面板中点击「创建群组」→ 输入名称 → 获得邀请码(自动复制)
- [ ] 点击「加入群组」→ 输入邀请码 → 加入成功
- [ ] 身份列表中出现新群组
### 一起记 — 记账归属
- [ ] 群组模式下记账 → 记录归属自己user_id = 我group_id = 群组
- [ ] 个人视图:看到自己所有记录(含群组中记的)
- [ ] 群组视图:看到所有成员在该群组下记的记录
- [ ] 编辑/删除:只能操作自己记的记录
### 一起记 — 退出/解散
- [ ] 退出群组 → 自己的记录回到个人视图,群组看不到
- [ ] 解散群组 → 群组消失,所有人的记录回到个人视图
- [ ] 退出/解散后自动切回个人模式

View File

@@ -13,6 +13,9 @@ DB_USER=your_mysql_user_here
DB_PASSWORD=your_mysql_password_here
DB_NAME=your_mysql_name_here
# Uploads
UPLOAD_DIR=./uploads
# Backup
BACKUP_DIR=/var/backups/xiaocai

112
server/package-lock.json generated
View File

@@ -12,11 +12,13 @@
"dotenv": "^17.4.2",
"express": "^4.18.2",
"express-rate-limit": "^8.5.2",
"multer": "^2.1.1",
"mysql2": "^3.9.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.11.0",
"shx": "^0.4.0",
"tsx": "^4.7.0",
@@ -574,6 +576,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -643,6 +655,12 @@
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -695,6 +713,23 @@
"node": ">=8"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -733,6 +768,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1537,6 +1587,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/mysql2": {
"version": "3.22.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.4.tgz",
@@ -1798,6 +1867,20 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/rechoir": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
@@ -2116,6 +2199,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-eof": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
@@ -2193,6 +2293,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2222,6 +2328,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

View File

@@ -13,11 +13,13 @@
"dotenv": "^17.4.2",
"express": "^4.18.2",
"express-rate-limit": "^8.5.2",
"multer": "^2.1.1",
"mysql2": "^3.9.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.11.0",
"shx": "^0.4.0",
"tsx": "^4.7.0",

View File

@@ -34,6 +34,55 @@ async function runMigrations(conn: mysql.Connection) {
await conn.query('ALTER TABLE budgets ADD UNIQUE KEY uk_user_month (user_id, month)')
console.log('[DB] budgets.user_id added')
}
// users 表添加 nickname 和 avatar_url
const hasNickname = await columnExists(conn, 'users', 'nickname')
if (!hasNickname) {
console.log('[DB] Migrating: adding nickname to users')
await conn.query("ALTER TABLE users ADD COLUMN nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称'")
}
const hasAvatarUrl = await columnExists(conn, 'users', 'avatar_url')
if (!hasAvatarUrl) {
console.log('[DB] Migrating: adding avatar_url to users')
await conn.query("ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL'")
}
// 群组表 + transactions.group_id
const hasGroupId = await columnExists(conn, 'transactions', 'group_id')
console.log(`[DB] transactions.group_id exists: ${hasGroupId}`)
if (!hasGroupId) {
console.log('[DB] Migrating: creating groups tables and adding group_id')
await conn.query(`
CREATE TABLE IF NOT EXISTS \`groups\` (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
invite_code VARCHAR(10) UNIQUE NOT NULL,
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await conn.query(`
CREATE TABLE IF NOT EXISTS group_members (
id INT AUTO_INCREMENT PRIMARY KEY,
group_id INT NOT NULL,
user_id INT NOT NULL,
role ENUM('owner', 'member') DEFAULT 'member',
nickname VARCHAR(50) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_group_user (group_id, user_id),
FOREIGN KEY (group_id) REFERENCES \`groups\`(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await conn.query("ALTER TABLE transactions ADD COLUMN group_id INT DEFAULT NULL COMMENT '群组标签'")
await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)")
console.log('[DB] Groups migration complete')
}
}
export async function initDatabase() {

View File

@@ -2,6 +2,8 @@ CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(100) UNIQUE,
session_key VARCHAR(100),
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -28,10 +30,12 @@ CREATE TABLE IF NOT EXISTS transactions (
category_id INT,
note VARCHAR(200),
date DATE NOT NULL,
group_id INT DEFAULT NULL COMMENT '群组标签NULL=纯个人记录',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date),
INDEX idx_user_type_date (user_id, type, date),
INDEX idx_group_date (group_id, date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -46,3 +50,25 @@ CREATE TABLE IF NOT EXISTS budgets (
UNIQUE KEY uk_user_month (user_id, month),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `groups` (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '群组名称',
invite_code VARCHAR(10) UNIQUE NOT NULL COMMENT '邀请码',
created_by INT NOT NULL COMMENT '创建者',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS group_members (
id INT AUTO_INCREMENT PRIMARY KEY,
group_id INT NOT NULL,
user_id INT NOT NULL,
role ENUM('owner', 'member') DEFAULT 'member' COMMENT '角色',
nickname VARCHAR(50) DEFAULT '' COMMENT '群内昵称',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_group_user (group_id, user_id),
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -2,6 +2,8 @@ import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
import path from 'path'
import fs from 'fs'
import { authMiddleware } from './middleware/auth'
import { initDatabase } from './db/init'
import pool from './db/connection'
@@ -11,6 +13,8 @@ import statsRoutes from './routes/stats'
import budgetRoutes from './routes/budget'
import categoryRoutes from './routes/category'
import backupRoutes from './routes/backup'
import userRoutes from './routes/user'
import groupRoutes from './routes/group'
import { backupDatabase } from './utils/backup'
// Warn if token secret is using default fallback
@@ -22,6 +26,9 @@ const app = express()
const PORT = process.env.PORT || 3000
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS || '').split(',').filter(Boolean)
// 反向代理环境下信任 X-Forwarded-* 头(修复 rate-limit 警告)
app.set('trust proxy', 1)
app.use(cors({
origin: (origin, callback) => {
// No origin = WeChat mini-program request (always allow)
@@ -54,6 +61,42 @@ const apiLimiter = rateLimit({
legacyHeaders: false,
})
// 头像 API公开不经过 auth 中间件)
app.get('/api/user/avatar/:filename', async (req, res) => {
try {
const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads')
const avatarDir = path.join(uploadDir, 'avatars')
const filename = path.basename(req.params.filename)
// 文件名格式校验:仅允许 数字_数字.ext
if (!/^\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) {
return res.status(400).json({ code: 40001, message: '无效文件名' })
}
const filepath = path.join(avatarDir, filename)
// 路径穿越检查:确保解析后仍在 avatarDir 内
if (!path.resolve(filepath).startsWith(path.resolve(avatarDir))) {
return res.status(400).json({ code: 40001, message: '无效路径' })
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ code: 40400, message: '头像不存在' })
}
const ext = path.extname(filename).toLowerCase()
const mimeMap: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp'
}
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=86400')
fs.createReadStream(filepath).pipe(res)
} catch {
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
app.use(authMiddleware)
app.use('/api/auth', authLimiter, authRoutes)
@@ -62,6 +105,8 @@ app.use('/api/stats', apiLimiter, statsRoutes)
app.use('/api/budget', apiLimiter, budgetRoutes)
app.use('/api/categories', apiLimiter, categoryRoutes)
app.use('/api/backup', apiLimiter, backupRoutes)
app.use('/api/user', apiLimiter, userRoutes)
app.use('/api/groups', apiLimiter, groupRoutes)
app.get('/api/health', async (_req, res) => {
try {
@@ -74,19 +119,25 @@ app.get('/api/health', async (_req, res) => {
let server: any
initDatabase().then(async () => {
// 每次启动时自动备份数据库
// 生产环境:先备份再迁移,迁移出问题可恢复
async function start() {
if (process.env.NODE_ENV === 'production') {
try {
await backupDatabase()
console.log('[Backup] 启动备份完成')
console.log('[Backup] 迁移前备份完成')
} catch (err) {
console.error('[Backup] 启动备份失败:', err)
console.error('[Backup] 备份失败:', err)
}
}
await initDatabase()
server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
})
}
start()
// Graceful shutdown
function shutdown(signal: string) {

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express'
import { createHmac } from 'crypto'
import { createHmac, timingSafeEqual } from 'crypto'
export interface AuthRequest extends Request {
userId?: number
@@ -48,10 +48,12 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
return res.status(401).json({ code: 40100, message: 'token无效' })
}
// Verify HMAC signature (full length)
// Verify HMAC signature (constant-time comparison)
const payload = `${userId}:${timestamp}`
const expectedSig = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
if (signature !== expectedSig) {
const sigBuf = Buffer.from(signature, 'hex')
const expectedBuf = Buffer.from(expectedSig, 'hex')
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}

View File

@@ -24,7 +24,9 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
)
const userId = (result as any).insertId
const token = signToken(userId)
res.json({ code: 0, data: { token, userId } })
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
} catch (err: any) {
console.error('[Auth] Demo login failed:', err.message)
res.status(500).json({ code: 50000, message: '服务器错误' })
@@ -59,8 +61,9 @@ router.post('/login', async (req: Request, res: Response) => {
// HMAC signed token with expiry
const token = signToken(userId)
res.json({ code: 0, data: { token, userId } })
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
} catch (err: any) {
console.error('[Auth] Login failed:', err.message)
res.status(500).json({ code: 50000, message: '服务器错误' })

View File

@@ -12,14 +12,36 @@ function validateMonth(m: string): string {
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const month = validateMonth((req.query.month as string) || getCurrentMonth())
const group_id = req.query.group_id as string | undefined
if (group_id && group_id !== 'null') {
// 群组视图:所有成员的预算总和
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group_id, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
const [rows] = await pool.query(
`SELECT COALESCE(SUM(amount), 0) as amount, month
FROM budgets
WHERE user_id IN (SELECT user_id FROM group_members WHERE group_id = ?) AND month = ?
GROUP BY month`,
[group_id, month]
)
const budget = (rows as any[])[0] || { amount: 0, month }
res.json({ code: 0, data: budget })
} else {
// 个人视图
const [rows] = await pool.query(
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
[req.userId, month]
)
const budget = (rows as any[])[0] || null
res.json({ code: 0, data: budget })
}
} catch (err) {
console.error('[Budget] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })

255
server/src/routes/group.ts Normal file
View File

@@ -0,0 +1,255 @@
import { Router, Response } from 'express'
import { randomInt } from 'crypto'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
/** 生成 6 位随机邀请码(密码学安全) */
function generateInviteCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars.charAt(randomInt(chars.length))
}
return code
}
/** 创建群组 */
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { name } = req.body
const trimmedName = typeof name === 'string' ? name.trim() : ''
if (!trimmedName) {
return res.status(400).json({ code: 40001, message: '群组名称不能为空' })
}
if (trimmedName.length > 100) {
return res.status(400).json({ code: 40001, message: '群组名称不能超过100个字符' })
}
// 生成唯一邀请码
let inviteCode = generateInviteCode()
let attempts = 0
while (attempts < 10) {
const [existing] = await pool.query('SELECT id FROM `groups` WHERE invite_code = ?', [inviteCode])
if ((existing as any[]).length === 0) break
inviteCode = generateInviteCode()
attempts++
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
const [result] = await conn.query(
'INSERT INTO `groups` (name, invite_code, created_by) VALUES (?, ?, ?)',
[trimmedName, inviteCode, req.userId]
)
const groupId = (result as any).insertId
// 创建者自动成为 owner
await conn.query(
'INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
[groupId, req.userId, 'owner']
)
await conn.commit()
res.json({ code: 0, data: { id: groupId, invite_code: inviteCode } })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 获取用户的群组列表 */
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT g.id, g.name, g.invite_code, g.created_by, g.created_at,
gm.role, gm.nickname as my_nickname,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) as member_count
FROM \`groups\` g
INNER JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
ORDER BY g.created_at DESC`,
[req.userId]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Group] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 获取群组详情 + 成员列表 */
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
// 验证用户是否是群组成员
const [memberCheck] = await pool.query(
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
[req.params.id, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
const [groupRows] = await pool.query(
'SELECT id, name, invite_code, created_by, created_at FROM `groups` WHERE id = ?',
[req.params.id]
)
const group = (groupRows as any[])[0]
if (!group) {
return res.status(404).json({ code: 40400, message: '群组不存在' })
}
// 获取成员列表
const [members] = await pool.query(
`SELECT gm.user_id, gm.role, gm.nickname, gm.created_at as joined_at,
u.nickname as user_nickname, u.avatar_url
FROM group_members gm
LEFT JOIN users u ON gm.user_id = u.id
WHERE gm.group_id = ?
ORDER BY gm.role DESC, gm.created_at ASC`,
[req.params.id]
)
res.json({ code: 0, data: { ...group, members } })
} catch (err) {
console.error('[Group] GET by ID error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 通过邀请码加入群组 */
router.post('/join', async (req: AuthRequest, res: Response) => {
try {
const { invite_code } = req.body
if (!invite_code || typeof invite_code !== 'string') {
return res.status(400).json({ code: 40001, message: '邀请码不能为空' })
}
const [groupRows] = await pool.query(
'SELECT id, name FROM `groups` WHERE invite_code = ?',
[invite_code.toUpperCase()]
)
const group = (groupRows as any[])[0]
if (!group) {
return res.status(404).json({ code: 40400, message: '邀请码无效' })
}
// 检查是否已是成员
const [existing] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group.id, req.userId]
)
if ((existing as any[]).length > 0) {
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
}
await pool.query(
'INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
[group.id, req.userId, 'member']
)
res.json({ code: 0, data: { group_id: group.id, name: group.name } })
} catch (err) {
console.error('[Group] join error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 退出群组 */
router.post('/:id/leave', async (req: AuthRequest, res: Response) => {
try {
const groupId = req.params.id
// 检查是否是成员
const [memberRows] = await pool.query(
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, req.userId]
)
const member = (memberRows as any[])[0]
if (!member) {
return res.status(404).json({ code: 40400, message: '你不是该群组成员' })
}
// owner 不能退出,只能解散
if (member.role === 'owner') {
return res.status(400).json({ code: 40003, message: '群主不能退出,请先转让或解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 清除该用户在该群组的记录标签
await conn.query(
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
[req.userId, groupId]
)
// 删除成员关系
await conn.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, req.userId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] leave error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 解散群组(仅 owner */
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [groupRows] = await pool.query(
'SELECT created_by FROM `groups` WHERE id = ?',
[req.params.id]
)
const group = (groupRows as any[])[0]
if (!group) {
return res.status(404).json({ code: 40400, message: '群组不存在' })
}
if (group.created_by !== req.userId) {
return res.status(403).json({ code: 40300, message: '仅群主可解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 清除该群组所有记录的标签
await conn.query('UPDATE transactions SET group_id = NULL WHERE group_id = ?', [req.params.id])
// 删除群组CASCADE 自动清理 group_members
await conn.query('DELETE FROM `groups` WHERE id = ?', [req.params.id])
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -6,19 +6,49 @@ import { getCurrentMonth, getMonthRange } from '../utils/date'
const router = Router()
const VALID_TYPES = ['expense', 'income']
/** 根据 group_id 参数构建 WHERE 条件(含成员验证) */
async function buildWhereClause(
userId: number | undefined,
groupId: string | undefined,
extraConditions: string[] = []
): Promise<{ where: string; params: any[] } | null> {
const params: any[] = []
if (groupId && groupId !== 'null') {
// 验证用户是群组成员
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, userId]
)
if ((memberCheck as any[]).length === 0) return null
params.push(groupId)
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
params.push(userId)
let where = `WHERE t.user_id = ?`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
router.get('/overview', async (req: AuthRequest, res: Response) => {
try {
const { month, startDate: qStart, endDate: qEnd } = req.query
const { month, startDate: qStart, endDate: qEnd, group_id } = req.query
let startDate: string
let endDate: string
let elapsedDays = 1
if (qStart && qEnd) {
// 按日期范围查询(用于今日等场景)
startDate = qStart as string
endDate = qEnd as string
elapsedDays = 1
// 计算日期范围天数
const start = new Date(startDate)
const end = new Date(endDate)
elapsedDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000) + 1)
} else {
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
@@ -31,13 +61,20 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
}
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as income,
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as income,
COUNT(*) as count
FROM transactions WHERE user_id = ? AND date >= ? AND date <= ?`,
[req.userId, startDate, endDate]
FROM transactions t ${result.where}`,
[...result.params, startDate, endDate]
)
const row = (rows as any[])[0]
const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0
@@ -54,7 +91,7 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
router.get('/category', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense' } = req.query
const { month, type = 'expense', group_id } = req.query
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
@@ -63,14 +100,21 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.type = ?', 't.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ? AND t.date >= ? AND t.date <= ?
${result.where}
GROUP BY c.id
ORDER BY amount DESC`,
[req.userId, type, startDate, endDate]
[...result.params, type, startDate, endDate]
)
res.json({ code: 0, data: rows })
@@ -82,7 +126,7 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
router.get('/trend', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense' } = req.query
const { month, type = 'expense', group_id } = req.query
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
@@ -91,13 +135,20 @@ router.get('/trend', async (req: AuthRequest, res: Response) => {
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.type = ?', 't.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT DATE_FORMAT(date, '%Y-%m-%d') as date, SUM(amount) as amount
FROM transactions
WHERE user_id = ? AND type = ? AND date >= ? AND date <= ?
GROUP BY date
ORDER BY date`,
[req.userId, type, startDate, endDate]
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, SUM(t.amount) as amount
FROM transactions t
${result.where}
GROUP BY t.date
ORDER BY t.date`,
[...result.params, type, startDate, endDate]
)
res.json({ code: 0, data: rows })

View File

@@ -16,12 +16,21 @@ function safeInt(val: any, fallback: number, min: number, max: number): number {
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at,
c.name as category_name, c.icon as category_icon, c.color as category_color
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
c.name as category_name, c.icon as category_icon, c.color as category_color,
u.nickname as creator_nickname
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.id = ? AND t.user_id = ?`,
[req.params.id, req.userId]
LEFT JOIN users u ON t.user_id = u.id
WHERE t.id = ? AND (
t.user_id = ?
OR t.user_id IN (
SELECT gm2.user_id FROM group_members gm1
JOIN group_members gm2 ON gm1.group_id = gm2.group_id
WHERE gm1.user_id = ?
)
)`,
[req.params.id, req.userId, req.userId]
)
const tx = (rows as any[])[0]
if (!tx) {
@@ -36,13 +45,30 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const { page, pageSize, type, startDate, endDate, sortBy } = req.query
const { page, pageSize, type, startDate, endDate, sortBy, group_id } = req.query
const pSize = safeInt(pageSize, 20, 1, 100)
const pNum = safeInt(page, 1, 1, 99999)
const offset = (pNum - 1) * pSize
let where = 'WHERE t.user_id = ?'
const params: any[] = [req.userId]
let where = ''
const params: any[] = []
if (group_id && group_id !== 'null') {
// 群组视图:群组下所有成员的记录
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group_id, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id)
} else {
// 个人视图:我的所有记录(不管 group_id
where = 'WHERE t.user_id = ?'
params.push(req.userId)
}
if (type && VALID_TYPES.includes(type as string)) {
where += ' AND t.type = ?'; params.push(type)
@@ -57,10 +83,12 @@ router.get('/', async (req: AuthRequest, res: Response) => {
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
const [rows] = await pool.query(
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at,
c.name as category_name, c.icon as category_icon, c.color as category_color
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
c.name as category_name, c.icon as category_icon, c.color as category_color,
u.nickname as creator_nickname
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN users u ON t.user_id = u.id
${where}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
@@ -89,7 +117,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { amount, type, category_id, note, date } = req.body
const { amount, type, category_id, note, date, group_id } = req.body
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' })
}
@@ -106,9 +134,21 @@ router.post('/', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
}
// 如果指定了 group_id验证类型和用户是群组成员
const validGroupId = group_id && typeof group_id === 'number' && Number.isInteger(group_id) ? group_id : null
if (validGroupId) {
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[validGroupId, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权在此群组记账' })
}
}
const [result] = await pool.query(
'INSERT INTO transactions (user_id, amount, type, category_id, note, date) VALUES (?, ?, ?, ?, ?, ?)',
[req.userId, amount, type, category_id || null, note || null, date]
'INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
[req.userId, amount, type, category_id || null, note || null, date, validGroupId]
)
res.json({ code: 0, data: { id: (result as any).insertId } })

133
server/src/routes/user.ts Normal file
View File

@@ -0,0 +1,133 @@
import { Router, Response } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
// 头像上传配置
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const AVATAR_DIR = path.resolve(UPLOAD_DIR, 'avatars')
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
// 确保目录存在(可能被手动删除)
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
cb(null, AVATAR_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }, // 最大 2MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 获取当前用户信息 */
router.get('/me', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
'SELECT id, nickname, avatar_url, created_at FROM users WHERE id = ?',
[req.userId]
)
const user = (rows as any[])[0]
if (!user) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
res.json({ code: 0, data: user })
} catch (err) {
console.error('[User] GET /me error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 更新用户昵称 */
router.put('/me', async (req: AuthRequest, res: Response) => {
try {
const { nickname } = req.body
if (nickname !== undefined) {
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
return res.status(400).json({ code: 40001, message: '昵称不能为空' })
}
if (nickname.length > 50) {
return res.status(400).json({ code: 40001, message: '昵称不能超过50个字符' })
}
}
const updates: string[] = []
const params: any[] = []
if (nickname !== undefined) {
updates.push('nickname = ?')
params.push(nickname.trim())
}
if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
}
params.push(req.userId)
await pool.query(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
res.json({ code: 0 })
} catch (err) {
console.error('[User] PUT /me error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 上传头像 */
router.post('/avatar', (req: AuthRequest, res: Response) => {
upload.single('file')(req, res, async (err) => {
if (err) {
return res.status(400).json({ code: 40001, message: err.message })
}
if (!req.file) {
return res.status(400).json({ code: 40001, message: '请选择图片文件' })
}
try {
// 先查询旧头像
const [rows] = await pool.query('SELECT avatar_url FROM users WHERE id = ?', [req.userId])
const oldUrl = (rows as any[])[0]?.avatar_url
// 先更新 DB再删旧文件保证 DB 和文件至少有一个正确)
await pool.query('UPDATE users SET avatar_url = ? WHERE id = ?', [req.file.filename, req.userId])
if (oldUrl) {
const oldPath = path.join(AVATAR_DIR, oldUrl)
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath)
}
}
res.json({ code: 0, data: { avatar_url: req.file.filename } })
} catch (err) {
console.error('[User] avatar upload error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
})
export default router

View File

@@ -1,21 +1,18 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import fs from 'fs'
import zlib from 'zlib'
import pool from '../db/connection'
const execFileAsync = promisify(execFile)
const gzipAsync = promisify(zlib.gzip)
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
const MAX_BACKUPS = 7 // 保留最近 7 天的备份
/**
* 执行数据库备份
* @returns 备份文件路径
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump
* 导出所有表的 INSERT 语句gzip 压缩存储
*/
export async function backupDatabase(): Promise<string> {
// 确保备份目录存在
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true })
}
@@ -24,64 +21,64 @@ export async function backupDatabase(): Promise<string> {
const filename = `xiaocai_${date}.sql.gz`
const filepath = path.join(BACKUP_DIR, filename)
const dbHost = process.env.DB_HOST || 'localhost'
const dbUser = process.env.DB_USER || 'root'
const dbPassword = process.env.DB_PASSWORD || ''
const dbName = process.env.DB_NAME || 'xiaocai'
const lines: string[] = []
lines.push(`-- 小菜记账 数据库备份`)
lines.push(`-- 时间: ${new Date().toISOString()}`)
lines.push('')
try {
// 使用 execFile 避免命令注入,参数通过数组传递
const args = [
`-h${dbHost}`,
`-u${dbUser}`,
...(dbPassword ? [`-p${dbPassword}`] : []),
dbName
]
// 获取所有表名
const [tables] = await pool.query('SHOW TABLES')
const tableNames = (tables as any[]).map(row => Object.values(row)[0] as string)
const { stdout } = await execFileAsync('mysqldump', args, { maxBuffer: 50 * 1024 * 1024 })
for (const tableName of tableNames) {
lines.push(`-- ----------------------------`)
lines.push(`-- 表: ${tableName}`)
lines.push(`-- ----------------------------`)
// 使用 Node.js zlib 压缩
const compressed = await gzipAsync(Buffer.from(stdout, 'utf-8'))
// 获取表数据
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
const data = rows as any[]
if (data.length === 0) {
lines.push(`-- ${tableName}: 无数据`)
lines.push('')
continue
}
// 获取列名
const columns = Object.keys(data[0])
const colList = columns.map(c => `\`${c}\``).join(', ')
// 生成 INSERT 语句(每 100 行一批)
const BATCH_SIZE = 100
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE)
const values = batch.map(row => {
const vals = columns.map(col => {
const v = row[col]
if (v === null) return 'NULL'
if (typeof v === 'number') return String(v)
if (typeof v === 'boolean') return v ? '1' : '0'
// mysql2 escape 处理所有字符串/日期转义(自带引号)
return pool.escape(v)
})
return `(${vals.join(', ')})`
}).join(',\n ')
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
lines.push(` ${values};`)
}
lines.push('')
}
const sql = lines.join('\n')
const compressed = await gzipAsync(Buffer.from(sql, 'utf-8'))
fs.writeFileSync(filepath, compressed)
console.log(`[Backup] 完成: ${filename}`)
// 清理旧备份
await cleanOldBackups()
console.log(`[Backup] 完成: ${filename} (${tableNames.length} 表)`)
return filepath
} catch (err) {
console.error('[Backup] 失败:', err)
throw err
}
}
/**
* 清理过期的备份文件
*/
async function cleanOldBackups(): Promise<void> {
try {
if (!fs.existsSync(BACKUP_DIR)) return
const files = fs.readdirSync(BACKUP_DIR)
.filter(f => f.startsWith('xiaocai_') && (f.endsWith('.sql.gz') || f.endsWith('.sql')))
.sort()
.reverse()
// 删除超出保留数量的文件
for (let i = MAX_BACKUPS; i < files.length; i++) {
const filepath = path.join(BACKUP_DIR, files[i])
fs.unlinkSync(filepath)
console.log(`[Backup] 清理旧备份: ${files[i]}`)
}
} catch (err) {
console.error('[Backup] 清理失败:', err)
}
}
/**
* 获取备份列表
*/
export function getBackupList(): Array<{ name: string; size: number; date: string }> {
if (!fs.existsSync(BACKUP_DIR)) return []

View File

@@ -60,7 +60,7 @@
"@types/range-parser" "*"
"@types/send" "*"
"@types/express@^4.17.21":
"@types/express@*", "@types/express@^4.17.21":
version "4.17.25"
resolved "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz"
integrity sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==
@@ -80,6 +80,13 @@
resolved "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz"
integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
"@types/multer@^2.1.0":
version "2.1.0"
resolved "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz"
integrity sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==
dependencies:
"@types/express" "*"
"@types/node@*", "@types/node@^20.11.0", "@types/node@>= 8":
version "20.19.41"
resolved "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz"
@@ -129,6 +136,11 @@ accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
append-field@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz"
integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==
array-flatten@1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz"
@@ -164,6 +176,18 @@ braces@^3.0.3:
dependencies:
fill-range "^7.1.1"
buffer-from@^1.0.0:
version "1.1.2"
resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
busboy@^1.6.0:
version "1.6.0"
resolved "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz"
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
dependencies:
streamsearch "^1.1.0"
bytes@~3.1.2:
version "3.1.2"
resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz"
@@ -185,6 +209,16 @@ call-bound@^1.0.2:
call-bind-apply-helpers "^1.0.2"
get-intrinsic "^1.3.0"
concat-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz"
integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==
dependencies:
buffer-from "^1.0.0"
inherits "^2.0.3"
readable-stream "^3.0.2"
typedarray "^0.0.6"
content-disposition@~0.5.4:
version "0.5.4"
resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz"
@@ -535,7 +569,7 @@ iconv-lite@~0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
inherits@~2.0.4:
inherits@^2.0.3, inherits@~2.0.4:
version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -669,6 +703,16 @@ ms@2.1.3:
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
multer@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz"
integrity sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==
dependencies:
append-field "^1.0.0"
busboy "^1.6.0"
concat-stream "^2.0.0"
type-is "^1.6.18"
mysql2@^3.9.0:
version "3.22.4"
resolved "https://registry.npmjs.org/mysql2/-/mysql2-3.22.4.tgz"
@@ -804,6 +848,15 @@ raw-body@~2.5.3:
iconv-lite "~0.4.24"
unpipe "~1.0.0"
readable-stream@^3.0.2:
version "3.6.2"
resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz"
integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
rechoir@^0.6.2:
version "0.6.2"
resolved "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz"
@@ -833,7 +886,7 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
safe-buffer@5.2.1:
safe-buffer@~5.2.0, safe-buffer@5.2.1:
version "5.2.1"
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -967,6 +1020,18 @@ statuses@~2.0.1, statuses@~2.0.2:
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz"
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
streamsearch@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
string_decoder@^1.1.1:
version "1.3.0"
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
dependencies:
safe-buffer "~5.2.0"
strip-eof@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz"
@@ -998,7 +1063,7 @@ tsx@^4.7.0:
optionalDependencies:
fsevents "~2.3.3"
type-is@~1.6.18:
type-is@^1.6.18, type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
@@ -1006,6 +1071,11 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typedarray@^0.0.6:
version "0.0.6"
resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
typescript@^5.3.0:
version "5.9.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz"
@@ -1021,6 +1091,11 @@ unpipe@~1.0.0:
resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
util-deprecate@^1.0.1:
version "1.0.2"
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
utils-merge@1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"