feat: 全面系统检查与修复 — 51项问题修复

## 重大修复

### 安全性修复
- 修复 notification read-all SQL 运算符优先级 bug
- 修复 link_url XSS 漏洞(验证 URL 协议)
- 修复 LIKE 通配符注入(转义 % 和 _)
- 修复 uploadImageIfNeeded 运算符优先级 bug
- 401 时清理所有本地存储(token/nickname/avatar/role/group)

### 数据完整性修复
- 修复公告已读状态共享问题(新建 notification_reads 表)
- 修复群组账单数据缺失(退出群组时保留 group_id)
- 修复群组解散后邀请码仍可加入
- 修复分类迁移未校验目标类型
- 修复群组公告缺少 group_id 必填校验

### 功能修复
- 修复 category PUT /sort 路由冲突
- 修复 GROUP BY 不完整问题
- 修复 budget API 类型不匹配
- 修复 categoryStore.migrateCategory 不刷新本地数据
- 修复 groupStore 并发请求问题
- 修复账单页覆盖 store 数据
- 修复群组预算查询返回 0 而非 null
- 修复通知页面 onShow 不刷新列表
- 修复统计页面不必要的重复请求

### 用户体验优化
- 添加通知详情查看功能(弹窗)
- 添加通知图片服务器上传
- 添加 Markdown 富文本工具栏
- 添加管理页面客户端认证检查
- 添加管理员公告页面下拉刷新
- 添加数据导出进度反馈
- 添加账单删除后筛选金额更新
- Numpad 添加安全区域 padding

### 代码质量提升
- 提取 requireAdmin 为共享中间件
- filter-panel 使用设计 token
- 修复 getCurrentMonth 时区不一致
- 备份功能使用分页查询避免内存问题
- 管理后台仪表盘添加缓存
- 邀请码碰撞重试后报错

## 新增文件
- server/src/middleware/requireAdmin.ts — 共享管理员权限中间件
- client/src/pages/admin/notifications/index.vue — 公告管理页面

## 数据库变更
- 新增 notification_reads 表(公告已读记录)
- 群组解散时保留 groups 记录和 transactions.group_id
This commit is contained in:
wangxiaogang
2026-06-07 15:35:37 +08:00
parent 4f42f2c342
commit bd7af8e512
35 changed files with 1683 additions and 759 deletions

View File

@@ -10,6 +10,10 @@ export interface Dashboard {
growthPercent: number
lastMonthTxCount: number
lastMonthTxAmount: number
monthlyExpense?: number
monthlyIncome?: number
lastMonthExpense?: number
lastMonthIncome?: number
}
/** 用户信息(管理员视角) */

View File

@@ -6,6 +6,7 @@ export interface LoginResult {
userId: number
nickname: string
avatar_url: string
role?: string
}
/** H5 演示登录 */

View File

@@ -15,5 +15,5 @@ export function getBudget(month?: string, group_id?: number | null) {
/** 设置预算 */
export function setBudget(amount: number, month: string) {
return request<{ id: number }>({ url: '/budget', method: 'POST', data: { amount, month } })
return request<void>({ url: '/budget', method: 'POST', data: { amount, month } })
}

View File

@@ -1,4 +1,5 @@
import { request } from '@/utils/request'
import { API_BASE } from '@/config'
/** 通知 */
export interface Notification {
@@ -64,6 +65,42 @@ export function markAllRead() {
return request({ url: '/notifications/read-all', method: 'PUT' })
}
/** 上传通知图片(返回文件名) */
export function uploadNotificationImage(filePath: string): Promise<{ image_url: string }> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('xc:token')
uni.uploadFile({
url: `${API_BASE}/notifications/upload-image`,
filePath,
name: 'file',
header: {
...(token ? { Authorization: `Bearer ${token}` } : {})
},
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.code === 0) {
resolve(data.data)
} else {
reject(data)
}
} catch {
reject({ message: '上传失败' })
}
},
fail: () => reject({ message: '网络错误' })
})
})
}
/** 获取通知图片完整 URL */
export function getNotificationImageUrl(filename: string): string {
if (!filename) return ''
// 如果已经是完整 URL 或 blob URL直接返回
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
return `${API_BASE}/notifications/image/${filename}`
}
/** 发布公告 */
export function publishNotification(data: PublishParams) {
return request<{ id: number }>({ url: '/notifications', method: 'POST', data })

View File

@@ -90,40 +90,42 @@ function onConfirm() {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.numpad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
padding: 0 32rpx;
gap: $space-sm;
padding: 0 $space-xl;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.key {
height: 96rpx;
border-radius: 24rpx;
background: #FFF8F0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-xl;
background: $bg;
@include flex-center;
&:active { background: #F0E0D6; }
&:active { background: $border; }
}
.key-text {
font-family: 'Fredoka', sans-serif;
font-size: 44rpx;
font-weight: 500;
color: #2D1B1B;
color: $text;
}
.key.fn {
background: #FFE8E0;
background: $primary-light;
&:active { background: #FFD0C0; }
}
.key.eq {
background: linear-gradient(135deg, #FF8C69, #E67355);
background: linear-gradient(135deg, $primary, $primary-dark);
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
&:active { background: #E67355; }
&:active { background: $primary-dark; }
}
// 自绘 backspace 图标
@@ -139,8 +141,8 @@ function onConfirm() {
top: 50%;
width: 16rpx;
height: 16rpx;
border-left: 4rpx solid #FF8C69;
border-bottom: 4rpx solid #FF8C69;
border-left: 4rpx solid $primary;
border-bottom: 4rpx solid $primary;
transform: translateY(-50%) rotate(45deg);
}
@@ -150,7 +152,7 @@ function onConfirm() {
top: 50%;
width: 26rpx;
height: 4rpx;
background: #FF8C69;
background: $primary;
transform: translateY(-50%);
border-radius: 2rpx;
}

View File

@@ -73,7 +73,7 @@ defineEmits(['item-tap', 'delete'])
const creatorAvatarUrl = computed(() => {
if (!props.item.creator_avatar) return ''
if (props.item.creator_avatar.startsWith('http')) return props.item.creator_avatar
if (props.item.creator_avatar.startsWith('http') || props.item.creator_avatar.startsWith('blob:')) return props.item.creator_avatar
// 截取服务端根地址(去掉 /api
const base = API_BASE.replace(/\/api$/, '')
return base + '/api/user/avatar/' + props.item.creator_avatar

View File

@@ -71,7 +71,7 @@
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "通知中心",
"enablePullDownRefresh": false
"enablePullDownRefresh": true
}
},
{
@@ -88,6 +88,14 @@
"navigationBarTitleText": "用户管理",
"enablePullDownRefresh": false
}
},
{
"path": "pages/admin/notifications",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "公告管理",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {

View File

@@ -43,7 +43,7 @@
</picker>
<view class="extra-row">
<Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="50" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" />
</view>
</view>
@@ -117,7 +117,18 @@ watch(currentCategories, (cats) => {
onMounted(async () => {
await waitForReady()
// 加载分类列表,失败时重试一次
try {
await catStore.fetchCategories()
} catch {
try {
await catStore.fetchCategories()
} catch {
uni.showToast({ title: '分类加载失败', icon: 'none' })
}
}
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {

View File

@@ -42,16 +42,20 @@
<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>
<text class="detail-label">支出总额</text>
<text class="detail-value expense">{{ formatAmount(dashboard.monthlyExpense || 0) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">收入总额</text>
<text class="detail-value income">{{ formatAmount(dashboard.monthlyIncome || 0) }}</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>
<text class="detail-label">上月支出</text>
<text class="detail-value">{{ formatAmount(dashboard.lastMonthExpense || 0) }}</text>
</view>
</view>
@@ -63,81 +67,13 @@
<text class="entry-text">用户管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goNotifications">
<view class="entry-item" @tap="goNotificationManage">
<Icon name="bell" :size="32" color="#FF8C69" />
<text class="entry-text">发布公告</text>
<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>
@@ -146,7 +82,6 @@ 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'
@@ -154,10 +89,29 @@ import type { Dashboard } from '@/api/admin'
const loading = ref(true)
const dashboard = ref<Dashboard | null>(null)
// 仪表盘缓存5分钟内不重复请求
let dashboardCache: { data: Dashboard; time: number } | null = null
const CACHE_TTL = 5 * 60 * 1000
onMounted(async () => {
await waitForReady()
// 客户端角色检查
const role = uni.getStorageSync('xc:role')
if (role !== 'admin') {
uni.showToast({ title: '需要管理员权限', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
try {
// 检查缓存是否有效
if (dashboardCache && Date.now() - dashboardCache.time < CACHE_TTL) {
dashboard.value = dashboardCache.data
} else {
dashboard.value = await getDashboard()
dashboardCache = { data: dashboard.value, time: Date.now() }
}
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
@@ -167,56 +121,7 @@ onMounted(async () => {
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' })
}
}
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
</script>
<style lang="scss" scoped>
@@ -336,6 +241,9 @@ async function handlePublish() {
font-size: $font-base;
font-weight: 500;
color: $text;
&.expense { color: $danger; }
&.income { color: $success; }
}
.section-title {
@@ -367,211 +275,4 @@ async function handlePublish() {
font-weight: 500;
color: $text;
}
/* 发布公告弹窗 */
.modal-mask {
@include modal-mask;
}
.publish-modal {
width: 100%;
max-height: 85vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $border;
&:active { background: darken($border, 5%); }
}
.modal-close-text {
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.modal-body {
flex: 1;
padding: $space-md $space-xl;
max-height: 60vh;
}
.form-group {
margin-bottom: $space-md;
}
.form-label {
font-size: $font-base;
font-weight: 600;
color: $text;
display: block;
margin-bottom: 12rpx;
}
.form-input {
height: 80rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
}
.form-textarea {
width: 100%;
height: 200rpx;
padding: $space-sm $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
}
.form-row {
display: flex;
gap: $space-md;
}
.switch-item {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-sm $space-lg;
background: $bg;
border-radius: $radius-md;
border: 2rpx solid $border;
&:active { background: $border; }
}
.switch-label {
font-size: $font-base;
color: $text;
}
.switch-toggle {
width: 44rpx;
height: 24rpx;
border-radius: 12rpx;
background: #D0C4C4;
position: relative;
transition: background $transition-normal;
&.on { background: $primary; }
&::after {
content: '';
position: absolute;
top: 2rpx;
left: 2rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: $surface;
transition: transform $transition-normal;
}
&.on::after { transform: translateX(20rpx); }
}
.modal-footer {
padding: $space-md $space-xl $space-xl;
border-top: 2rpx solid $border;
}
.publish-btn-text {
display: block;
text-align: center;
font-size: 30rpx;
font-weight: 600;
color: $surface;
padding: $space-lg;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-lg;
&:active { opacity: 0.8; }
}
.image-upload {
width: 200rpx;
height: 200rpx;
border: 2rpx dashed #D0C4C4;
border-radius: $radius-md;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $space-xs;
&:active { background: $bg; }
}
.upload-text {
font-size: $font-md;
color: $text-muted;
}
.image-preview {
position: relative;
width: 200rpx;
height: 200rpx;
}
.preview-img {
width: 100%;
height: 100%;
border-radius: $radius-md;
}
.image-remove {
position: absolute;
top: -12rpx;
right: -12rpx;
width: 40rpx;
height: 40rpx;
background: $danger;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.image-remove-text {
font-size: $font-lg;
color: $surface;
line-height: 1;
}
.picker-input {
display: flex;
align-items: center;
.placeholder { color: $text-muted; }
}
</style>

View File

@@ -0,0 +1,822 @@
<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-action" @tap="openPublishModal">
<Icon name="plus" :size="40" color="#FF8C69" />
</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 class="empty-btn" @tap="openPublishModal">
<text class="empty-btn-text">发布公告</text>
</view>
</view>
<view v-else class="notification-list">
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ pinned: item.is_pinned }">
<view class="notif-header">
<view class="notif-tags">
<text v-if="item.is_pinned" class="tag pin">置顶</text>
<text v-if="item.is_urgent" class="tag urgent">强提醒</text>
<text class="tag type">{{ getTypeName(item.type) }}</text>
</view>
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
</view>
<text class="notif-title">{{ item.title }}</text>
<text class="notif-content" v-if="item.content">{{ item.content }}</text>
<image v-if="item.image_url" class="notif-image" :src="getNotificationImageUrl(item.image_url)" mode="widthFix" />
<view class="notif-footer">
<view class="notif-actions">
<view class="action-btn" @tap="handleTogglePin(item)">
<Icon name="check" :size="28" :color="item.is_pinned ? '#FF8C69' : '#8B7E7E'" />
<text class="action-text">{{ item.is_pinned ? '取消置顶' : '置顶' }}</text>
</view>
<view class="action-btn" @tap="openEditModal(item)">
<Icon name="edit" :size="28" color="#8B7E7E" />
<text class="action-text">编辑</text>
</view>
<view class="action-btn danger" @tap="handleDelete(item)">
<Icon name="trash" :size="28" color="#FF6B6B" />
<text class="action-text danger">删除</text>
</view>
</view>
</view>
</view>
<view v-if="noMore && list.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</view>
<!-- 发布/编辑公告弹窗 -->
<view class="modal-mask" v-if="showModal" @tap="showModal = false">
<view class="publish-modal" @tap.stop>
<view class="modal-header">
<text class="modal-title">{{ isEditing ? '编辑公告' : '发布公告' }}</text>
<view class="modal-close" @tap="showModal = 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="form.title" placeholder="输入公告标题" maxlength="100" />
</view>
<view class="form-group">
<text class="form-label">内容</text>
<view class="editor-toolbar">
<view class="toolbar-btn" @tap="insertFormat('**', '**')">
<text class="toolbar-text bold">B</text>
</view>
<view class="toolbar-btn" @tap="insertFormat('*', '*')">
<text class="toolbar-text italic">I</text>
</view>
<view class="toolbar-btn" @tap="insertFormat('~~', '~~')">
<text class="toolbar-text strike">S</text>
</view>
<view class="toolbar-btn" @tap="insertFormat('\n- ', '')">
<text class="toolbar-text"></text>
</view>
<view class="toolbar-btn" @tap="insertFormat('\n> ', '')">
<text class="toolbar-text">"</text>
</view>
</view>
<textarea
class="form-textarea rich"
v-model="form.content"
placeholder="输入公告内容支持 Markdown 格式"
maxlength="5000"
:auto-height="false"
ref="contentEditor"
/>
<text class="form-hint">支持 **粗体**、*斜体*、~~删除线~~、列表、引用等格式</text>
</view>
<view class="form-group">
<text class="form-label">链接(可选)</text>
<input class="form-input" v-model="form.link_url" placeholder="https://..." />
</view>
<view class="form-group">
<text class="form-label">配图可选</text>
<view v-if="form.image_url" class="image-preview">
<image class="preview-img" :src="getPreviewImageSrc(form.image_url)" mode="aspectFill" />
<view class="image-remove" @tap="form.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="form.publish_at" @change="e => form.publish_at = e.detail.value">
<view class="form-input picker-input">
<text :class="{ placeholder: !form.publish_at }">{{ form.publish_at || '立即发布' }}</text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">过期时间可选</text>
<picker mode="date" :value="form.expire_at" @change="e => form.expire_at = e.detail.value">
<view class="form-input picker-input">
<text :class="{ placeholder: !form.expire_at }">{{ form.expire_at || '永不过期' }}</text>
</view>
</picker>
</view>
<view class="form-row">
<view class="switch-item" @tap="form.is_pinned = !form.is_pinned">
<text class="switch-label">置顶</text>
<view class="switch-toggle" :class="{ on: form.is_pinned }"></view>
</view>
<view class="switch-item" @tap="form.is_urgent = !form.is_urgent">
<text class="switch-label">强提醒</text>
<view class="switch-toggle" :class="{ on: form.is_urgent }"></view>
</view>
</view>
</scroll-view>
<view class="modal-footer" @tap="handleSubmit">
<text class="submit-btn-text">{{ isEditing ? '保存修改' : '发布' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import {
getNotifications,
publishNotification,
updateNotification,
deleteNotification,
uploadNotificationImage,
getNotificationImageUrl
} from '@/api/notification'
import Icon from '@/components/Icon/Icon.vue'
import type { Notification } from '@/api/notification'
const list = ref<Notification[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const showModal = ref(false)
const isEditing = ref(false)
const editingId = ref<number | null>(null)
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
const defaultForm = {
title: '',
content: '',
is_pinned: false,
is_urgent: false,
link_url: '',
image_url: '',
publish_at: '',
expire_at: ''
}
const form = ref({ ...defaultForm })
onMounted(async () => {
await waitForReady()
// 客户端角色检查
const role = uni.getStorageSync('xc:role')
if (role !== 'admin') {
uni.showToast({ title: '需要管理员权限', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
await loadNotifications(true)
})
function goBack() {
uni.navigateBack()
}
function getTypeName(type: string) {
const map: Record<string, string> = {
system: '系统',
group: '群组',
personal: '个人'
}
return map[type] || type
}
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 loadNotifications(reset = false) {
if (reset) {
page.value = 1
list.value = []
}
loading.value = true
try {
const data = await getNotifications({ page: page.value, pageSize: 20 })
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 {
loading.value = false
}
}
function openPublishModal() {
isEditing.value = false
editingId.value = null
form.value = { ...defaultForm }
showModal.value = true
}
function openEditModal(item: Notification) {
isEditing.value = true
editingId.value = item.id
form.value = {
title: item.title,
content: item.content || '',
is_pinned: !!item.is_pinned,
is_urgent: !!item.is_urgent,
link_url: item.link_url || '',
image_url: item.image_url || '',
publish_at: item.publish_at || '',
expire_at: item.expire_at || ''
}
showModal.value = true
}
/** 选择配图 */
function chooseImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: (res) => {
form.value.image_url = res.tempFilePaths[0]
}
})
}
/** 获取预览图片地址 */
function getPreviewImageSrc(url: string) {
if (!url) return ''
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('/')) return url
return getNotificationImageUrl(url)
}
/** 插入格式化标记 */
function insertFormat(prefix: string, suffix: string) {
// 简化实现:直接在内容末尾添加
form.value.content += prefix + '文本' + suffix
}
/** 上传图片 */
async function uploadImageIfNeeded(imageUrl: string): Promise<string> {
if (!imageUrl) return ''
// 如果已经是完整 URL 或 blob URL直接返回
if (imageUrl.startsWith('http') || imageUrl.startsWith('blob:')) {
return imageUrl
}
// 临时路径需要上传
uni.showLoading({ title: '上传图片中...' })
try {
const result = await uploadNotificationImage(imageUrl)
return result.image_url
} finally {
uni.hideLoading()
}
}
async function handleSubmit() {
if (!form.value.title.trim()) {
uni.showToast({ title: '请输入标题', icon: 'none' })
return
}
try {
const imageUrl = await uploadImageIfNeeded(form.value.image_url)
const data = {
title: form.value.title.trim(),
content: form.value.content.trim(),
is_pinned: form.value.is_pinned,
is_urgent: form.value.is_urgent,
link_url: form.value.link_url.trim() || undefined,
image_url: imageUrl || undefined,
publish_at: form.value.publish_at || undefined,
expire_at: form.value.expire_at || undefined
}
if (isEditing.value && editingId.value) {
await updateNotification(editingId.value, data)
uni.showToast({ title: '已更新', icon: 'success' })
} else {
await publishNotification(data)
uni.showToast({ title: '已发布', icon: 'success' })
}
showModal.value = false
await loadNotifications(true)
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
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 handleDelete(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' })
}
}
}
})
}
onPullDownRefresh(async () => {
await loadNotifications(true)
uni.stopPullDownRefresh()
})
onReachBottom(() => {
if (noMore.value || loading.value) return
page.value++
loadNotifications(false)
})
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed {
@include sticky-header;
}
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-xl $space-md;
}
.nav-back {
@include nav-back;
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-action {
width: 88rpx;
height: 88rpx;
@include flex-center;
&:active { opacity: 0.6; }
}
.loading-box, .empty-box {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-sm;
padding: 120rpx 0;
}
.loading-text, .empty-text {
font-size: $font-lg;
color: $text-muted;
}
.empty-btn {
margin-top: $space-lg;
padding: $space-sm $space-xl;
background: $primary;
border-radius: $radius-lg;
&:active { opacity: 0.8; }
}
.empty-btn-text {
font-size: $font-base;
color: $surface;
font-weight: 600;
}
.notification-list {
padding: 0 $space-xl;
}
.notification-item {
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
padding: $space-lg;
margin-bottom: $space-sm;
&.pinned {
border-color: $primary;
background: #FFFAF7;
}
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-sm;
}
.notif-tags {
display: flex;
gap: $space-xs;
}
.tag {
font-size: $font-xs;
padding: 4rpx 12rpx;
border-radius: $radius-sm;
&.pin {
background: #FFE8E0;
color: $primary;
}
&.urgent {
background: #FFF0E6;
color: #E67355;
}
&.type {
background: $bg;
color: $text-sec;
}
}
.notif-time {
font-size: $font-xs;
color: $text-muted;
}
.notif-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-xs;
}
.notif-content {
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: $space-sm;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.notif-image {
width: 100%;
border-radius: $radius-md;
margin-bottom: $space-sm;
}
.notif-footer {
border-top: 2rpx solid $border;
padding-top: $space-sm;
}
.notif-actions {
display: flex;
justify-content: flex-end;
gap: $space-lg;
}
.action-btn {
display: flex;
align-items: center;
gap: $space-xs;
padding: $space-xs $space-sm;
border-radius: $radius-md;
&:active { background: $bg; }
&.danger {
.action-text.danger { color: $danger; }
}
}
.action-text {
font-size: $font-sm;
color: $text-sec;
}
.no-more {
text-align: center;
padding: $space-lg 0;
}
.no-more-text {
font-size: $font-md;
color: $text-muted;
}
/* 弹窗样式 */
.modal-mask {
@include modal-mask;
}
.publish-modal {
width: 100%;
max-height: 90vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.modal-close {
width: 56rpx;
height: 56rpx;
@include flex-center;
border-radius: 50%;
background: $border;
&:active { background: darken($border, 5%); }
}
.modal-close-text {
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.modal-body {
flex: 1;
padding: $space-md $space-xl;
max-height: 65vh;
}
.form-group {
margin-bottom: $space-md;
}
.form-label {
font-size: $font-base;
font-weight: 600;
color: $text;
display: block;
margin-bottom: 12rpx;
}
.form-input {
height: 80rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
}
.editor-toolbar {
display: flex;
gap: $space-xs;
padding: $space-xs;
background: $bg;
border-radius: $radius-lg $radius-lg 0 0;
border: 2rpx solid $border;
border-bottom: none;
}
.toolbar-btn {
width: 56rpx;
height: 56rpx;
@include flex-center;
border-radius: $radius-sm;
&:active { background: $border; }
}
.toolbar-text {
font-size: $font-lg;
color: $text;
&.bold { font-weight: 700; }
&.italic { font-style: italic; }
&.strike { text-decoration: line-through; }
}
.form-textarea {
width: 100%;
height: 200rpx;
padding: $space-sm $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
&.rich {
border-radius: 0 0 $radius-lg $radius-lg;
min-height: 300rpx;
}
}
.form-hint {
font-size: $font-xs;
color: $text-muted;
margin-top: $space-xs;
display: block;
}
.form-row {
display: flex;
gap: $space-md;
}
.switch-item {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-sm $space-lg;
background: $bg;
border-radius: $radius-md;
border: 2rpx solid $border;
&:active { background: $border; }
}
.switch-label {
font-size: $font-base;
color: $text;
}
.switch-toggle {
width: 44rpx;
height: 24rpx;
border-radius: 12rpx;
background: #D0C4C4;
position: relative;
transition: background $transition-normal;
&.on { background: $primary; }
&::after {
content: '';
position: absolute;
top: 2rpx;
left: 2rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: $surface;
transition: transform $transition-normal;
}
&.on::after { transform: translateX(20rpx); }
}
.modal-footer {
padding: $space-md $space-xl $space-xl;
border-top: 2rpx solid $border;
}
.submit-btn-text {
display: block;
text-align: center;
font-size: 30rpx;
font-weight: 600;
color: $surface;
padding: $space-lg;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-lg;
&:active { opacity: 0.8; }
}
.image-upload {
width: 200rpx;
height: 200rpx;
border: 2rpx dashed #D0C4C4;
border-radius: $radius-md;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $space-xs;
&:active { background: $bg; }
}
.upload-text {
font-size: $font-md;
color: $text-muted;
}
.image-preview {
position: relative;
width: 200rpx;
height: 200rpx;
}
.preview-img {
width: 100%;
height: 100%;
border-radius: $radius-md;
}
.image-remove {
position: absolute;
top: -12rpx;
right: -12rpx;
width: 40rpx;
height: 40rpx;
background: $danger;
border-radius: 50%;
@include flex-center;
}
.image-remove-text {
font-size: $font-lg;
color: $surface;
line-height: 1;
}
.picker-input {
display: flex;
align-items: center;
.placeholder { color: $text-muted; }
}
</style>

View File

@@ -213,24 +213,21 @@ function handleReset() {
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;
// 转换金额:元 → 分,防御 NaN
const minVal = parseFloat(minAmountStr.value);
const maxVal = parseFloat(maxAmountStr.value);
filters.minAmount = minAmountStr.value && !isNaN(minVal) ? Math.round(minVal * 100) : 0;
filters.maxAmount = maxAmountStr.value && !isNaN(maxVal) ? Math.round(maxVal * 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;
// 转换金额:元 → 分,防御 NaN
const minVal = parseFloat(minAmountStr.value);
const maxVal = parseFloat(maxAmountStr.value);
filters.minAmount = minAmountStr.value && !isNaN(minVal) ? Math.round(minVal * 100) : 0;
filters.maxAmount = maxAmountStr.value && !isNaN(maxVal) ? Math.round(maxVal * 100) : 0;
uni.showModal({
title: "保存方案",
@@ -271,23 +268,17 @@ async function handleDeleteSaved(id: number) {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.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;
@include modal-mask;
}
.filter-panel {
width: 100%;
max-height: 80vh;
background: #ffffff;
border-radius: 40rpx 40rpx 0 0;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -297,72 +288,70 @@ async function handleDeleteSaved(id: number) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 40rpx 24rpx;
border-bottom: 2rpx solid #f0e0d6;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.filter-title {
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
color: #2d1b1b;
color: $text;
}
.filter-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
@include flex-center;
border-radius: 50%;
background: #f0e0d6;
background: $border;
&:active {
background: #e0d6d0;
background: darken($border, 5%);
}
}
.filter-close-text {
font-size: 36rpx;
color: #8b7e7e;
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.filter-body {
flex: 1;
padding: 24rpx 40rpx;
padding: $space-md $space-xl;
max-height: 55vh;
overflow-x: hidden;
}
.filter-section {
margin-bottom: 32rpx;
margin-bottom: $space-lg;
width: 100%;
}
.section-label {
font-size: 26rpx;
font-size: $font-base;
font-weight: 600;
color: #2d1b1b;
color: $text;
display: block;
margin-bottom: 16rpx;
margin-bottom: $space-sm;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
gap: $space-xs;
width: 100%;
}
.chip {
padding: 12rpx 24rpx;
background: #fff8f0;
border-radius: 20rpx;
border: 2rpx solid #f0e0d6;
transition: all 0.2s;
padding: $space-xs $space-md;
background: $bg;
border-radius: $radius-xl;
border: 2rpx solid $border;
transition: all $transition-normal;
&.active {
background: #ffe8e0;
border-color: #ff8c69;
background: $primary-light;
border-color: $primary;
}
&:active {
@@ -371,10 +360,10 @@ async function handleDeleteSaved(id: number) {
}
.chip-text {
font-size: 24rpx;
color: #2d1b1b;
font-size: $font-md;
color: $text;
.active & {
color: #ff8c69;
color: $primary;
font-weight: 600;
}
}
@@ -382,127 +371,123 @@ async function handleDeleteSaved(id: number) {
.date-row {
display: flex;
align-items: center;
gap: 16rpx;
gap: $space-sm;
width: 100%;
}
.date-picker {
flex: 1;
height: 72rpx;
padding: 0 20rpx;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #f0e0d6;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
display: flex;
align-items: center;
}
.date-text {
font-size: 26rpx;
color: #2d1b1b;
font-size: $font-base;
color: $text;
&.placeholder {
color: #bfb3b3;
color: $text-muted;
}
}
.date-sep {
font-size: 26rpx;
color: #8b7e7e;
font-size: $font-base;
color: $text-sec;
}
.amount-row {
display: flex;
align-items: center;
gap: 16rpx;
gap: $space-sm;
width: 100%;
}
.amount-input {
flex: 1;
height: 72rpx;
padding: 0 20rpx;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #f0e0d6;
font-size: 26rpx;
color: #2d1b1b;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-base;
color: $text;
}
.amount-sep {
font-size: 26rpx;
color: #8b7e7e;
font-size: $font-base;
color: $text-sec;
}
.keyword-input {
height: 72rpx;
padding: 0 20rpx;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #f0e0d6;
font-size: 26rpx;
color: #2d1b1b;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-base;
color: $text;
}
.saved-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 20rpx;
background: #fff8f0;
border-radius: 16rpx;
margin-bottom: 8rpx;
padding: $space-sm $space-md;
background: $bg;
border-radius: $radius-lg;
margin-bottom: $space-xs;
&:active {
background: #f0e0d6;
background: $border;
}
}
.saved-name {
font-size: 26rpx;
color: #2d1b1b;
font-size: $font-base;
color: $text;
}
.saved-delete {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
@include flex-center;
&:active {
opacity: 0.6;
}
}
.saved-delete-text {
font-size: 28rpx;
color: #bfb3b3;
font-size: $font-lg;
color: $text-muted;
}
.filter-footer {
display: flex;
gap: 16rpx;
padding: 24rpx 40rpx 40rpx;
border-top: 2rpx solid #f0e0d6;
gap: $space-sm;
padding: $space-md $space-xl $space-xl;
border-top: 2rpx solid $border;
}
.footer-btn {
flex: 1;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-xl;
@include flex-center;
&.reset {
background: #fff8f0;
border: 2rpx solid #f0e0d6;
background: $bg;
border: 2rpx solid $border;
}
&.save {
background: #fff0e6;
border: 2rpx solid #ff8c69;
background: $surface-warm;
border: 2rpx solid $primary;
}
&.apply {
background: linear-gradient(135deg, #ff8c69, #e67355);
background: linear-gradient(135deg, $primary, $primary-dark);
}
&:active {
@@ -511,16 +496,16 @@ async function handleDeleteSaved(id: number) {
}
.footer-btn-text {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
&.reset-text {
color: #8b7e7e;
color: $text-sec;
}
&.save-text {
color: #ff8c69;
color: $primary;
}
&.apply-text {
color: #ffffff;
color: $surface;
}
}
</style>

View File

@@ -88,10 +88,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
@@ -100,7 +100,6 @@ import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
import type { FilterParams } from '@/api/filter'
const txStore = useTransactionStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const filterType = ref('all')
@@ -162,7 +161,7 @@ async function loadTx(reset = false, silent = false) {
if (!silent) txList.value = []
}
const seq = ++loadSeq
const params: any = { page: page.value, pageSize }
const params: any = { page: page.value, pageSize, group_id: groupStore.currentGroupId }
if (filterType.value !== 'all') params.type = filterType.value
// 合并高级筛选参数
const af = advancedFilters.value
@@ -174,19 +173,17 @@ async function loadTx(reset = false, silent = false) {
if (af.keyword) params.keyword = af.keyword
try {
loadError.value = false
const data = await txStore.fetchTransactions(params)
const data = await getTransactions(params)
// 丢弃过期请求的结果
if (seq !== loadSeq) return
if (reset) {
txList.value = txStore.transactions
txList.value = data.list
} else {
txList.value = [...txList.value, ...txStore.transactions]
txList.value = [...txList.value, ...data.list]
}
total.value = txStore.total
if (data) {
total.value = data.total
filteredExpense.value = data.filteredExpense || 0
filteredIncome.value = data.filteredIncome || 0
}
} catch (e) {
if (seq !== loadSeq) return
console.error('Load bills error:', e)
@@ -222,6 +219,12 @@ async function onDelete(item: any) {
await txStore.deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
// 更新筛选金额
if (item.type === 'expense') {
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
} else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
}
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })

View File

@@ -121,7 +121,7 @@
<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" />
<image v-if="urgentNotice.image_url" class="urgent-image" :src="getNotificationImageUrl(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>
@@ -146,6 +146,7 @@ import { useGroupStore } from '@/stores/group'
import { useNotificationStore } from '@/stores/notification'
import { waitForReady } from '@/utils/app-ready'
import { getOverview } from '@/api/stats'
import { getNotificationImageUrl } from '@/api/notification'
import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
@@ -222,7 +223,7 @@ async function loadData(silent = false) {
userStore.fetchUserInfo(),
notifStore.fetchUnreadCount(),
notifStore.fetchUrgentNotifications()
])
]).catch(() => {})
} catch (e) {
console.error('Load error:', e)
if (!silent) loadError.value = true

View File

@@ -43,20 +43,11 @@
<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" />
<image v-if="item.image_url" class="notif-image" :src="getNotificationImageUrl(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>
@@ -65,23 +56,40 @@
<text class="no-more-text">没有更多了</text>
</view>
</view>
<!-- 通知详情弹窗 -->
<view class="modal-mask" v-if="detailItem" @tap="detailItem = null">
<view class="detail-modal" @tap.stop>
<view class="detail-header">
<text class="detail-title">{{ detailItem.title }}</text>
<view class="detail-close" @tap="detailItem = null">
<text class="detail-close-text">×</text>
</view>
</view>
<scroll-view class="detail-body" scroll-y>
<image v-if="detailItem.image_url" class="detail-image" :src="getNotificationImageUrl(detailItem.image_url)" mode="widthFix" />
<text class="detail-content" v-if="detailItem.content">{{ detailItem.content }}</text>
<view class="detail-meta">
<text class="detail-time">{{ formatTime(detailItem.created_at) }}</text>
<text v-if="detailItem.link_url" class="detail-link" @tap="openLink(detailItem.link_url)">查看详情</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useNotificationStore } from '@/stores/notification'
import { useUserStore } from '@/stores/user'
import { getNotifications, markRead, markAllRead, deleteNotification, updateNotification } from '@/api/notification'
import { getNotifications, markRead, markAllRead, getNotificationImageUrl } 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: '全部' },
@@ -95,6 +103,7 @@ const list = ref<Notification[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const detailItem = ref<Notification | null>(null)
let loadSeq = 0
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
@@ -106,6 +115,10 @@ onMounted(async () => {
onShow(() => {
notifStore.fetchUnreadCount()
// 返回页面时刷新列表(如全部已读后)
if (list.value.length > 0) {
loadNotifications(true)
}
})
function switchTab(key: string) {
@@ -171,6 +184,7 @@ function formatTime(dateStr: string) {
}
async function handleTap(item: Notification) {
// 标记已读
if (!item.is_read) {
try {
await markRead(item.id)
@@ -180,6 +194,8 @@ async function handleTap(item: Notification) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
// 显示详情
detailItem.value = item
}
async function handleMarkAllRead() {
@@ -205,40 +221,15 @@ function openLink(url: string) {
// #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()
}
onPullDownRefresh(async () => {
await loadNotifications(true)
uni.stopPullDownRefresh()
})
onReachBottom(() => {
if (noMore.value || loading.value) return
page.value++
@@ -429,24 +420,90 @@ onReachBottom(() => {
color: $text-muted;
}
.admin-actions {
/* 通知详情弹窗 */
.modal-mask {
@include modal-mask;
}
.detail-modal {
width: 100%;
max-height: 80vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
gap: 12rpx;
margin-top: 12rpx;
flex-direction: column;
}
.admin-btn {
padding: $space-xs $space-sm;
background: $surface-warm;
border-radius: $radius-sm;
&.danger { background: $danger-light; }
&:active { opacity: 0.7; }
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.admin-btn-text {
.detail-title {
flex: 1;
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.detail-close {
width: 56rpx;
height: 56rpx;
@include flex-center;
border-radius: 50%;
background: $border;
margin-left: $space-md;
&:active { background: darken($border, 5%); }
}
.detail-close-text {
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.detail-body {
flex: 1;
padding: $space-md $space-xl $space-xl;
max-height: 60vh;
}
.detail-image {
width: 100%;
border-radius: $radius-md;
margin-bottom: $space-md;
}
.detail-content {
font-size: $font-lg;
color: $text;
line-height: 1.8;
display: block;
white-space: pre-wrap;
word-break: break-all;
}
.detail-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: $space-lg;
padding-top: $space-md;
border-top: 2rpx solid $border;
}
.detail-time {
font-size: $font-sm;
color: $text-muted;
}
.detail-link {
font-size: $font-sm;
color: $primary;
&.danger-text { color: $danger; }
text-decoration: underline;
&:active { opacity: 0.7; }
}
</style>

View File

@@ -34,7 +34,7 @@
<view class="tips-card">
<text class="tips-title">提示</text>
<text class="tips-text"> 昵称最长 50 个字符</text>
<text class="tips-text"> 头像支持 JPGPNG 格式最大 2MB</text>
<text class="tips-text"> 头像支持 JPGPNGWebP 格式最大 2MB</text>
</view>
</view>
</template>

View File

@@ -291,7 +291,7 @@ function getLocalDateStr(): string {
}
async function exportData() {
uni.showLoading({ title: '导出中...' })
uni.showLoading({ title: '导出中 0%' })
try {
// 分页获取全部数据(服务端 pageSize 上限 100
const allList: any[] = []
@@ -302,6 +302,9 @@ async function exportData() {
allList.push(...data.list)
total = data.total
page++
// 更新进度
const progress = Math.min(100, Math.round((allList.length / total) * 100))
uni.showLoading({ title: `导出中 ${progress}%` })
} while (allList.length < total)
if (allList.length === 0) {

View File

@@ -235,9 +235,10 @@ onShow(() => {
if (initialLoaded.value) loadData(true)
})
// Only re-fetch category/trend on tab switch; re-fetch all on month change
// 月份变化时重新请求所有数据
watch(currentMonth, () => loadData())
watch(tabType, () => loadData(true))
// tab 切换时只请求分类和趋势数据
watch(tabType, () => loadTabData())
async function loadData(silent = false) {
const seq = ++loadSeq
@@ -261,6 +262,22 @@ async function loadData(silent = false) {
}
}
// tab 切换时只加载分类和趋势数据(不重复请求上月对比和最大单笔)
async function loadTabData() {
const seq = ++loadSeq
try {
await Promise.all([
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value),
fetchMaxSingle()
])
if (seq !== loadSeq) return
} catch (e) {
if (seq !== loadSeq) return
console.error('Stats tab load error:', e)
}
}
async function fetchMaxSingle() {
try {
const [y, m] = currentMonth.value.split('-').map(Number)

View File

@@ -38,6 +38,7 @@ export const useCategoryStore = defineStore('category', () => {
async function migrateCategory(fromId: number, toId: number) {
await api.migrateCategory(fromId, toId)
await fetchCategories()
}
async function sortCategories(ids: number[]) {

View File

@@ -22,27 +22,27 @@ export const useGroupStore = defineStore('group', () => {
const isGroupMode = computed(() => currentGroupId.value !== null)
/** 切换到个人模式 */
function switchToPersonal() {
async function switchToPersonal() {
currentGroupId.value = null
uni.removeStorageSync('xc:currentGroupId')
refreshAll()
await refreshAll()
}
/** 切换到群组模式 */
function switchToGroup(groupId: number) {
async function switchToGroup(groupId: number) {
currentGroupId.value = groupId
uni.setStorageSync('xc:currentGroupId', groupId)
refreshAll()
await refreshAll()
}
/** 切换身份后全局刷新所有数据 */
function refreshAll() {
/** 切换身份后全局刷新所有数据(串行执行避免数据错乱) */
async function refreshAll() {
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const txStore = useTransactionStore()
statsStore.fetchOverview()
budgetStore.fetchBudget()
txStore.fetchTransactions({ page: 1 })
await statsStore.fetchOverview()
await budgetStore.fetchBudget()
await txStore.fetchTransactions({ page: 1 })
}
/** 获取群组列表 */
@@ -127,7 +127,8 @@ export const useGroupStore = defineStore('group', () => {
function init() {
const saved = uni.getStorageSync('xc:currentGroupId')
if (saved) {
currentGroupId.value = saved
// 确保类型为数字(某些平台可能返回字符串)
currentGroupId.value = typeof saved === 'string' ? Number(saved) : saved
}
}

View File

@@ -11,10 +11,10 @@ const _ready = new Promise<void>(resolve => { _resolve = resolve })
/** 标记 App 启动完成(登录成功) */
export function markReady() { _resolve() }
/** 等待 App 就绪,最多等 3 秒超时 */
/** 等待 App 就绪,最多等 8 秒超时(小程序登录可能较慢) */
export function waitForReady(): Promise<void> {
return Promise.race([
_ready,
new Promise<void>(resolve => setTimeout(resolve, 3000))
new Promise<void>(resolve => setTimeout(resolve, 8000))
])
}

View File

@@ -44,9 +44,12 @@ export function formatMonth(date: string): string {
return `${year}${parseInt(month)}`
}
/** 获取当前月份UTC+8 时区,与服务端保持一致) */
export function getCurrentMonth(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
const offset = 8 * 60 * 60 * 1000
const local = new Date(d.getTime() + offset)
return local.toISOString().slice(0, 7)
}
export function formatAmountRaw(fen: number): string {

View File

@@ -1,4 +1,5 @@
import { API_BASE } from '@/config'
import type { LoginResult } from '@/api/auth'
interface RequestOptions {
url: string
@@ -6,14 +7,6 @@ interface RequestOptions {
data?: any
}
interface LoginResult {
token: string
userId: number
nickname: string
avatar_url: string
role?: string
}
let isRedirectingToLogin = false
/** 重登录期间挂起的请求,登录成功后统一重试 */
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
@@ -63,6 +56,10 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
if (statusCode === 401) {
uni.removeStorageSync('xc:token')
uni.removeStorageSync('xc:userId')
uni.removeStorageSync('xc:nickname')
uni.removeStorageSync('xc:avatar_url')
uni.removeStorageSync('xc:role')
uni.removeStorageSync('xc:currentGroupId')
// 加入重试队列
pendingRetries.push({ resolve, reject, options })

View File

@@ -148,6 +148,23 @@ async function runMigrations(conn: mysql.Connection) {
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)')
}
// 公告已读记录表(解决系统/群组公告已读状态共享问题)
const hasNotificationReads = await tableExists(conn, 'notification_reads')
if (!hasNotificationReads) {
console.log('[DB] Migrating: creating notification_reads table')
await conn.query(`
CREATE TABLE IF NOT EXISTS notification_reads (
notification_id INT NOT NULL,
user_id INT NOT NULL,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (notification_id, user_id),
INDEX idx_user (user_id),
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
}
export async function initDatabase() {

View File

@@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS notifications (
type ENUM('system', 'group', 'personal') NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT,
is_read TINYINT(1) DEFAULT 0,
is_read TINYINT(1) DEFAULT 0 COMMENT '仅用于个人通知,系统/群组公告使用 notification_reads 表',
is_pinned TINYINT(1) DEFAULT 0 COMMENT '是否置顶',
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
publish_at TIMESTAMP NULL COMMENT '定时发布时间NULL=立即发布)',
@@ -95,6 +95,17 @@ CREATE TABLE IF NOT EXISTS notifications (
INDEX idx_expire (expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 公告已读记录表(解决系统/群组公告已读状态共享问题)
CREATE TABLE IF NOT EXISTS notification_reads (
notification_id INT NOT NULL,
user_id INT NOT NULL,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (notification_id, user_id),
INDEX idx_user (user_id),
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS saved_filters (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,

View File

@@ -100,6 +100,42 @@ app.get('/api/user/avatar/:filename', async (req, res) => {
}
})
// 通知图片 API公开不经过 auth 中间件)
app.get('/api/notifications/image/:filename', async (req, res) => {
try {
const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads')
const notifDir = path.join(uploadDir, 'notifications')
const filename = path.basename(req.params.filename)
// 文件名格式校验:允许 notif_用户ID_数字.ext
if (!/^notif_\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) {
return res.status(400).json({ code: 40001, message: '无效文件名' })
}
const filepath = path.join(notifDir, filename)
// 路径穿越检查
if (!path.resolve(filepath).startsWith(path.resolve(notifDir))) {
return res.status(400).json({ code: 40001, message: '无效路径' })
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ code: 40400, message: '图片不存在' })
}
const ext = path.extname(filename).toLowerCase()
const mimeMap: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp'
}
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=86400')
fs.createReadStream(filepath).pipe(res)
} catch {
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
app.use(authMiddleware)
app.use('/api/auth', authLimiter, authRoutes)

View File

@@ -0,0 +1,18 @@
import { Response, NextFunction } from 'express'
import pool from '../db/connection'
import { AuthRequest } from './auth'
/** 管理员权限检查中间件 */
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
try {
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()
} catch (err) {
console.error('[Auth] requireAdmin error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
}

View File

@@ -1,19 +1,10 @@
import { Router, Response, NextFunction } from 'express'
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
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))
@@ -22,11 +13,15 @@ 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
`SELECT COUNT(*) as count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as totalIncome
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
`SELECT COUNT(*) as count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as totalIncome
FROM transactions
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
@@ -41,9 +36,9 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
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) {
if (lastMonth.totalExpense > 0) {
growth = Math.round(((thisMonth.totalExpense - lastMonth.totalExpense) / lastMonth.totalExpense) * 100)
} else if (thisMonth.totalExpense > 0) {
growth = 100
}
@@ -52,12 +47,14 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
data: {
totalUsers: (userCount as any[])[0].count,
monthlyTxCount: thisMonth.count,
monthlyTxAmount: thisMonth.total,
monthlyExpense: thisMonth.totalExpense,
monthlyIncome: thisMonth.totalIncome,
dailyActive: (activeToday as any[])[0].count,
monthlyActive: (activeThisMonth as any[])[0].count,
growthPercent: growth,
lastMonthTxCount: lastMonth.count,
lastMonthTxAmount: lastMonth.total
lastMonthExpense: lastMonth.totalExpense,
lastMonthIncome: lastMonth.totalIncome
}
})
} catch (err) {
@@ -78,8 +75,10 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
const params: any[] = []
if (keyword && typeof keyword === 'string' && keyword.trim()) {
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
where = 'WHERE u.nickname LIKE ?'
params.push(`%${keyword.trim()}%`)
params.push(`%${escapedKeyword}%`)
}
const [rows] = await pool.query(
@@ -125,6 +124,17 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
}
// 如果要降级管理员,检查是否至少保留一个管理员
if (role === 'user') {
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
[req.params.id]
)
if ((adminCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40002, 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: '用户不存在' })
@@ -144,6 +154,19 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '不能删除自己' })
}
// 检查是否是管理员,如果是则确保至少保留一个
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.params.id])
const user = (userRows as any[])[0]
if (user && user.role === 'admin') {
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
[req.params.id]
)
if ((adminCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40002, 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: '用户不存在' })

View File

@@ -1,29 +1,18 @@
import { Router, Response } from 'express'
import { AuthRequest } from '../middleware/auth'
import { backupDatabase, getBackupList } from '../utils/backup'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
// 管理员用户 ID 列表(逗号分隔),未配置时默认仅 ID=1
const ADMIN_IDS = process.env.ADMIN_USER_IDS
? process.env.ADMIN_USER_IDS.split(',').map(Number)
: [1]
/** 检查是否为管理员,非管理员返回 403 */
function requireAdmin(req: AuthRequest, res: Response): boolean {
if (!req.userId || !ADMIN_IDS.includes(req.userId)) {
res.status(403).json({ code: 40300, message: '无权限' })
return false
}
return true
}
// 所有路由都需要管理员权限
router.use((req, res, next) => requireAdmin(req, res, next))
// 手动触发备份
router.post('/', async (req: AuthRequest, res: Response) => {
if (!requireAdmin(req, res)) return
try {
const filepath = await backupDatabase()
res.json({ code: 0, data: { filepath } })
await backupDatabase()
res.json({ code: 0, message: '备份成功' })
} catch (err) {
console.error('[Backup] 手动备份失败:', err)
res.status(500).json({ code: 50000, message: '备份失败' })
@@ -32,7 +21,6 @@ router.post('/', async (req: AuthRequest, res: Response) => {
// 获取备份列表
router.get('/', async (req: AuthRequest, res: Response) => {
if (!requireAdmin(req, res)) return
try {
const list = getBackupList()
res.json({ code: 0, data: list })

View File

@@ -25,19 +25,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
const [groupRows] = await pool.query(
`SELECT COALESCE(SUM(amount), 0) as amount, month
`SELECT SUM(amount) as amount, month
FROM budgets
WHERE user_id IN (SELECT user_id FROM group_members WHERE group_id = ?) AND month = ?
GROUP BY month`,
[group_id, month]
)
const [myRows] = await pool.query(
'SELECT COALESCE(amount, 0) as amount FROM budgets WHERE user_id = ? AND month = ?',
'SELECT amount FROM budgets WHERE user_id = ? AND month = ?',
[req.userId, month]
)
const groupBudget = (groupRows as any[])[0] || { amount: 0, month }
const groupBudget = (groupRows as any[])[0]
const myAmount = (myRows as any[])[0]?.amount || 0
// 如果没有任何成员设置预算,返回 null
if (!groupBudget) {
res.json({ code: 0, data: null })
} else {
res.json({ code: 0, data: { amount: groupBudget.amount, myAmount, month } })
}
} else {
// 个人视图
const [rows] = await pool.query(

View File

@@ -18,6 +18,34 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
})
// 更新分类排序(批量更新,使用 CASE WHEN 一次完成)
// 注意:此路由必须在 PUT /:id 之前注册,否则 /sort 会被当作 :id 参数
router.put('/sort', async (req: AuthRequest, res: Response) => {
try {
const { ids } = req.body // [id1, id2, id3, ...]
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 校验每个元素必须是正整数
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 使用参数化查询构建 CASE WHEN 语句
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
const placeholders = ids.map(() => '?').join(',')
await pool.query(
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
[...whenParams, ...ids, req.userId]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Category] sort error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { name, icon, color, type } = req.body
@@ -96,15 +124,31 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
await conn.beginTransaction()
// 验证目标分类存在且属于当前用户或是默认分类
// 验证分类存在且属于当前用户
const [sourceRows] = await conn.query(
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
[req.params.id, req.userId]
)
const sourceCategory = (sourceRows as any[])[0]
if (!sourceCategory) {
await conn.rollback()
return res.status(404).json({ code: 40400, message: '源分类不存在' })
}
// 验证目标分类存在且属于当前用户或是默认分类,并且类型一致
const [targetRows] = await conn.query(
'SELECT id FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
[targetId, req.userId]
)
if ((targetRows as any[]).length === 0) {
const targetCategory = (targetRows as any[])[0]
if (!targetCategory) {
await conn.rollback()
return res.status(404).json({ code: 40400, message: '目标分类不存在' })
}
if (sourceCategory.type !== targetCategory.type) {
await conn.rollback()
return res.status(400).json({ code: 40002, message: '只能迁移到同类型的分类' })
}
// 迁移记录
await conn.query(
@@ -129,33 +173,6 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
}
})
// 更新分类排序(批量更新,使用 CASE WHEN 一次完成)
router.put('/sort', async (req: AuthRequest, res: Response) => {
try {
const { ids } = req.body // [id1, id2, id3, ...]
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 校验每个元素必须是正整数
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 使用参数化查询构建 CASE WHEN 语句
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
const placeholders = ids.map(() => '?').join(',')
await pool.query(
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
[...whenParams, ...ids, req.userId]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Category] sort error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(

View File

@@ -36,6 +36,9 @@ router.post('/', async (req: AuthRequest, res: Response) => {
inviteCode = generateInviteCode()
attempts++
}
if (attempts >= 10) {
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
}
const conn = await pool.getConnection()
try {
@@ -103,6 +106,15 @@ router.post('/join', async (req: AuthRequest, res: Response) => {
return res.status(404).json({ code: 40400, message: '邀请码无效' })
}
// 检查群组是否已解散(没有任何成员)
const [memberCount] = await pool.query(
'SELECT COUNT(*) as count FROM group_members WHERE group_id = ?',
[group.id]
)
if ((memberCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40003, message: '该群组已解散' })
}
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
const [result] = await pool.query(
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
@@ -139,37 +151,20 @@ router.post('/:id/leave', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40003, message: '群主不能退出,请先转让或解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 清除该用户在该群组的记录标签
await conn.query(
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
[req.userId, groupId]
)
// 删除成员关系
await conn.query(
// 删除成员关系(保留 transactions.group_id历史记录仍属于该群组
await pool.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, req.userId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] leave error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 解散群组(仅 owner */
/** 解散群组(仅 owner — 保留群组记录和 transactions.group_id仅移除所有成员 */
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [groupRows] = await pool.query(
@@ -184,24 +179,10 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => {
return res.status(403).json({ code: 40300, message: '仅群主可解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 移除所有成员(保留群组记录和 transactions.group_id历史数据不丢失
await pool.query('DELETE FROM group_members WHERE group_id = ?', [req.params.id])
// 清除该群组所有记录的标签
await conn.query('UPDATE transactions SET group_id = NULL WHERE group_id = ?', [req.params.id])
// 删除群组CASCADE 自动清理 group_members
await conn.query('DELETE FROM `groups` WHERE id = ?', [req.params.id])
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
@@ -235,6 +216,9 @@ router.post('/:id/refresh-code', async (req: AuthRequest, res: Response) => {
inviteCode = generateInviteCode()
attempts++
}
if (attempts >= 10) {
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
}
await pool.query('UPDATE `groups` SET invite_code = ? WHERE id = ?', [inviteCode, groupId])
res.json({ code: 0, data: { invite_code: inviteCode } })
@@ -299,30 +283,13 @@ router.delete('/:id/members/:userId', async (req: AuthRequest, res: Response) =>
return res.status(403).json({ code: 40300, message: '仅群主可移除成员' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 清除该成员在群组的记录标签
await conn.query(
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
[targetUserId, groupId]
)
// 删除成员关系
await conn.query(
// 删除成员关系(保留 transactions.group_id历史记录仍属于该群组
await pool.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, targetUserId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] remove member error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })

View File

@@ -1,17 +1,80 @@
import { Router, Response, NextFunction } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
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: '需要管理员权限' })
// 通知图片上传配置
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const NOTIFICATION_DIR = path.resolve(UPLOAD_DIR, 'notifications')
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
next()
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
cb(null, NOTIFICATION_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `notif_${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 最大 5MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
const [rows] = await pool.query(
'SELECT type, group_id FROM notifications WHERE id = ?',
[req.params.id]
)
const notification = (rows as any[])[0]
if (!notification) {
return res.status(404).json({ code: 40400, message: '公告不存在' })
}
// 检查是否是管理员
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 next()
}
// 群组公告:检查是否是群主
if (notification.type === 'group' && notification.group_id) {
const [groupRows] = await pool.query(
'SELECT created_by FROM `groups` WHERE id = ?',
[notification.group_id]
)
const group = (groupRows as any[])[0]
if (group && group.created_by === req.userId) {
return next()
}
}
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
}
/** 获取通知列表 */
@@ -34,13 +97,18 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
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
`SELECT n.id, n.type, n.title, n.content, n.created_at, n.group_id,
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url,
CASE
WHEN n.type = 'personal' THEN n.is_read
ELSE CASE WHEN nr.notification_id IS NOT NULL THEN 1 ELSE 0 END
END as is_read
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
${where}
ORDER BY n.is_pinned DESC, n.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]
[req.userId, ...params, pSize, offset]
)
const [countResult] = await pool.query(
@@ -67,16 +135,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
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
`SELECT * FROM (
SELECT n.id, n.type, n.title, n.content, n.created_at,
n.is_pinned, n.is_urgent, n.image_url, n.link_url,
CASE
WHEN n.type = 'personal' THEN n.is_read
ELSE CASE WHEN nr.notification_id IS NOT NULL THEN 1 ELSE 0 END
END as is_read
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
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.is_urgent = 1
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
) AS sub
WHERE is_read = 0
ORDER BY created_at DESC
LIMIT 5`,
[req.userId, req.userId]
[req.userId, req.userId, req.userId]
)
res.json({ code: 0, data: rows })
} catch (err) {
@@ -89,12 +165,19 @@ router.get('/pinned', async (req: AuthRequest, res: Response) => {
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]
`SELECT COUNT(*) as count FROM (
SELECT n.id
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
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())
AND (
(n.type = 'personal' AND n.is_read = 0)
OR (n.type != 'personal' AND nr.notification_id IS NULL)
)
) as unread`,
[req.userId, req.userId, req.userId]
)
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
} catch (err) {
@@ -106,14 +189,29 @@ router.get('/unread-count', async (req: AuthRequest, res: Response) => {
/** 标记单条已读 */
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 = ?)))`,
// 先检查通知是否存在且用户可见
const [notiRows] = await pool.query(
`SELECT n.id, n.type, n.user_id FROM notifications n
WHERE n.id = ?
AND (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 = ?)))`,
[req.params.id, req.userId, req.userId]
)
if ((result as any).affectedRows === 0) {
const notification = (notiRows as any[])[0]
if (!notification) {
return res.status(404).json({ code: 40400, message: '通知不存在' })
}
if (notification.type === 'personal' && notification.user_id === req.userId) {
// 个人通知:直接更新 is_read 字段
await pool.query('UPDATE notifications SET is_read = 1 WHERE id = ?', [req.params.id])
} else {
// 系统/群组公告:插入 notification_reads 记录IGNORE 避免重复)
await pool.query(
'INSERT IGNORE INTO notification_reads (notification_id, user_id) VALUES (?, ?)',
[req.params.id, req.userId]
)
}
res.json({ code: 0 })
} catch (err) {
console.error('[Notification] mark-read error:', err)
@@ -123,21 +221,72 @@ router.put('/:id/read', async (req: AuthRequest, res: Response) => {
/** 全部标记已读 */
router.put('/read-all', async (req: AuthRequest, res: Response) => {
const conn = await pool.getConnection()
try {
await pool.query(
await conn.beginTransaction()
// 1. 标记所有个人通知为已读
await conn.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
WHERE user_id = ? AND type = 'personal' 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]
[req.userId]
)
// 2. 为所有未读的系统/群组公告插入已读记录
await conn.query(
`INSERT IGNORE INTO notification_reads (notification_id, user_id)
SELECT n.id, ?
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
WHERE ((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())
AND nr.notification_id IS NULL`,
[req.userId, req.userId, req.userId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
console.error('[Notification] read-all error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
} finally {
conn.release()
}
})
/** 上传通知图片(管理员) */
router.post('/upload-image', requireAdmin, (req: AuthRequest, res: Response) => {
upload.single('file')(req, res, async (err) => {
if (err) {
return res.status(400).json({ code: 40001, message: err.message })
}
if (!req.file) {
return res.status(400).json({ code: 40001, message: '请选择图片文件' })
}
try {
res.json({ code: 0, data: { image_url: req.file.filename } })
} catch (err) {
console.error('[Notification] image upload error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
})
/** 验证 URL 安全性(防止 javascript: 等危险协议) */
function isValidUrl(url: string): boolean {
if (!url) return true // 空值允许
try {
const parsed = new URL(url)
return ['http:', 'https:'].includes(parsed.protocol)
} catch {
// 相对路径也允许
return url.startsWith('/') || url.startsWith('./')
}
}
/** 发布公告(管理员/群主) */
router.post('/', async (req: AuthRequest, res: Response) => {
@@ -150,6 +299,12 @@ router.post('/', async (req: AuthRequest, res: Response) => {
if (title.length > 100) {
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
}
if (content && typeof content === 'string' && content.length > 5000) {
return res.status(400).json({ code: 40001, message: '内容不能超过5000字' })
}
if (link_url && !isValidUrl(link_url)) {
return res.status(400).json({ code: 40001, message: '链接格式无效' })
}
// 系统公告需要管理员权限
if (type === 'system') {
@@ -160,8 +315,11 @@ router.post('/', async (req: AuthRequest, res: Response) => {
}
}
// 群组公告需要群主权限
if (type === 'group' && group_id) {
// 群组公告需要 group_id 且需要群主权限
if (type === 'group') {
if (!group_id || typeof group_id !== 'number') {
return res.status(400).json({ code: 40001, message: '群组公告必须指定群组' })
}
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) {
@@ -194,11 +352,25 @@ router.post('/', async (req: AuthRequest, res: Response) => {
}
})
/** 编辑公告(管理员) */
router.put('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
/** 编辑公告(管理员或群主 */
router.put('/:id', requireNotificationEditAuth, async (req: AuthRequest, res: Response) => {
try {
const { title, content, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
// 验证字段
if (title !== undefined && (typeof title !== 'string' || title.trim().length === 0)) {
return res.status(400).json({ code: 40001, message: '标题不能为空' })
}
if (title && title.length > 100) {
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
}
if (content && typeof content === 'string' && content.length > 5000) {
return res.status(400).json({ code: 40001, message: '内容不能超过5000字' })
}
if (link_url && !isValidUrl(link_url)) {
return res.status(400).json({ code: 40001, message: '链接格式无效' })
}
const updates: string[] = []
const params: any[] = []

View File

@@ -22,8 +22,9 @@ async function buildWhereClause(
)
if ((memberCheck as any[]).length === 0) return null
params.push(groupId, groupId)
let where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
// 群组视图:显示该群组所有记录(包括已退出成员的历史记录)
params.push(groupId)
let where = `WHERE t.group_id = ?`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
@@ -123,7 +124,7 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${result.where}
GROUP BY c.id
GROUP BY c.id, c.name, c.icon, c.color
ORDER BY amount DESC`,
[...result.params, type, startDate, endDate]
)

View File

@@ -15,6 +15,7 @@ function safeInt(val: any, fallback: number, min: number, max: number): number {
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
// 用户可以查看:自己的记录 + 自己所在群组的所有记录(包括已退出成员的历史记录)
const [rows] = await pool.query(
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
c.name as category_name, c.icon as category_icon, c.color as category_color,
@@ -24,11 +25,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
LEFT JOIN users u ON t.user_id = u.id
WHERE t.id = ? AND (
t.user_id = ?
OR t.user_id IN (
SELECT gm2.user_id FROM group_members gm1
JOIN group_members gm2 ON gm1.group_id = gm2.group_id
WHERE gm1.user_id = ?
)
OR t.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)
)`,
[req.params.id, req.userId, req.userId]
)
@@ -54,7 +51,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
const params: any[] = []
if (group_id && group_id !== 'null') {
// 群组视图:群组下所有成员的记录
// 群组视图:验证用户是群组成员,然后显示该群组所有记录(包括已退出成员的历史记录
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group_id, req.userId]
@@ -62,8 +59,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.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id, group_id)
where = 'WHERE t.group_id = ?'
params.push(group_id)
} else {
// 个人视图:我的所有记录(不管 group_id
where = 'WHERE t.user_id = ?'
@@ -102,7 +99,9 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
}
if (keyword && typeof keyword === 'string' && keyword.trim()) {
where += ' AND t.note LIKE ?'; params.push(`%${keyword.trim()}%`)
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
where += ' AND t.note LIKE ?'; params.push(`%${escapedKeyword}%`)
}
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'

View File

@@ -8,9 +8,13 @@ const gzipAsync = promisify(zlib.gzip)
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
// 分页查询大小,避免一次性加载大表到内存
const QUERY_BATCH_SIZE = 1000
/**
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump
* 导出所有表的 INSERT 语句gzip 压缩存储
* 使用分页查询避免大表内存问题
*/
export async function backupDatabase(): Promise<string> {
if (!fs.existsSync(BACKUP_DIR)) {
@@ -35,31 +39,40 @@ export async function backupDatabase(): Promise<string> {
lines.push(`-- 表: ${tableName}`)
lines.push(`-- ----------------------------`)
// 获取表数
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
const data = rows as any[]
// 获取表总行
const [countResult] = await pool.query(`SELECT COUNT(*) as count FROM \`${tableName}\``)
const totalRows = (countResult as any[])[0].count
if (data.length === 0) {
if (totalRows === 0) {
lines.push(`-- ${tableName}: 无数据`)
lines.push('')
continue
}
// 获取列名
const columns = Object.keys(data[0])
// 获取列名(从第一行)
const [firstRow] = await pool.query(`SELECT * FROM \`${tableName}\` LIMIT 1`)
const columns = Object.keys((firstRow as any[])[0])
const colList = columns.map(c => `\`${c}\``).join(', ')
// 生成 INSERT 语句(每 100 行一批)
const BATCH_SIZE = 100
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE)
// 分页查询并生成 INSERT 语句
let offset = 0
while (offset < totalRows) {
const [rows] = await pool.query(
`SELECT * FROM \`${tableName}\` LIMIT ? OFFSET ?`,
[QUERY_BATCH_SIZE, offset]
)
const data = rows as any[]
// 每 100 行生成一条 INSERT
const INSERT_BATCH = 100
for (let i = 0; i < data.length; i += INSERT_BATCH) {
const batch = data.slice(i, i + INSERT_BATCH)
const values = batch.map(row => {
const vals = columns.map(col => {
const v = row[col]
if (v === null) return 'NULL'
if (typeof v === 'number') return String(v)
if (typeof v === 'boolean') return v ? '1' : '0'
// mysql2 escape 处理所有字符串/日期转义(自带引号)
return pool.escape(v)
})
return `(${vals.join(', ')})`
@@ -68,6 +81,9 @@ export async function backupDatabase(): Promise<string> {
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
lines.push(` ${values};`)
}
offset += QUERY_BATCH_SIZE
}
lines.push('')
}