feat: 迭代三大模块 + 全系统 Bug 修复
新功能: - 账单筛选统计:多维度筛选面板 + 保存方案 + 结果统计 - 通知公告:系统公告/群组通知/个人提醒 + 通知中心页面 - 管理员后台:数据看板 + 用户管理 + 发布公告 Bug 修复: - 统计页 fetchPrevMonthData/fetchMaxSingle 添加 group_id - 群组视图添加 t.group_id 过滤,防止私人数据泄露 - 通知列表添加群组通知条件 - 通知标记已读添加错误处理 - 管理员 API 添加 affectedRows 检查 - 筛选面板金额为 0 时正确回填 - profile onShow 补充 userStore.fetchUserInfo - 全系统样式修复(overflow/box-sizing)
This commit is contained in:
287
client/src/pages/admin/index.vue
Normal file
287
client/src/pages/admin/index.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<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 class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="dashboard" class="content">
|
||||
<!-- 指标卡 -->
|
||||
<view class="metric-grid">
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ dashboard.totalUsers }}</text>
|
||||
<text class="metric-label">总用户</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ dashboard.monthlyTxCount }}</text>
|
||||
<text class="metric-label">本月交易</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ dashboard.dailyActive }}</text>
|
||||
<text class="metric-label">今日活跃</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value" :class="dashboard.growthPercent >= 0 ? 'up' : 'down'">
|
||||
{{ dashboard.growthPercent >= 0 ? '+' : '' }}{{ dashboard.growthPercent }}%
|
||||
</text>
|
||||
<text class="metric-label">环比增长</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 本月详情 -->
|
||||
<view class="detail-card">
|
||||
<text class="detail-title">本月概况</text>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">交易总额</text>
|
||||
<text class="detail-value">{{ formatAmount(dashboard.monthlyTxAmount) }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">月活用户</text>
|
||||
<text class="detail-value">{{ dashboard.monthlyActive }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">上月交易</text>
|
||||
<text class="detail-value">{{ dashboard.lastMonthTxCount }} 笔 / {{ formatAmount(dashboard.lastMonthTxAmount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能入口 -->
|
||||
<view class="section-title">管理功能</view>
|
||||
<view class="entry-list">
|
||||
<view class="entry-item" @tap="goUsers">
|
||||
<Icon name="user" :size="32" color="#FF8C69" />
|
||||
<text class="entry-text">用户管理</text>
|
||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||
</view>
|
||||
<view class="entry-item" @tap="goNotifications">
|
||||
<Icon name="bell" :size="32" color="#FF8C69" />
|
||||
<text class="entry-text">发布公告</text>
|
||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { getDashboard } from '@/api/admin'
|
||||
import { publishNotification } from '@/api/notification'
|
||||
import { formatAmount } from '@/utils/format'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import type { Dashboard } from '@/api/admin'
|
||||
|
||||
const loading = ref(true)
|
||||
const dashboard = ref<Dashboard | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try {
|
||||
dashboard.value = await getDashboard()
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function goBack() { uni.navigateBack() }
|
||||
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
|
||||
function goNotifications() {
|
||||
uni.showModal({
|
||||
title: '发布公告',
|
||||
editable: true,
|
||||
placeholderText: '输入公告标题',
|
||||
success: async (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
const content = res.content
|
||||
uni.showModal({
|
||||
title: '公告内容',
|
||||
editable: true,
|
||||
placeholderText: '输入公告内容(可选)',
|
||||
success: async (res2) => {
|
||||
if (res2.confirm !== undefined) {
|
||||
try {
|
||||
await publishNotification({ title: content.trim(), content: res2.content || '' })
|
||||
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: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.nav-placeholder { width: 88rpx; }
|
||||
|
||||
.loading-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.content { padding: 0 40rpx; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
|
||||
&.up { color: #7BC67E; }
|
||||
&.down { color: #FF6B6B; }
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12rpx 0;
|
||||
border-bottom: 2rpx solid #F8F0EB;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.entry-list { margin-bottom: 32rpx; }
|
||||
|
||||
.entry-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:active { background: #FFF8F0; }
|
||||
}
|
||||
|
||||
.entry-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
</style>
|
||||
319
client/src/pages/admin/users.vue
Normal file
319
client/src/pages/admin/users.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<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 class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<view class="search-bar">
|
||||
<input class="search-input" v-model="keyword" placeholder="搜索昵称" @confirm="loadUsers(true)" />
|
||||
</view>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<view v-if="loading && list.length === 0" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="list.length === 0" class="empty-box">
|
||||
<Icon name="user" :size="48" color="#BFB3B3" />
|
||||
<text class="empty-text">暂无用户</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="user-list">
|
||||
<view v-for="user in list" :key="user.id" class="user-item">
|
||||
<image class="user-avatar" :src="getAvatarUrl(user.avatar_url)" mode="aspectFill" />
|
||||
<view class="user-info">
|
||||
<view class="user-name-row">
|
||||
<text class="user-name">{{ user.nickname || '用户' + user.id }}</text>
|
||||
<view class="role-tag" :style="{ background: user.role === 'admin' ? '#FFE8E0' : '#F0E0D6' }">
|
||||
<text class="role-tag-text" :style="{ color: user.role === 'admin' ? '#FF8C69' : '#8B7E7E' }">{{ user.role === 'admin' ? '管理员' : '用户' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="user-meta">{{ user.tx_count }} 笔记录 · {{ user.group_count }} 个群组</text>
|
||||
<text class="user-time">注册于 {{ formatDate(user.created_at) }}</text>
|
||||
</view>
|
||||
<view class="user-actions">
|
||||
<view class="action-btn" @tap="handleToggleRole(user)">
|
||||
<text class="action-text">{{ user.role === 'admin' ? '取消管理' : '设为管理' }}</text>
|
||||
</view>
|
||||
<view class="action-btn danger" @tap="handleDelete(user)">
|
||||
<text class="action-text danger-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onReachBottom } from '@dcloudio/uni-app'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { API_BASE } from '@/config'
|
||||
import { getUsers, updateUserRole, deleteUser } from '@/api/admin'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import type { AdminUser } from '@/api/admin'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const keyword = ref('')
|
||||
const list = ref<AdminUser[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
await loadUsers(true)
|
||||
})
|
||||
|
||||
async function loadUsers(reset = false) {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, pageSize: 20 }
|
||||
if (keyword.value.trim()) params.keyword = keyword.value.trim()
|
||||
const data = await getUsers(params)
|
||||
if (reset) {
|
||||
list.value = data.list
|
||||
} else {
|
||||
list.value = [...list.value, ...data.list]
|
||||
}
|
||||
total.value = data.total
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getAvatarUrl(avatarUrl: string): string {
|
||||
if (!avatarUrl) return ''
|
||||
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
|
||||
return `${API_BASE}/user/avatar/${avatarUrl}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function handleToggleRole(user: AdminUser) {
|
||||
if (user.id === userStore.userInfo?.id) {
|
||||
uni.showToast({ title: '不能修改自己的角色', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const newRole = user.role === 'admin' ? 'user' : 'admin'
|
||||
const label = newRole === 'admin' ? '设为管理员' : '取消管理员'
|
||||
uni.showModal({
|
||||
title: label,
|
||||
content: `确定${label}「${user.nickname}」?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await updateUserRole(user.id, newRole)
|
||||
user.role = newRole
|
||||
uni.showToast({ title: '已更新', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete(user: AdminUser) {
|
||||
uni.showModal({
|
||||
title: '删除用户',
|
||||
content: `确定删除「${user.nickname}」?该用户的所有数据将被清除。`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteUser(user.id)
|
||||
list.value = list.value.filter(u => u.id !== user.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() { uni.navigateBack() }
|
||||
|
||||
onReachBottom(() => {
|
||||
if (list.value.length >= total.value || loading.value) return
|
||||
page.value++
|
||||
loadUsers(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: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.nav-placeholder { width: 88rpx; }
|
||||
|
||||
.search-bar {
|
||||
padding: 0 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.loading-box, .empty-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.loading-text, .empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.user-list { padding: 0 40rpx; }
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: #F0E0D6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
|
||||
.user-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.role-tag-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.user-time {
|
||||
font-size: 22rpx;
|
||||
color: #BFB3B3;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8rpx 16rpx;
|
||||
background: #FFF0E6;
|
||||
border-radius: 12rpx;
|
||||
text-align: center;
|
||||
|
||||
&.danger { background: #FFE8E8; }
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 22rpx;
|
||||
color: #FF8C69;
|
||||
&.danger-text { color: #FF6B6B; }
|
||||
}
|
||||
</style>
|
||||
428
client/src/pages/bills/filter-panel.vue
Normal file
428
client/src/pages/bills/filter-panel.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<view class="filter-mask" @tap="$emit('close')">
|
||||
<view class="filter-panel" @tap.stop>
|
||||
<view class="filter-header">
|
||||
<text class="filter-title">筛选</text>
|
||||
<view class="filter-close" @tap="$emit('close')">
|
||||
<text class="filter-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="filter-body" scroll-y>
|
||||
<!-- 分类 -->
|
||||
<view class="filter-section">
|
||||
<text class="section-label">分类</text>
|
||||
<view class="chip-row">
|
||||
<view v-for="cat in categories" :key="cat.id" class="chip" :class="{ active: localFilters.category_ids?.includes(cat.id) }" @tap="toggleCategory(cat.id)">
|
||||
<text class="chip-text">{{ cat.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 日期范围 -->
|
||||
<view class="filter-section">
|
||||
<text class="section-label">日期范围</text>
|
||||
<view class="date-row">
|
||||
<picker mode="date" :value="localFilters.startDate || ''" @change="onStartDateChange">
|
||||
<view class="date-picker">
|
||||
<text class="date-text" :class="{ placeholder: !localFilters.startDate }">{{ localFilters.startDate || '开始日期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text class="date-sep">至</text>
|
||||
<picker mode="date" :value="localFilters.endDate || ''" @change="onEndDateChange">
|
||||
<view class="date-picker">
|
||||
<text class="date-text" :class="{ placeholder: !localFilters.endDate }">{{ localFilters.endDate || '结束日期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 金额区间 -->
|
||||
<view class="filter-section">
|
||||
<text class="section-label">金额区间(元)</text>
|
||||
<view class="amount-row">
|
||||
<input class="amount-input" type="digit" v-model="minAmountStr" placeholder="最小金额" />
|
||||
<text class="amount-sep">-</text>
|
||||
<input class="amount-input" type="digit" v-model="maxAmountStr" placeholder="最大金额" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关键词 -->
|
||||
<view class="filter-section">
|
||||
<text class="section-label">搜索备注</text>
|
||||
<input class="keyword-input" v-model="localFilters.keyword" placeholder="输入关键词" />
|
||||
</view>
|
||||
|
||||
<!-- 已保存方案 -->
|
||||
<view class="filter-section" v-if="savedFilters.length > 0">
|
||||
<text class="section-label">已保存方案</text>
|
||||
<view v-for="sf in savedFilters" :key="sf.id" class="saved-item" @tap="applySaved(sf)">
|
||||
<text class="saved-name">{{ sf.name }}</text>
|
||||
<view class="saved-delete" @tap.stop="handleDeleteSaved(sf.id)">
|
||||
<text class="saved-delete-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="filter-footer">
|
||||
<view class="footer-btn reset" @tap="handleReset">
|
||||
<text class="footer-btn-text reset-text">重置</text>
|
||||
</view>
|
||||
<view class="footer-btn save" @tap="handleSave">
|
||||
<text class="footer-btn-text save-text">保存</text>
|
||||
</view>
|
||||
<view class="footer-btn apply" @tap="handleApply">
|
||||
<text class="footer-btn-text apply-text">应用</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useCategoryStore } from '@/stores/category'
|
||||
import { useFilterStore } from '@/stores/filter'
|
||||
import type { FilterParams, SavedFilter } from '@/api/filter'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
apply: [filters: FilterParams]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
initialFilters?: FilterParams
|
||||
}>()
|
||||
|
||||
const categoryStore = useCategoryStore()
|
||||
const filterStore = useFilterStore()
|
||||
|
||||
const categories = ref<any[]>([])
|
||||
const savedFilters = ref<SavedFilter[]>([])
|
||||
const minAmountStr = ref('')
|
||||
const maxAmountStr = ref('')
|
||||
|
||||
const localFilters = reactive<FilterParams>({
|
||||
type: '',
|
||||
category_ids: [],
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
minAmount: 0,
|
||||
maxAmount: 0,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
categories.value = [...categoryStore.getByType('expense'), ...categoryStore.getByType('income')]
|
||||
await filterStore.fetchSavedFilters()
|
||||
savedFilters.value = filterStore.savedFilters
|
||||
|
||||
// 应用初始筛选条件
|
||||
if (props.initialFilters) {
|
||||
Object.assign(localFilters, props.initialFilters)
|
||||
if (localFilters.minAmount != null && localFilters.minAmount > 0) minAmountStr.value = (localFilters.minAmount / 100).toString()
|
||||
if (localFilters.maxAmount != null && localFilters.maxAmount > 0) maxAmountStr.value = (localFilters.maxAmount / 100).toString()
|
||||
}
|
||||
})
|
||||
|
||||
function toggleCategory(id: number) {
|
||||
if (!localFilters.category_ids) localFilters.category_ids = []
|
||||
const idx = localFilters.category_ids.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
localFilters.category_ids.splice(idx, 1)
|
||||
} else {
|
||||
localFilters.category_ids.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function onStartDateChange(e: any) {
|
||||
localFilters.startDate = e.detail.value
|
||||
}
|
||||
|
||||
function onEndDateChange(e: any) {
|
||||
localFilters.endDate = e.detail.value
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Object.assign(localFilters, { type: '', category_ids: [], startDate: '', endDate: '', minAmount: 0, maxAmount: 0, keyword: '' })
|
||||
minAmountStr.value = ''
|
||||
maxAmountStr.value = ''
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const filters: FilterParams = { ...localFilters }
|
||||
// 转换金额:元 → 分
|
||||
filters.minAmount = minAmountStr.value ? Math.round(parseFloat(minAmountStr.value) * 100) : 0
|
||||
filters.maxAmount = maxAmountStr.value ? Math.round(parseFloat(maxAmountStr.value) * 100) : 0
|
||||
emit('apply', filters)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const filters: FilterParams = { ...localFilters }
|
||||
filters.minAmount = minAmountStr.value ? Math.round(parseFloat(minAmountStr.value) * 100) : 0
|
||||
filters.maxAmount = maxAmountStr.value ? Math.round(parseFloat(maxAmountStr.value) * 100) : 0
|
||||
|
||||
uni.showModal({
|
||||
title: '保存方案',
|
||||
editable: true,
|
||||
placeholderText: '输入方案名称',
|
||||
success: async (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
try {
|
||||
await filterStore.saveFilter(res.content.trim(), filters)
|
||||
savedFilters.value = filterStore.savedFilters
|
||||
uni.showToast({ title: '已保存', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applySaved(sf: SavedFilter) {
|
||||
Object.assign(localFilters, sf.filters)
|
||||
minAmountStr.value = (sf.filters.minAmount != null && sf.filters.minAmount > 0) ? (sf.filters.minAmount / 100).toString() : ''
|
||||
maxAmountStr.value = (sf.filters.maxAmount != null && sf.filters.maxAmount > 0) ? (sf.filters.maxAmount / 100).toString() : ''
|
||||
}
|
||||
|
||||
async function handleDeleteSaved(id: number) {
|
||||
try {
|
||||
await filterStore.deleteFilter(id)
|
||||
savedFilters.value = filterStore.savedFilters
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 40rpx 24rpx;
|
||||
border-bottom: 2rpx solid #F0E0D6;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.filter-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #F0E0D6;
|
||||
&:active { background: #E0D6D0; }
|
||||
}
|
||||
|
||||
.filter-close-text {
|
||||
font-size: 36rpx;
|
||||
color: #8B7E7E;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.filter-body {
|
||||
flex: 1;
|
||||
padding: 24rpx 40rpx;
|
||||
max-height: 55vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 32rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 12rpx 24rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: #FFE8E0;
|
||||
border-color: #FF8C69;
|
||||
}
|
||||
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
font-size: 24rpx;
|
||||
color: #2D1B1B;
|
||||
.active & { color: #FF8C69; font-weight: 600; }
|
||||
}
|
||||
|
||||
.date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
font-size: 26rpx;
|
||||
color: #2D1B1B;
|
||||
&.placeholder { color: #BFB3B3; }
|
||||
}
|
||||
|
||||
.date-sep {
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.amount-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 26rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.amount-sep {
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 26rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.saved-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 8rpx;
|
||||
|
||||
&:active { background: #F0E0D6; }
|
||||
}
|
||||
|
||||
.saved-name {
|
||||
font-size: 26rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.saved-delete {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.saved-delete-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.filter-footer {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 40rpx 40rpx;
|
||||
border-top: 2rpx solid #F0E0D6;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.reset { background: #FFF8F0; border: 2rpx solid #F0E0D6; }
|
||||
&.save { background: #FFF0E6; border: 2rpx solid #FF8C69; }
|
||||
&.apply { background: linear-gradient(135deg, #FF8C69, #E67355); }
|
||||
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.footer-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
&.reset-text { color: #8B7E7E; }
|
||||
&.save-text { color: #FF8C69; }
|
||||
&.apply-text { color: #FFFFFF; }
|
||||
}
|
||||
</style>
|
||||
@@ -11,8 +11,22 @@
|
||||
<view class="tab" :class="{ active: filterType === 'expense' }" @tap="switchFilter('expense')">支出</view>
|
||||
<view class="tab" :class="{ active: filterType === 'income' }" @tap="switchFilter('income')">收入</view>
|
||||
</view>
|
||||
<view class="filter-btn" :class="{ active: hasAdvancedFilter }" @tap="showFilter = true">
|
||||
<Icon name="filter" :size="28" :color="hasAdvancedFilter ? '#FF8C69' : '#8B7E7E'" />
|
||||
<text class="filter-btn-text">筛选</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选结果统计 -->
|
||||
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
|
||||
<text class="filter-stats-text">共 {{ total }} 笔</text>
|
||||
<text class="filter-stats-text" v-if="filteredExpense > 0">支出 {{ formatAmount(filteredExpense) }}</text>
|
||||
<text class="filter-stats-text" v-if="filteredIncome > 0">收入 {{ formatAmount(filteredIncome) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 筛选面板 -->
|
||||
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" />
|
||||
|
||||
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
|
||||
<view v-for="group in groupedTx" :key="group.date" class="date-group">
|
||||
<view class="group-header">
|
||||
@@ -81,8 +95,10 @@ 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'
|
||||
import FilterPanel from './filter-panel.vue'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { formatAmount, formatDate } from '@/utils/format'
|
||||
import type { FilterParams } from '@/api/filter'
|
||||
|
||||
const txStore = useTransactionStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -95,6 +111,16 @@ const total = ref(0)
|
||||
const loadError = ref(false)
|
||||
let loadSeq = 0 // 请求序号,防止并发覆盖
|
||||
|
||||
// 高级筛选
|
||||
const showFilter = ref(false)
|
||||
const advancedFilters = ref<FilterParams>({})
|
||||
const filteredExpense = ref(0)
|
||||
const filteredIncome = ref(0)
|
||||
const hasAdvancedFilter = computed(() => {
|
||||
const f = advancedFilters.value
|
||||
return !!(f.category_ids?.length || f.startDate || f.endDate || f.minAmount || f.maxAmount || f.keyword)
|
||||
})
|
||||
|
||||
const noMore = computed(() => txList.value.length >= total.value && total.value > 0)
|
||||
|
||||
const groupedTx = computed(() => {
|
||||
@@ -138,9 +164,17 @@ async function loadTx(reset = false, silent = false) {
|
||||
const seq = ++loadSeq
|
||||
const params: any = { page: page.value, pageSize }
|
||||
if (filterType.value !== 'all') params.type = filterType.value
|
||||
// 合并高级筛选参数
|
||||
const af = advancedFilters.value
|
||||
if (af.category_ids?.length === 1) params.category_id = af.category_ids[0]
|
||||
if (af.startDate) params.startDate = af.startDate
|
||||
if (af.endDate) params.endDate = af.endDate
|
||||
if (af.minAmount) params.minAmount = af.minAmount
|
||||
if (af.maxAmount) params.maxAmount = af.maxAmount
|
||||
if (af.keyword) params.keyword = af.keyword
|
||||
try {
|
||||
loadError.value = false
|
||||
await txStore.fetchTransactions(params)
|
||||
const data = await txStore.fetchTransactions(params)
|
||||
// 丢弃过期请求的结果
|
||||
if (seq !== loadSeq) return
|
||||
if (reset) {
|
||||
@@ -149,6 +183,10 @@ async function loadTx(reset = false, silent = false) {
|
||||
txList.value = [...txList.value, ...txStore.transactions]
|
||||
}
|
||||
total.value = txStore.total
|
||||
if (data) {
|
||||
filteredExpense.value = data.filteredExpense || 0
|
||||
filteredIncome.value = data.filteredIncome || 0
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq) return
|
||||
console.error('Load bills error:', e)
|
||||
@@ -156,6 +194,12 @@ async function loadTx(reset = false, silent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function onFilterApply(filters: FilterParams) {
|
||||
advancedFilters.value = filters
|
||||
showFilter.value = false
|
||||
loadTx(true)
|
||||
}
|
||||
|
||||
function onEdit(item: any) {
|
||||
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
||||
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
|
||||
@@ -227,7 +271,13 @@ onPullDownRefresh(() => {
|
||||
padding: 16rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 32rpx; }
|
||||
.tabs-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 0 24rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
@@ -251,6 +301,41 @@ onPullDownRefresh(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
background: #FFF0E6;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
|
||||
&.active {
|
||||
border-color: #FF8C69;
|
||||
background: #FFE8E0;
|
||||
}
|
||||
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.filter-btn-text {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
.active & { color: #FF8C69; }
|
||||
}
|
||||
|
||||
.filter-stats {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 0 40rpx 16rpx;
|
||||
}
|
||||
|
||||
.filter-stats-text {
|
||||
font-size: 24rpx;
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.tx-list { padding: 0 40rpx; }
|
||||
|
||||
.date-group {
|
||||
|
||||
@@ -62,11 +62,8 @@
|
||||
<view class="group-info">
|
||||
<view class="group-name-row">
|
||||
<text class="group-name">{{ group.name }}</text>
|
||||
<view v-if="group.role === 'owner'" class="role-badge owner">
|
||||
<text class="role-badge-text">群主</text>
|
||||
</view>
|
||||
<view v-else class="role-badge member">
|
||||
<text class="role-badge-text">成员</text>
|
||||
<view class="role-badge" :style="{ background: group.role === 'owner' ? '#FFE8E0' : '#F0E0D6' }">
|
||||
<text class="role-badge-text" :style="{ color: group.role === 'owner' ? '#FF8C69' : '#8B7E7E' }">{{ group.role === 'owner' ? '群主' : '成员' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="group-meta">{{ group.member_count }} 人</text>
|
||||
@@ -122,7 +119,9 @@
|
||||
<image class="member-avatar" :src="getAvatarUrl(m.avatar_url)" mode="aspectFill" />
|
||||
<view class="member-info">
|
||||
<text class="member-name">{{ m.nickname || '用户' + m.user_id }}</text>
|
||||
<text class="member-role" :class="m.role">{{ m.role === 'owner' ? '群主' : '成员' }}</text>
|
||||
<view class="member-role-wrap">
|
||||
<text class="member-role-text" :style="{ color: m.role === 'owner' ? '#FF8C69' : '#BFB3B3' }">{{ m.role === 'owner' ? '群主' : '成员' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="m.role !== 'owner' && currentGroup?.role === 'owner'" class="member-remove" @tap="handleRemoveMember(m)">
|
||||
<text class="member-remove-text">移除</text>
|
||||
@@ -514,17 +513,11 @@ function handleDissolve(group: Group) {
|
||||
.role-badge {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
|
||||
&.owner { background: #FFE8E0; }
|
||||
&.member { background: #F0E0D6; }
|
||||
}
|
||||
|
||||
.role-badge-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 500;
|
||||
|
||||
.owner & { color: #FF8C69; }
|
||||
.member & { color: #8B7E7E; }
|
||||
}
|
||||
|
||||
.group-meta {
|
||||
@@ -623,6 +616,8 @@ function handleDissolve(group: Group) {
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -667,6 +662,8 @@ function handleDissolve(group: Group) {
|
||||
.member-list {
|
||||
max-height: 60vh;
|
||||
padding: 16rpx 40rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
@@ -675,6 +672,8 @@ function handleDissolve(group: Group) {
|
||||
padding: 20rpx 0;
|
||||
gap: 20rpx;
|
||||
border-bottom: 2rpx solid #F8F0EB;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
@@ -697,15 +696,17 @@ function handleDissolve(group: Group) {
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
font-size: 22rpx;
|
||||
display: block;
|
||||
.member-role-wrap {
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
&.owner { color: #FF8C69; }
|
||||
&.member { color: #BFB3B3; }
|
||||
.member-role-text {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.member-remove {
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
<Icon v-else name="user" :size="36" color="#8B7E7E" />
|
||||
</view>
|
||||
<text class="greeting">{{ greeting }},{{ userStore.nickname }}</text>
|
||||
<view class="bell" @tap="showNotification">
|
||||
<view class="bell" @tap="goNotifications">
|
||||
<Icon name="bell" :size="40" color="#8B7E7E" />
|
||||
<view v-if="notifStore.unreadCount > 0" class="bell-badge">
|
||||
<text class="bell-badge-text">{{ notifStore.unreadCount > 99 ? '99+' : notifStore.unreadCount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -120,6 +123,7 @@ import { useStatsStore } from '@/stores/stats'
|
||||
import { useBudgetStore } from '@/stores/budget'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { getOverview } from '@/api/stats'
|
||||
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
||||
@@ -134,6 +138,7 @@ const statsStore = useStatsStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const notifStore = useNotificationStore()
|
||||
|
||||
const DEFAULT_BUDGET = 500000 // 5000元(分)
|
||||
|
||||
@@ -167,7 +172,8 @@ async function loadData(silent = false) {
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1 }),
|
||||
fetchTodayData(today),
|
||||
userStore.fetchUserInfo()
|
||||
userStore.fetchUserInfo(),
|
||||
notifStore.fetchUnreadCount()
|
||||
])
|
||||
recentTx.value = txStore.transactions.slice(0, 5)
|
||||
} catch (e) {
|
||||
@@ -209,8 +215,8 @@ function goAdd(type: string) {
|
||||
uni.navigateTo({ url: `/pages/add/index?type=${type}` })
|
||||
}
|
||||
|
||||
function showNotification() {
|
||||
uni.showToast({ title: '暂无通知', icon: 'none' })
|
||||
function goNotifications() {
|
||||
uni.navigateTo({ url: '/pages/notifications/index' })
|
||||
}
|
||||
|
||||
function onTxTap(item: any) {
|
||||
@@ -281,10 +287,31 @@ function goBills() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
right: 8rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
background: #FF6B6B;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bell-badge-text {
|
||||
font-size: 18rpx;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
margin: 0 40rpx;
|
||||
padding: 48rpx;
|
||||
|
||||
346
client/src/pages/notifications/index.vue
Normal file
346
client/src/pages/notifications/index.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<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-action" @tap="handleMarkAllRead">
|
||||
<text class="nav-action-text">全部已读</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<view class="tabs-wrap">
|
||||
<view class="tabs">
|
||||
<view v-for="tab in tabs" :key="tab.key" class="tab" :class="{ active: currentTab === tab.key }" @tap="switchTab(tab.key)">
|
||||
<text class="tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<view v-if="loading && list.length === 0" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="list.length === 0" class="empty-box">
|
||||
<Icon name="bell" :size="48" color="#BFB3B3" />
|
||||
<text class="empty-text">暂无通知</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="notification-list">
|
||||
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ unread: !item.is_read }" @tap="handleTap(item)">
|
||||
<view class="notif-icon" :style="{ background: getNotifBg(item.type) }">
|
||||
<Icon :name="getIconName(item.type)" :size="28" :color="getIconColor(item.type)" />
|
||||
</view>
|
||||
<view class="notif-content">
|
||||
<text class="notif-title">{{ item.title }}</text>
|
||||
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
|
||||
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
|
||||
</view>
|
||||
<view v-if="!item.is_read" class="unread-dot"></view>
|
||||
</view>
|
||||
|
||||
<view v-if="noMore && list.length > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { getNotifications, markRead, markAllRead } from '@/api/notification'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import type { Notification } from '@/api/notification'
|
||||
|
||||
const notifStore = useNotificationStore()
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'system', label: '公告' },
|
||||
{ key: 'group', label: '群组' },
|
||||
{ key: 'personal', label: '个人' },
|
||||
]
|
||||
|
||||
const currentTab = ref('all')
|
||||
const list = ref<Notification[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadSeq = 0
|
||||
|
||||
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
await loadNotifications(true)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
notifStore.fetchUnreadCount()
|
||||
})
|
||||
|
||||
function switchTab(key: string) {
|
||||
currentTab.value = key
|
||||
loadNotifications(true)
|
||||
}
|
||||
|
||||
async function loadNotifications(reset = false) {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
list.value = []
|
||||
}
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, pageSize: 20 }
|
||||
if (currentTab.value !== 'all') params.type = currentTab.value
|
||||
const data = await getNotifications(params)
|
||||
if (seq !== loadSeq) return
|
||||
if (reset) {
|
||||
list.value = data.list
|
||||
} else {
|
||||
list.value = [...list.value, ...data.list]
|
||||
}
|
||||
total.value = data.total
|
||||
} catch (e) {
|
||||
console.error('Load notifications error:', e)
|
||||
} finally {
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getIconName(type: string) {
|
||||
if (type === 'system') return 'bell'
|
||||
if (type === 'group') return 'user'
|
||||
return 'alert-circle'
|
||||
}
|
||||
|
||||
function getIconColor(type: string) {
|
||||
if (type === 'system') return '#FF8C69'
|
||||
if (type === 'group') return '#8B7E7E'
|
||||
return '#BFB3B3'
|
||||
}
|
||||
|
||||
function getNotifBg(type: string) {
|
||||
if (type === 'system') return '#FFE8E0'
|
||||
if (type === 'group') return '#F0E0D6'
|
||||
return '#FFF0E6'
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}天前`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
async function handleTap(item: Notification) {
|
||||
if (!item.is_read) {
|
||||
try {
|
||||
await markRead(item.id)
|
||||
item.is_read = 1
|
||||
notifStore.fetchUnreadCount()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
try {
|
||||
await markAllRead()
|
||||
list.value.forEach(item => { item.is_read = 1 })
|
||||
notifStore.fetchUnreadCount()
|
||||
uni.showToast({ title: '已全部标记已读', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
onReachBottom(() => {
|
||||
if (noMore.value || loading.value) return
|
||||
page.value++
|
||||
loadNotifications(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: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.nav-action {
|
||||
padding: 8rpx 16rpx;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-action-text {
|
||||
font-size: 26rpx;
|
||||
color: #FF8C69;
|
||||
}
|
||||
|
||||
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 24rpx; }
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
background: #FFF0E6;
|
||||
border-radius: 24rpx;
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: #FFFFFF;
|
||||
color: #FF8C69;
|
||||
font-weight: 600;
|
||||
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-box, .empty-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.loading-text, .empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.notification-list { padding: 0 40rpx; }
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
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);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.unread { border-left: 6rpx solid #FF8C69; }
|
||||
}
|
||||
|
||||
.notif-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-content { flex: 1; min-width: 0; }
|
||||
|
||||
.notif-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notif-body {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
font-size: 22rpx;
|
||||
color: #BFB3B3;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: #FF8C69;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
.no-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
</style>
|
||||
@@ -64,6 +64,15 @@
|
||||
</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>
|
||||
@@ -170,7 +179,8 @@ onShow(async () => {
|
||||
statsStore.fetchOverview(),
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
|
||||
groupStore.fetchGroups()
|
||||
groupStore.fetchGroups(),
|
||||
userStore.fetchUserInfo()
|
||||
])
|
||||
} catch {}
|
||||
})
|
||||
@@ -206,6 +216,10 @@ function goGroupManage() {
|
||||
uni.navigateTo({ url: '/pages/group-manage/index' })
|
||||
}
|
||||
|
||||
function goAdmin() {
|
||||
uni.navigateTo({ url: '/pages/admin/index' })
|
||||
}
|
||||
|
||||
function promptCreateGroup() {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 小程序不支持 prompt,使用输入框页面
|
||||
@@ -493,6 +507,11 @@ async function exportData() {
|
||||
.mi-label { flex: 1; font-size: 28rpx; font-weight: 500; color: #2D1B1B; }
|
||||
.mi-extra { font-size: 28rpx; color: #FF8C69; font-weight: 600; margin-right: 16rpx; }
|
||||
|
||||
.admin-entry {
|
||||
gap: 16rpx;
|
||||
.mi-label { color: #FF8C69; font-weight: 600; }
|
||||
}
|
||||
|
||||
.about-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useStatsStore } from '@/stores/stats'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { getTransactions } from '@/api/transaction'
|
||||
import { getOverview } from '@/api/stats'
|
||||
@@ -131,6 +132,7 @@ import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||
import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue'
|
||||
|
||||
const statsStore = useStatsStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const currentMonth = ref(getCurrentMonth())
|
||||
const tabType = ref<'expense' | 'income'>('expense')
|
||||
@@ -266,7 +268,7 @@ async function fetchMaxSingle() {
|
||||
const startDate = `${currentMonth.value}-01`
|
||||
const lastDay = new Date(y, m, 0).getDate()
|
||||
const endDate = `${currentMonth.value}-${String(lastDay).padStart(2, '0')}`
|
||||
const data = await getTransactions({ page: 1, pageSize: 1, type: tabType.value, startDate, endDate, sortBy: 'amount' })
|
||||
const data = await getTransactions({ page: 1, pageSize: 1, type: tabType.value, startDate, endDate, sortBy: 'amount', group_id: groupStore.currentGroupId })
|
||||
maxSingle.value = data.list?.[0]?.amount || 0
|
||||
} catch {
|
||||
maxSingle.value = 0
|
||||
@@ -277,7 +279,7 @@ async function fetchPrevMonthData() {
|
||||
try {
|
||||
const [y, m] = currentMonth.value.split('-').map(Number)
|
||||
const prevMonth = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
|
||||
const data = await getOverview({ month: prevMonth })
|
||||
const data = await getOverview({ month: prevMonth, group_id: groupStore.currentGroupId })
|
||||
// 确保数据字段是数字类型,避免 null/undefined 导致 Infinity
|
||||
prevMonthData.value = {
|
||||
expense: Number(data?.expense) || 0,
|
||||
|
||||
Reference in New Issue
Block a user