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