Compare commits
3 Commits
1e975cd828
...
a35689cdda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a35689cdda | ||
|
|
83571de723 | ||
|
|
bc6eca1e9b |
52
client/src/api/admin.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/** 数据看板 */
|
||||
export interface Dashboard {
|
||||
totalUsers: number
|
||||
monthlyTxCount: number
|
||||
monthlyTxAmount: number
|
||||
dailyActive: number
|
||||
monthlyActive: number
|
||||
growthPercent: number
|
||||
lastMonthTxCount: number
|
||||
lastMonthTxAmount: number
|
||||
}
|
||||
|
||||
/** 用户信息(管理员视角) */
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
role: 'user' | 'admin'
|
||||
created_at: string
|
||||
tx_count: number
|
||||
group_count: number
|
||||
}
|
||||
|
||||
/** 用户列表响应 */
|
||||
export interface AdminUserList {
|
||||
list: AdminUser[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 获取数据看板 */
|
||||
export function getDashboard() {
|
||||
return request<Dashboard>({ url: '/admin/dashboard' })
|
||||
}
|
||||
|
||||
/** 获取用户列表 */
|
||||
export function getUsers(params?: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request<AdminUserList>({ url: '/admin/users', data: params })
|
||||
}
|
||||
|
||||
/** 更新用户角色 */
|
||||
export function updateUserRole(id: number, role: 'user' | 'admin') {
|
||||
return request({ url: `/admin/users/${id}/status`, method: 'PUT', data: { role } })
|
||||
}
|
||||
|
||||
/** 删除用户 */
|
||||
export function deleteUser(id: number) {
|
||||
return request({ url: `/admin/users/${id}`, method: 'DELETE' })
|
||||
}
|
||||
35
client/src/api/filter.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/** 保存的筛选方案 */
|
||||
export interface SavedFilter {
|
||||
id: number
|
||||
name: string
|
||||
filters: FilterParams
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 筛选参数 */
|
||||
export interface FilterParams {
|
||||
type?: string
|
||||
category_ids?: number[]
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** 获取保存的筛选方案 */
|
||||
export function getSavedFilters() {
|
||||
return request<SavedFilter[]>({ url: '/filters' })
|
||||
}
|
||||
|
||||
/** 保存筛选方案 */
|
||||
export function saveFilter(data: { name: string; filters: FilterParams }) {
|
||||
return request<{ id: number }>({ url: '/filters', method: 'POST', data })
|
||||
}
|
||||
|
||||
/** 删除筛选方案 */
|
||||
export function deleteFilter(id: number) {
|
||||
return request({ url: `/filters/${id}`, method: 'DELETE' })
|
||||
}
|
||||
80
client/src/api/notification.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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
|
||||
is_pinned?: number
|
||||
is_urgent?: number
|
||||
publish_at?: string | null
|
||||
expire_at?: string | null
|
||||
image_url?: string
|
||||
link_url?: string
|
||||
}
|
||||
|
||||
/** 通知列表响应 */
|
||||
export interface NotificationList {
|
||||
list: Notification[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 发布公告参数 */
|
||||
export interface PublishParams {
|
||||
title: string
|
||||
content?: string
|
||||
type?: 'system' | 'group' | 'personal'
|
||||
group_id?: number
|
||||
is_pinned?: boolean
|
||||
is_urgent?: boolean
|
||||
publish_at?: string
|
||||
expire_at?: string
|
||||
image_url?: string
|
||||
link_url?: string
|
||||
}
|
||||
|
||||
/** 获取通知列表 */
|
||||
export function getNotifications(params?: { type?: string; page?: number; pageSize?: number }) {
|
||||
return request<NotificationList>({ url: '/notifications', data: params })
|
||||
}
|
||||
|
||||
/** 获取置顶/强提醒公告 */
|
||||
export function getPinnedNotifications() {
|
||||
return request<Notification[]>({ url: '/notifications/pinned' })
|
||||
}
|
||||
|
||||
/** 获取未读数量 */
|
||||
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: PublishParams) {
|
||||
return request<{ id: number }>({ url: '/notifications', method: 'POST', data })
|
||||
}
|
||||
|
||||
/** 编辑公告 */
|
||||
export function updateNotification(id: number, data: Partial<PublishParams>) {
|
||||
return request({ url: `/notifications/${id}`, method: 'PUT', data })
|
||||
}
|
||||
|
||||
/** 删除公告 */
|
||||
export function deleteNotification(id: number) {
|
||||
return request({ url: `/notifications/${id}`, method: 'DELETE' })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 获取交易列表 */
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface UserInfo {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
role: 'user' | 'admin'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ const iconMap: Record<string, Record<string, string>> = {
|
||||
'#8B7E7E': '/static/icons/refresh-gray.png',
|
||||
'#FF8C69': '/static/icons/refresh-orange.png',
|
||||
},
|
||||
filter: {
|
||||
'#8B7E7E': '/static/icons/filter-gray.png',
|
||||
'#FF8C69': '/static/icons/filter-orange.png',
|
||||
},
|
||||
settings: {
|
||||
'#8B7E7E': '/static/icons/settings-gray.png',
|
||||
'#FF8C69': '/static/icons/settings-orange.png',
|
||||
},
|
||||
}
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
|
||||
@@ -52,7 +52,7 @@ function onKeyTap(key: string) {
|
||||
|
||||
if (key === '.') {
|
||||
if (current.includes('.')) return
|
||||
if (!current) { emitValue('0.'); return }
|
||||
if (!current || current === '0') { emitValue('0.'); return }
|
||||
}
|
||||
|
||||
let next: string
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -149,11 +149,13 @@ onMounted(async () => {
|
||||
if (currentCategories.value.length > 0 && !selectedCat.value) {
|
||||
selectedCat.value = currentCategories.value[0].id
|
||||
}
|
||||
initialLoaded.value = true
|
||||
})
|
||||
|
||||
// 返回页面时刷新分类(可能新增了分类)
|
||||
const initialLoaded = ref(false)
|
||||
onShow(() => {
|
||||
catStore.fetchCategories()
|
||||
if (initialLoaded.value) catStore.fetchCategories()
|
||||
})
|
||||
|
||||
function onDateChange(e: any) {
|
||||
|
||||
596
client/src/pages/admin/index.vue
Normal file
@@ -0,0 +1,596 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header-fixed">
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||
</view>
|
||||
<text class="nav-title">管理后台</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="dashboard" class="content">
|
||||
<!-- 指标卡 -->
|
||||
<view class="metric-grid">
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ dashboard.totalUsers }}</text>
|
||||
<text class="metric-label">总用户</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ dashboard.monthlyTxCount }}</text>
|
||||
<text class="metric-label">本月交易</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value">{{ dashboard.dailyActive }}</text>
|
||||
<text class="metric-label">今日活跃</text>
|
||||
</view>
|
||||
<view class="metric-card">
|
||||
<text class="metric-value" :class="dashboard.growthPercent >= 0 ? 'up' : 'down'">
|
||||
{{ dashboard.growthPercent >= 0 ? '+' : '' }}{{ dashboard.growthPercent }}%
|
||||
</text>
|
||||
<text class="metric-label">环比增长</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 本月详情 -->
|
||||
<view class="detail-card">
|
||||
<text class="detail-title">本月概况</text>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">交易总额</text>
|
||||
<text class="detail-value">{{ formatAmount(dashboard.monthlyTxAmount) }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">月活用户</text>
|
||||
<text class="detail-value">{{ dashboard.monthlyActive }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">上月交易</text>
|
||||
<text class="detail-value">{{ dashboard.lastMonthTxCount }} 笔 / {{ formatAmount(dashboard.lastMonthTxAmount) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能入口 -->
|
||||
<view class="section-title">管理功能</view>
|
||||
<view class="entry-list">
|
||||
<view class="entry-item" @tap="goUsers">
|
||||
<Icon name="user" :size="32" color="#FF8C69" />
|
||||
<text class="entry-text">用户管理</text>
|
||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||
</view>
|
||||
<view class="entry-item" @tap="goNotifications">
|
||||
<Icon name="bell" :size="32" color="#FF8C69" />
|
||||
<text class="entry-text">发布公告</text>
|
||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发布公告弹窗 -->
|
||||
<view class="modal-mask" v-if="showPublishModal" @tap="showPublishModal = false">
|
||||
<view class="publish-modal" @tap.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">发布公告</text>
|
||||
<view class="modal-close" @tap="showPublishModal = false">
|
||||
<text class="modal-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="modal-body" scroll-y>
|
||||
<view class="form-group">
|
||||
<text class="form-label">标题 *</text>
|
||||
<input class="form-input" v-model="publishForm.title" placeholder="输入公告标题" maxlength="100" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">内容</text>
|
||||
<textarea class="form-textarea" v-model="publishForm.content" placeholder="输入公告内容" maxlength="2000" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">链接(可选)</text>
|
||||
<input class="form-input" v-model="publishForm.link_url" placeholder="https://..." />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">配图(可选)</text>
|
||||
<view v-if="publishForm.image_url" class="image-preview">
|
||||
<image class="preview-img" :src="publishForm.image_url" mode="aspectFill" />
|
||||
<view class="image-remove" @tap="publishForm.image_url = ''">
|
||||
<text class="image-remove-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="image-upload" @tap="chooseImage">
|
||||
<Icon name="plus" :size="40" color="#BFB3B3" />
|
||||
<text class="upload-text">选择图片</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">定时发布(可选)</text>
|
||||
<picker mode="date" :value="publishForm.publish_at" @change="e => publishForm.publish_at = e.detail.value">
|
||||
<view class="form-input picker-input">
|
||||
<text :class="{ placeholder: !publishForm.publish_at }">{{ publishForm.publish_at || '立即发布' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">过期时间(可选)</text>
|
||||
<picker mode="date" :value="publishForm.expire_at" @change="e => publishForm.expire_at = e.detail.value">
|
||||
<view class="form-input picker-input">
|
||||
<text :class="{ placeholder: !publishForm.expire_at }">{{ publishForm.expire_at || '永不过期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="switch-item" @tap="publishForm.is_pinned = !publishForm.is_pinned">
|
||||
<text class="switch-label">📌 置顶</text>
|
||||
<view class="switch-toggle" :class="{ on: publishForm.is_pinned }"></view>
|
||||
</view>
|
||||
<view class="switch-item" @tap="publishForm.is_urgent = !publishForm.is_urgent">
|
||||
<text class="switch-label">🔔 强提醒</text>
|
||||
<view class="switch-toggle" :class="{ on: publishForm.is_urgent }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="modal-footer" @tap="handlePublish">
|
||||
<text class="publish-btn-text">发布</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { getDashboard } from '@/api/admin'
|
||||
import { publishNotification } from '@/api/notification'
|
||||
import { formatAmount } from '@/utils/format'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import type { Dashboard } from '@/api/admin'
|
||||
|
||||
const loading = ref(true)
|
||||
const dashboard = ref<Dashboard | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try {
|
||||
dashboard.value = await getDashboard()
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function goBack() { uni.navigateBack() }
|
||||
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
|
||||
const showPublishModal = ref(false)
|
||||
const publishForm = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
is_pinned: false,
|
||||
is_urgent: false,
|
||||
link_url: '',
|
||||
image_url: '',
|
||||
publish_at: '',
|
||||
expire_at: ''
|
||||
})
|
||||
|
||||
function goNotifications() {
|
||||
publishForm.value = { title: '', content: '', is_pinned: false, is_urgent: false, link_url: '', image_url: '', publish_at: '', expire_at: '' }
|
||||
showPublishModal.value = true
|
||||
}
|
||||
|
||||
/** 选择配图 */
|
||||
function chooseImage() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
success: (res) => {
|
||||
publishForm.value.image_url = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!publishForm.value.title.trim()) {
|
||||
uni.showToast({ title: '请输入标题', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await publishNotification({
|
||||
title: publishForm.value.title.trim(),
|
||||
content: publishForm.value.content.trim(),
|
||||
is_pinned: publishForm.value.is_pinned,
|
||||
is_urgent: publishForm.value.is_urgent,
|
||||
link_url: publishForm.value.link_url.trim() || undefined,
|
||||
image_url: publishForm.value.image_url || undefined,
|
||||
publish_at: publishForm.value.publish_at || undefined,
|
||||
expire_at: publishForm.value.expire_at || undefined
|
||||
})
|
||||
showPublishModal.value = false
|
||||
uni.showToast({ title: '已发布', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '发布失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
}
|
||||
|
||||
.status-bar { background: #FFF8F0; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.nav-placeholder { width: 88rpx; }
|
||||
|
||||
.loading-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.content { padding: 0 40rpx; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
|
||||
&.up { color: #7BC67E; }
|
||||
&.down { color: #FF6B6B; }
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12rpx 0;
|
||||
border-bottom: 2rpx solid #F8F0EB;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.entry-list { margin-bottom: 32rpx; }
|
||||
|
||||
.entry-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:active { background: #FFF8F0; }
|
||||
}
|
||||
|
||||
.entry-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
/* 发布公告弹窗 */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.publish-modal {
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 40rpx 24rpx;
|
||||
border-bottom: 2rpx solid #F0E0D6;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #F0E0D6;
|
||||
&:active { background: #E0D6D0; }
|
||||
}
|
||||
|
||||
.modal-close-text {
|
||||
font-size: 36rpx;
|
||||
color: #8B7E7E;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 24rpx 40rpx;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.switch-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
|
||||
&:active { background: #F0E0D6; }
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
font-size: 26rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.switch-toggle {
|
||||
width: 44rpx;
|
||||
height: 24rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #D0C4C4;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
|
||||
&.on { background: #FF8C69; }
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 2rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: #FFFFFF;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&.on::after { transform: translateX(20rpx); }
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 24rpx 40rpx 40rpx;
|
||||
border-top: 2rpx solid #F0E0D6;
|
||||
}
|
||||
|
||||
.publish-btn-text {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
padding: 20rpx;
|
||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||
border-radius: 20rpx;
|
||||
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.image-upload {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border: 2rpx dashed #D0C4C4;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
|
||||
&:active { background: #FFF8F0; }
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.image-remove {
|
||||
position: absolute;
|
||||
top: -12rpx;
|
||||
right: -12rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background: #FF6B6B;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-remove-text {
|
||||
font-size: 28rpx;
|
||||
color: #FFFFFF;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.picker-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.placeholder { color: #BFB3B3; }
|
||||
}
|
||||
</style>
|
||||
319
client/src/pages/admin/users.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header-fixed">
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||
<view class="nav-bar">
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||
</view>
|
||||
<text class="nav-title">用户管理</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索 -->
|
||||
<view class="search-bar">
|
||||
<input class="search-input" v-model="keyword" placeholder="搜索昵称" @confirm="loadUsers(true)" />
|
||||
</view>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<view v-if="loading && list.length === 0" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="list.length === 0" class="empty-box">
|
||||
<Icon name="user" :size="48" color="#BFB3B3" />
|
||||
<text class="empty-text">暂无用户</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="user-list">
|
||||
<view v-for="user in list" :key="user.id" class="user-item">
|
||||
<image class="user-avatar" :src="getAvatarUrl(user.avatar_url)" mode="aspectFill" />
|
||||
<view class="user-info">
|
||||
<view class="user-name-row">
|
||||
<text class="user-name">{{ user.nickname || '用户' + user.id }}</text>
|
||||
<view class="role-tag" :style="{ background: user.role === 'admin' ? '#FFE8E0' : '#F0E0D6' }">
|
||||
<text class="role-tag-text" :style="{ color: user.role === 'admin' ? '#FF8C69' : '#8B7E7E' }">{{ user.role === 'admin' ? '管理员' : '用户' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="user-meta">{{ user.tx_count }} 笔记录 · {{ user.group_count }} 个群组</text>
|
||||
<text class="user-time">注册于 {{ formatDate(user.created_at) }}</text>
|
||||
</view>
|
||||
<view class="user-actions">
|
||||
<view class="action-btn" @tap="handleToggleRole(user)">
|
||||
<text class="action-text">{{ user.role === 'admin' ? '取消管理' : '设为管理' }}</text>
|
||||
</view>
|
||||
<view class="action-btn danger" @tap="handleDelete(user)">
|
||||
<text class="action-text danger-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onReachBottom } from '@dcloudio/uni-app'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { API_BASE } from '@/config'
|
||||
import { getUsers, updateUserRole, deleteUser } from '@/api/admin'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import type { AdminUser } from '@/api/admin'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const keyword = ref('')
|
||||
const list = ref<AdminUser[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
await loadUsers(true)
|
||||
})
|
||||
|
||||
async function loadUsers(reset = false) {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
list.value = []
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, pageSize: 20 }
|
||||
if (keyword.value.trim()) params.keyword = keyword.value.trim()
|
||||
const data = await getUsers(params)
|
||||
if (reset) {
|
||||
list.value = data.list
|
||||
} else {
|
||||
list.value = [...list.value, ...data.list]
|
||||
}
|
||||
total.value = data.total
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getAvatarUrl(avatarUrl: string): string {
|
||||
if (!avatarUrl) return ''
|
||||
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
|
||||
return `${API_BASE}/user/avatar/${avatarUrl}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function handleToggleRole(user: AdminUser) {
|
||||
if (user.id === userStore.userInfo?.id) {
|
||||
uni.showToast({ title: '不能修改自己的角色', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const newRole = user.role === 'admin' ? 'user' : 'admin'
|
||||
const label = newRole === 'admin' ? '设为管理员' : '取消管理员'
|
||||
uni.showModal({
|
||||
title: label,
|
||||
content: `确定${label}「${user.nickname}」?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await updateUserRole(user.id, newRole)
|
||||
user.role = newRole
|
||||
uni.showToast({ title: '已更新', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleDelete(user: AdminUser) {
|
||||
uni.showModal({
|
||||
title: '删除用户',
|
||||
content: `确定删除「${user.nickname}」?该用户的所有数据将被清除。`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteUser(user.id)
|
||||
list.value = list.value.filter(u => u.id !== user.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() { uni.navigateBack() }
|
||||
|
||||
onReachBottom(() => {
|
||||
if (list.value.length >= total.value || loading.value) return
|
||||
page.value++
|
||||
loadUsers(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
}
|
||||
|
||||
.status-bar { background: #FFF8F0; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.nav-placeholder { width: 88rpx; }
|
||||
|
||||
.search-bar {
|
||||
padding: 0 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.loading-box, .empty-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.loading-text, .empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.user-list { padding: 0 40rpx; }
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: #F0E0D6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
|
||||
.user-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.role-tag-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.user-time {
|
||||
font-size: 22rpx;
|
||||
color: #BFB3B3;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8rpx 16rpx;
|
||||
background: #FFF0E6;
|
||||
border-radius: 12rpx;
|
||||
text-align: center;
|
||||
|
||||
&.danger { background: #FFE8E8; }
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 22rpx;
|
||||
color: #FF8C69;
|
||||
&.danger-text { color: #FF6B6B; }
|
||||
}
|
||||
</style>
|
||||
533
client/src/pages/bills/filter-panel.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<view class="filter-mask" @tap="$emit('close')">
|
||||
<view class="filter-panel" @tap.stop>
|
||||
<view class="filter-header">
|
||||
<text class="filter-title">筛选</text>
|
||||
<view class="filter-close" @tap="$emit('close')">
|
||||
<text class="filter-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="filter-body" scroll-y>
|
||||
<!-- 分类 -->
|
||||
<view class="filter-section">
|
||||
<text class="section-label">分类</text>
|
||||
<view class="chip-row">
|
||||
<view
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
class="chip"
|
||||
:class="{ active: localFilters.category_ids?.includes(cat.id) }"
|
||||
@tap="toggleCategory(cat.id)"
|
||||
>
|
||||
<text class="chip-text">{{ cat.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 日期范围 -->
|
||||
<view class="filter-section">
|
||||
<text class="section-label">日期范围</text>
|
||||
<view class="date-row">
|
||||
<picker
|
||||
class="date-picker"
|
||||
mode="date"
|
||||
:value="localFilters.startDate || ''"
|
||||
@change="onStartDateChange"
|
||||
>
|
||||
<text
|
||||
class="date-text"
|
||||
:class="{ placeholder: !localFilters.startDate }"
|
||||
>{{ localFilters.startDate || "开始日期" }}
|
||||
</text>
|
||||
</picker>
|
||||
<text class="date-sep">至</text>
|
||||
<picker
|
||||
class="date-picker"
|
||||
mode="date"
|
||||
:value="localFilters.endDate || ''"
|
||||
@change="onEndDateChange"
|
||||
>
|
||||
<text
|
||||
class="date-text"
|
||||
:class="{ placeholder: !localFilters.endDate }"
|
||||
>{{ localFilters.endDate || "结束日期" }}
|
||||
</text>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 金额区间 -->
|
||||
<view class="filter-section">
|
||||
<text class="section-label">金额区间(元)</text>
|
||||
<view class="amount-row">
|
||||
<input
|
||||
class="amount-input"
|
||||
type="digit"
|
||||
v-model="minAmountStr"
|
||||
placeholder="最小金额"
|
||||
/>
|
||||
<text class="amount-sep">-</text>
|
||||
<input
|
||||
class="amount-input"
|
||||
type="digit"
|
||||
v-model="maxAmountStr"
|
||||
placeholder="最大金额"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关键词 -->
|
||||
<view class="filter-section">
|
||||
<text class="section-label">搜索备注</text>
|
||||
<input
|
||||
class="keyword-input"
|
||||
v-model="localFilters.keyword"
|
||||
placeholder="输入关键词"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 已保存方案 -->
|
||||
<view class="filter-section" v-if="savedFilters.length > 0">
|
||||
<text class="section-label">已保存方案</text>
|
||||
<view
|
||||
v-for="sf in savedFilters"
|
||||
:key="sf.id"
|
||||
class="saved-item"
|
||||
@tap="applySaved(sf)"
|
||||
>
|
||||
<text class="saved-name">{{ sf.name }}</text>
|
||||
<view class="saved-delete" @tap.stop="handleDeleteSaved(sf.id)">
|
||||
<text class="saved-delete-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="filter-footer">
|
||||
<view class="footer-btn reset" @tap="handleReset">
|
||||
<text class="footer-btn-text reset-text">重置</text>
|
||||
</view>
|
||||
<view class="footer-btn save" @tap="handleSave">
|
||||
<text class="footer-btn-text save-text">保存</text>
|
||||
</view>
|
||||
<view class="footer-btn apply" @tap="handleApply">
|
||||
<text class="footer-btn-text apply-text">应用</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { useCategoryStore } from "@/stores/category";
|
||||
import { useFilterStore } from "@/stores/filter";
|
||||
import type { FilterParams, SavedFilter } from "@/api/filter";
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
apply: [filters: FilterParams];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
initialFilters?: FilterParams;
|
||||
}>();
|
||||
|
||||
const categoryStore = useCategoryStore();
|
||||
const filterStore = useFilterStore();
|
||||
|
||||
const categories = ref<any[]>([]);
|
||||
const savedFilters = ref<SavedFilter[]>([]);
|
||||
const minAmountStr = ref("");
|
||||
const maxAmountStr = ref("");
|
||||
|
||||
const localFilters = reactive<FilterParams>({
|
||||
type: "",
|
||||
category_ids: [],
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
minAmount: 0,
|
||||
maxAmount: 0,
|
||||
keyword: "",
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
initCategorys();
|
||||
initSavedFilters();
|
||||
|
||||
// 应用初始筛选条件
|
||||
if (props.initialFilters) {
|
||||
Object.assign(localFilters, props.initialFilters);
|
||||
if (localFilters.minAmount != null && localFilters.minAmount > 0)
|
||||
minAmountStr.value = (localFilters.minAmount / 100).toString();
|
||||
if (localFilters.maxAmount != null && localFilters.maxAmount > 0)
|
||||
maxAmountStr.value = (localFilters.maxAmount / 100).toString();
|
||||
}
|
||||
});
|
||||
|
||||
const initCategorys = async () => {
|
||||
await categoryStore.fetchCategories();
|
||||
categories.value = [
|
||||
...categoryStore.getByType("expense"),
|
||||
...categoryStore.getByType("income"),
|
||||
];
|
||||
};
|
||||
|
||||
const initSavedFilters = async () => {
|
||||
await filterStore.fetchSavedFilters();
|
||||
savedFilters.value = filterStore.savedFilters;
|
||||
};
|
||||
|
||||
function toggleCategory(id: number) {
|
||||
if (!localFilters.category_ids) localFilters.category_ids = [];
|
||||
const idx = localFilters.category_ids.indexOf(id);
|
||||
if (idx >= 0) {
|
||||
localFilters.category_ids.splice(idx, 1);
|
||||
} else {
|
||||
localFilters.category_ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
function onStartDateChange(e: any) {
|
||||
localFilters.startDate = e.detail.value;
|
||||
}
|
||||
|
||||
function onEndDateChange(e: any) {
|
||||
localFilters.endDate = e.detail.value;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
Object.assign(localFilters, {
|
||||
type: "",
|
||||
category_ids: [],
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
minAmount: 0,
|
||||
maxAmount: 0,
|
||||
keyword: "",
|
||||
});
|
||||
minAmountStr.value = "";
|
||||
maxAmountStr.value = "";
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const filters: FilterParams = { ...localFilters };
|
||||
// 转换金额:元 → 分
|
||||
filters.minAmount = minAmountStr.value
|
||||
? Math.round(parseFloat(minAmountStr.value) * 100)
|
||||
: 0;
|
||||
filters.maxAmount = maxAmountStr.value
|
||||
? Math.round(parseFloat(maxAmountStr.value) * 100)
|
||||
: 0;
|
||||
emit("apply", filters);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const filters: FilterParams = { ...localFilters };
|
||||
filters.minAmount = minAmountStr.value
|
||||
? Math.round(parseFloat(minAmountStr.value) * 100)
|
||||
: 0;
|
||||
filters.maxAmount = maxAmountStr.value
|
||||
? Math.round(parseFloat(maxAmountStr.value) * 100)
|
||||
: 0;
|
||||
|
||||
uni.showModal({
|
||||
title: "保存方案",
|
||||
editable: true,
|
||||
placeholderText: "输入方案名称",
|
||||
success: async (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
try {
|
||||
await filterStore.saveFilter(res.content.trim(), filters);
|
||||
savedFilters.value = filterStore.savedFilters;
|
||||
uni.showToast({ title: "已保存", icon: "success" });
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || "保存失败", icon: "none" });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function applySaved(sf: SavedFilter) {
|
||||
Object.assign(localFilters, sf.filters);
|
||||
minAmountStr.value =
|
||||
sf.filters.minAmount != null && sf.filters.minAmount > 0
|
||||
? (sf.filters.minAmount / 100).toString()
|
||||
: "";
|
||||
maxAmountStr.value =
|
||||
sf.filters.maxAmount != null && sf.filters.maxAmount > 0
|
||||
? (sf.filters.maxAmount / 100).toString()
|
||||
: "";
|
||||
}
|
||||
|
||||
async function handleDeleteSaved(id: number) {
|
||||
try {
|
||||
await filterStore.deleteFilter(id);
|
||||
savedFilters.value = filterStore.savedFilters;
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: #ffffff;
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 40rpx 24rpx;
|
||||
border-bottom: 2rpx solid #f0e0d6;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #2d1b1b;
|
||||
}
|
||||
|
||||
.filter-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #f0e0d6;
|
||||
&:active {
|
||||
background: #e0d6d0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-close-text {
|
||||
font-size: 36rpx;
|
||||
color: #8b7e7e;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.filter-body {
|
||||
flex: 1;
|
||||
padding: 24rpx 40rpx;
|
||||
max-height: 55vh;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 32rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #2d1b1b;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 12rpx 24rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: #ffe8e0;
|
||||
border-color: #ff8c69;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
font-size: 24rpx;
|
||||
color: #2d1b1b;
|
||||
.active & {
|
||||
color: #ff8c69;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
font-size: 26rpx;
|
||||
color: #2d1b1b;
|
||||
&.placeholder {
|
||||
color: #bfb3b3;
|
||||
}
|
||||
}
|
||||
|
||||
.date-sep {
|
||||
font-size: 26rpx;
|
||||
color: #8b7e7e;
|
||||
}
|
||||
|
||||
.amount-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
font-size: 26rpx;
|
||||
color: #2d1b1b;
|
||||
}
|
||||
|
||||
.amount-sep {
|
||||
font-size: 26rpx;
|
||||
color: #8b7e7e;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
font-size: 26rpx;
|
||||
color: #2d1b1b;
|
||||
}
|
||||
|
||||
.saved-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 8rpx;
|
||||
|
||||
&:active {
|
||||
background: #f0e0d6;
|
||||
}
|
||||
}
|
||||
|
||||
.saved-name {
|
||||
font-size: 26rpx;
|
||||
color: #2d1b1b;
|
||||
}
|
||||
|
||||
.saved-delete {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.saved-delete-text {
|
||||
font-size: 28rpx;
|
||||
color: #bfb3b3;
|
||||
}
|
||||
|
||||
.filter-footer {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 40rpx 40rpx;
|
||||
border-top: 2rpx solid #f0e0d6;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.reset {
|
||||
background: #fff8f0;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
}
|
||||
&.save {
|
||||
background: #fff0e6;
|
||||
border: 2rpx solid #ff8c69;
|
||||
}
|
||||
&.apply {
|
||||
background: linear-gradient(135deg, #ff8c69, #e67355);
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
&.reset-text {
|
||||
color: #8b7e7e;
|
||||
}
|
||||
&.save-text {
|
||||
color: #ff8c69;
|
||||
}
|
||||
&.apply-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,21 @@
|
||||
<view class="tab" :class="{ active: filterType === 'expense' }" @tap="switchFilter('expense')">支出</view>
|
||||
<view class="tab" :class="{ active: filterType === 'income' }" @tap="switchFilter('income')">收入</view>
|
||||
</view>
|
||||
<view class="filter-btn" :class="{ active: hasAdvancedFilter }" @tap="showFilter = true">
|
||||
<Icon name="filter" :size="28" :color="hasAdvancedFilter ? '#FF8C69' : '#8B7E7E'" />
|
||||
<text class="filter-btn-text">筛选</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选结果统计 -->
|
||||
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
|
||||
<text class="filter-stats-text">共 {{ total }} 笔</text>
|
||||
<text class="filter-stats-text" v-if="filteredExpense > 0">支出 {{ formatAmount(filteredExpense) }}</text>
|
||||
<text class="filter-stats-text" v-if="filteredIncome > 0">收入 {{ formatAmount(filteredIncome) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 筛选面板 -->
|
||||
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" />
|
||||
|
||||
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
|
||||
<view v-for="group in groupedTx" :key="group.date" class="date-group">
|
||||
@@ -81,8 +95,10 @@ import { waitForReady } from '@/utils/app-ready'
|
||||
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||
import FilterPanel from './filter-panel.vue'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { formatAmount, formatDate } from '@/utils/format'
|
||||
import type { FilterParams } from '@/api/filter'
|
||||
|
||||
const txStore = useTransactionStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -95,6 +111,16 @@ const total = ref(0)
|
||||
const loadError = ref(false)
|
||||
let loadSeq = 0 // 请求序号,防止并发覆盖
|
||||
|
||||
// 高级筛选
|
||||
const showFilter = ref(false)
|
||||
const advancedFilters = ref<FilterParams>({})
|
||||
const filteredExpense = ref(0)
|
||||
const filteredIncome = ref(0)
|
||||
const hasAdvancedFilter = computed(() => {
|
||||
const f = advancedFilters.value
|
||||
return !!(f.category_ids?.length || f.startDate || f.endDate || f.minAmount || f.maxAmount || f.keyword)
|
||||
})
|
||||
|
||||
const noMore = computed(() => txList.value.length >= total.value && total.value > 0)
|
||||
|
||||
const groupedTx = computed(() => {
|
||||
@@ -138,9 +164,17 @@ async function loadTx(reset = false, silent = false) {
|
||||
const seq = ++loadSeq
|
||||
const params: any = { page: page.value, pageSize }
|
||||
if (filterType.value !== 'all') params.type = filterType.value
|
||||
// 合并高级筛选参数
|
||||
const af = advancedFilters.value
|
||||
if (af.category_ids?.length) params.category_ids = af.category_ids.join(',')
|
||||
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 {
|
||||
|
||||
@@ -116,19 +116,22 @@ function syncBudgetData() {
|
||||
|
||||
/** 获取个人支出(不传 group_id,始终只算自己的消费) */
|
||||
async function fetchMyExpense() {
|
||||
try {
|
||||
const data = await getOverview({ month: currentMonth, group_id: null })
|
||||
monthExpense.value = Number(data?.expense) || 0
|
||||
} catch {
|
||||
monthExpense.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
budgetStore.fetchBudget(currentMonth),
|
||||
fetchMyExpense()
|
||||
])
|
||||
await budgetStore.fetchBudget(currentMonth)
|
||||
syncBudgetData()
|
||||
// 非关键:支出数据失败不影响预算显示
|
||||
fetchMyExpense()
|
||||
} catch (e) {
|
||||
console.error('Budget page load error:', e)
|
||||
} finally {
|
||||
@@ -141,11 +144,9 @@ onMounted(async () => {
|
||||
onShow(async () => {
|
||||
if (!initialLoaded.value) return
|
||||
try {
|
||||
await Promise.all([
|
||||
budgetStore.fetchBudget(currentMonth),
|
||||
fetchMyExpense()
|
||||
])
|
||||
await budgetStore.fetchBudget(currentMonth)
|
||||
syncBudgetData()
|
||||
fetchMyExpense()
|
||||
} catch {}
|
||||
})
|
||||
|
||||
@@ -419,7 +420,7 @@ function goBack() {
|
||||
.preview-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
.cursor {
|
||||
.input-cursor {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -62,11 +62,8 @@
|
||||
<view class="group-info">
|
||||
<view class="group-name-row">
|
||||
<text class="group-name">{{ group.name }}</text>
|
||||
<view v-if="group.role === 'owner'" class="role-badge owner">
|
||||
<text class="role-badge-text">群主</text>
|
||||
</view>
|
||||
<view v-else class="role-badge member">
|
||||
<text class="role-badge-text">成员</text>
|
||||
<view class="role-badge" :style="{ background: group.role === 'owner' ? '#FFE8E0' : '#F0E0D6' }">
|
||||
<text class="role-badge-text" :style="{ color: group.role === 'owner' ? '#FF8C69' : '#8B7E7E' }">{{ group.role === 'owner' ? '群主' : '成员' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="group-meta">{{ group.member_count }} 人</text>
|
||||
@@ -122,7 +119,9 @@
|
||||
<image class="member-avatar" :src="getAvatarUrl(m.avatar_url)" mode="aspectFill" />
|
||||
<view class="member-info">
|
||||
<text class="member-name">{{ m.nickname || '用户' + m.user_id }}</text>
|
||||
<text class="member-role" :class="m.role">{{ m.role === 'owner' ? '群主' : '成员' }}</text>
|
||||
<view class="member-role-wrap">
|
||||
<text class="member-role-text" :style="{ color: m.role === 'owner' ? '#FF8C69' : '#BFB3B3' }">{{ m.role === 'owner' ? '群主' : '成员' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="m.role !== 'owner' && currentGroup?.role === 'owner'" class="member-remove" @tap="handleRemoveMember(m)">
|
||||
<text class="member-remove-text">移除</text>
|
||||
@@ -514,17 +513,11 @@ function handleDissolve(group: Group) {
|
||||
.role-badge {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
|
||||
&.owner { background: #FFE8E0; }
|
||||
&.member { background: #F0E0D6; }
|
||||
}
|
||||
|
||||
.role-badge-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 500;
|
||||
|
||||
.owner & { color: #FF8C69; }
|
||||
.member & { color: #8B7E7E; }
|
||||
}
|
||||
|
||||
.group-meta {
|
||||
@@ -623,6 +616,8 @@ function handleDissolve(group: Group) {
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -667,6 +662,8 @@ function handleDissolve(group: Group) {
|
||||
.member-list {
|
||||
max-height: 60vh;
|
||||
padding: 16rpx 40rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
@@ -675,6 +672,8 @@ function handleDissolve(group: Group) {
|
||||
padding: 20rpx 0;
|
||||
gap: 20rpx;
|
||||
border-bottom: 2rpx solid #F8F0EB;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
@@ -697,15 +696,17 @@ function handleDissolve(group: Group) {
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
font-size: 22rpx;
|
||||
display: block;
|
||||
.member-role-wrap {
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
&.owner { color: #FF8C69; }
|
||||
&.member { color: #BFB3B3; }
|
||||
.member-role-text {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.member-remove {
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
<Icon v-else name="user" :size="36" color="#8B7E7E" />
|
||||
</view>
|
||||
<text class="greeting">{{ greeting }},{{ userStore.nickname }}</text>
|
||||
<view class="bell" @tap="showNotification">
|
||||
<view class="bell" @tap="goNotifications">
|
||||
<Icon name="bell" :size="40" color="#8B7E7E" />
|
||||
<view v-if="notifStore.unreadCount > 0" class="bell-badge">
|
||||
<text class="bell-badge-text">{{ notifStore.unreadCount > 99 ? '99+' : notifStore.unreadCount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -109,6 +112,26 @@
|
||||
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||
<text class="state-text">还没有记录,快去记一笔吧</text>
|
||||
</view>
|
||||
|
||||
<!-- 强提醒公告弹窗 -->
|
||||
<view class="modal-mask" v-if="urgentNotice" @tap="dismissUrgent">
|
||||
<view class="urgent-modal" @tap.stop>
|
||||
<view class="urgent-header">
|
||||
<Icon name="bell" :size="32" color="#FF8C69" />
|
||||
<text class="urgent-title">{{ urgentNotice.title }}</text>
|
||||
</view>
|
||||
<scroll-view class="urgent-body" scroll-y>
|
||||
<image v-if="urgentNotice.image_url" class="urgent-image" :src="urgentNotice.image_url" mode="widthFix" />
|
||||
<text class="urgent-content">{{ urgentNotice.content }}</text>
|
||||
<view v-if="urgentNotice.link_url" class="urgent-link" @tap="openLink(urgentNotice.link_url)">
|
||||
<text class="urgent-link-text">查看详情</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="urgent-footer" @tap="dismissUrgent">
|
||||
<text class="urgent-btn-text">我知道了</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -120,6 +143,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 +158,7 @@ const statsStore = useStatsStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
const userStore = useUserStore()
|
||||
const groupStore = useGroupStore()
|
||||
const notifStore = useNotificationStore()
|
||||
|
||||
const DEFAULT_BUDGET = 500000 // 5000元(分)
|
||||
|
||||
@@ -155,6 +180,27 @@ const greeting = computed(() => {
|
||||
return '晚上好'
|
||||
})
|
||||
|
||||
// 强提醒公告:取第一条未读的
|
||||
const urgentNotice = computed(() => notifStore.urgentNotifications[0] || null)
|
||||
|
||||
function dismissUrgent() {
|
||||
if (urgentNotice.value) {
|
||||
notifStore.markRead(urgentNotice.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
// #ifdef H5
|
||||
window.open(url, '_blank')
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
async function loadData(silent = false) {
|
||||
await waitForReady()
|
||||
try {
|
||||
@@ -162,14 +208,21 @@ async function loadData(silent = false) {
|
||||
loadError.value = false
|
||||
const now = new Date()
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
// 关键数据:任一失败则显示错误
|
||||
await Promise.all([
|
||||
statsStore.fetchOverview(),
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1 }),
|
||||
fetchTodayData(today),
|
||||
userStore.fetchUserInfo()
|
||||
fetchTodayData(today)
|
||||
])
|
||||
recentTx.value = txStore.transactions.slice(0, 5)
|
||||
|
||||
// 非关键数据:失败不影响页面显示
|
||||
Promise.allSettled([
|
||||
userStore.fetchUserInfo(),
|
||||
notifStore.fetchUnreadCount(),
|
||||
notifStore.fetchUrgentNotifications()
|
||||
])
|
||||
} catch (e) {
|
||||
console.error('Load error:', e)
|
||||
if (!silent) loadError.value = true
|
||||
@@ -209,8 +262,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 +334,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;
|
||||
@@ -489,4 +563,85 @@ function goBills() {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
/* 强提醒公告弹窗 */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.urgent-modal {
|
||||
width: 80%;
|
||||
max-height: 70vh;
|
||||
background: #FFFFFF;
|
||||
border-radius: 32rpx;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.urgent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 32rpx 32rpx 16rpx;
|
||||
}
|
||||
|
||||
.urgent-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.urgent-body {
|
||||
flex: 1;
|
||||
padding: 0 32rpx 24rpx;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.urgent-image {
|
||||
width: 100%;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.urgent-content {
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.urgent-link {
|
||||
margin-top: 16rpx;
|
||||
padding: 12rpx 0;
|
||||
}
|
||||
|
||||
.urgent-link-text {
|
||||
font-size: 28rpx;
|
||||
color: #FF8C69;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.urgent-footer {
|
||||
padding: 24rpx 32rpx;
|
||||
border-top: 2rpx solid #F0E0D6;
|
||||
text-align: center;
|
||||
|
||||
&:active { background: #FFF8F0; }
|
||||
}
|
||||
|
||||
.urgent-btn-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #FF8C69;
|
||||
}
|
||||
</style>
|
||||
|
||||
460
client/src/pages/notifications/index.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header-fixed">
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||
<view class="nav-bar" :style="{ paddingRight: capsuleRight + 'px' }">
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||
</view>
|
||||
<text class="nav-title">通知中心</text>
|
||||
<view class="nav-action" @tap="handleMarkAllRead">
|
||||
<text class="nav-action-text">全部已读</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab 切换 -->
|
||||
<view class="tabs-wrap">
|
||||
<view class="tabs">
|
||||
<view v-for="tab in tabs" :key="tab.key" class="tab" :class="{ active: currentTab === tab.key }" @tap="switchTab(tab.key)">
|
||||
<text class="tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<view v-if="loading && list.length === 0" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="list.length === 0" class="empty-box">
|
||||
<Icon name="bell" :size="48" color="#BFB3B3" />
|
||||
<text class="empty-text">暂无通知</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="notification-list">
|
||||
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ unread: !item.is_read, pinned: item.is_pinned }" @tap="handleTap(item)">
|
||||
<view class="notif-icon" :style="{ background: getNotifBg(item.type) }">
|
||||
<Icon :name="getIconName(item.type)" :size="28" :color="getIconColor(item.type)" />
|
||||
</view>
|
||||
<view class="notif-content">
|
||||
<view class="notif-title-row">
|
||||
<text v-if="item.is_pinned" class="pin-badge">📌</text>
|
||||
<text class="notif-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
|
||||
<image v-if="item.image_url" class="notif-image" :src="item.image_url" mode="widthFix" />
|
||||
<view class="notif-meta">
|
||||
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
|
||||
<text v-if="item.link_url" class="notif-link" @tap.stop="openLink(item.link_url)">查看详情</text>
|
||||
</view>
|
||||
<!-- 管理员操作 -->
|
||||
<view v-if="isAdmin && item.type === 'system'" class="admin-actions">
|
||||
<view class="admin-btn" @tap.stop="handleTogglePin(item)">
|
||||
<text class="admin-btn-text">{{ item.is_pinned ? '取消置顶' : '置顶' }}</text>
|
||||
</view>
|
||||
<view class="admin-btn danger" @tap.stop="handleDeleteNotif(item)">
|
||||
<text class="admin-btn-text danger-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!item.is_read" class="unread-dot"></view>
|
||||
</view>
|
||||
|
||||
<view v-if="noMore && list.length > 0" class="no-more">
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getNotifications, markRead, markAllRead, deleteNotification, updateNotification } from '@/api/notification'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import type { Notification } from '@/api/notification'
|
||||
|
||||
const notifStore = useNotificationStore()
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.userInfo?.role === 'admin')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'system', label: '公告' },
|
||||
{ key: 'group', label: '群组' },
|
||||
{ key: 'personal', label: '个人' },
|
||||
]
|
||||
|
||||
const currentTab = ref('all')
|
||||
const list = ref<Notification[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
let loadSeq = 0
|
||||
|
||||
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
await loadNotifications(true)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
notifStore.fetchUnreadCount()
|
||||
})
|
||||
|
||||
function switchTab(key: string) {
|
||||
currentTab.value = key
|
||||
loadNotifications(true)
|
||||
}
|
||||
|
||||
async function loadNotifications(reset = false) {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
list.value = []
|
||||
}
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, pageSize: 20 }
|
||||
if (currentTab.value !== 'all') params.type = currentTab.value
|
||||
const data = await getNotifications(params)
|
||||
if (seq !== loadSeq) return
|
||||
if (reset) {
|
||||
list.value = data.list
|
||||
} else {
|
||||
list.value = [...list.value, ...data.list]
|
||||
}
|
||||
total.value = data.total
|
||||
} catch (e) {
|
||||
console.error('Load notifications error:', e)
|
||||
} finally {
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getIconName(type: string) {
|
||||
if (type === 'system') return 'bell'
|
||||
if (type === 'group') return 'user'
|
||||
return 'alert-circle'
|
||||
}
|
||||
|
||||
function getIconColor(type: string) {
|
||||
if (type === 'system') return '#FF8C69'
|
||||
if (type === 'group') return '#8B7E7E'
|
||||
return '#BFB3B3'
|
||||
}
|
||||
|
||||
function getNotifBg(type: string) {
|
||||
if (type === 'system') return '#FFE8E0'
|
||||
if (type === 'group') return '#F0E0D6'
|
||||
return '#FFF0E6'
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}天前`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
async function handleTap(item: Notification) {
|
||||
if (!item.is_read) {
|
||||
try {
|
||||
await markRead(item.id)
|
||||
item.is_read = 1
|
||||
notifStore.fetchUnreadCount()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
try {
|
||||
await markAllRead()
|
||||
list.value.forEach(item => { item.is_read = 1 })
|
||||
notifStore.fetchUnreadCount()
|
||||
uni.showToast({ title: '已全部标记已读', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
// #ifdef H5
|
||||
window.open(url, '_blank')
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.setClipboardData({
|
||||
data: url,
|
||||
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 管理员:切换置顶状态 */
|
||||
async function handleTogglePin(item: Notification) {
|
||||
try {
|
||||
await updateNotification(item.id, { is_pinned: !item.is_pinned })
|
||||
item.is_pinned = item.is_pinned ? 0 : 1
|
||||
uni.showToast({ title: item.is_pinned ? '已置顶' : '已取消置顶', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理员:删除公告 */
|
||||
function handleDeleteNotif(item: Notification) {
|
||||
uni.showModal({
|
||||
title: '删除公告',
|
||||
content: `确定删除「${item.title}」?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteNotification(item.id)
|
||||
list.value = list.value.filter(n => n.id !== item.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
onReachBottom(() => {
|
||||
if (noMore.value || loading.value) return
|
||||
page.value++
|
||||
loadNotifications(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
}
|
||||
|
||||
.status-bar { background: #FFF8F0; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.nav-action {
|
||||
padding: 8rpx 16rpx;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-action-text {
|
||||
font-size: 26rpx;
|
||||
color: #FF8C69;
|
||||
}
|
||||
|
||||
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 24rpx; }
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
background: #FFF0E6;
|
||||
border-radius: 24rpx;
|
||||
padding: 8rpx;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: #FFFFFF;
|
||||
color: #FF8C69;
|
||||
font-weight: 600;
|
||||
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-box, .empty-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.loading-text, .empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.notification-list { padding: 0 40rpx; }
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.unread { border-left: 6rpx solid #FF8C69; }
|
||||
}
|
||||
|
||||
.notif-icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-content { flex: 1; min-width: 0; }
|
||||
|
||||
.notif-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.pin-badge {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.notif-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notif-body {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notif-image {
|
||||
width: 100%;
|
||||
border-radius: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.notif-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
font-size: 22rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: #FF8C69;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.notif-link {
|
||||
font-size: 22rpx;
|
||||
color: #FF8C69;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pinned {
|
||||
border-color: #FF8C69;
|
||||
background: #FFFAF7;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
.no-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
padding: 8rpx 16rpx;
|
||||
background: #FFF0E6;
|
||||
border-radius: 12rpx;
|
||||
|
||||
&.danger { background: #FFE8E8; }
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.admin-btn-text {
|
||||
font-size: 22rpx;
|
||||
color: #FF8C69;
|
||||
&.danger-text { color: #FF6B6B; }
|
||||
}
|
||||
</style>
|
||||
@@ -64,6 +64,15 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 管理员入口 -->
|
||||
<view v-if="userStore.userInfo?.role === 'admin'" class="menu-card mt">
|
||||
<view class="menu-item admin-entry" @tap="goAdmin">
|
||||
<Icon name="settings" :size="32" color="#FF8C69" />
|
||||
<text class="mi-label">管理后台</text>
|
||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关于弹窗 -->
|
||||
<view class="modal-mask" v-if="showAbout" @tap="showAbout = false">
|
||||
<view class="modal" @tap.stop>
|
||||
@@ -170,7 +179,8 @@ onShow(async () => {
|
||||
statsStore.fetchOverview(),
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
|
||||
groupStore.fetchGroups()
|
||||
groupStore.fetchGroups(),
|
||||
userStore.fetchUserInfo()
|
||||
])
|
||||
} catch {}
|
||||
})
|
||||
@@ -206,6 +216,10 @@ function goGroupManage() {
|
||||
uni.navigateTo({ url: '/pages/group-manage/index' })
|
||||
}
|
||||
|
||||
function goAdmin() {
|
||||
uni.navigateTo({ url: '/pages/admin/index' })
|
||||
}
|
||||
|
||||
function promptCreateGroup() {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 小程序不支持 prompt,使用输入框页面
|
||||
@@ -463,7 +477,7 @@ async function exportData() {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.p-skeleton {
|
||||
.qs-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
:height="420"
|
||||
/>
|
||||
<view class="trend-list">
|
||||
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
|
||||
<view v-for="point in trendData" :key="point.date" class="trend-item">
|
||||
<text class="trend-date">{{ point.date.slice(8) }}日</text>
|
||||
<view class="trend-bar-bg">
|
||||
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
|
||||
@@ -119,8 +119,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
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,12 +133,11 @@ 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')
|
||||
const overview = computed(() => statsStore.overview)
|
||||
const categoryStats = computed(() => statsStore.categoryStats)
|
||||
const trendData = computed(() => statsStore.trendData)
|
||||
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
|
||||
const loading = ref(true)
|
||||
const loadError = ref(false)
|
||||
const maxSingle = ref(0)
|
||||
@@ -266,7 +267,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 +278,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,
|
||||
|
||||
|
Before Width: | Height: | Size: 836 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 367 B After Width: | Height: | Size: 455 B |
|
Before Width: | Height: | Size: 380 B After Width: | Height: | Size: 459 B |
|
Before Width: | Height: | Size: 375 B After Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 377 B After Width: | Height: | Size: 449 B |
|
Before Width: | Height: | Size: 713 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 286 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 378 B After Width: | Height: | Size: 878 B |
|
Before Width: | Height: | Size: 240 B After Width: | Height: | Size: 459 B |
|
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 465 B |
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 345 B After Width: | Height: | Size: 410 B |
|
Before Width: | Height: | Size: 719 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 1.5 KiB |
BIN
client/src/static/icons/filter-gray.png
Normal file
|
After Width: | Height: | Size: 534 B |
BIN
client/src/static/icons/filter-orange.png
Normal file
|
After Width: | Height: | Size: 529 B |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 223 B After Width: | Height: | Size: 350 B |
|
Before Width: | Height: | Size: 231 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 231 B After Width: | Height: | Size: 1.9 KiB |
BIN
client/src/static/icons/settings-gray.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
client/src/static/icons/settings-orange.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 369 B After Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 504 B After Width: | Height: | Size: 640 B |
|
Before Width: | Height: | Size: 506 B After Width: | Height: | Size: 644 B |
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 557 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 480 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 294 B After Width: | Height: | Size: 1.3 KiB |
25
client/src/stores/filter.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/filter'
|
||||
import type { FilterParams } from '@/api/filter'
|
||||
|
||||
export const useFilterStore = defineStore('filter', () => {
|
||||
const savedFilters = ref<api.SavedFilter[]>([])
|
||||
|
||||
async function fetchSavedFilters() {
|
||||
savedFilters.value = await api.getSavedFilters()
|
||||
}
|
||||
|
||||
async function saveFilter(name: string, filters: FilterParams) {
|
||||
const result = await api.saveFilter({ name, filters })
|
||||
await fetchSavedFilters()
|
||||
return result.id
|
||||
}
|
||||
|
||||
async function deleteFilter(id: number) {
|
||||
await api.deleteFilter(id)
|
||||
savedFilters.value = savedFilters.value.filter(f => f.id !== id)
|
||||
}
|
||||
|
||||
return { savedFilters, fetchSavedFilters, saveFilter, deleteFilter }
|
||||
})
|
||||
@@ -50,19 +50,19 @@ export const useGroupStore = defineStore('group', () => {
|
||||
loading.value = true
|
||||
try {
|
||||
groups.value = await api.getGroups()
|
||||
} catch {
|
||||
groups.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 验证 currentGroupId 是否仍有效(群组可能已被解散或退出)
|
||||
// 仅在成功获取后验证 currentGroupId 是否仍有效
|
||||
if (currentGroupId.value !== null) {
|
||||
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
||||
if (!stillValid) {
|
||||
switchToPersonal()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 网络失败时保留上次数据,不丢失群组选择
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建群组 */
|
||||
|
||||
34
client/src/stores/notification.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/notification'
|
||||
import type { Notification } from '@/api/notification'
|
||||
|
||||
export const useNotificationStore = defineStore('notification', () => {
|
||||
const unreadCount = ref(0)
|
||||
const urgentNotifications = ref<Notification[]>([])
|
||||
|
||||
async function fetchUnreadCount() {
|
||||
const data = await api.getUnreadCount()
|
||||
unreadCount.value = data.count
|
||||
}
|
||||
|
||||
/** 获取未读的强提醒公告(首页弹窗用) */
|
||||
async function fetchUrgentNotifications() {
|
||||
urgentNotifications.value = await api.getPinnedNotifications()
|
||||
}
|
||||
|
||||
async function markRead(id: number) {
|
||||
await api.markRead(id)
|
||||
if (unreadCount.value > 0) unreadCount.value--
|
||||
// 从强提醒列表中移除
|
||||
urgentNotifications.value = urgentNotifications.value.filter(n => n.id !== id)
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
await api.markAllRead()
|
||||
unreadCount.value = 0
|
||||
urgentNotifications.value = []
|
||||
}
|
||||
|
||||
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
|
||||
})
|
||||
@@ -19,9 +19,9 @@ export const useTransactionStore = defineStore('transaction', () => {
|
||||
})
|
||||
transactions.value = data.list
|
||||
total.value = data.total
|
||||
return data
|
||||
} catch (err) {
|
||||
transactions.value = []
|
||||
total.value = 0
|
||||
// 不清空数据,保留上次成功的状态,避免并发请求失败时丢失已有数据
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/** 执行重登录 */
|
||||
@@ -67,13 +69,18 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
|
||||
if (!isRedirectingToLogin) {
|
||||
isRedirectingToLogin = true
|
||||
const retries = [...pendingRetries]
|
||||
pendingRetries = []
|
||||
|
||||
reLogin()
|
||||
.then(() => retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject)))
|
||||
.then(() => {
|
||||
// 处理快照时已入队的 + reLogin 期间新入队的请求
|
||||
const retries = [...pendingRetries]
|
||||
pendingRetries = []
|
||||
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||
})
|
||||
.catch(() => {
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||
const retries = [...pendingRetries]
|
||||
pendingRetries = []
|
||||
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
||||
})
|
||||
.finally(() => { isRedirectingToLogin = false })
|
||||
|
||||
119
scripts/generate-icons.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 从 Lucide SVG 图标生成高质量 PNG
|
||||
* 用法: node scripts/generate-icons.js
|
||||
*/
|
||||
const sharp = require('sharp')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ICONS_DIR = path.join(__dirname, '../client/src/static/icons')
|
||||
const LUCIDE_DIR = path.join(__dirname, '../node_modules/lucide-static/icons')
|
||||
|
||||
// 颜色映射:hex -> RGB
|
||||
const COLORS = {
|
||||
'#2D1B1B': { r: 45, g: 27, b: 27 }, // 暖深棕
|
||||
'#8B7E7E': { r: 139, g: 126, b: 126 }, // 灰色
|
||||
'#BFB3B3': { r: 191, g: 179, b: 179 }, // 浅灰
|
||||
'#E0D6D0': { r: 224, g: 214, b: 208 }, // 极浅灰
|
||||
'#FF8C69': { r: 255, g: 140, b: 105 }, // 珊瑚橙
|
||||
'#FF6B6B': { r: 255, g: 107, b: 107 }, // 红色
|
||||
'#7BC67E': { r: 123, g: 198, b: 126 }, // 绿色
|
||||
'#FFFFFF': { r: 255, g: 255, b: 255 }, // 白色
|
||||
}
|
||||
|
||||
// 图标配置:{ lucideName, variants: { colorHex: outputName } }
|
||||
const ICONS = [
|
||||
// 导航
|
||||
{ lucide: 'arrow-left', variants: { '#2D1B1B': 'arrow-left-dark', '#8B7E7E': 'arrow-left-gray' } },
|
||||
{ lucide: 'arrow-right', variants: { '#8B7E7E': 'arrow-right-gray', '#E0D6D0': 'arrow-right-pale' } },
|
||||
{ lucide: 'chevron-right', variants: { '#BFB3B3': 'chevron-right-light' } },
|
||||
|
||||
// 操作
|
||||
{ lucide: 'plus', variants: { '#FFFFFF': 'plus-white' } },
|
||||
{ lucide: 'check', variants: { '#FFFFFF': 'check-white' } },
|
||||
{ lucide: 'edit', variants: { '#8B7E7E': 'edit-gray', '#BFB3B3': 'edit-light' } },
|
||||
{ lucide: 'trash-2', variants: { '#FF8C69': 'trash-orange', '#FF6B6B': 'trash-red', '#FFFFFF': 'trash-white' } },
|
||||
{ lucide: 'refresh-cw', variants: { '#8B7E7E': 'refresh-gray', '#FF8C69': 'refresh-orange' } },
|
||||
|
||||
// 功能
|
||||
{ lucide: 'calendar', variants: { '#8B7E7E': 'calendar-gray' } },
|
||||
{ lucide: 'bell', variants: { '#8B7E7E': 'bell-gray' } },
|
||||
{ lucide: 'user', variants: { '#8B7E7E': 'user-gray', '#FFFFFF': 'user-white' } },
|
||||
{ lucide: 'alert-circle', variants: { '#BFB3B3': 'alert-circle-light' } },
|
||||
|
||||
// 趋势
|
||||
{ lucide: 'trending-down', variants: { '#FF6B6B': 'trend-down-red' } },
|
||||
{ lucide: 'trending-up', variants: { '#7BC67E': 'trend-up-green' } },
|
||||
]
|
||||
|
||||
// TabBar 图标(需要 normal + active 两套)
|
||||
const TABBAR_ICONS = [
|
||||
{ lucide: 'home', normal: 'home', active: 'home-active', normalColor: '#BFB3B3', activeColor: '#FF8C69' },
|
||||
{ lucide: 'bar-chart-2', normal: 'chart', active: 'chart-active', normalColor: '#BFB3B3', activeColor: '#FF8C69' },
|
||||
{ lucide: 'receipt', normal: 'bill', active: 'bill-active', normalColor: '#BFB3B3', activeColor: '#FF8C69' },
|
||||
{ lucide: 'user', normal: 'user', active: 'user-active', normalColor: '#BFB3B3', activeColor: '#FF8C69' },
|
||||
]
|
||||
|
||||
const SIZE = 96 // 输出 PNG 尺寸(px)
|
||||
|
||||
async function generateIcon(svgName, outputPath, color) {
|
||||
const svgPath = path.join(LUCIDE_DIR, `${svgName}.svg`)
|
||||
if (!fs.existsSync(svgPath)) {
|
||||
console.warn(`⚠ SVG not found: ${svgName}.svg`)
|
||||
return false
|
||||
}
|
||||
|
||||
let svgContent = fs.readFileSync(svgPath, 'utf-8')
|
||||
|
||||
// 替换 SVG 中的 stroke 颜色
|
||||
const { r, g, b } = COLORS[color]
|
||||
svgContent = svgContent.replace(/stroke="[^"]*"/g, `stroke="rgb(${r},${g},${b})"`)
|
||||
// 也处理可能的 fill
|
||||
svgContent = svgContent.replace(/fill="none"/g, 'fill="none"')
|
||||
|
||||
// 设置输出尺寸
|
||||
svgContent = svgContent.replace(/width="[^"]*"/, `width="${SIZE}"`)
|
||||
svgContent = svgContent.replace(/height="[^"]*"/, `height="${SIZE}"`)
|
||||
|
||||
await sharp(Buffer.from(svgContent))
|
||||
.resize(SIZE, SIZE)
|
||||
.png()
|
||||
.toFile(outputPath)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🎨 开始生成图标...\n')
|
||||
|
||||
let total = 0
|
||||
let success = 0
|
||||
|
||||
// 生成普通图标
|
||||
for (const icon of ICONS) {
|
||||
for (const [color, outputName] of Object.entries(icon.variants)) {
|
||||
const outputPath = path.join(ICONS_DIR, `${outputName}.png`)
|
||||
total++
|
||||
if (await generateIcon(icon.lucide, outputPath, color)) {
|
||||
console.log(` ✓ ${outputName}.png`)
|
||||
success++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 TabBar 图标
|
||||
for (const icon of TABBAR_ICONS) {
|
||||
for (const [outputName, color] of [[icon.normal, icon.normalColor], [icon.active, icon.activeColor]]) {
|
||||
const outputPath = path.join(ICONS_DIR, `${outputName}.png`)
|
||||
total++
|
||||
if (await generateIcon(icon.lucide, outputPath, color)) {
|
||||
console.log(` ✓ ${outputName}.png (tabbar)`)
|
||||
success++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ 完成: ${success}/${total} 个图标已生成`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
@@ -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<boolean> {
|
||||
const [rows] = await conn.query(
|
||||
`SELECT COUNT(*) as cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
|
||||
[table]
|
||||
)
|
||||
return (rows as any[])[0].cnt > 0
|
||||
}
|
||||
|
||||
async function runMigrations(conn: mysql.Connection) {
|
||||
console.log('[DB] Checking migrations...')
|
||||
|
||||
@@ -83,6 +91,63 @@ 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
|
||||
`)
|
||||
}
|
||||
|
||||
// 公告系统增强:扩展 notifications 表
|
||||
const hasPinned = await columnExists(conn, 'notifications', 'is_pinned')
|
||||
if (!hasPinned) {
|
||||
console.log('[DB] Migrating: extending notifications table')
|
||||
await conn.query('ALTER TABLE notifications ADD COLUMN is_pinned TINYINT(1) DEFAULT 0 AFTER is_read')
|
||||
await conn.query('ALTER TABLE notifications ADD COLUMN is_urgent TINYINT(1) DEFAULT 0 AFTER is_pinned')
|
||||
await conn.query('ALTER TABLE notifications ADD COLUMN publish_at TIMESTAMP NULL AFTER is_urgent')
|
||||
await conn.query('ALTER TABLE notifications ADD COLUMN expire_at TIMESTAMP NULL AFTER publish_at')
|
||||
await conn.query('ALTER TABLE notifications ADD COLUMN image_url VARCHAR(500) DEFAULT \'\' AFTER expire_at')
|
||||
await conn.query('ALTER TABLE notifications ADD COLUMN link_url VARCHAR(500) DEFAULT \'\' AFTER image_url')
|
||||
await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)')
|
||||
await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)')
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
|
||||
@@ -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,33 @@ 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,
|
||||
is_pinned TINYINT(1) DEFAULT 0 COMMENT '是否置顶',
|
||||
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
|
||||
publish_at TIMESTAMP NULL COMMENT '定时发布时间(NULL=立即发布)',
|
||||
expire_at TIMESTAMP NULL COMMENT '过期时间(NULL=永不过期)',
|
||||
image_url VARCHAR(500) DEFAULT '' COMMENT '公告配图',
|
||||
link_url VARCHAR(500) DEFAULT '' COMMENT '公告链接',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_read (user_id, is_read),
|
||||
INDEX idx_type (type),
|
||||
INDEX idx_pinned (is_pinned, created_at),
|
||||
INDEX idx_expire (expire_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
filters JSON NOT NULL COMMENT '筛选条件:{type, categories[], startDate, endDate, minAmount, maxAmount, keyword}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -15,6 +15,9 @@ import categoryRoutes from './routes/category'
|
||||
import backupRoutes from './routes/backup'
|
||||
import 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 {
|
||||
|
||||
158
server/src/routes/admin.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 管理员权限检查中间件 */
|
||||
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||
const user = (rows as any[])[0]
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ code: 40300, message: '需要管理员权限,请先登录获取管理员身份' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// 所有路由都需要管理员权限
|
||||
router.use((req, res, next) => requireAdmin(req, res, next))
|
||||
|
||||
/** 数据看板 */
|
||||
router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
|
||||
const [txThisMonth] = await pool.query(
|
||||
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
|
||||
FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||
)
|
||||
const [txLastMonth] = await pool.query(
|
||||
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
|
||||
FROM transactions
|
||||
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
||||
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||
)
|
||||
const [activeToday] = await pool.query(
|
||||
`SELECT COUNT(DISTINCT user_id) as count FROM transactions WHERE date = CURDATE()`
|
||||
)
|
||||
const [activeThisMonth] = await pool.query(
|
||||
`SELECT COUNT(DISTINCT user_id) as count FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||
)
|
||||
|
||||
const thisMonth = (txThisMonth as any[])[0]
|
||||
const lastMonth = (txLastMonth as any[])[0]
|
||||
let growth = 0
|
||||
if (lastMonth.total > 0) {
|
||||
growth = Math.round(((thisMonth.total - lastMonth.total) / lastMonth.total) * 100)
|
||||
} else if (thisMonth.total > 0) {
|
||||
growth = 100
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
totalUsers: (userCount as any[])[0].count,
|
||||
monthlyTxCount: thisMonth.count,
|
||||
monthlyTxAmount: thisMonth.total,
|
||||
dailyActive: (activeToday as any[])[0].count,
|
||||
monthlyActive: (activeThisMonth as any[])[0].count,
|
||||
growthPercent: growth,
|
||||
lastMonthTxCount: lastMonth.count,
|
||||
lastMonthTxAmount: lastMonth.total
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Admin] dashboard error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 用户列表 */
|
||||
router.get('/users', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { page = '1', pageSize = '20', keyword } = req.query
|
||||
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||||
const pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20))
|
||||
const offset = (pNum - 1) * pSize
|
||||
|
||||
let where = ''
|
||||
const params: any[] = []
|
||||
|
||||
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||
where = 'WHERE u.nickname LIKE ?'
|
||||
params.push(`%${keyword.trim()}%`)
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at,
|
||||
(SELECT COUNT(*) FROM transactions WHERE user_id = u.id) as tx_count,
|
||||
(SELECT COUNT(*) FROM group_members WHERE user_id = u.id) as group_count
|
||||
FROM users u
|
||||
${where}
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pSize, offset]
|
||||
)
|
||||
|
||||
const [countResult] = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM users u ${where}`,
|
||||
params
|
||||
)
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
list: rows,
|
||||
total: (countResult as any)[0].total,
|
||||
page: pNum,
|
||||
pageSize: pSize
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Admin] users error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新用户状态(禁用/启用) */
|
||||
router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { role } = req.body
|
||||
if (!role || !['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ code: 40001, message: '角色无效' })
|
||||
}
|
||||
// 不能修改自己的角色
|
||||
if (Number(req.params.id) === req.userId) {
|
||||
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Admin] update user status error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除用户 */
|
||||
router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 不能删除自己
|
||||
if (Number(req.params.id) === req.userId) {
|
||||
return res.status(400).json({ code: 40001, message: '不能删除自己' })
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Admin] delete user error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -23,12 +23,16 @@ 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)
|
||||
console.error('[Auth] Demo login failed:', err)
|
||||
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)
|
||||
console.error('[Auth] Login failed:', err)
|
||||
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
|
||||
|
||||
69
server/src/routes/filter.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Router, Response } from 'express'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 获取保存的筛选方案 */
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, filters, created_at FROM saved_filters WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[req.userId]
|
||||
)
|
||||
// 解析 JSON 字段
|
||||
const list = (rows as any[]).map(row => ({
|
||||
...row,
|
||||
filters: typeof row.filters === 'string' ? JSON.parse(row.filters) : row.filters
|
||||
}))
|
||||
res.json({ code: 0, data: list })
|
||||
} catch (err) {
|
||||
console.error('[Filter] GET error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 保存筛选方案 */
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, filters } = req.body
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '方案名称不能为空' })
|
||||
}
|
||||
if (name.length > 50) {
|
||||
return res.status(400).json({ code: 40001, message: '方案名称不能超过50字' })
|
||||
}
|
||||
if (!filters || typeof filters !== 'object') {
|
||||
return res.status(400).json({ code: 40001, message: '筛选条件无效' })
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO saved_filters (user_id, name, filters) VALUES (?, ?, ?)',
|
||||
[req.userId, name.trim(), JSON.stringify(filters)]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||
} catch (err) {
|
||||
console.error('[Filter] POST error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除筛选方案 */
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
'DELETE FROM saved_filters WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.userId]
|
||||
)
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '方案不存在' })
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Filter] DELETE error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
249
server/src/routes/notification.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
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.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 = ?)))
|
||||
AND (n.expire_at IS NULL OR n.expire_at > NOW())
|
||||
AND (n.publish_at IS NULL OR n.publish_at <= NOW())`
|
||||
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,
|
||||
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url
|
||||
FROM notifications n
|
||||
${where}
|
||||
ORDER BY n.is_pinned DESC, 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('/pinned', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at,
|
||||
n.is_pinned, n.is_urgent, n.image_url, n.link_url
|
||||
FROM notifications n
|
||||
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 = ?)))
|
||||
AND n.is_urgent = 1 AND n.is_read = 0
|
||||
AND (n.expire_at IS NULL OR n.expire_at > NOW())
|
||||
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 5`,
|
||||
[req.userId, req.userId]
|
||||
)
|
||||
res.json({ code: 0, data: rows })
|
||||
} catch (err) {
|
||||
console.error('[Notification] pinned 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
|
||||
AND (expire_at IS NULL OR expire_at > NOW())
|
||||
AND (publish_at IS NULL OR publish_at <= NOW())`,
|
||||
[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) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
||||
AND is_read = 0
|
||||
AND (expire_at IS NULL OR expire_at > NOW())
|
||||
AND (publish_at IS NULL OR publish_at <= NOW())`,
|
||||
[req.userId, 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 { title, content, type = 'system', group_id, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = 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字' })
|
||||
}
|
||||
|
||||
// 系统公告需要管理员权限
|
||||
if (type === 'system') {
|
||||
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: '仅管理员可发布系统公告' })
|
||||
}
|
||||
}
|
||||
|
||||
// 群组公告需要群主权限
|
||||
if (type === 'group' && group_id) {
|
||||
const [groupRows] = await pool.query('SELECT created_by FROM `groups` WHERE id = ?', [group_id])
|
||||
const group = (groupRows as any[])[0]
|
||||
if (!group || group.created_by !== req.userId) {
|
||||
return res.status(403).json({ code: 40300, message: '仅群主可发布群组公告' })
|
||||
}
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO notifications (type, title, content, user_id, group_id, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
type,
|
||||
title.trim(),
|
||||
content || '',
|
||||
type === 'personal' ? req.userId : null,
|
||||
type === 'group' ? group_id : null,
|
||||
is_pinned ? 1 : 0,
|
||||
is_urgent ? 1 : 0,
|
||||
publish_at || null,
|
||||
expire_at || null,
|
||||
image_url || '',
|
||||
link_url || ''
|
||||
]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||
} catch (err) {
|
||||
console.error('[Notification] POST error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 编辑公告(管理员) */
|
||||
router.put('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { title, content, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
|
||||
|
||||
const updates: string[] = []
|
||||
const params: any[] = []
|
||||
|
||||
if (title !== undefined) { updates.push('title = ?'); params.push(title.trim()) }
|
||||
if (content !== undefined) { updates.push('content = ?'); params.push(content) }
|
||||
if (is_pinned !== undefined) { updates.push('is_pinned = ?'); params.push(is_pinned ? 1 : 0) }
|
||||
if (is_urgent !== undefined) { updates.push('is_urgent = ?'); params.push(is_urgent ? 1 : 0) }
|
||||
if (publish_at !== undefined) { updates.push('publish_at = ?'); params.push(publish_at || null) }
|
||||
if (expire_at !== undefined) { updates.push('expire_at = ?'); params.push(expire_at || null) }
|
||||
if (image_url !== undefined) { updates.push('image_url = ?'); params.push(image_url) }
|
||||
if (link_url !== undefined) { updates.push('link_url = ?'); params.push(link_url) }
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
|
||||
}
|
||||
|
||||
params.push(req.params.id)
|
||||
const [result] = await pool.query(
|
||||
`UPDATE notifications SET ${updates.join(', ')} WHERE id = ?`,
|
||||
params
|
||||
)
|
||||
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '公告不存在' })
|
||||
}
|
||||
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Notification] PUT error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除公告(管理员) */
|
||||
router.delete('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [result] = await pool.query('DELETE FROM notifications 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('[Notification] DELETE error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -22,8 +22,8 @@ async function buildWhereClause(
|
||||
)
|
||||
if ((memberCheck as any[]).length === 0) return null
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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, category_ids, 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,31 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
if (endDate && DATE_REGEX.test(endDate as string)) {
|
||||
where += ' AND t.date <= ?'; params.push(endDate)
|
||||
}
|
||||
// 支持单个 category_id 或多个 category_ids
|
||||
if (category_ids) {
|
||||
const ids = String(category_ids).split(',').map(Number).filter(n => !isNaN(n))
|
||||
if (ids.length > 0) {
|
||||
where += ` AND t.category_id IN (${ids.map(() => '?').join(',')})`
|
||||
params.push(...ids)
|
||||
}
|
||||
} else 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 +125,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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||