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

## 重大修复

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

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

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

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

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

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

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

View File

@@ -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)) {

View File

@@ -42,16 +42,20 @@
<view class="detail-card">
<text class="detail-title">本月概况</text>
<view class="detail-row">
<text class="detail-label">交易总额</text>
<text class="detail-value">{{ formatAmount(dashboard.monthlyTxAmount) }}</text>
<text class="detail-label">支出总额</text>
<text class="detail-value expense">{{ formatAmount(dashboard.monthlyExpense || 0) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">收入总额</text>
<text class="detail-value income">{{ formatAmount(dashboard.monthlyIncome || 0) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">月活用户</text>
<text class="detail-value">{{ dashboard.monthlyActive }}</text>
</view>
<view class="detail-row">
<text class="detail-label">上月交易</text>
<text class="detail-value">{{ dashboard.lastMonthTxCount }} / {{ formatAmount(dashboard.lastMonthTxAmount) }}</text>
<text class="detail-label">上月支出</text>
<text class="detail-value">{{ formatAmount(dashboard.lastMonthExpense || 0) }}</text>
</view>
</view>
@@ -63,81 +67,13 @@
<text class="entry-text">用户管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goNotifications">
<view class="entry-item" @tap="goNotificationManage">
<Icon name="bell" :size="32" color="#FF8C69" />
<text class="entry-text">发布公告</text>
<text class="entry-text">公告管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
</view>
<!-- 发布公告弹窗 -->
<view class="modal-mask" v-if="showPublishModal" @tap="showPublishModal = false">
<view class="publish-modal" @tap.stop>
<view class="modal-header">
<text class="modal-title">发布公告</text>
<view class="modal-close" @tap="showPublishModal = false">
<text class="modal-close-text">×</text>
</view>
</view>
<scroll-view class="modal-body" scroll-y>
<view class="form-group">
<text class="form-label">标题 *</text>
<input class="form-input" v-model="publishForm.title" placeholder="输入公告标题" maxlength="100" />
</view>
<view class="form-group">
<text class="form-label">内容</text>
<textarea class="form-textarea" v-model="publishForm.content" placeholder="输入公告内容" maxlength="2000" />
</view>
<view class="form-group">
<text class="form-label">链接可选</text>
<input class="form-input" v-model="publishForm.link_url" placeholder="https://..." />
</view>
<view class="form-group">
<text class="form-label">配图可选</text>
<view v-if="publishForm.image_url" class="image-preview">
<image class="preview-img" :src="publishForm.image_url" mode="aspectFill" />
<view class="image-remove" @tap="publishForm.image_url = ''">
<text class="image-remove-text">×</text>
</view>
</view>
<view v-else class="image-upload" @tap="chooseImage">
<Icon name="plus" :size="40" color="#BFB3B3" />
<text class="upload-text">选择图片</text>
</view>
</view>
<view class="form-group">
<text class="form-label">定时发布可选</text>
<picker mode="date" :value="publishForm.publish_at" @change="e => publishForm.publish_at = e.detail.value">
<view class="form-input picker-input">
<text :class="{ placeholder: !publishForm.publish_at }">{{ publishForm.publish_at || '立即发布' }}</text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">过期时间可选</text>
<picker mode="date" :value="publishForm.expire_at" @change="e => publishForm.expire_at = e.detail.value">
<view class="form-input picker-input">
<text :class="{ placeholder: !publishForm.expire_at }">{{ publishForm.expire_at || '永不过期' }}</text>
</view>
</picker>
</view>
<view class="form-row">
<view class="switch-item" @tap="publishForm.is_pinned = !publishForm.is_pinned">
<text class="switch-label">📌 置顶</text>
<view class="switch-toggle" :class="{ on: publishForm.is_pinned }"></view>
</view>
<view class="switch-item" @tap="publishForm.is_urgent = !publishForm.is_urgent">
<text class="switch-label">🔔 强提醒</text>
<view class="switch-toggle" :class="{ on: publishForm.is_urgent }"></view>
</view>
</view>
</scroll-view>
<view class="modal-footer" @tap="handlePublish">
<text class="publish-btn-text">发布</text>
</view>
</view>
</view>
</view>
</template>
@@ -146,7 +82,6 @@ import { ref, onMounted } from 'vue'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getDashboard } from '@/api/admin'
import { publishNotification } from '@/api/notification'
import { formatAmount } from '@/utils/format'
import Icon from '@/components/Icon/Icon.vue'
import type { Dashboard } from '@/api/admin'
@@ -154,10 +89,29 @@ import type { Dashboard } from '@/api/admin'
const loading = ref(true)
const dashboard = ref<Dashboard | null>(null)
// 仪表盘缓存5分钟内不重复请求
let dashboardCache: { data: Dashboard; time: number } | null = null
const CACHE_TTL = 5 * 60 * 1000
onMounted(async () => {
await waitForReady()
// 客户端角色检查
const role = uni.getStorageSync('xc:role')
if (role !== 'admin') {
uni.showToast({ title: '需要管理员权限', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
return
}
try {
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>

View File

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

View File

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

View File

@@ -88,10 +88,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
@@ -100,7 +100,6 @@ import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
import type { FilterParams } from '@/api/filter'
const txStore = useTransactionStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const filterType = ref('all')
@@ -162,7 +161,7 @@ async function loadTx(reset = false, silent = false) {
if (!silent) txList.value = []
}
const seq = ++loadSeq
const params: any = { page: page.value, pageSize }
const params: any = { page: page.value, pageSize, group_id: groupStore.currentGroupId }
if (filterType.value !== 'all') params.type = filterType.value
// 合并高级筛选参数
const af = advancedFilters.value
@@ -174,19 +173,17 @@ async function loadTx(reset = false, silent = false) {
if (af.keyword) params.keyword = af.keyword
try {
loadError.value = false
const data = await txStore.fetchTransactions(params)
const data = await getTransactions(params)
// 丢弃过期请求的结果
if (seq !== loadSeq) return
if (reset) {
txList.value = txStore.transactions
txList.value = data.list
} else {
txList.value = [...txList.value, ...txStore.transactions]
}
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' })

View File

@@ -121,7 +121,7 @@
<text class="urgent-title">{{ urgentNotice.title }}</text>
</view>
<scroll-view class="urgent-body" scroll-y>
<image v-if="urgentNotice.image_url" class="urgent-image" :src="urgentNotice.image_url" mode="widthFix" />
<image v-if="urgentNotice.image_url" class="urgent-image" :src="getNotificationImageUrl(urgentNotice.image_url)" mode="widthFix" />
<text class="urgent-content">{{ urgentNotice.content }}</text>
<view v-if="urgentNotice.link_url" class="urgent-link" @tap="openLink(urgentNotice.link_url)">
<text class="urgent-link-text">查看详情</text>
@@ -146,6 +146,7 @@ import { useGroupStore } from '@/stores/group'
import { useNotificationStore } from '@/stores/notification'
import { waitForReady } from '@/utils/app-ready'
import { getOverview } from '@/api/stats'
import { getNotificationImageUrl } from '@/api/notification'
import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
@@ -222,7 +223,7 @@ async function loadData(silent = false) {
userStore.fetchUserInfo(),
notifStore.fetchUnreadCount(),
notifStore.fetchUrgentNotifications()
])
]).catch(() => {})
} catch (e) {
console.error('Load error:', e)
if (!silent) loadError.value = true

View File

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

View File

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

View File

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

View File

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