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