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

@@ -22,6 +22,7 @@
<view class="amount-display">
<text class="currency">¥</text>
<text class="digits">{{ displayAmount }}</text>
<text class="cursor">|</text>
</view>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
@@ -46,7 +47,7 @@
</view>
</view>
<Numpad @input="onInput" @delete="onDelete" @confirm="save" />
<Numpad v-model="amountStr" @confirm="save" />
<SaveSuccess v-model:visible="showSuccess" :text="editId ? '修改成功' : '记账成功'" />
</view>
@@ -86,8 +87,8 @@ const dateLabel = computed(() => {
})
const displayAmount = computed(() => {
const num = parseFloat(amountStr.value) || 0
return num.toFixed(2)
if (!amountStr.value) return ''
return amountStr.value
})
const currentCategories = computed(() => catStore.getByType(type.value))
@@ -151,33 +152,6 @@ function onDateChange(e: any) {
selectedDate.value = e.detail.value
}
function onInput(key: string) {
if (key === '.' && amountStr.value.includes('.')) return
// When current value is '0', replace with new key (but '0' and '00' should stay as '0')
let next: string
if (amountStr.value === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = amountStr.value + key
}
const num = parseFloat(next)
if (!isNaN(num) && num > 9999999.99) return
// Check decimal limit on the resulting value (handles "00" adding 2 chars)
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
}
amountStr.value = next
}
function onDelete() {
if (amountStr.value.length > 1) {
amountStr.value = amountStr.value.slice(0, -1)
} else {
amountStr.value = '0'
}
}
async function save() {
if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value) * 100)
@@ -329,6 +303,18 @@ function goBack() {
font-variant-numeric: tabular-nums;
}
.cursor {
font-family: 'Fredoka', monospace;
font-size: 88rpx;
font-weight: 300;
color: #FF8C69;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.category-scroll {
max-height: 320rpx;
}

View File

@@ -71,8 +71,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
@@ -106,7 +107,17 @@ const groupedTx = computed(() => {
return Object.values(groups).sort((a, b) => b.date.localeCompare(a.date))
})
onMounted(() => loadTx(true))
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
loadTx(true).finally(() => { initialLoaded.value = true })
})
// 静默刷新(切换群组/返回时)
onShow(() => {
if (initialLoaded.value) loadTx(true)
})
function switchFilter(type: string) {
filterType.value = type

View File

@@ -49,18 +49,13 @@
<view class="section-title">自定义金额</view>
<view class="custom-input">
<text class="input-prefix">¥</text>
<text class="input-value" :class="{ placeholder: !amountStr }">
{{ amountStr || '0.00' }}
</text>
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text>
<text class="input-cursor">|</text>
<text class="input-unit"></text>
</view>
<!-- Numpad -->
<Numpad
@input="onInput"
@delete="onDelete"
@confirm="onConfirm"
/>
<Numpad v-model="amountStr" @confirm="onConfirm" />
</view>
</view>
</template>
@@ -113,32 +108,6 @@ function selectPreset(val: number) {
amountStr.value = String(val)
}
function onInput(key: string) {
if (key === '.' && amountStr.value.includes('.')) return
if (key === '.' && !amountStr.value) {
amountStr.value = '0.'
return
}
let next: string
if (amountStr.value === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = amountStr.value + key
}
const parts = next.split('.')
if (parts[1] && parts[1].length > 2) return
if (next.length > 10) return
amountStr.value = next
}
function onDelete() {
if (amountStr.value.length > 1) {
amountStr.value = amountStr.value.slice(0, -1)
} else {
amountStr.value = '0'
}
}
async function onConfirm() {
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) {
@@ -153,6 +122,7 @@ async function onConfirm() {
await budgetStore.setBudget(amount, currentMonth)
budget.value = budgetStore.budget
uni.showToast({ title: '预算已设置', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
@@ -348,4 +318,16 @@ function goBack() {
font-size: 28rpx;
color: #8B7E7E;
}
.input-cursor {
font-family: 'Fredoka', monospace;
font-size: 56rpx;
font-weight: 300;
color: #FF8C69;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
</style>

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

View File

@@ -2,11 +2,12 @@
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="header" :style="{ paddingRight: capsuleRight + 'px' }">
<view class="avatar-wrap">
<Icon name="user" :size="36" color="#8B7E7E" />
<image v-if="userStore.avatarUrl" :src="userStore.avatarUrl" class="avatar-img" mode="aspectFill" />
<Icon v-else name="user" :size="36" color="#8B7E7E" />
</view>
<text class="greeting">{{ greeting }}小菜</text>
<text class="greeting">{{ greeting }}{{ userStore.nickname }}</text>
<view class="bell" @tap="showNotification">
<Icon name="bell" :size="40" color="#8B7E7E" />
</view>
@@ -117,9 +118,12 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
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 { getOverview } from '@/api/stats'
import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
@@ -128,11 +132,13 @@ import Skeleton from '@/components/Skeleton/Skeleton.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const DEFAULT_BUDGET = 500000 // 5000元
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 recentTx = ref<any[]>([])
const loading = ref(true)
const loadError = ref(false)
@@ -149,9 +155,10 @@ const greeting = computed(() => {
return '晚上好'
})
async function loadData() {
async function loadData(silent = false) {
await waitForReady()
try {
loading.value = true
if (!silent) loading.value = true
loadError.value = false
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
@@ -159,14 +166,13 @@ async function loadData() {
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 }),
fetchTodayData(today)
fetchTodayData(today),
userStore.fetchUserInfo()
])
overview.value = statsStore.overview
budget.value = budgetStore.budget
recentTx.value = txStore.transactions.slice(0, 5)
} catch (e) {
console.error('Load error:', e)
loadError.value = true
if (!silent) loadError.value = true
} finally {
loading.value = false
}
@@ -174,7 +180,7 @@ async function loadData() {
async function fetchTodayData(today: string) {
try {
const data = await getOverview({ startDate: today, endDate: today })
const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId })
todayExpense.value = data.expense || 0
todayIncome.value = data.income || 0
todayCount.value = data.count || 0
@@ -192,7 +198,7 @@ onMounted(() => {
// Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => {
if (initialLoaded.value && !loading.value) loadData()
if (initialLoaded.value && !loading.value) loadData(true)
})
onPullDownRefresh(() => {
@@ -249,6 +255,12 @@ function goBills() {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
}
.greeting {

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>

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>

View File

@@ -119,8 +119,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import { getOverview } from '@/api/stats'
import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format'
@@ -133,9 +134,9 @@ const statsStore = useStatsStore()
const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense')
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const categoryStats = ref<any[]>([])
const trendData = ref<any[]>([])
const overview = computed(() => statsStore.overview)
const categoryStats = computed(() => statsStore.categoryStats)
const trendData = computed(() => statsStore.trendData)
const loading = ref(true)
const loadError = ref(false)
const maxSingle = ref(0)
@@ -221,15 +222,24 @@ const monthCompareText = computed(() => {
return '持平'
})
onMounted(() => loadData())
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
loadData().finally(() => { initialLoaded.value = true })
})
onShow(() => {
if (initialLoaded.value) loadData(true)
})
// Only re-fetch category/trend on tab switch; re-fetch all on month change
watch(currentMonth, () => loadData())
watch(tabType, () => loadTabData())
async function loadData() {
async function loadData(silent = false) {
try {
loading.value = true
if (!silent) loading.value = true
loadError.value = false
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
@@ -238,9 +248,6 @@ async function loadData() {
fetchMaxSingle(),
fetchPrevMonthData()
])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
console.error('Stats load error:', e)
loadError.value = true
@@ -259,9 +266,6 @@ async function loadTabData() {
fetchMaxSingle(),
fetchPrevMonthData()
])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
console.error('Stats tab load error:', e)
}