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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -34,5 +34,9 @@ client/unpackage/
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Uploads (user-generated content)
|
||||
server/uploads/
|
||||
|
||||
# Playwright
|
||||
.playwright-mcp/
|
||||
server/backups/
|
||||
|
||||
93
CLAUDE.md
93
CLAUDE.md
@@ -16,6 +16,8 @@
|
||||
- **运行时**: Node.js + Express
|
||||
- **数据库**: MySQL 8.0 (mysql2 连接池)
|
||||
- **认证**: HMAC-SHA256 签名 token,30 天过期
|
||||
- **文件上传**: multer (头像,最大 2MB)
|
||||
- **备份**: Node.js mysql2 导出 (不依赖 mysqldump)
|
||||
- **CORS**: 已配置,支持 credentials
|
||||
|
||||
## Design System
|
||||
@@ -37,10 +39,17 @@
|
||||
|
||||
### 关键原则
|
||||
- 最小触摸目标 44×44px
|
||||
- 禁止使用 emoji 作为图标,统一使用 SvgIcon 组件
|
||||
- 禁止使用 emoji 作为图标,统一使用 Icon 组件
|
||||
- 尊重 prefers-reduced-motion
|
||||
- 文字对比度 ≥ 4.5:1 (WCAG AA)
|
||||
|
||||
### 小程序胶囊适配
|
||||
- 微信小程序右上角有胶囊按钮,页面顶部右侧元素(图标、按钮)需避开
|
||||
- 使用 `system.ts` 导出的 `capsuleRight` 计算安全距离
|
||||
- 导航栏添加动态 padding: `:style="{ paddingRight: capsuleRight + 'px' }"`
|
||||
- 居中标题页面(统计、账单、我的)不需要处理
|
||||
- 有右侧元素的页面(首页 bell、编辑页保存按钮)必须处理
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### 命名
|
||||
@@ -58,41 +67,69 @@ client/src/
|
||||
│ ├── add/ # 添加/编辑记录
|
||||
│ ├── stats/ # 统计分析
|
||||
│ ├── bills/ # 全部账单 (下拉刷新/上拉加载)
|
||||
│ ├── profile/ # 个人设置
|
||||
│ └── category-manage/ # 分类管理
|
||||
│ ├── profile/ # 我的(身份切换入口)
|
||||
│ ├── profile-edit/ # 编辑资料(昵称+头像)
|
||||
│ ├── group-manage/ # 群组管理(创建/加入/退出/解散)
|
||||
│ ├── category-manage/ # 分类管理
|
||||
│ └── budget/ # 预算设置
|
||||
├── components/ # 公共组件
|
||||
│ ├── BudgetBar/ # 预算进度条
|
||||
│ ├── CategoryIcon/ # 分类图标 (首字+颜色)
|
||||
│ ├── Icon/ # 图标组件 (PNG 映射)
|
||||
│ ├── Numpad/ # 数字键盘
|
||||
│ ├── SvgIcon/ # SVG 图标 (base64 data URI)
|
||||
│ ├── Skeleton/ # 骨架屏
|
||||
│ └── TransactionItem/ # 交易列表项
|
||||
├── stores/ # Pinia stores
|
||||
│ ├── transaction.ts # 交易 CRUD
|
||||
│ ├── transaction.ts # 交易 CRUD (自动传递 group_id)
|
||||
│ ├── category.ts # 分类 CRUD
|
||||
│ ├── budget.ts # 预算管理
|
||||
│ └── stats.ts # 统计概览
|
||||
│ ├── stats.ts # 统计概览 (自动传递 group_id)
|
||||
│ ├── user.ts # 用户信息 (昵称/头像)
|
||||
│ └── group.ts # 群组管理 (身份切换)
|
||||
├── api/ # API 封装
|
||||
│ ├── auth.ts # 登录接口
|
||||
│ ├── transaction.ts # 交易接口
|
||||
│ ├── stats.ts # 统计接口
|
||||
│ ├── user.ts # 用户信息接口 (含头像上传)
|
||||
│ └── group.ts # 群组接口
|
||||
├── utils/
|
||||
│ ├── format.ts # 金额/日期格式化
|
||||
│ ├── request.ts # HTTP 请求封装 (401 处理/自动重登录)
|
||||
│ └── system.ts # 系统信息 (状态栏高度等)
|
||||
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
|
||||
│ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight)
|
||||
│ └── app-ready.ts # App 启动就绪机制 (waitForReady)
|
||||
├── config.ts # 全局配置 (API_BASE)
|
||||
└── static/icons/ # tabBar 图标 (PNG)
|
||||
|
||||
server/src/
|
||||
├── index.ts # Express 入口 (CORS, JSON 解析)
|
||||
├── middleware/auth.ts # HMAC token 验证中间件
|
||||
├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册)
|
||||
├── middleware/auth.ts # HMAC token 验证 (timingSafeEqual)
|
||||
├── routes/
|
||||
│ ├── auth.ts # 登录 / token 签发
|
||||
│ ├── transaction.ts # 交易 CRUD (用户隔离)
|
||||
│ ├── auth.ts # 登录 / token 签发 (返回用户信息)
|
||||
│ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换)
|
||||
│ ├── category.ts # 分类 CRUD (默认+自定义)
|
||||
│ ├── budget.ts # 预算 CRUD (按月)
|
||||
│ └── stats.ts # 统计查询
|
||||
│ ├── stats.ts # 统计查询 (支持 group_id)
|
||||
│ ├── user.ts # 用户信息 + 头像上传
|
||||
│ ├── group.ts # 群组 CRUD (创建/加入/退出/解散)
|
||||
│ └── backup.ts # 备份 API
|
||||
├── utils/
|
||||
│ ├── backup.ts # Node.js 数据库备份 (gzip)
|
||||
│ └── date.ts # 日期工具
|
||||
└── db/
|
||||
├── connection.ts # MySQL 连接池
|
||||
├── init.ts # 建表 + seed (multipleStatements)
|
||||
├── init.ts # 建表 + seed + 迁移 (幂等)
|
||||
├── schema.sql # 表结构
|
||||
└── seed.sql # 默认分类数据
|
||||
```
|
||||
|
||||
### 数据库表
|
||||
- `users` — 用户 (openid, nickname, avatar_url)
|
||||
- `categories` — 分类 (user_id=0 默认, 其他=自定义)
|
||||
- `transactions` — 交易记录 (user_id, group_id, amount 分)
|
||||
- `budgets` — 预算 (user_id, month, amount)
|
||||
- `groups` — 群组 (name, invite_code, created_by)
|
||||
- `group_members` — 群组成员 (group_id, user_id, role)
|
||||
|
||||
### 金额处理
|
||||
- 存储单位: 分 (整数,避免浮点精度问题)
|
||||
- 显示单位: 元 (保留两位小数)
|
||||
@@ -101,15 +138,28 @@ server/src/
|
||||
- 使用 `formatAmount()` 带 ¥ 前缀,`formatAmountRaw()` 纯数字
|
||||
|
||||
### 数据持久化
|
||||
- 前端本地存储 Key: `xc:token`, `xc:userId`
|
||||
- 前端本地存储 Key:
|
||||
- `xc:token` — 认证 token
|
||||
- `xc:userId` — 用户 ID
|
||||
- `xc:nickname` — 用户昵称
|
||||
- `xc:avatar_url` — 头像文件名
|
||||
- `xc:currentGroupId` — 当前选中群组 ID (null=个人模式)
|
||||
- 后端 MySQL 数据库
|
||||
- API 基础地址: `request.ts` 中的 `BASE_URL`
|
||||
- API 基础地址: `config.ts` 中的 `API_BASE`
|
||||
|
||||
### 认证流程
|
||||
- H5: 页面加载时自动 login 获取 token
|
||||
- H5: 页面加载时自动 demo-login 获取 token
|
||||
- 微信小程序: `App.vue` onLaunch 调用 `wx.login` 获取 code,换 token
|
||||
- Token: HMAC-SHA256 签名,30 天过期
|
||||
- 401 处理: 自动清除 token,小程序触发 reLogin,H5 刷新页面
|
||||
- 401 处理: 请求入队 → 自动重登录 → 队列中所有请求重试
|
||||
- 启动就绪: `app-ready.ts` 的 `waitForReady()` 确保页面在登录完成后才加载数据
|
||||
|
||||
### 一起记 (群组)
|
||||
- 群组仅做数据聚合,记录始终属于记录者 (user_id)
|
||||
- 个人视图: `WHERE user_id = 我` (所有记录)
|
||||
- 群组视图: `WHERE group_id = X AND user_id IN (当前成员)`
|
||||
- 退出群组: 该用户的 group_id 设为 NULL,记录回到个人视图
|
||||
- 身份切换: 「我的」页底部弹窗选择,全局生效
|
||||
|
||||
### Git
|
||||
- 分支: `master` → `main` (注意远程)
|
||||
@@ -141,9 +191,10 @@ cd server && npm run db:init
|
||||
|
||||
## 部署
|
||||
|
||||
- 服务器: `106.14.208.43:3000`
|
||||
- 开发服务: `dev.xiaocai.j35.site:3000`
|
||||
- 正式服务: `xiaocai.j35.site`
|
||||
- MySQL: 本地 3306 端口
|
||||
- 微信小程序上线需配置 HTTPS 域名 (替换 `request.ts` 中的 `BASE_URL`)
|
||||
- 小程序开发版自动连接 dev 服务,正式版/H5 连接线上 (config.ts)
|
||||
|
||||
### 手动部署步骤
|
||||
|
||||
@@ -163,7 +214,7 @@ cd /var/www/xiaocai && npm install --production
|
||||
# 5. 重启服务
|
||||
pm2 restart xiaocai-server
|
||||
# 或直接运行
|
||||
node dist/index.js
|
||||
NODE_ENV=production node dist/index.js
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
12
DEV.md
12
DEV.md
@@ -26,10 +26,17 @@
|
||||
- 不要为了注释而注释,避免冗余的废话(例如 `i++; // 自增 i`)。
|
||||
- 多行 JSDoc/文档注释保持可读性,**不要压缩成单行**。
|
||||
|
||||
## 小程序适配要点
|
||||
|
||||
- 页面顶部右侧有元素时,使用 `capsuleRight` 避开胶囊按钮
|
||||
- 新页面需要加载数据时,在 `onMounted` 中先 `await waitForReady()` 再请求
|
||||
- API 地址统一在 `config.ts` 中配置,不要在各文件中硬编码
|
||||
- 头像等用户文件 URL 只存文件名,前端拼接 `API_BASE + 路径 + 文件名`
|
||||
|
||||
## Never 规则
|
||||
|
||||
- Never 修改 dist/ 目录
|
||||
- Never 使用内联样式,除非需要动态计算
|
||||
- Never 使用内联样式,除非需要动态计算(胶囊 padding 等除外)
|
||||
- Never 在渲染路径中执行耗时操作
|
||||
- Never 在列表渲染中省略 key 属性
|
||||
- Never 修改 lock 文件
|
||||
@@ -39,4 +46,5 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
- 代码提交前检查代码,包括但不限于代码格式、注释、测试覆盖率等。
|
||||
- 代码提交前必须检查代码,包括但不限于代码格式、注释、测试覆盖率、文档更新等。
|
||||
- 影响到历史数据吧,必须要准备好数据迁移方案。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@ import { request } from '@/utils/request'
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
userId: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
/** H5 演示登录 */
|
||||
|
||||
@@ -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
58
client/src/api/group.ts
Normal 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' })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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
46
client/src/api/user.ts
Normal 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('网络错误'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
26
client/src/config.ts
Normal 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()
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
380
client/src/pages/group-manage/index.vue
Normal file
380
client/src/pages/group-manage/index.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
250
client/src/pages/profile-edit/index.vue
Normal file
250
client/src/pages/profile-edit/index.vue
Normal 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">• 头像支持 JPG、PNG 格式,最大 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
113
client/src/stores/group.ts
Normal 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)
|
||||
|
||||
/** 当前选中的群组 ID,null 表示个人模式 */
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
52
client/src/stores/user.ts
Normal 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 ''
|
||||
// 已是完整 URL(http/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 }
|
||||
})
|
||||
20
client/src/utils/app-ready.ts
Normal file
20
client/src/utils/app-ready.ts
Normal 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))
|
||||
])
|
||||
}
|
||||
@@ -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 {
|
||||
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
||||
// "用户不存在" 不弹 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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:只看到 ¥30(B 的记录已不在)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库设计
|
||||
|
||||
### 现有表(不变)
|
||||
|
||||
```
|
||||
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 清理 members,transactions.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 = 群组
|
||||
- [ ] 个人视图:看到自己所有记录(含群组中记的)
|
||||
- [ ] 群组视图:看到所有成员在该群组下记的记录
|
||||
- [ ] 编辑/删除:只能操作自己记的记录
|
||||
|
||||
### 一起记 — 退出/解散
|
||||
- [ ] 退出群组 → 自己的记录回到个人视图,群组看不到
|
||||
- [ ] 解散群组 → 群组消失,所有人的记录回到个人视图
|
||||
- [ ] 退出/解散后自动切回个人模式
|
||||
@@ -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
112
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
// 每次启动时自动备份数据库
|
||||
try {
|
||||
await backupDatabase()
|
||||
console.log('[Backup] 启动备份完成')
|
||||
} catch (err) {
|
||||
console.error('[Backup] 启动备份失败:', err)
|
||||
// 生产环境:先备份再迁移,迁移出问题可恢复
|
||||
async function start() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
try {
|
||||
await backupDatabase()
|
||||
console.log('[Backup] 迁移前备份完成')
|
||||
} catch (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) {
|
||||
|
||||
@@ -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无效' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '服务器错误' })
|
||||
|
||||
@@ -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
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
|
||||
[req.userId, month]
|
||||
)
|
||||
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 budget = (rows as any[])[0] || null
|
||||
res.json({ code: 0, data: budget })
|
||||
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
255
server/src/routes/group.ts
Normal 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
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
133
server/src/routes/user.ts
Normal 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
|
||||
@@ -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'))
|
||||
fs.writeFileSync(filepath, compressed)
|
||||
// 获取表数据
|
||||
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
|
||||
const data = rows as any[]
|
||||
|
||||
console.log(`[Backup] 完成: ${filename}`)
|
||||
|
||||
// 清理旧备份
|
||||
await cleanOldBackups()
|
||||
|
||||
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]}`)
|
||||
if (data.length === 0) {
|
||||
lines.push(`-- ${tableName}: 无数据`)
|
||||
lines.push('')
|
||||
continue
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Backup] 清理失败:', err)
|
||||
|
||||
// 获取列名
|
||||
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} (${tableNames.length} 表)`)
|
||||
return filepath
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份列表
|
||||
*/
|
||||
export function getBackupList(): Array<{ name: string; size: number; date: string }> {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return []
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user