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

@@ -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>