Files
xiaocai/client/src/pages/profile/index.vue
wangxiaogang 6af876a8f4 feat: 品牌文案配置化 + 全页面下拉刷新
- 新增 configStore 管理品牌文案(appName, appSlogan, appDescription)
- 关于弹窗、分享标题使用 configStore 读取品牌名
- 所有数据页面添加下拉刷新功能
- 所有数据页面 onShow 时自动刷新数据
2026-06-08 10:59:09 +08:00

735 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">我的</text>
</view>
<view class="profile-card" @tap="goProfileEdit">
<view class="p-avatar">
<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">{{ userStore.nickname }}</text>
<text class="p-tagline">{{ userStore.slogan }}</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">
<view class="qs-card">
<text class="qs-label">本月支出</text>
<text class="qs-value" v-if="!loading">{{ formatAmount(overview.expense) }}</text>
<view class="qs-skeleton" v-else></view>
</view>
<view class="qs-card">
<text class="qs-label">本月收入</text>
<text class="qs-value income" v-if="!loading">{{ formatAmount(overview.income) }}</text>
<view class="qs-skeleton" v-else></view>
</view>
</view>
<view class="menu-card">
<view class="menu-item" @tap="goBudget">
<text class="mi-label">预算设置</text>
<text class="mi-extra" v-if="budget">{{ formatAmount(budget.amount) }}</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goCategoryManage">
<text class="mi-label">分类管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="exportData">
<text class="mi-label">数据导出</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<view class="menu-card mt">
<view class="menu-item" @tap="goFeedback">
<text class="mi-label">意见反馈</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="showAbout = true">
<text class="mi-label">关于小菜</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goPrivacy">
<text class="mi-label">隐私政策</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<!-- 管理员入口 -->
<view v-if="userStore.userInfo?.role === 'admin'" class="menu-card mt">
<view class="menu-item admin-entry" @tap="goAdmin">
<Icon name="settings" :size="32" color="#FF8C69" />
<text class="mi-label">管理后台</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<!-- 关于弹窗 -->
<view class="modal-mask" v-if="showAbout" @tap="showAbout = false">
<view class="modal" @tap.stop>
<text class="about-title">{{ configStore.appName }}</text>
<text class="about-version">v{{ configStore.config.app_version || '1.0.0' }}</text>
<text class="about-desc">{{ configStore.appSlogan }}</text>
<text class="about-desc">{{ configStore.appDescription }}</text>
<view class="modal-btns modal-btns-top">
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
</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, computed, onMounted } from 'vue'
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 { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const configStore = useConfigStore()
const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget)
const loading = ref(true)
const showAbout = ref(false)
const showIdentity = ref(false)
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
loading.value = true
try {
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
userStore.fetchUserInfo(),
groupStore.fetchGroups()
])
} catch (e) {
console.error('Profile load error:', e)
} finally {
loading.value = false
initialLoaded.value = true
}
})
// 返回页面时静默刷新
onShow(async () => {
if (!initialLoaded.value) return
try {
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
groupStore.fetchGroups(),
userStore.fetchUserInfo()
])
} catch {}
})
onPullDownRefresh(async () => {
try {
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
groupStore.fetchGroups(),
userStore.fetchUserInfo(),
configStore.fetchConfig()
])
} catch {}
uni.stopPullDownRefresh()
})
function goBudget() {
uni.navigateTo({ url: '/pages/budget/index' })
}
function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
function goPrivacy() {
uni.navigateTo({ url: '/pages/privacy/index' })
}
function goFeedback() {
uni.navigateTo({ url: '/pages/feedback/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 goAdmin() {
uni.navigateTo({ url: '/pages/admin/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')}`
}
async function exportData() {
uni.showLoading({ title: '导出中 0%' })
try {
// 分页获取全部数据(服务端 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++
// 更新进度
const progress = Math.min(100, Math.round((allList.length / total) * 100))
uni.showLoading({ title: `导出中 ${progress}%` })
} while (allList.length < total)
if (allList.length === 0) {
uni.hideLoading()
uni.showToast({ title: '暂无数据', icon: 'none' })
return
}
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
}
return val
}
const header = '日期,类型,分类,金额(元),备注\n'
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(',')
}).join('\n')
const csv = '' + header + rows
// #ifdef H5
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `小菜记账_${getLocalDateStr()}.csv`
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
uni.hideLoading()
uni.openDocument({
filePath,
fileType: 'csv',
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
} catch {
uni.hideLoading()
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed {
@include sticky-header;
}
.status-bar {
@include status-bar;
}
.page-title {
display: block;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
padding: $space-sm 0 $space-md;
}
.profile-card {
margin: 0 $space-xl;
padding: $space-2xl;
background: linear-gradient(135deg, $primary, #FFB89A);
border-radius: $radius-2xl;
box-shadow: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.1);
display: flex;
align-items: center;
gap: $space-lg;
&:active { opacity: 0.85; }
}
.p-avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
}
.p-name {
font-size: $font-3xl;
font-weight: 600;
color: $surface;
display: block;
}
.p-tagline {
font-size: $font-lg;
color: rgba(255, 255, 255, 0.8);
display: block;
margin-top: 4rpx;
}
.p-streak {
font-size: $font-md;
color: rgba(255, 255, 255, 0.65);
display: block;
margin-top: $space-sm;
}
.quick-stats {
display: flex;
gap: $space-md;
padding: 0 $space-xl;
margin-top: $space-lg;
}
.qs-card {
flex: 1;
padding: $space-lg $space-md;
background: $surface;
border-radius: 32rpx;
border: 2rpx solid $border;
box-shadow: $shadow-md;
text-align: center;
}
.qs-label {
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: $space-xs;
}
.qs-value {
font-family: 'Fredoka', sans-serif;
font-size: $font-xl;
font-weight: 600;
color: $text;
&.income { color: $success; }
}
.qs-skeleton {
width: 160rpx;
height: 40rpx;
@include shimmer;
border-radius: $radius-xs;
margin: 0 auto;
}
@media (prefers-reduced-motion: reduce) {
.qs-skeleton {
animation: none;
}
}
.menu-card {
margin: $space-xl $space-xl 0;
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
overflow: hidden;
&.mt { margin-top: $space-lg; }
}
.menu-item {
height: 112rpx;
padding: 0 $space-xl;
display: flex;
align-items: center;
border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; }
&:active { background: $bg; }
}
.mi-label { flex: 1; font-size: $font-lg; font-weight: 500; color: $text; }
.mi-extra { font-size: $font-lg; color: $primary; font-weight: 600; margin-right: $space-sm; }
.admin-entry {
gap: $space-sm;
.mi-label { color: $primary; font-weight: 600; }
}
.about-title {
font-size: $font-3xl;
font-weight: 600;
color: $primary;
display: block;
text-align: center;
}
.about-version {
font-size: $font-md;
color: $text-muted;
display: block;
text-align: center;
margin: $space-xs 0 $space-md;
}
.about-desc {
font-size: $font-lg;
color: $text-sec;
display: block;
text-align: center;
margin-bottom: $space-xs;
}
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal {
width: 600rpx;
background: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-btns {
display: flex;
gap: $space-md;
}
.modal-btns-top {
margin-top: $space-xl;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
font-weight: 600;
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
}
.p-avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
}
.identity-card {
margin: $space-md $space-xl 0;
padding: $font-lg $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
display: flex;
align-items: center;
gap: $space-lg;
&:active { background: $surface-warm; }
}
.id-info {
flex: 1;
display: flex;
flex-direction: column;
}
.id-name {
font-size: $font-lg;
font-weight: 600;
color: $text;
}
.id-desc {
font-size: $font-md;
color: $text-muted;
margin-top: 4rpx;
}
.identity-modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
padding: $space-xl;
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
max-height: 70vh;
overflow-y: auto;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
display: block;
text-align: center;
margin-bottom: $space-lg;
}
.id-option {
display: flex;
align-items: center;
gap: $space-lg;
padding: $font-lg $space-md;
border-radius: $radius-lg;
margin-bottom: 12rpx;
&.active { background: $surface-warm; }
&:active { background: $bg; }
}
.id-option-info {
flex: 1;
display: flex;
flex-direction: column;
}
.id-option-name {
font-size: $font-lg;
font-weight: 500;
color: $text;
}
.id-option-desc {
font-size: $font-md;
color: $text-muted;
margin-top: 2rpx;
}
.id-actions {
display: flex;
gap: $space-md;
margin-top: $space-md;
padding-top: $space-md;
border-top: 1rpx solid $border;
}
.id-action-btn {
flex: 1;
height: 80rpx;
border-radius: $radius-lg;
background: $surface-warm;
display: flex;
align-items: center;
justify-content: center;
&:active { background: darken($surface-warm, 3%); }
}
.id-action-text {
font-size: $font-lg;
font-weight: 500;
color: $primary;
}
.id-manage {
margin-top: $space-sm;
padding: $space-md $space-lg;
text-align: center;
&:active { background: $surface-warm; border-radius: $radius-md; }
}
.id-manage-text {
font-size: $font-base;
color: $text-sec;
}
</style>