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

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