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:
52
client/src/api/admin.ts
Normal file
52
client/src/api/admin.ts
Normal 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
35
client/src/api/filter.ts
Normal 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' })
|
||||||
|
}
|
||||||
45
client/src/api/notification.ts
Normal file
45
client/src/api/notification.ts
Normal 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 })
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ export interface TransactionList {
|
|||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
filteredExpense?: number
|
||||||
|
filteredIncome?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取交易列表参数 */
|
/** 获取交易列表参数 */
|
||||||
@@ -34,6 +36,10 @@ export interface TransactionParams {
|
|||||||
endDate?: string
|
endDate?: string
|
||||||
sortBy?: string
|
sortBy?: string
|
||||||
group_id?: number | null
|
group_id?: number | null
|
||||||
|
category_id?: number
|
||||||
|
minAmount?: number
|
||||||
|
maxAmount?: number
|
||||||
|
keyword?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取交易列表 */
|
/** 获取交易列表 */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface UserInfo {
|
|||||||
id: number
|
id: number
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
|
role: 'user' | 'admin'
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ const iconMap: Record<string, Record<string, string>> = {
|
|||||||
'#8B7E7E': '/static/icons/refresh-gray.png',
|
'#8B7E7E': '/static/icons/refresh-gray.png',
|
||||||
'#FF8C69': '/static/icons/refresh-orange.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(() => {
|
const iconSrc = computed(() => {
|
||||||
|
|||||||
@@ -65,6 +65,29 @@
|
|||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "一起记"
|
"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": {
|
"globalStyle": {
|
||||||
|
|||||||
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,7 +11,21 @@
|
|||||||
<view class="tab" :class="{ active: filterType === 'expense' }" @tap="switchFilter('expense')">支出</view>
|
<view class="tab" :class="{ active: filterType === 'expense' }" @tap="switchFilter('expense')">支出</view>
|
||||||
<view class="tab" :class="{ active: filterType === 'income' }" @tap="switchFilter('income')">收入</view>
|
<view class="tab" :class="{ active: filterType === 'income' }" @tap="switchFilter('income')">收入</view>
|
||||||
</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>
|
||||||
|
|
||||||
|
<!-- 筛选结果统计 -->
|
||||||
|
<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 class="tx-list" v-if="!txStore.loading || txList.length > 0">
|
||||||
<view v-for="group in groupedTx" :key="group.date" class="date-group">
|
<view v-for="group in groupedTx" :key="group.date" class="date-group">
|
||||||
@@ -81,8 +95,10 @@ import { waitForReady } from '@/utils/app-ready'
|
|||||||
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||||
|
import FilterPanel from './filter-panel.vue'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import { formatAmount, formatDate } from '@/utils/format'
|
import { formatAmount, formatDate } from '@/utils/format'
|
||||||
|
import type { FilterParams } from '@/api/filter'
|
||||||
|
|
||||||
const txStore = useTransactionStore()
|
const txStore = useTransactionStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -95,6 +111,16 @@ const total = ref(0)
|
|||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
let loadSeq = 0 // 请求序号,防止并发覆盖
|
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 noMore = computed(() => txList.value.length >= total.value && total.value > 0)
|
||||||
|
|
||||||
const groupedTx = computed(() => {
|
const groupedTx = computed(() => {
|
||||||
@@ -138,9 +164,17 @@ async function loadTx(reset = false, silent = false) {
|
|||||||
const seq = ++loadSeq
|
const seq = ++loadSeq
|
||||||
const params: any = { page: page.value, pageSize }
|
const params: any = { page: page.value, pageSize }
|
||||||
if (filterType.value !== 'all') params.type = filterType.value
|
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 {
|
try {
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
await txStore.fetchTransactions(params)
|
const data = await txStore.fetchTransactions(params)
|
||||||
// 丢弃过期请求的结果
|
// 丢弃过期请求的结果
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
if (reset) {
|
if (reset) {
|
||||||
@@ -149,6 +183,10 @@ async function loadTx(reset = false, silent = false) {
|
|||||||
txList.value = [...txList.value, ...txStore.transactions]
|
txList.value = [...txList.value, ...txStore.transactions]
|
||||||
}
|
}
|
||||||
total.value = txStore.total
|
total.value = txStore.total
|
||||||
|
if (data) {
|
||||||
|
filteredExpense.value = data.filteredExpense || 0
|
||||||
|
filteredIncome.value = data.filteredIncome || 0
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
console.error('Load bills error:', e)
|
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) {
|
function onEdit(item: any) {
|
||||||
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
||||||
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
|
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
|
||||||
@@ -227,7 +271,13 @@ onPullDownRefresh(() => {
|
|||||||
padding: 16rpx 0 24rpx;
|
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 {
|
.tabs {
|
||||||
display: inline-flex;
|
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; }
|
.tx-list { padding: 0 40rpx; }
|
||||||
|
|
||||||
.date-group {
|
.date-group {
|
||||||
|
|||||||
@@ -62,11 +62,8 @@
|
|||||||
<view class="group-info">
|
<view class="group-info">
|
||||||
<view class="group-name-row">
|
<view class="group-name-row">
|
||||||
<text class="group-name">{{ group.name }}</text>
|
<text class="group-name">{{ group.name }}</text>
|
||||||
<view v-if="group.role === 'owner'" class="role-badge owner">
|
<view class="role-badge" :style="{ background: group.role === 'owner' ? '#FFE8E0' : '#F0E0D6' }">
|
||||||
<text class="role-badge-text">群主</text>
|
<text class="role-badge-text" :style="{ color: group.role === 'owner' ? '#FF8C69' : '#8B7E7E' }">{{ group.role === 'owner' ? '群主' : '成员' }}</text>
|
||||||
</view>
|
|
||||||
<view v-else class="role-badge member">
|
|
||||||
<text class="role-badge-text">成员</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="group-meta">{{ group.member_count }} 人</text>
|
<text class="group-meta">{{ group.member_count }} 人</text>
|
||||||
@@ -122,7 +119,9 @@
|
|||||||
<image class="member-avatar" :src="getAvatarUrl(m.avatar_url)" mode="aspectFill" />
|
<image class="member-avatar" :src="getAvatarUrl(m.avatar_url)" mode="aspectFill" />
|
||||||
<view class="member-info">
|
<view class="member-info">
|
||||||
<text class="member-name">{{ m.nickname || '用户' + m.user_id }}</text>
|
<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>
|
||||||
<view v-if="m.role !== 'owner' && currentGroup?.role === 'owner'" class="member-remove" @tap="handleRemoveMember(m)">
|
<view v-if="m.role !== 'owner' && currentGroup?.role === 'owner'" class="member-remove" @tap="handleRemoveMember(m)">
|
||||||
<text class="member-remove-text">移除</text>
|
<text class="member-remove-text">移除</text>
|
||||||
@@ -514,17 +513,11 @@ function handleDissolve(group: Group) {
|
|||||||
.role-badge {
|
.role-badge {
|
||||||
padding: 2rpx 12rpx;
|
padding: 2rpx 12rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
|
|
||||||
&.owner { background: #FFE8E0; }
|
|
||||||
&.member { background: #F0E0D6; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-badge-text {
|
.role-badge-text {
|
||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
.owner & { color: #FF8C69; }
|
|
||||||
.member & { color: #8B7E7E; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-meta {
|
.group-meta {
|
||||||
@@ -623,6 +616,8 @@ function handleDissolve(group: Group) {
|
|||||||
border-radius: 40rpx 40rpx 0 0;
|
border-radius: 40rpx 40rpx 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -667,6 +662,8 @@ function handleDissolve(group: Group) {
|
|||||||
.member-list {
|
.member-list {
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
padding: 16rpx 40rpx 40rpx;
|
padding: 16rpx 40rpx 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-item {
|
.member-item {
|
||||||
@@ -675,6 +672,8 @@ function handleDissolve(group: Group) {
|
|||||||
padding: 20rpx 0;
|
padding: 20rpx 0;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
border-bottom: 2rpx solid #F8F0EB;
|
border-bottom: 2rpx solid #F8F0EB;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
}
|
}
|
||||||
@@ -697,15 +696,17 @@ function handleDissolve(group: Group) {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: #2D1B1B;
|
||||||
display: block;
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-role {
|
.member-role-wrap {
|
||||||
font-size: 22rpx;
|
|
||||||
display: block;
|
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
&.owner { color: #FF8C69; }
|
.member-role-text {
|
||||||
&.member { color: #BFB3B3; }
|
font-size: 22rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-remove {
|
.member-remove {
|
||||||
|
|||||||
@@ -8,8 +8,11 @@
|
|||||||
<Icon v-else name="user" :size="36" color="#8B7E7E" />
|
<Icon v-else name="user" :size="36" color="#8B7E7E" />
|
||||||
</view>
|
</view>
|
||||||
<text class="greeting">{{ greeting }},{{ userStore.nickname }}</text>
|
<text class="greeting">{{ greeting }},{{ userStore.nickname }}</text>
|
||||||
<view class="bell" @tap="showNotification">
|
<view class="bell" @tap="goNotifications">
|
||||||
<Icon name="bell" :size="40" color="#8B7E7E" />
|
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -120,6 +123,7 @@ import { useStatsStore } from '@/stores/stats'
|
|||||||
import { useBudgetStore } from '@/stores/budget'
|
import { useBudgetStore } from '@/stores/budget'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useNotificationStore } from '@/stores/notification'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getOverview } from '@/api/stats'
|
import { getOverview } from '@/api/stats'
|
||||||
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
||||||
@@ -134,6 +138,7 @@ const statsStore = useStatsStore()
|
|||||||
const budgetStore = useBudgetStore()
|
const budgetStore = useBudgetStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const notifStore = useNotificationStore()
|
||||||
|
|
||||||
const DEFAULT_BUDGET = 500000 // 5000元(分)
|
const DEFAULT_BUDGET = 500000 // 5000元(分)
|
||||||
|
|
||||||
@@ -167,7 +172,8 @@ async function loadData(silent = false) {
|
|||||||
budgetStore.fetchBudget(),
|
budgetStore.fetchBudget(),
|
||||||
txStore.fetchTransactions({ page: 1 }),
|
txStore.fetchTransactions({ page: 1 }),
|
||||||
fetchTodayData(today),
|
fetchTodayData(today),
|
||||||
userStore.fetchUserInfo()
|
userStore.fetchUserInfo(),
|
||||||
|
notifStore.fetchUnreadCount()
|
||||||
])
|
])
|
||||||
recentTx.value = txStore.transactions.slice(0, 5)
|
recentTx.value = txStore.transactions.slice(0, 5)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -209,8 +215,8 @@ function goAdd(type: string) {
|
|||||||
uni.navigateTo({ url: `/pages/add/index?type=${type}` })
|
uni.navigateTo({ url: `/pages/add/index?type=${type}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNotification() {
|
function goNotifications() {
|
||||||
uni.showToast({ title: '暂无通知', icon: 'none' })
|
uni.navigateTo({ url: '/pages/notifications/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTxTap(item: any) {
|
function onTxTap(item: any) {
|
||||||
@@ -281,10 +287,31 @@ function goBills() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:active { opacity: 0.6; }
|
&: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 {
|
.overview-card {
|
||||||
margin: 0 40rpx;
|
margin: 0 40rpx;
|
||||||
padding: 48rpx;
|
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>
|
</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-mask" v-if="showAbout" @tap="showAbout = false">
|
||||||
<view class="modal" @tap.stop>
|
<view class="modal" @tap.stop>
|
||||||
@@ -170,7 +179,8 @@ onShow(async () => {
|
|||||||
statsStore.fetchOverview(),
|
statsStore.fetchOverview(),
|
||||||
budgetStore.fetchBudget(),
|
budgetStore.fetchBudget(),
|
||||||
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
|
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
|
||||||
groupStore.fetchGroups()
|
groupStore.fetchGroups(),
|
||||||
|
userStore.fetchUserInfo()
|
||||||
])
|
])
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
@@ -206,6 +216,10 @@ function goGroupManage() {
|
|||||||
uni.navigateTo({ url: '/pages/group-manage/index' })
|
uni.navigateTo({ url: '/pages/group-manage/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goAdmin() {
|
||||||
|
uni.navigateTo({ url: '/pages/admin/index' })
|
||||||
|
}
|
||||||
|
|
||||||
function promptCreateGroup() {
|
function promptCreateGroup() {
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
// 小程序不支持 prompt,使用输入框页面
|
// 小程序不支持 prompt,使用输入框页面
|
||||||
@@ -493,6 +507,11 @@ async function exportData() {
|
|||||||
.mi-label { flex: 1; font-size: 28rpx; font-weight: 500; color: #2D1B1B; }
|
.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; }
|
.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 {
|
.about-title {
|
||||||
font-size: 40rpx;
|
font-size: 40rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { useStatsStore } from '@/stores/stats'
|
import { useStatsStore } from '@/stores/stats'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getTransactions } from '@/api/transaction'
|
import { getTransactions } from '@/api/transaction'
|
||||||
import { getOverview } from '@/api/stats'
|
import { getOverview } from '@/api/stats'
|
||||||
@@ -131,6 +132,7 @@ import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
|||||||
import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue'
|
import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue'
|
||||||
|
|
||||||
const statsStore = useStatsStore()
|
const statsStore = useStatsStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
const currentMonth = ref(getCurrentMonth())
|
const currentMonth = ref(getCurrentMonth())
|
||||||
const tabType = ref<'expense' | 'income'>('expense')
|
const tabType = ref<'expense' | 'income'>('expense')
|
||||||
@@ -266,7 +268,7 @@ async function fetchMaxSingle() {
|
|||||||
const startDate = `${currentMonth.value}-01`
|
const startDate = `${currentMonth.value}-01`
|
||||||
const lastDay = new Date(y, m, 0).getDate()
|
const lastDay = new Date(y, m, 0).getDate()
|
||||||
const endDate = `${currentMonth.value}-${String(lastDay).padStart(2, '0')}`
|
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
|
maxSingle.value = data.list?.[0]?.amount || 0
|
||||||
} catch {
|
} catch {
|
||||||
maxSingle.value = 0
|
maxSingle.value = 0
|
||||||
@@ -277,7 +279,7 @@ async function fetchPrevMonthData() {
|
|||||||
try {
|
try {
|
||||||
const [y, m] = currentMonth.value.split('-').map(Number)
|
const [y, m] = currentMonth.value.split('-').map(Number)
|
||||||
const prevMonth = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
|
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
|
// 确保数据字段是数字类型,避免 null/undefined 导致 Infinity
|
||||||
prevMonthData.value = {
|
prevMonthData.value = {
|
||||||
expense: Number(data?.expense) || 0,
|
expense: Number(data?.expense) || 0,
|
||||||
|
|||||||
BIN
client/src/static/icons/filter-gray.png
Normal file
BIN
client/src/static/icons/filter-gray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 534 B |
BIN
client/src/static/icons/filter-orange.png
Normal file
BIN
client/src/static/icons/filter-orange.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 529 B |
BIN
client/src/static/icons/settings-gray.png
Normal file
BIN
client/src/static/icons/settings-gray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
client/src/static/icons/settings-orange.png
Normal file
BIN
client/src/static/icons/settings-orange.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
25
client/src/stores/filter.ts
Normal file
25
client/src/stores/filter.ts
Normal 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 }
|
||||||
|
})
|
||||||
24
client/src/stores/notification.ts
Normal file
24
client/src/stores/notification.ts
Normal 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 }
|
||||||
|
})
|
||||||
@@ -19,6 +19,7 @@ export const useTransactionStore = defineStore('transaction', () => {
|
|||||||
})
|
})
|
||||||
transactions.value = data.list
|
transactions.value = data.list
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
transactions.value = []
|
transactions.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
if (userInfo.value) {
|
if (userInfo.value) {
|
||||||
uni.setStorageSync('xc:nickname', userInfo.value.nickname)
|
uni.setStorageSync('xc:nickname', userInfo.value.nickname)
|
||||||
uni.setStorageSync('xc:avatar_url', userInfo.value.avatar_url)
|
uni.setStorageSync('xc:avatar_url', userInfo.value.avatar_url)
|
||||||
|
uni.setStorageSync('xc:role', userInfo.value.role)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface LoginResult {
|
|||||||
userId: number
|
userId: number
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let isRedirectingToLogin = false
|
let isRedirectingToLogin = false
|
||||||
@@ -23,6 +24,7 @@ export function saveLoginResult(data: LoginResult) {
|
|||||||
uni.setStorageSync('xc:userId', data.userId)
|
uni.setStorageSync('xc:userId', data.userId)
|
||||||
uni.setStorageSync('xc:nickname', data.nickname)
|
uni.setStorageSync('xc:nickname', data.nickname)
|
||||||
uni.setStorageSync('xc:avatar_url', data.avatar_url)
|
uni.setStorageSync('xc:avatar_url', data.avatar_url)
|
||||||
|
if (data.role) uni.setStorageSync('xc:role', data.role)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 执行重登录 */
|
/** 执行重登录 */
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ async function columnExists(conn: mysql.Connection, table: string, column: strin
|
|||||||
return (rows as any[])[0].cnt > 0
|
return (rows as any[])[0].cnt > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tableExists(conn: mysql.Connection, table: string): Promise<boolean> {
|
||||||
|
const [rows] = await conn.query(
|
||||||
|
`SELECT COUNT(*) as cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
|
||||||
|
[table]
|
||||||
|
)
|
||||||
|
return (rows as any[])[0].cnt > 0
|
||||||
|
}
|
||||||
|
|
||||||
async function runMigrations(conn: mysql.Connection) {
|
async function runMigrations(conn: mysql.Connection) {
|
||||||
console.log('[DB] Checking migrations...')
|
console.log('[DB] Checking migrations...')
|
||||||
|
|
||||||
@@ -83,6 +91,49 @@ async function runMigrations(conn: mysql.Connection) {
|
|||||||
await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)")
|
await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)")
|
||||||
console.log('[DB] Groups migration complete')
|
console.log('[DB] Groups migration complete')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// users 表添加 role(管理员角色)
|
||||||
|
const hasRole = await columnExists(conn, 'users', 'role')
|
||||||
|
if (!hasRole) {
|
||||||
|
console.log('[DB] Migrating: adding role to users')
|
||||||
|
await conn.query("ALTER TABLE users ADD COLUMN role ENUM('user', 'admin') DEFAULT 'user' AFTER avatar_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知表
|
||||||
|
const hasNotifications = await tableExists(conn, 'notifications')
|
||||||
|
if (!hasNotifications) {
|
||||||
|
console.log('[DB] Migrating: creating notifications table')
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT DEFAULT NULL COMMENT 'NULL=系统公告,非NULL=个人通知',
|
||||||
|
group_id INT DEFAULT NULL COMMENT '群组通知关联',
|
||||||
|
type ENUM('system', 'group', 'personal') NOT NULL,
|
||||||
|
title VARCHAR(100) NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
is_read TINYINT(1) DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_read (user_id, is_read),
|
||||||
|
INDEX idx_type (type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存的筛选方案表
|
||||||
|
const hasSavedFilters = await tableExists(conn, 'saved_filters')
|
||||||
|
if (!hasSavedFilters) {
|
||||||
|
console.log('[DB] Migrating: creating saved_filters table')
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
filters JSON NOT NULL COMMENT '筛选条件',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initDatabase() {
|
export async function initDatabase() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
session_key VARCHAR(100),
|
session_key VARCHAR(100),
|
||||||
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
|
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
|
||||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||||
|
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -72,3 +73,25 @@ CREATE TABLE IF NOT EXISTS group_members (
|
|||||||
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
|
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT DEFAULT NULL COMMENT 'NULL=系统公告,非NULL=个人通知',
|
||||||
|
group_id INT DEFAULT NULL COMMENT '群组通知关联',
|
||||||
|
type ENUM('system', 'group', 'personal') NOT NULL,
|
||||||
|
title VARCHAR(100) NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
is_read TINYINT(1) DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_read (user_id, is_read),
|
||||||
|
INDEX idx_type (type)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
filters JSON NOT NULL COMMENT '筛选条件:{type, categories[], startDate, endDate, minAmount, maxAmount, keyword}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import categoryRoutes from './routes/category'
|
|||||||
import backupRoutes from './routes/backup'
|
import backupRoutes from './routes/backup'
|
||||||
import userRoutes from './routes/user'
|
import userRoutes from './routes/user'
|
||||||
import groupRoutes from './routes/group'
|
import groupRoutes from './routes/group'
|
||||||
|
import notificationRoutes from './routes/notification'
|
||||||
|
import filterRoutes from './routes/filter'
|
||||||
|
import adminRoutes from './routes/admin'
|
||||||
import { backupDatabase } from './utils/backup'
|
import { backupDatabase } from './utils/backup'
|
||||||
|
|
||||||
// Warn if token secret is using default fallback
|
// Warn if token secret is using default fallback
|
||||||
@@ -107,6 +110,9 @@ app.use('/api/categories', apiLimiter, categoryRoutes)
|
|||||||
app.use('/api/backup', apiLimiter, backupRoutes)
|
app.use('/api/backup', apiLimiter, backupRoutes)
|
||||||
app.use('/api/user', apiLimiter, userRoutes)
|
app.use('/api/user', apiLimiter, userRoutes)
|
||||||
app.use('/api/groups', apiLimiter, groupRoutes)
|
app.use('/api/groups', apiLimiter, groupRoutes)
|
||||||
|
app.use('/api/notifications', apiLimiter, notificationRoutes)
|
||||||
|
app.use('/api/filters', apiLimiter, filterRoutes)
|
||||||
|
app.use('/api/admin', apiLimiter, adminRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
158
server/src/routes/admin.ts
Normal file
158
server/src/routes/admin.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Router, Response, NextFunction } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
/** 管理员权限检查中间件 */
|
||||||
|
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||||
|
const user = (rows as any[])[0]
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ code: 40300, message: '需要管理员权限,请先登录获取管理员身份' })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有路由都需要管理员权限
|
||||||
|
router.use((req, res, next) => requireAdmin(req, res, next))
|
||||||
|
|
||||||
|
/** 数据看板 */
|
||||||
|
router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
|
||||||
|
const [txThisMonth] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
|
||||||
|
FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||||
|
)
|
||||||
|
const [txLastMonth] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
|
||||||
|
FROM transactions
|
||||||
|
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
||||||
|
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||||
|
)
|
||||||
|
const [activeToday] = await pool.query(
|
||||||
|
`SELECT COUNT(DISTINCT user_id) as count FROM transactions WHERE date = CURDATE()`
|
||||||
|
)
|
||||||
|
const [activeThisMonth] = await pool.query(
|
||||||
|
`SELECT COUNT(DISTINCT user_id) as count FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||||
|
)
|
||||||
|
|
||||||
|
const thisMonth = (txThisMonth as any[])[0]
|
||||||
|
const lastMonth = (txLastMonth as any[])[0]
|
||||||
|
let growth = 0
|
||||||
|
if (lastMonth.total > 0) {
|
||||||
|
growth = Math.round(((thisMonth.total - lastMonth.total) / lastMonth.total) * 100)
|
||||||
|
} else if (thisMonth.total > 0) {
|
||||||
|
growth = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
totalUsers: (userCount as any[])[0].count,
|
||||||
|
monthlyTxCount: thisMonth.count,
|
||||||
|
monthlyTxAmount: thisMonth.total,
|
||||||
|
dailyActive: (activeToday as any[])[0].count,
|
||||||
|
monthlyActive: (activeThisMonth as any[])[0].count,
|
||||||
|
growthPercent: growth,
|
||||||
|
lastMonthTxCount: lastMonth.count,
|
||||||
|
lastMonthTxAmount: lastMonth.total
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin] dashboard error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 用户列表 */
|
||||||
|
router.get('/users', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page = '1', pageSize = '20', keyword } = req.query
|
||||||
|
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||||||
|
const pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20))
|
||||||
|
const offset = (pNum - 1) * pSize
|
||||||
|
|
||||||
|
let where = ''
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||||
|
where = 'WHERE u.nickname LIKE ?'
|
||||||
|
params.push(`%${keyword.trim()}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at,
|
||||||
|
(SELECT COUNT(*) FROM transactions WHERE user_id = u.id) as tx_count,
|
||||||
|
(SELECT COUNT(*) FROM group_members WHERE user_id = u.id) as group_count
|
||||||
|
FROM users u
|
||||||
|
${where}
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, pSize, offset]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [countResult] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as total FROM users u ${where}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
list: rows,
|
||||||
|
total: (countResult as any)[0].total,
|
||||||
|
page: pNum,
|
||||||
|
pageSize: pSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin] users error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 更新用户状态(禁用/启用) */
|
||||||
|
router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { role } = req.body
|
||||||
|
if (!role || !['user', 'admin'].includes(role)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '角色无效' })
|
||||||
|
}
|
||||||
|
// 不能修改自己的角色
|
||||||
|
if (Number(req.params.id) === req.userId) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin] update user status error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 删除用户 */
|
||||||
|
router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// 不能删除自己
|
||||||
|
if (Number(req.params.id) === req.userId) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '不能删除自己' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin] delete user error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -23,10 +23,14 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
|
|||||||
[demoOpenid, 'demo', 'demo']
|
[demoOpenid, 'demo', 'demo']
|
||||||
)
|
)
|
||||||
const userId = (result as any).insertId
|
const userId = (result as any).insertId
|
||||||
|
|
||||||
|
// 首个用户自动设为管理员
|
||||||
|
await autoSetAdmin(userId)
|
||||||
|
|
||||||
const token = signToken(userId)
|
const token = signToken(userId)
|
||||||
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
|
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
|
||||||
const userInfo = (userRows as any[])[0]
|
const userInfo = (userRows as any[])[0]
|
||||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
|
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Auth] Demo login failed:', err.message)
|
console.error('[Auth] Demo login failed:', err.message)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
@@ -59,15 +63,27 @@ router.post('/login', async (req: Request, res: Response) => {
|
|||||||
)
|
)
|
||||||
const userId = (result as any).insertId
|
const userId = (result as any).insertId
|
||||||
|
|
||||||
|
// 首个用户自动设为管理员
|
||||||
|
await autoSetAdmin(userId)
|
||||||
|
|
||||||
// HMAC signed token with expiry
|
// HMAC signed token with expiry
|
||||||
const token = signToken(userId)
|
const token = signToken(userId)
|
||||||
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
|
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
|
||||||
const userInfo = (userRows as any[])[0]
|
const userInfo = (userRows as any[])[0]
|
||||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
|
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Auth] Login failed:', err.message)
|
console.error('[Auth] Login failed:', err.message)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 首个用户自动设为管理员 */
|
||||||
|
async function autoSetAdmin(userId: number) {
|
||||||
|
const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin'])
|
||||||
|
if ((adminCount as any[])[0].count === 0) {
|
||||||
|
await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId])
|
||||||
|
console.log(`[Auth] User ${userId} auto-promoted to admin`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
69
server/src/routes/filter.ts
Normal file
69
server/src/routes/filter.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Router, Response } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
/** 获取保存的筛选方案 */
|
||||||
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT id, name, filters, created_at FROM saved_filters WHERE user_id = ? ORDER BY created_at DESC',
|
||||||
|
[req.userId]
|
||||||
|
)
|
||||||
|
// 解析 JSON 字段
|
||||||
|
const list = (rows as any[]).map(row => ({
|
||||||
|
...row,
|
||||||
|
filters: typeof row.filters === 'string' ? JSON.parse(row.filters) : row.filters
|
||||||
|
}))
|
||||||
|
res.json({ code: 0, data: list })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Filter] GET error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 保存筛选方案 */
|
||||||
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, filters } = req.body
|
||||||
|
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '方案名称不能为空' })
|
||||||
|
}
|
||||||
|
if (name.length > 50) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '方案名称不能超过50字' })
|
||||||
|
}
|
||||||
|
if (!filters || typeof filters !== 'object') {
|
||||||
|
return res.status(400).json({ code: 40001, message: '筛选条件无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO saved_filters (user_id, name, filters) VALUES (?, ?, ?)',
|
||||||
|
[req.userId, name.trim(), JSON.stringify(filters)]
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Filter] POST error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 删除筛选方案 */
|
||||||
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'DELETE FROM saved_filters WHERE id = ? AND user_id = ?',
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
)
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '方案不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Filter] DELETE error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
129
server/src/routes/notification.ts
Normal file
129
server/src/routes/notification.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Router, Response } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
/** 获取通知列表 */
|
||||||
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { type, page = '1', pageSize = '20' } = req.query
|
||||||
|
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||||||
|
const pSize = Math.min(50, Math.max(1, parseInt(pageSize as string) || 20))
|
||||||
|
const offset = (pNum - 1) * pSize
|
||||||
|
|
||||||
|
let where = 'WHERE (n.user_id = ? OR (n.type = \'system\' AND n.user_id IS NULL) OR (n.type = \'group\' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))'
|
||||||
|
const params: any[] = [req.userId, req.userId]
|
||||||
|
|
||||||
|
if (type && ['system', 'group', 'personal'].includes(type as string)) {
|
||||||
|
where += ' AND n.type = ?'
|
||||||
|
params.push(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id
|
||||||
|
FROM notifications n
|
||||||
|
${where}
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, pSize, offset]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [countResult] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as total FROM notifications n ${where}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
list: rows,
|
||||||
|
total: (countResult as any)[0].total,
|
||||||
|
page: pNum,
|
||||||
|
pageSize: pSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] GET error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 获取未读数量 */
|
||||||
|
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as count FROM notifications
|
||||||
|
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))) AND is_read = 0`,
|
||||||
|
[req.userId, req.userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] unread-count error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 标记单条已读 */
|
||||||
|
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'UPDATE notifications SET is_read = 1 WHERE id = ? AND (user_id = ? OR (type = \'system\' AND user_id IS NULL) OR (type = \'group\' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))',
|
||||||
|
[req.params.id, req.userId, req.userId]
|
||||||
|
)
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '通知不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] mark-read error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 全部标记已读 */
|
||||||
|
router.put('/read-all', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE notifications SET is_read = 1
|
||||||
|
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL)) AND is_read = 0`,
|
||||||
|
[req.userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] read-all error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 发布系统公告(管理员) */
|
||||||
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// 验证管理员权限
|
||||||
|
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||||
|
const user = (userRows as any[])[0]
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ code: 40300, message: '仅管理员可发布公告' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, content } = req.body
|
||||||
|
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '标题不能为空' })
|
||||||
|
}
|
||||||
|
if (title.length > 100) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO notifications (type, title, content) VALUES (?, ?, ?)',
|
||||||
|
['system', title.trim(), content || '']
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] POST error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -22,8 +22,8 @@ async function buildWhereClause(
|
|||||||
)
|
)
|
||||||
if ((memberCheck as any[]).length === 0) return null
|
if ((memberCheck as any[]).length === 0) return null
|
||||||
|
|
||||||
params.push(groupId)
|
params.push(groupId, groupId)
|
||||||
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
|
let where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
|
||||||
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
|
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
|
||||||
return { where, params }
|
return { where, params }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { page, pageSize, type, startDate, endDate, sortBy, group_id } = req.query
|
const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, minAmount, maxAmount, keyword } = req.query
|
||||||
const pSize = safeInt(pageSize, 20, 1, 100)
|
const pSize = safeInt(pageSize, 20, 1, 100)
|
||||||
const pNum = safeInt(page, 1, 1, 99999)
|
const pNum = safeInt(page, 1, 1, 99999)
|
||||||
const offset = (pNum - 1) * pSize
|
const offset = (pNum - 1) * pSize
|
||||||
@@ -62,8 +62,8 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
if ((memberCheck as any[]).length === 0) {
|
if ((memberCheck as any[]).length === 0) {
|
||||||
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||||
}
|
}
|
||||||
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
|
where = 'WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
|
||||||
params.push(group_id)
|
params.push(group_id, group_id)
|
||||||
} else {
|
} else {
|
||||||
// 个人视图:我的所有记录(不管 group_id)
|
// 个人视图:我的所有记录(不管 group_id)
|
||||||
where = 'WHERE t.user_id = ?'
|
where = 'WHERE t.user_id = ?'
|
||||||
@@ -79,6 +79,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
if (endDate && DATE_REGEX.test(endDate as string)) {
|
if (endDate && DATE_REGEX.test(endDate as string)) {
|
||||||
where += ' AND t.date <= ?'; params.push(endDate)
|
where += ' AND t.date <= ?'; params.push(endDate)
|
||||||
}
|
}
|
||||||
|
if (category_id) {
|
||||||
|
where += ' AND t.category_id = ?'; params.push(category_id)
|
||||||
|
}
|
||||||
|
if (minAmount) {
|
||||||
|
const min = parseInt(minAmount as string)
|
||||||
|
if (!isNaN(min) && min > 0) {
|
||||||
|
where += ' AND t.amount >= ?'; params.push(min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxAmount) {
|
||||||
|
const max = parseInt(maxAmount as string)
|
||||||
|
if (!isNaN(max) && max > 0) {
|
||||||
|
where += ' AND t.amount <= ?'; params.push(max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||||
|
where += ' AND t.note LIKE ?'; params.push(`%${keyword.trim()}%`)
|
||||||
|
}
|
||||||
|
|
||||||
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
|
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
|
||||||
|
|
||||||
@@ -100,13 +118,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 筛选后的合计金额
|
||||||
|
const [sumResult] = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as totalExpense,
|
||||||
|
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as totalIncome
|
||||||
|
FROM transactions t ${where}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
code: 0,
|
code: 0,
|
||||||
data: {
|
data: {
|
||||||
list: rows,
|
list: rows,
|
||||||
total: (countResult as any)[0].total,
|
total: (countResult as any)[0].total,
|
||||||
page: pNum,
|
page: pNum,
|
||||||
pageSize: pSize
|
pageSize: pSize,
|
||||||
|
filteredExpense: (sumResult as any)[0].totalExpense,
|
||||||
|
filteredIncome: (sumResult as any)[0].totalIncome
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const upload = multer({
|
|||||||
router.get('/me', async (req: AuthRequest, res: Response) => {
|
router.get('/me', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
'SELECT id, nickname, avatar_url, created_at FROM users WHERE id = ?',
|
'SELECT id, nickname, avatar_url, role, created_at FROM users WHERE id = ?',
|
||||||
[req.userId]
|
[req.userId]
|
||||||
)
|
)
|
||||||
const user = (rows as any[])[0]
|
const user = (rows as any[])[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user