From 83571de723274983ca8993656bfeefb097413f47 Mon Sep 17 00:00:00 2001 From: wangxiaogang Date: Sat, 6 Jun 2026 17:55:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=AD=E4=BB=A3=E4=B8=89=E5=A4=A7?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20+=20=E5=85=A8=E7=B3=BB=E7=BB=9F=20Bug=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能: - 账单筛选统计:多维度筛选面板 + 保存方案 + 结果统计 - 通知公告:系统公告/群组通知/个人提醒 + 通知中心页面 - 管理员后台:数据看板 + 用户管理 + 发布公告 Bug 修复: - 统计页 fetchPrevMonthData/fetchMaxSingle 添加 group_id - 群组视图添加 t.group_id 过滤,防止私人数据泄露 - 通知列表添加群组通知条件 - 通知标记已读添加错误处理 - 管理员 API 添加 affectedRows 检查 - 筛选面板金额为 0 时正确回填 - profile onShow 补充 userStore.fetchUserInfo - 全系统样式修复(overflow/box-sizing) --- client/src/api/admin.ts | 52 +++ client/src/api/filter.ts | 35 ++ client/src/api/notification.ts | 45 ++ client/src/api/transaction.ts | 6 + client/src/api/user.ts | 1 + client/src/components/Icon/Icon.vue | 8 + client/src/pages.json | 23 ++ client/src/pages/admin/index.vue | 287 +++++++++++++ client/src/pages/admin/users.vue | 319 +++++++++++++++ client/src/pages/bills/filter-panel.vue | 428 ++++++++++++++++++++ client/src/pages/bills/index.vue | 89 +++- client/src/pages/group-manage/index.vue | 35 +- client/src/pages/index/index.vue | 35 +- client/src/pages/notifications/index.vue | 346 ++++++++++++++++ client/src/pages/profile/index.vue | 21 +- client/src/pages/stats/index.vue | 6 +- client/src/static/icons/filter-gray.png | Bin 0 -> 534 bytes client/src/static/icons/filter-orange.png | Bin 0 -> 529 bytes client/src/static/icons/settings-gray.png | Bin 0 -> 2836 bytes client/src/static/icons/settings-orange.png | Bin 0 -> 2811 bytes client/src/stores/filter.ts | 25 ++ client/src/stores/notification.ts | 24 ++ client/src/stores/transaction.ts | 1 + client/src/stores/user.ts | 1 + client/src/utils/request.ts | 2 + server/src/db/init.ts | 51 +++ server/src/db/schema.sql | 23 ++ server/src/index.ts | 6 + server/src/routes/admin.ts | 158 ++++++++ server/src/routes/auth.ts | 24 +- server/src/routes/filter.ts | 69 ++++ server/src/routes/notification.ts | 129 ++++++ server/src/routes/stats.ts | 4 +- server/src/routes/transaction.ts | 37 +- server/src/routes/user.ts | 2 +- 35 files changed, 2255 insertions(+), 37 deletions(-) create mode 100644 client/src/api/admin.ts create mode 100644 client/src/api/filter.ts create mode 100644 client/src/api/notification.ts create mode 100644 client/src/pages/admin/index.vue create mode 100644 client/src/pages/admin/users.vue create mode 100644 client/src/pages/bills/filter-panel.vue create mode 100644 client/src/pages/notifications/index.vue create mode 100644 client/src/static/icons/filter-gray.png create mode 100644 client/src/static/icons/filter-orange.png create mode 100644 client/src/static/icons/settings-gray.png create mode 100644 client/src/static/icons/settings-orange.png create mode 100644 client/src/stores/filter.ts create mode 100644 client/src/stores/notification.ts create mode 100644 server/src/routes/admin.ts create mode 100644 server/src/routes/filter.ts create mode 100644 server/src/routes/notification.ts diff --git a/client/src/api/admin.ts b/client/src/api/admin.ts new file mode 100644 index 0000000..7950be3 --- /dev/null +++ b/client/src/api/admin.ts @@ -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({ url: '/admin/dashboard' }) +} + +/** 获取用户列表 */ +export function getUsers(params?: { page?: number; pageSize?: number; keyword?: string }) { + return request({ 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' }) +} diff --git a/client/src/api/filter.ts b/client/src/api/filter.ts new file mode 100644 index 0000000..50e6e15 --- /dev/null +++ b/client/src/api/filter.ts @@ -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({ 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' }) +} diff --git a/client/src/api/notification.ts b/client/src/api/notification.ts new file mode 100644 index 0000000..d4542f4 --- /dev/null +++ b/client/src/api/notification.ts @@ -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({ 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 }) +} diff --git a/client/src/api/transaction.ts b/client/src/api/transaction.ts index bb130d5..94b3325 100644 --- a/client/src/api/transaction.ts +++ b/client/src/api/transaction.ts @@ -23,6 +23,8 @@ export interface TransactionList { total: number page: number pageSize: number + filteredExpense?: number + filteredIncome?: number } /** 获取交易列表参数 */ @@ -34,6 +36,10 @@ export interface TransactionParams { endDate?: string sortBy?: string group_id?: number | null + category_id?: number + minAmount?: number + maxAmount?: number + keyword?: string } /** 获取交易列表 */ diff --git a/client/src/api/user.ts b/client/src/api/user.ts index 1501c58..39fdfbb 100644 --- a/client/src/api/user.ts +++ b/client/src/api/user.ts @@ -6,6 +6,7 @@ export interface UserInfo { id: number nickname: string avatar_url: string + role: 'user' | 'admin' created_at: string } diff --git a/client/src/components/Icon/Icon.vue b/client/src/components/Icon/Icon.vue index 9e01a66..e45e62c 100644 --- a/client/src/components/Icon/Icon.vue +++ b/client/src/components/Icon/Icon.vue @@ -63,6 +63,14 @@ const iconMap: Record> = { '#8B7E7E': '/static/icons/refresh-gray.png', '#FF8C69': '/static/icons/refresh-orange.png', }, + filter: { + '#8B7E7E': '/static/icons/filter-gray.png', + '#FF8C69': '/static/icons/filter-orange.png', + }, + settings: { + '#8B7E7E': '/static/icons/settings-gray.png', + '#FF8C69': '/static/icons/settings-orange.png', + }, } const iconSrc = computed(() => { diff --git a/client/src/pages.json b/client/src/pages.json index 0b2539f..3c5f087 100644 --- a/client/src/pages.json +++ b/client/src/pages.json @@ -65,6 +65,29 @@ "navigationStyle": "custom", "navigationBarTitleText": "一起记" } + }, + { + "path": "pages/notifications/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "通知中心", + "enablePullDownRefresh": false + } + }, + { + "path": "pages/admin/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "管理后台" + } + }, + { + "path": "pages/admin/users", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "用户管理", + "enablePullDownRefresh": false + } } ], "globalStyle": { diff --git a/client/src/pages/admin/index.vue b/client/src/pages/admin/index.vue new file mode 100644 index 0000000..8f6c264 --- /dev/null +++ b/client/src/pages/admin/index.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/client/src/pages/admin/users.vue b/client/src/pages/admin/users.vue new file mode 100644 index 0000000..bb0b7c1 --- /dev/null +++ b/client/src/pages/admin/users.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/client/src/pages/bills/filter-panel.vue b/client/src/pages/bills/filter-panel.vue new file mode 100644 index 0000000..b300488 --- /dev/null +++ b/client/src/pages/bills/filter-panel.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/client/src/pages/bills/index.vue b/client/src/pages/bills/index.vue index c55d5cb..05cc092 100644 --- a/client/src/pages/bills/index.vue +++ b/client/src/pages/bills/index.vue @@ -11,8 +11,22 @@ 支出 收入 + + + 筛选 + + + + 共 {{ total }} 笔 + 支出 {{ formatAmount(filteredExpense) }} + 收入 {{ formatAmount(filteredIncome) }} + + + + + @@ -81,8 +95,10 @@ import { waitForReady } from '@/utils/app-ready' import TransactionItem from '@/components/TransactionItem/TransactionItem.vue' import Icon from '@/components/Icon/Icon.vue' import Skeleton from '@/components/Skeleton/Skeleton.vue' +import FilterPanel from './filter-panel.vue' import { statusBarHeight } from '@/utils/system' import { formatAmount, formatDate } from '@/utils/format' +import type { FilterParams } from '@/api/filter' const txStore = useTransactionStore() const userStore = useUserStore() @@ -95,6 +111,16 @@ const total = ref(0) const loadError = ref(false) let loadSeq = 0 // 请求序号,防止并发覆盖 +// 高级筛选 +const showFilter = ref(false) +const advancedFilters = ref({}) +const filteredExpense = ref(0) +const filteredIncome = ref(0) +const hasAdvancedFilter = computed(() => { + const f = advancedFilters.value + return !!(f.category_ids?.length || f.startDate || f.endDate || f.minAmount || f.maxAmount || f.keyword) +}) + const noMore = computed(() => txList.value.length >= total.value && total.value > 0) const groupedTx = computed(() => { @@ -138,9 +164,17 @@ async function loadTx(reset = false, silent = false) { const seq = ++loadSeq const params: any = { page: page.value, pageSize } if (filterType.value !== 'all') params.type = filterType.value + // 合并高级筛选参数 + const af = advancedFilters.value + if (af.category_ids?.length === 1) params.category_id = af.category_ids[0] + if (af.startDate) params.startDate = af.startDate + if (af.endDate) params.endDate = af.endDate + if (af.minAmount) params.minAmount = af.minAmount + if (af.maxAmount) params.maxAmount = af.maxAmount + if (af.keyword) params.keyword = af.keyword try { loadError.value = false - await txStore.fetchTransactions(params) + const data = await txStore.fetchTransactions(params) // 丢弃过期请求的结果 if (seq !== loadSeq) return if (reset) { @@ -149,6 +183,10 @@ async function loadTx(reset = false, silent = false) { txList.value = [...txList.value, ...txStore.transactions] } total.value = txStore.total + if (data) { + filteredExpense.value = data.filteredExpense || 0 + filteredIncome.value = data.filteredIncome || 0 + } } catch (e) { if (seq !== loadSeq) return console.error('Load bills error:', e) @@ -156,6 +194,12 @@ async function loadTx(reset = false, silent = false) { } } +function onFilterApply(filters: FilterParams) { + advancedFilters.value = filters + showFilter.value = false + loadTx(true) +} + function onEdit(item: any) { if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) { uni.showToast({ title: '只能编辑自己的记录', icon: 'none' }) @@ -227,7 +271,13 @@ onPullDownRefresh(() => { padding: 16rpx 0 24rpx; } -.tabs-wrap { display: flex; justify-content: center; margin: 0 0 32rpx; } +.tabs-wrap { + display: flex; + align-items: center; + justify-content: center; + margin: 0 0 24rpx; + gap: 16rpx; +} .tabs { display: inline-flex; @@ -251,6 +301,41 @@ onPullDownRefresh(() => { } } +.filter-btn { + display: flex; + align-items: center; + gap: 6rpx; + padding: 12rpx 20rpx; + background: #FFF0E6; + border-radius: 20rpx; + border: 2rpx solid #F0E0D6; + + &.active { + border-color: #FF8C69; + background: #FFE8E0; + } + + &:active { opacity: 0.7; } +} + +.filter-btn-text { + font-size: 24rpx; + color: #8B7E7E; + .active & { color: #FF8C69; } +} + +.filter-stats { + display: flex; + gap: 24rpx; + padding: 0 40rpx 16rpx; +} + +.filter-stats-text { + font-size: 24rpx; + font-family: 'Fredoka', sans-serif; + color: #8B7E7E; +} + .tx-list { padding: 0 40rpx; } .date-group { diff --git a/client/src/pages/group-manage/index.vue b/client/src/pages/group-manage/index.vue index 911c495..a05462a 100644 --- a/client/src/pages/group-manage/index.vue +++ b/client/src/pages/group-manage/index.vue @@ -62,11 +62,8 @@ {{ group.name }} - - 群主 - - - 成员 + + {{ group.role === 'owner' ? '群主' : '成员' }} {{ group.member_count }} 人 @@ -122,7 +119,9 @@ {{ m.nickname || '用户' + m.user_id }} - {{ m.role === 'owner' ? '群主' : '成员' }} + + {{ m.role === 'owner' ? '群主' : '成员' }} + 移除 @@ -514,17 +513,11 @@ function handleDissolve(group: Group) { .role-badge { padding: 2rpx 12rpx; border-radius: 8rpx; - - &.owner { background: #FFE8E0; } - &.member { background: #F0E0D6; } } .role-badge-text { font-size: 20rpx; font-weight: 500; - - .owner & { color: #FF8C69; } - .member & { color: #8B7E7E; } } .group-meta { @@ -623,6 +616,8 @@ function handleDissolve(group: Group) { border-radius: 40rpx 40rpx 0 0; display: flex; flex-direction: column; + box-sizing: border-box; + overflow: hidden; } .modal-header { @@ -667,6 +662,8 @@ function handleDissolve(group: Group) { .member-list { max-height: 60vh; padding: 16rpx 40rpx 40rpx; + box-sizing: border-box; + width: 100%; } .member-item { @@ -675,6 +672,8 @@ function handleDissolve(group: Group) { padding: 20rpx 0; gap: 20rpx; border-bottom: 2rpx solid #F8F0EB; + width: 100%; + box-sizing: border-box; &:last-child { border-bottom: none; } } @@ -697,15 +696,17 @@ function handleDissolve(group: Group) { font-weight: 500; color: #2D1B1B; display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.member-role { - font-size: 22rpx; - display: block; +.member-role-wrap { margin-top: 4rpx; +} - &.owner { color: #FF8C69; } - &.member { color: #BFB3B3; } +.member-role-text { + font-size: 22rpx; } .member-remove { diff --git a/client/src/pages/index/index.vue b/client/src/pages/index/index.vue index a135582..4a3901e 100644 --- a/client/src/pages/index/index.vue +++ b/client/src/pages/index/index.vue @@ -8,8 +8,11 @@ {{ greeting }},{{ userStore.nickname }} - + + + {{ notifStore.unreadCount > 99 ? '99+' : notifStore.unreadCount }} + @@ -120,6 +123,7 @@ import { useStatsStore } from '@/stores/stats' import { useBudgetStore } from '@/stores/budget' import { useUserStore } from '@/stores/user' import { useGroupStore } from '@/stores/group' +import { useNotificationStore } from '@/stores/notification' import { waitForReady } from '@/utils/app-ready' import { getOverview } from '@/api/stats' import { formatAmount, formatAmountRaw } from '@/utils/format' @@ -134,6 +138,7 @@ const statsStore = useStatsStore() const budgetStore = useBudgetStore() const userStore = useUserStore() const groupStore = useGroupStore() +const notifStore = useNotificationStore() const DEFAULT_BUDGET = 500000 // 5000元(分) @@ -167,7 +172,8 @@ async function loadData(silent = false) { budgetStore.fetchBudget(), txStore.fetchTransactions({ page: 1 }), fetchTodayData(today), - userStore.fetchUserInfo() + userStore.fetchUserInfo(), + notifStore.fetchUnreadCount() ]) recentTx.value = txStore.transactions.slice(0, 5) } catch (e) { @@ -209,8 +215,8 @@ function goAdd(type: string) { uni.navigateTo({ url: `/pages/add/index?type=${type}` }) } -function showNotification() { - uni.showToast({ title: '暂无通知', icon: 'none' }) +function goNotifications() { + uni.navigateTo({ url: '/pages/notifications/index' }) } function onTxTap(item: any) { @@ -281,10 +287,31 @@ function goBills() { display: flex; align-items: center; justify-content: center; + position: relative; &:active { opacity: 0.6; } } +.bell-badge { + position: absolute; + top: 8rpx; + right: 8rpx; + min-width: 32rpx; + height: 32rpx; + padding: 0 8rpx; + background: #FF6B6B; + border-radius: 16rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.bell-badge-text { + font-size: 18rpx; + font-weight: 600; + color: #FFFFFF; +} + .overview-card { margin: 0 40rpx; padding: 48rpx; diff --git a/client/src/pages/notifications/index.vue b/client/src/pages/notifications/index.vue new file mode 100644 index 0000000..eb8e1a7 --- /dev/null +++ b/client/src/pages/notifications/index.vue @@ -0,0 +1,346 @@ + + + + + diff --git a/client/src/pages/profile/index.vue b/client/src/pages/profile/index.vue index a99509f..ee2ec50 100644 --- a/client/src/pages/profile/index.vue +++ b/client/src/pages/profile/index.vue @@ -64,6 +64,15 @@ + + + + + 管理后台 + + + + @@ -170,7 +179,8 @@ onShow(async () => { statsStore.fetchOverview(), budgetStore.fetchBudget(), txStore.fetchTransactions({ page: 1, pageSize: 1 }), - groupStore.fetchGroups() + groupStore.fetchGroups(), + userStore.fetchUserInfo() ]) } catch {} }) @@ -206,6 +216,10 @@ function goGroupManage() { uni.navigateTo({ url: '/pages/group-manage/index' }) } +function goAdmin() { + uni.navigateTo({ url: '/pages/admin/index' }) +} + function promptCreateGroup() { // #ifdef MP-WEIXIN // 小程序不支持 prompt,使用输入框页面 @@ -493,6 +507,11 @@ async function exportData() { .mi-label { flex: 1; font-size: 28rpx; font-weight: 500; color: #2D1B1B; } .mi-extra { font-size: 28rpx; color: #FF8C69; font-weight: 600; margin-right: 16rpx; } +.admin-entry { + gap: 16rpx; + .mi-label { color: #FF8C69; font-weight: 600; } +} + .about-title { font-size: 40rpx; font-weight: 600; diff --git a/client/src/pages/stats/index.vue b/client/src/pages/stats/index.vue index 03f5091..ef9a6d8 100644 --- a/client/src/pages/stats/index.vue +++ b/client/src/pages/stats/index.vue @@ -121,6 +121,7 @@ import { ref, computed, onMounted, watch } from 'vue' import { onShow, onPullDownRefresh } from '@dcloudio/uni-app' import { useStatsStore } from '@/stores/stats' +import { useGroupStore } from '@/stores/group' import { waitForReady } from '@/utils/app-ready' import { getTransactions } from '@/api/transaction' import { getOverview } from '@/api/stats' @@ -131,6 +132,7 @@ import Skeleton from '@/components/Skeleton/Skeleton.vue' import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue' const statsStore = useStatsStore() +const groupStore = useGroupStore() const currentMonth = ref(getCurrentMonth()) const tabType = ref<'expense' | 'income'>('expense') @@ -266,7 +268,7 @@ async function fetchMaxSingle() { const startDate = `${currentMonth.value}-01` const lastDay = new Date(y, m, 0).getDate() const endDate = `${currentMonth.value}-${String(lastDay).padStart(2, '0')}` - const data = await getTransactions({ page: 1, pageSize: 1, type: tabType.value, startDate, endDate, sortBy: 'amount' }) + const data = await getTransactions({ page: 1, pageSize: 1, type: tabType.value, startDate, endDate, sortBy: 'amount', group_id: groupStore.currentGroupId }) maxSingle.value = data.list?.[0]?.amount || 0 } catch { maxSingle.value = 0 @@ -277,7 +279,7 @@ async function fetchPrevMonthData() { try { const [y, m] = currentMonth.value.split('-').map(Number) const prevMonth = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}` - const data = await getOverview({ month: prevMonth }) + const data = await getOverview({ month: prevMonth, group_id: groupStore.currentGroupId }) // 确保数据字段是数字类型,避免 null/undefined 导致 Infinity prevMonthData.value = { expense: Number(data?.expense) || 0, diff --git a/client/src/static/icons/filter-gray.png b/client/src/static/icons/filter-gray.png new file mode 100644 index 0000000000000000000000000000000000000000..3c212d05934f52ee68a4714dd451384d00ca85d1 GIT binary patch literal 534 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wk0|Vm; zPZ!6KiaBrZIOZL4;Au^q-xhuBW`D8hx;7C#2hq^G3Q``-mG6H1Uouf^mtm!^f#mWu zpivAL8Z%}5b0y9_tc$(&>GP*qyVeOUE3|3-t$HWF{IwGYd&d3xpWEHDf8G@|opP-$ zF^)4K?E3Y+Kb}7LuTxrkmzznU;nJex#t+u5-zU3gUG%RC$J*Vm?p_X;->bKN&q|ik zyjA7l@wH#F^uK(3CAIz9?$ztX_ALB4pLN6kYIepQHVk@>dV5$tD!!?=WxiMbx!>%{ zc9|cyw(oy@k}(KqjRFIchOPzk7Aa|?PYmbe-=5ys=lhdI?HDjrDth=m6>qs1?$hWj zT^J2i<#!Hpq_WkOBxy-8Pt^USVbBPS~YZH&u6NM631^TL|x03SrN!m+j_IKWt*97Tfgu2g?mRPGp|^;zS@z2B_OJREyL6- rs literal 0 HcmV?d00001 diff --git a/client/src/static/icons/filter-orange.png b/client/src/static/icons/filter-orange.png new file mode 100644 index 0000000000000000000000000000000000000000..d57788ea3a49494cb13f87a3ae26ba015dd7a6d3 GIT binary patch literal 529 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wk0|Vn> zPZ!6KiaBrR1m+zw5ODc!GUseypHO`3YQ=-B%U0deXYsnb;LNGt^R@`hZ*I5fE}z5! zG>L(sfioap|JB=7`)ul7{r+54_*!erkJG-j7lZE_JQQKR_4VkZ|6#l5EnH}F>(<}3 zjM2MyfA#IZ_wQHzskQl4EsPQYQfDhzZN6T$+9{`8yT|dq;$HV(uQtE_HeG+J!}AIE znrpw_yju3VV*fG2y~lwP*|*<6ek;ZPHOb+89z*nmJ<$T@iGMFQoR|G~u}x2zJO1~N zXlw2#*UW7XG4N>g?qE8@mcx^>hpohIQW|qu+owDi9aaX02c~yadM~KWde}cnvXARb zK|~rG^O=MONgfZoDg7x`r&67jXU;3SdBJ1Z@)$FdNiQ|0^Y*4x{bAd{x-ta8Uv|2! z?hu0o1KW&)48{@(ZH+yWKwBhEsW(Ib1K%O=2(!$)qwy8%SDbG+R_!{s<9Nj0j!G7` zDQ+>05(Z~{oC{Ph3tvrA@LHz6D%EhRkAFxvlh#Z*o)NJdsh|jiyoD;Y-@O)-t7R{2Lvts`c3tlQrcKwD}%V6 Lu6{1-oD!M<@si^1 literal 0 HcmV?d00001 diff --git a/client/src/static/icons/settings-gray.png b/client/src/static/icons/settings-gray.png new file mode 100644 index 0000000000000000000000000000000000000000..3a6876f7af95d1da39211f509188c0e90cc0ffb2 GIT binary patch literal 2836 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 { + const savedFilters = ref([]) + + 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 } +}) diff --git a/client/src/stores/notification.ts b/client/src/stores/notification.ts new file mode 100644 index 0000000..518fe9b --- /dev/null +++ b/client/src/stores/notification.ts @@ -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 } +}) diff --git a/client/src/stores/transaction.ts b/client/src/stores/transaction.ts index ba03490..6221d5e 100644 --- a/client/src/stores/transaction.ts +++ b/client/src/stores/transaction.ts @@ -19,6 +19,7 @@ export const useTransactionStore = defineStore('transaction', () => { }) transactions.value = data.list total.value = data.total + return data } catch (err) { transactions.value = [] total.value = 0 diff --git a/client/src/stores/user.ts b/client/src/stores/user.ts index c226cb4..cc208cc 100644 --- a/client/src/stores/user.ts +++ b/client/src/stores/user.ts @@ -24,6 +24,7 @@ export const useUserStore = defineStore('user', () => { if (userInfo.value) { uni.setStorageSync('xc:nickname', userInfo.value.nickname) uni.setStorageSync('xc:avatar_url', userInfo.value.avatar_url) + uni.setStorageSync('xc:role', userInfo.value.role) } } diff --git a/client/src/utils/request.ts b/client/src/utils/request.ts index 0c76bdb..378745f 100644 --- a/client/src/utils/request.ts +++ b/client/src/utils/request.ts @@ -11,6 +11,7 @@ interface LoginResult { userId: number nickname: string avatar_url: string + role?: string } let isRedirectingToLogin = false @@ -23,6 +24,7 @@ export function saveLoginResult(data: LoginResult) { uni.setStorageSync('xc:userId', data.userId) uni.setStorageSync('xc:nickname', data.nickname) uni.setStorageSync('xc:avatar_url', data.avatar_url) + if (data.role) uni.setStorageSync('xc:role', data.role) } /** 执行重登录 */ diff --git a/server/src/db/init.ts b/server/src/db/init.ts index b92f35f..aec1b79 100644 --- a/server/src/db/init.ts +++ b/server/src/db/init.ts @@ -11,6 +11,14 @@ async function columnExists(conn: mysql.Connection, table: string, column: strin return (rows as any[])[0].cnt > 0 } +async function tableExists(conn: mysql.Connection, table: string): Promise { + 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) { 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)") 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() { diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 7847581..804f2a4 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users ( session_key VARCHAR(100), nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称', avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL', + role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) 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 (user_id) REFERENCES users(id) ON DELETE CASCADE ) 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; diff --git a/server/src/index.ts b/server/src/index.ts index 7b4d95b..d3cba71 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -15,6 +15,9 @@ import categoryRoutes from './routes/category' import backupRoutes from './routes/backup' import userRoutes from './routes/user' 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' // 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/user', apiLimiter, userRoutes) 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) => { try { diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts new file mode 100644 index 0000000..77fcbfb --- /dev/null +++ b/server/src/routes/admin.ts @@ -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 diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 2448fad..1a19a5d 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -23,10 +23,14 @@ router.post('/demo-login', async (_req: Request, res: Response) => { [demoOpenid, 'demo', 'demo'] ) const userId = (result as any).insertId + + // 首个用户自动设为管理员 + await autoSetAdmin(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] - 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) { console.error('[Auth] Demo login failed:', err.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 + // 首个用户自动设为管理员 + await autoSetAdmin(userId) + // HMAC signed token with expiry 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] - 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) { console.error('[Auth] Login failed:', err.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 diff --git a/server/src/routes/filter.ts b/server/src/routes/filter.ts new file mode 100644 index 0000000..603dad8 --- /dev/null +++ b/server/src/routes/filter.ts @@ -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 diff --git a/server/src/routes/notification.ts b/server/src/routes/notification.ts new file mode 100644 index 0000000..c63d004 --- /dev/null +++ b/server/src/routes/notification.ts @@ -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 diff --git a/server/src/routes/stats.ts b/server/src/routes/stats.ts index 30edc60..5aa3f77 100644 --- a/server/src/routes/stats.ts +++ b/server/src/routes/stats.ts @@ -22,8 +22,8 @@ async function buildWhereClause( ) if ((memberCheck as any[]).length === 0) return null - params.push(groupId) - let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)` + params.push(groupId, groupId) + 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 ') return { where, params } } diff --git a/server/src/routes/transaction.ts b/server/src/routes/transaction.ts index d3cdc8f..20a389e 100644 --- a/server/src/routes/transaction.ts +++ b/server/src/routes/transaction.ts @@ -45,7 +45,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => { router.get('/', async (req: AuthRequest, res: Response) => { 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 pNum = safeInt(page, 1, 1, 99999) const offset = (pNum - 1) * pSize @@ -62,8 +62,8 @@ router.get('/', async (req: AuthRequest, res: Response) => { if ((memberCheck as any[]).length === 0) { return res.status(403).json({ code: 40300, message: '无权访问此群组' }) } - where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)' - params.push(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, group_id) } else { // 个人视图:我的所有记录(不管 group_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)) { 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' @@ -100,13 +118,24 @@ router.get('/', async (req: AuthRequest, res: Response) => { 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({ code: 0, data: { list: rows, total: (countResult as any)[0].total, page: pNum, - pageSize: pSize + pageSize: pSize, + filteredExpense: (sumResult as any)[0].totalExpense, + filteredIncome: (sumResult as any)[0].totalIncome } }) } catch (err) { diff --git a/server/src/routes/user.ts b/server/src/routes/user.ts index 54506ee..894f2a0 100644 --- a/server/src/routes/user.ts +++ b/server/src/routes/user.ts @@ -48,7 +48,7 @@ const upload = multer({ router.get('/me', async (req: AuthRequest, res: Response) => { try { 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] ) const user = (rows as any[])[0]