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

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

View File

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

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

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

View File

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

View File

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

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

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