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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user