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:
@@ -10,6 +10,10 @@ export interface Dashboard {
|
||||
growthPercent: number
|
||||
lastMonthTxCount: number
|
||||
lastMonthTxAmount: number
|
||||
monthlyExpense?: number
|
||||
monthlyIncome?: number
|
||||
lastMonthExpense?: number
|
||||
lastMonthIncome?: number
|
||||
}
|
||||
|
||||
/** 用户信息(管理员视角) */
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface LoginResult {
|
||||
userId: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** H5 演示登录 */
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
await catStore.fetchCategories()
|
||||
|
||||
// 加载分类列表,失败时重试一次
|
||||
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)) {
|
||||
|
||||
@@ -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 {
|
||||
dashboard.value = await getDashboard()
|
||||
// 检查缓存是否有效
|
||||
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>
|
||||
|
||||
822
client/src/pages/admin/notifications/index.vue
Normal file
822
client/src/pages/admin/notifications/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
total.value = txStore.total
|
||||
if (data) {
|
||||
filteredExpense.value = data.filteredExpense || 0
|
||||
filteredIncome.value = data.filteredIncome || 0
|
||||
txList.value = [...txList.value, ...data.list]
|
||||
}
|
||||
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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<view class="tips-card">
|
||||
<text class="tips-title">提示</text>
|
||||
<text class="tips-text">• 昵称最长 50 个字符</text>
|
||||
<text class="tips-text">• 头像支持 JPG、PNG 格式,最大 2MB</text>
|
||||
<text class="tips-text">• 头像支持 JPG、PNG、WebP 格式,最大 2MB</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
server/src/middleware/requireAdmin.ts
Normal file
18
server/src/middleware/requireAdmin.ts
Normal 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: '服务器错误' })
|
||||
}
|
||||
}
|
||||
@@ -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: '用户不存在' })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
res.json({ code: 0, data: { amount: groupBudget.amount, myAmount, month } })
|
||||
// 如果没有任何成员设置预算,返回 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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
// 删除成员关系(保留 transactions.group_id,历史记录仍属于该群组)
|
||||
await pool.query(
|
||||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[groupId, req.userId]
|
||||
)
|
||||
|
||||
// 清除该用户在该群组的记录标签
|
||||
await conn.query(
|
||||
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
|
||||
[req.userId, groupId]
|
||||
)
|
||||
|
||||
// 删除成员关系
|
||||
await conn.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()
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} 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()
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} 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()
|
||||
// 删除成员关系(保留 transactions.group_id,历史记录仍属于该群组)
|
||||
await pool.query(
|
||||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[groupId, targetUserId]
|
||||
)
|
||||
|
||||
// 清除该成员在群组的记录标签
|
||||
await conn.query(
|
||||
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
|
||||
[targetUserId, groupId]
|
||||
)
|
||||
|
||||
// 删除成员关系
|
||||
await conn.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()
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Group] remove member error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
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
|
||||
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
|
||||
`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.expire_at IS NULL OR n.expire_at > NOW())
|
||||
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
|
||||
) 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,22 +221,73 @@ 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) => {
|
||||
try {
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,38 +39,50 @@ 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)
|
||||
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(', ')})`
|
||||
}).join(',\n ')
|
||||
// 分页查询并生成 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[]
|
||||
|
||||
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
|
||||
lines.push(` ${values};`)
|
||||
// 每 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'
|
||||
return pool.escape(v)
|
||||
})
|
||||
return `(${vals.join(', ')})`
|
||||
}).join(',\n ')
|
||||
|
||||
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
|
||||
lines.push(` ${values};`)
|
||||
}
|
||||
|
||||
offset += QUERY_BATCH_SIZE
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user