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 0000000..3c212d0 Binary files /dev/null and b/client/src/static/icons/filter-gray.png differ diff --git a/client/src/static/icons/filter-orange.png b/client/src/static/icons/filter-orange.png new file mode 100644 index 0000000..d57788e Binary files /dev/null and b/client/src/static/icons/filter-orange.png differ diff --git a/client/src/static/icons/settings-gray.png b/client/src/static/icons/settings-gray.png new file mode 100644 index 0000000..3a6876f Binary files /dev/null and b/client/src/static/icons/settings-gray.png differ diff --git a/client/src/static/icons/settings-orange.png b/client/src/static/icons/settings-orange.png new file mode 100644 index 0000000..d9ac02a Binary files /dev/null and b/client/src/static/icons/settings-orange.png differ diff --git a/client/src/stores/filter.ts b/client/src/stores/filter.ts new file mode 100644 index 0000000..29acb00 --- /dev/null +++ b/client/src/stores/filter.ts @@ -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([]) + + 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]