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:
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user