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:
wangxiaogang
2026-06-06 17:55:29 +08:00
parent bc6eca1e9b
commit 83571de723
35 changed files with 2255 additions and 37 deletions

52
client/src/api/admin.ts Normal file
View File

@@ -0,0 +1,52 @@
import { request } from '@/utils/request'
/** 数据看板 */
export interface Dashboard {
totalUsers: number
monthlyTxCount: number
monthlyTxAmount: number
dailyActive: number
monthlyActive: number
growthPercent: number
lastMonthTxCount: number
lastMonthTxAmount: number
}
/** 用户信息(管理员视角) */
export interface AdminUser {
id: number
nickname: string
avatar_url: string
role: 'user' | 'admin'
created_at: string
tx_count: number
group_count: number
}
/** 用户列表响应 */
export interface AdminUserList {
list: AdminUser[]
total: number
page: number
pageSize: number
}
/** 获取数据看板 */
export function getDashboard() {
return request<Dashboard>({ url: '/admin/dashboard' })
}
/** 获取用户列表 */
export function getUsers(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request<AdminUserList>({ url: '/admin/users', data: params })
}
/** 更新用户角色 */
export function updateUserRole(id: number, role: 'user' | 'admin') {
return request({ url: `/admin/users/${id}/status`, method: 'PUT', data: { role } })
}
/** 删除用户 */
export function deleteUser(id: number) {
return request({ url: `/admin/users/${id}`, method: 'DELETE' })
}

35
client/src/api/filter.ts Normal file
View File

@@ -0,0 +1,35 @@
import { request } from '@/utils/request'
/** 保存的筛选方案 */
export interface SavedFilter {
id: number
name: string
filters: FilterParams
created_at: string
}
/** 筛选参数 */
export interface FilterParams {
type?: string
category_ids?: number[]
startDate?: string
endDate?: string
minAmount?: number
maxAmount?: number
keyword?: string
}
/** 获取保存的筛选方案 */
export function getSavedFilters() {
return request<SavedFilter[]>({ url: '/filters' })
}
/** 保存筛选方案 */
export function saveFilter(data: { name: string; filters: FilterParams }) {
return request<{ id: number }>({ url: '/filters', method: 'POST', data })
}
/** 删除筛选方案 */
export function deleteFilter(id: number) {
return request({ url: `/filters/${id}`, method: 'DELETE' })
}

View File

@@ -0,0 +1,45 @@
import { request } from '@/utils/request'
/** 通知 */
export interface Notification {
id: number
type: 'system' | 'group' | 'personal'
title: string
content: string
is_read: number
created_at: string
group_id?: number | null
}
/** 通知列表响应 */
export interface NotificationList {
list: Notification[]
total: number
page: number
pageSize: number
}
/** 获取通知列表 */
export function getNotifications(params?: { type?: string; page?: number; pageSize?: number }) {
return request<NotificationList>({ url: '/notifications', data: params })
}
/** 获取未读数量 */
export function getUnreadCount() {
return request<{ count: number }>({ url: '/notifications/unread-count' })
}
/** 标记单条已读 */
export function markRead(id: number) {
return request({ url: `/notifications/${id}/read`, method: 'PUT' })
}
/** 全部标记已读 */
export function markAllRead() {
return request({ url: '/notifications/read-all', method: 'PUT' })
}
/** 发布系统公告(管理员) */
export function publishNotification(data: { title: string; content?: string }) {
return request({ url: '/notifications', method: 'POST', data })
}

View File

@@ -23,6 +23,8 @@ export interface TransactionList {
total: number
page: number
pageSize: number
filteredExpense?: number
filteredIncome?: number
}
/** 获取交易列表参数 */
@@ -34,6 +36,10 @@ export interface TransactionParams {
endDate?: string
sortBy?: string
group_id?: number | null
category_id?: number
minAmount?: number
maxAmount?: number
keyword?: string
}
/** 获取交易列表 */

View File

@@ -6,6 +6,7 @@ export interface UserInfo {
id: number
nickname: string
avatar_url: string
role: 'user' | 'admin'
created_at: string
}

View File

@@ -63,6 +63,14 @@ const iconMap: Record<string, Record<string, string>> = {
'#8B7E7E': '/static/icons/refresh-gray.png',
'#FF8C69': '/static/icons/refresh-orange.png',
},
filter: {
'#8B7E7E': '/static/icons/filter-gray.png',
'#FF8C69': '/static/icons/filter-orange.png',
},
settings: {
'#8B7E7E': '/static/icons/settings-gray.png',
'#FF8C69': '/static/icons/settings-orange.png',
},
}
const iconSrc = computed(() => {

View File

@@ -65,6 +65,29 @@
"navigationStyle": "custom",
"navigationBarTitleText": "一起记"
}
},
{
"path": "pages/notifications/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "通知中心",
"enablePullDownRefresh": false
}
},
{
"path": "pages/admin/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "管理后台"
}
},
{
"path": "pages/admin/users",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "用户管理",
"enablePullDownRefresh": false
}
}
],
"globalStyle": {

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

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

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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;

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

View File

@@ -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;

View File

@@ -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,

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api/filter'
import type { FilterParams } from '@/api/filter'
export const useFilterStore = defineStore('filter', () => {
const savedFilters = ref<api.SavedFilter[]>([])
async function fetchSavedFilters() {
savedFilters.value = await api.getSavedFilters()
}
async function saveFilter(name: string, filters: FilterParams) {
const result = await api.saveFilter({ name, filters })
await fetchSavedFilters()
return result.id
}
async function deleteFilter(id: number) {
await api.deleteFilter(id)
savedFilters.value = savedFilters.value.filter(f => f.id !== id)
}
return { savedFilters, fetchSavedFilters, saveFilter, deleteFilter }
})

View File

@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api/notification'
export const useNotificationStore = defineStore('notification', () => {
const unreadCount = ref(0)
async function fetchUnreadCount() {
const data = await api.getUnreadCount()
unreadCount.value = data.count
}
async function markRead(id: number) {
await api.markRead(id)
if (unreadCount.value > 0) unreadCount.value--
}
async function markAllRead() {
await api.markAllRead()
unreadCount.value = 0
}
return { unreadCount, fetchUnreadCount, markRead, markAllRead }
})

View File

@@ -19,6 +19,7 @@ export const useTransactionStore = defineStore('transaction', () => {
})
transactions.value = data.list
total.value = data.total
return data
} catch (err) {
transactions.value = []
total.value = 0

View File

@@ -24,6 +24,7 @@ export const useUserStore = defineStore('user', () => {
if (userInfo.value) {
uni.setStorageSync('xc:nickname', userInfo.value.nickname)
uni.setStorageSync('xc:avatar_url', userInfo.value.avatar_url)
uni.setStorageSync('xc:role', userInfo.value.role)
}
}

View File

@@ -11,6 +11,7 @@ interface LoginResult {
userId: number
nickname: string
avatar_url: string
role?: string
}
let isRedirectingToLogin = false
@@ -23,6 +24,7 @@ export function saveLoginResult(data: LoginResult) {
uni.setStorageSync('xc:userId', data.userId)
uni.setStorageSync('xc:nickname', data.nickname)
uni.setStorageSync('xc:avatar_url', data.avatar_url)
if (data.role) uni.setStorageSync('xc:role', data.role)
}
/** 执行重登录 */