feat: 全面公告系统 + 全系统 Bug 修复
全面公告系统: - notifications 表新增置顶/强提醒/定时发布/过期/配图/链接字段 - 后端增强:7 个接口(列表/置顶/未读数/已读/发布/编辑/删除) - 首页强提醒公告弹窗 - 通知中心:置顶标记、配图展示、链接跳转、管理员操作 - 管理员发布表单:标题+内容+链接+配图+置顶+强提醒+定时+过期 Bug 修复: - 401 重试队列竞态导致请求永久挂起 - 多分类筛选条件被静默丢弃 - 网络错误导致群组选择丢失 - Numpad 输入 '.' 显示异常 - transaction store 错误时清空数据 - 首页/预算页 Promise.all 部分失败阻塞关键数据 - CSS prefers-reduced-motion 类名不匹配 - 通知 read-all 遗漏群组通知 - auth.ts 错误日志缺少 stack trace - stats 页面 v-for key 使用 index - add 页面重复调用 fetchCategories
This commit is contained in:
@@ -9,6 +9,12 @@ export interface Notification {
|
|||||||
is_read: number
|
is_read: number
|
||||||
created_at: string
|
created_at: string
|
||||||
group_id?: number | null
|
group_id?: number | null
|
||||||
|
is_pinned?: number
|
||||||
|
is_urgent?: number
|
||||||
|
publish_at?: string | null
|
||||||
|
expire_at?: string | null
|
||||||
|
image_url?: string
|
||||||
|
link_url?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通知列表响应 */
|
/** 通知列表响应 */
|
||||||
@@ -19,11 +25,30 @@ export interface NotificationList {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 发布公告参数 */
|
||||||
|
export interface PublishParams {
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
type?: 'system' | 'group' | 'personal'
|
||||||
|
group_id?: number
|
||||||
|
is_pinned?: boolean
|
||||||
|
is_urgent?: boolean
|
||||||
|
publish_at?: string
|
||||||
|
expire_at?: string
|
||||||
|
image_url?: string
|
||||||
|
link_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取通知列表 */
|
/** 获取通知列表 */
|
||||||
export function getNotifications(params?: { type?: string; page?: number; pageSize?: number }) {
|
export function getNotifications(params?: { type?: string; page?: number; pageSize?: number }) {
|
||||||
return request<NotificationList>({ url: '/notifications', data: params })
|
return request<NotificationList>({ url: '/notifications', data: params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取置顶/强提醒公告 */
|
||||||
|
export function getPinnedNotifications() {
|
||||||
|
return request<Notification[]>({ url: '/notifications/pinned' })
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取未读数量 */
|
/** 获取未读数量 */
|
||||||
export function getUnreadCount() {
|
export function getUnreadCount() {
|
||||||
return request<{ count: number }>({ url: '/notifications/unread-count' })
|
return request<{ count: number }>({ url: '/notifications/unread-count' })
|
||||||
@@ -39,7 +64,17 @@ export function markAllRead() {
|
|||||||
return request({ url: '/notifications/read-all', method: 'PUT' })
|
return request({ url: '/notifications/read-all', method: 'PUT' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发布系统公告(管理员) */
|
/** 发布公告 */
|
||||||
export function publishNotification(data: { title: string; content?: string }) {
|
export function publishNotification(data: PublishParams) {
|
||||||
return request({ url: '/notifications', method: 'POST', data })
|
return request<{ id: number }>({ url: '/notifications', method: 'POST', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑公告 */
|
||||||
|
export function updateNotification(id: number, data: Partial<PublishParams>) {
|
||||||
|
return request({ url: `/notifications/${id}`, method: 'PUT', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除公告 */
|
||||||
|
export function deleteNotification(id: number) {
|
||||||
|
return request({ url: `/notifications/${id}`, method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function onKeyTap(key: string) {
|
|||||||
|
|
||||||
if (key === '.') {
|
if (key === '.') {
|
||||||
if (current.includes('.')) return
|
if (current.includes('.')) return
|
||||||
if (!current) { emitValue('0.'); return }
|
if (!current || current === '0') { emitValue('0.'); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
let next: string
|
let next: string
|
||||||
|
|||||||
@@ -149,11 +149,13 @@ onMounted(async () => {
|
|||||||
if (currentCategories.value.length > 0 && !selectedCat.value) {
|
if (currentCategories.value.length > 0 && !selectedCat.value) {
|
||||||
selectedCat.value = currentCategories.value[0].id
|
selectedCat.value = currentCategories.value[0].id
|
||||||
}
|
}
|
||||||
|
initialLoaded.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 返回页面时刷新分类(可能新增了分类)
|
// 返回页面时刷新分类(可能新增了分类)
|
||||||
|
const initialLoaded = ref(false)
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
catStore.fetchCategories()
|
if (initialLoaded.value) catStore.fetchCategories()
|
||||||
})
|
})
|
||||||
|
|
||||||
function onDateChange(e: any) {
|
function onDateChange(e: any) {
|
||||||
|
|||||||
@@ -70,6 +70,74 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -99,33 +167,56 @@ onMounted(async () => {
|
|||||||
|
|
||||||
function goBack() { uni.navigateBack() }
|
function goBack() { uni.navigateBack() }
|
||||||
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
|
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() {
|
function goNotifications() {
|
||||||
uni.showModal({
|
publishForm.value = { title: '', content: '', is_pinned: false, is_urgent: false, link_url: '', image_url: '', publish_at: '', expire_at: '' }
|
||||||
title: '发布公告',
|
showPublishModal.value = true
|
||||||
editable: true,
|
}
|
||||||
placeholderText: '输入公告标题',
|
|
||||||
success: async (res) => {
|
/** 选择配图 */
|
||||||
if (res.confirm && res.content) {
|
function chooseImage() {
|
||||||
const content = res.content
|
uni.chooseImage({
|
||||||
uni.showModal({
|
count: 1,
|
||||||
title: '公告内容',
|
sizeType: ['compressed'],
|
||||||
editable: true,
|
success: (res) => {
|
||||||
placeholderText: '输入公告内容(可选)',
|
publishForm.value.image_url = res.tempFilePaths[0]
|
||||||
success: async (res2) => {
|
}
|
||||||
if (res2.confirm !== undefined) {
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublish() {
|
||||||
|
if (!publishForm.value.title.trim()) {
|
||||||
|
uni.showToast({ title: '请输入标题', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await publishNotification({ title: content.trim(), content: res2.content || '' })
|
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' })
|
uni.showToast({ title: '已发布', icon: 'success' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uni.showToast({ title: e.message || '发布失败', icon: 'none' })
|
uni.showToast({ title: e.message || '发布失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -284,4 +375,222 @@ function goNotifications() {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: #2D1B1B;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 发布公告弹窗 */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 40rpx 40rpx 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx 40rpx 24rpx;
|
||||||
|
border-bottom: 2rpx solid #F0E0D6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2D1B1B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #F0E0D6;
|
||||||
|
&:active { background: #E0D6D0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-text {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #8B7E7E;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx 40rpx;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2D1B1B;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
background: #FFF8F0;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
border: 2rpx solid #F0E0D6;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #2D1B1B;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 200rpx;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
background: #FFF8F0;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
border: 2rpx solid #F0E0D6;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #2D1B1B;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
background: #FFF8F0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: 2rpx solid #F0E0D6;
|
||||||
|
|
||||||
|
&:active { background: #F0E0D6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #2D1B1B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-toggle {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #D0C4C4;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&.on { background: #FF8C69; }
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2rpx;
|
||||||
|
left: 2rpx;
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #FFFFFF;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.on::after { transform: translateX(20rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 24rpx 40rpx 40rpx;
|
||||||
|
border-top: 2rpx solid #F0E0D6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-btn-text {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FFFFFF;
|
||||||
|
padding: 20rpx;
|
||||||
|
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border: 2rpx dashed #D0C4C4;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
|
||||||
|
&:active { background: #FFF8F0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #BFB3B3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -12rpx;
|
||||||
|
right: -12rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
background: #FF6B6B;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-remove-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.placeholder { color: #BFB3B3; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,7 +13,13 @@
|
|||||||
<view class="filter-section">
|
<view class="filter-section">
|
||||||
<text class="section-label">分类</text>
|
<text class="section-label">分类</text>
|
||||||
<view class="chip-row">
|
<view class="chip-row">
|
||||||
<view v-for="cat in categories" :key="cat.id" class="chip" :class="{ active: localFilters.category_ids?.includes(cat.id) }" @tap="toggleCategory(cat.id)">
|
<view
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.id"
|
||||||
|
class="chip"
|
||||||
|
:class="{ active: localFilters.category_ids?.includes(cat.id) }"
|
||||||
|
@tap="toggleCategory(cat.id)"
|
||||||
|
>
|
||||||
<text class="chip-text">{{ cat.name }}</text>
|
<text class="chip-text">{{ cat.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -23,16 +29,30 @@
|
|||||||
<view class="filter-section">
|
<view class="filter-section">
|
||||||
<text class="section-label">日期范围</text>
|
<text class="section-label">日期范围</text>
|
||||||
<view class="date-row">
|
<view class="date-row">
|
||||||
<picker mode="date" :value="localFilters.startDate || ''" @change="onStartDateChange">
|
<picker
|
||||||
<view class="date-picker">
|
class="date-picker"
|
||||||
<text class="date-text" :class="{ placeholder: !localFilters.startDate }">{{ localFilters.startDate || '开始日期' }}</text>
|
mode="date"
|
||||||
</view>
|
:value="localFilters.startDate || ''"
|
||||||
|
@change="onStartDateChange"
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
class="date-text"
|
||||||
|
:class="{ placeholder: !localFilters.startDate }"
|
||||||
|
>{{ localFilters.startDate || "开始日期" }}
|
||||||
|
</text>
|
||||||
</picker>
|
</picker>
|
||||||
<text class="date-sep">至</text>
|
<text class="date-sep">至</text>
|
||||||
<picker mode="date" :value="localFilters.endDate || ''" @change="onEndDateChange">
|
<picker
|
||||||
<view class="date-picker">
|
class="date-picker"
|
||||||
<text class="date-text" :class="{ placeholder: !localFilters.endDate }">{{ localFilters.endDate || '结束日期' }}</text>
|
mode="date"
|
||||||
</view>
|
:value="localFilters.endDate || ''"
|
||||||
|
@change="onEndDateChange"
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
class="date-text"
|
||||||
|
:class="{ placeholder: !localFilters.endDate }"
|
||||||
|
>{{ localFilters.endDate || "结束日期" }}
|
||||||
|
</text>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -41,22 +61,41 @@
|
|||||||
<view class="filter-section">
|
<view class="filter-section">
|
||||||
<text class="section-label">金额区间(元)</text>
|
<text class="section-label">金额区间(元)</text>
|
||||||
<view class="amount-row">
|
<view class="amount-row">
|
||||||
<input class="amount-input" type="digit" v-model="minAmountStr" placeholder="最小金额" />
|
<input
|
||||||
|
class="amount-input"
|
||||||
|
type="digit"
|
||||||
|
v-model="minAmountStr"
|
||||||
|
placeholder="最小金额"
|
||||||
|
/>
|
||||||
<text class="amount-sep">-</text>
|
<text class="amount-sep">-</text>
|
||||||
<input class="amount-input" type="digit" v-model="maxAmountStr" placeholder="最大金额" />
|
<input
|
||||||
|
class="amount-input"
|
||||||
|
type="digit"
|
||||||
|
v-model="maxAmountStr"
|
||||||
|
placeholder="最大金额"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 关键词 -->
|
<!-- 关键词 -->
|
||||||
<view class="filter-section">
|
<view class="filter-section">
|
||||||
<text class="section-label">搜索备注</text>
|
<text class="section-label">搜索备注</text>
|
||||||
<input class="keyword-input" v-model="localFilters.keyword" placeholder="输入关键词" />
|
<input
|
||||||
|
class="keyword-input"
|
||||||
|
v-model="localFilters.keyword"
|
||||||
|
placeholder="输入关键词"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 已保存方案 -->
|
<!-- 已保存方案 -->
|
||||||
<view class="filter-section" v-if="savedFilters.length > 0">
|
<view class="filter-section" v-if="savedFilters.length > 0">
|
||||||
<text class="section-label">已保存方案</text>
|
<text class="section-label">已保存方案</text>
|
||||||
<view v-for="sf in savedFilters" :key="sf.id" class="saved-item" @tap="applySaved(sf)">
|
<view
|
||||||
|
v-for="sf in savedFilters"
|
||||||
|
:key="sf.id"
|
||||||
|
class="saved-item"
|
||||||
|
@tap="applySaved(sf)"
|
||||||
|
>
|
||||||
<text class="saved-name">{{ sf.name }}</text>
|
<text class="saved-name">{{ sf.name }}</text>
|
||||||
<view class="saved-delete" @tap.stop="handleDeleteSaved(sf.id)">
|
<view class="saved-delete" @tap.stop="handleDeleteSaved(sf.id)">
|
||||||
<text class="saved-delete-text">×</text>
|
<text class="saved-delete-text">×</text>
|
||||||
@@ -81,116 +120,152 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from "vue";
|
||||||
import { useCategoryStore } from '@/stores/category'
|
import { useCategoryStore } from "@/stores/category";
|
||||||
import { useFilterStore } from '@/stores/filter'
|
import { useFilterStore } from "@/stores/filter";
|
||||||
import type { FilterParams, SavedFilter } from '@/api/filter'
|
import type { FilterParams, SavedFilter } from "@/api/filter";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: [];
|
||||||
apply: [filters: FilterParams]
|
apply: [filters: FilterParams];
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
initialFilters?: FilterParams
|
initialFilters?: FilterParams;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
const categoryStore = useCategoryStore()
|
const categoryStore = useCategoryStore();
|
||||||
const filterStore = useFilterStore()
|
const filterStore = useFilterStore();
|
||||||
|
|
||||||
const categories = ref<any[]>([])
|
const categories = ref<any[]>([]);
|
||||||
const savedFilters = ref<SavedFilter[]>([])
|
const savedFilters = ref<SavedFilter[]>([]);
|
||||||
const minAmountStr = ref('')
|
const minAmountStr = ref("");
|
||||||
const maxAmountStr = ref('')
|
const maxAmountStr = ref("");
|
||||||
|
|
||||||
const localFilters = reactive<FilterParams>({
|
const localFilters = reactive<FilterParams>({
|
||||||
type: '',
|
type: "",
|
||||||
category_ids: [],
|
category_ids: [],
|
||||||
startDate: '',
|
startDate: "",
|
||||||
endDate: '',
|
endDate: "",
|
||||||
minAmount: 0,
|
minAmount: 0,
|
||||||
maxAmount: 0,
|
maxAmount: 0,
|
||||||
keyword: '',
|
keyword: "",
|
||||||
})
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
categories.value = [...categoryStore.getByType('expense'), ...categoryStore.getByType('income')]
|
initCategorys();
|
||||||
await filterStore.fetchSavedFilters()
|
initSavedFilters();
|
||||||
savedFilters.value = filterStore.savedFilters
|
|
||||||
|
|
||||||
// 应用初始筛选条件
|
// 应用初始筛选条件
|
||||||
if (props.initialFilters) {
|
if (props.initialFilters) {
|
||||||
Object.assign(localFilters, props.initialFilters)
|
Object.assign(localFilters, props.initialFilters);
|
||||||
if (localFilters.minAmount != null && localFilters.minAmount > 0) minAmountStr.value = (localFilters.minAmount / 100).toString()
|
if (localFilters.minAmount != null && localFilters.minAmount > 0)
|
||||||
if (localFilters.maxAmount != null && localFilters.maxAmount > 0) maxAmountStr.value = (localFilters.maxAmount / 100).toString()
|
minAmountStr.value = (localFilters.minAmount / 100).toString();
|
||||||
|
if (localFilters.maxAmount != null && localFilters.maxAmount > 0)
|
||||||
|
maxAmountStr.value = (localFilters.maxAmount / 100).toString();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const initCategorys = async () => {
|
||||||
|
await categoryStore.fetchCategories();
|
||||||
|
categories.value = [
|
||||||
|
...categoryStore.getByType("expense"),
|
||||||
|
...categoryStore.getByType("income"),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const initSavedFilters = async () => {
|
||||||
|
await filterStore.fetchSavedFilters();
|
||||||
|
savedFilters.value = filterStore.savedFilters;
|
||||||
|
};
|
||||||
|
|
||||||
function toggleCategory(id: number) {
|
function toggleCategory(id: number) {
|
||||||
if (!localFilters.category_ids) localFilters.category_ids = []
|
if (!localFilters.category_ids) localFilters.category_ids = [];
|
||||||
const idx = localFilters.category_ids.indexOf(id)
|
const idx = localFilters.category_ids.indexOf(id);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
localFilters.category_ids.splice(idx, 1)
|
localFilters.category_ids.splice(idx, 1);
|
||||||
} else {
|
} else {
|
||||||
localFilters.category_ids.push(id)
|
localFilters.category_ids.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStartDateChange(e: any) {
|
function onStartDateChange(e: any) {
|
||||||
localFilters.startDate = e.detail.value
|
localFilters.startDate = e.detail.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEndDateChange(e: any) {
|
function onEndDateChange(e: any) {
|
||||||
localFilters.endDate = e.detail.value
|
localFilters.endDate = e.detail.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
Object.assign(localFilters, { type: '', category_ids: [], startDate: '', endDate: '', minAmount: 0, maxAmount: 0, keyword: '' })
|
Object.assign(localFilters, {
|
||||||
minAmountStr.value = ''
|
type: "",
|
||||||
maxAmountStr.value = ''
|
category_ids: [],
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
minAmount: 0,
|
||||||
|
maxAmount: 0,
|
||||||
|
keyword: "",
|
||||||
|
});
|
||||||
|
minAmountStr.value = "";
|
||||||
|
maxAmountStr.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleApply() {
|
function handleApply() {
|
||||||
const filters: FilterParams = { ...localFilters }
|
const filters: FilterParams = { ...localFilters };
|
||||||
// 转换金额:元 → 分
|
// 转换金额:元 → 分
|
||||||
filters.minAmount = minAmountStr.value ? Math.round(parseFloat(minAmountStr.value) * 100) : 0
|
filters.minAmount = minAmountStr.value
|
||||||
filters.maxAmount = maxAmountStr.value ? Math.round(parseFloat(maxAmountStr.value) * 100) : 0
|
? Math.round(parseFloat(minAmountStr.value) * 100)
|
||||||
emit('apply', filters)
|
: 0;
|
||||||
|
filters.maxAmount = maxAmountStr.value
|
||||||
|
? Math.round(parseFloat(maxAmountStr.value) * 100)
|
||||||
|
: 0;
|
||||||
|
emit("apply", filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const filters: FilterParams = { ...localFilters }
|
const filters: FilterParams = { ...localFilters };
|
||||||
filters.minAmount = minAmountStr.value ? Math.round(parseFloat(minAmountStr.value) * 100) : 0
|
filters.minAmount = minAmountStr.value
|
||||||
filters.maxAmount = maxAmountStr.value ? Math.round(parseFloat(maxAmountStr.value) * 100) : 0
|
? Math.round(parseFloat(minAmountStr.value) * 100)
|
||||||
|
: 0;
|
||||||
|
filters.maxAmount = maxAmountStr.value
|
||||||
|
? Math.round(parseFloat(maxAmountStr.value) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '保存方案',
|
title: "保存方案",
|
||||||
editable: true,
|
editable: true,
|
||||||
placeholderText: '输入方案名称',
|
placeholderText: "输入方案名称",
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm && res.content) {
|
if (res.confirm && res.content) {
|
||||||
try {
|
try {
|
||||||
await filterStore.saveFilter(res.content.trim(), filters)
|
await filterStore.saveFilter(res.content.trim(), filters);
|
||||||
savedFilters.value = filterStore.savedFilters
|
savedFilters.value = filterStore.savedFilters;
|
||||||
uni.showToast({ title: '已保存', icon: 'success' })
|
uni.showToast({ title: "已保存", icon: "success" });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
|
uni.showToast({ title: e.message || "保存失败", icon: "none" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySaved(sf: SavedFilter) {
|
function applySaved(sf: SavedFilter) {
|
||||||
Object.assign(localFilters, sf.filters)
|
Object.assign(localFilters, sf.filters);
|
||||||
minAmountStr.value = (sf.filters.minAmount != null && sf.filters.minAmount > 0) ? (sf.filters.minAmount / 100).toString() : ''
|
minAmountStr.value =
|
||||||
maxAmountStr.value = (sf.filters.maxAmount != null && sf.filters.maxAmount > 0) ? (sf.filters.maxAmount / 100).toString() : ''
|
sf.filters.minAmount != null && sf.filters.minAmount > 0
|
||||||
|
? (sf.filters.minAmount / 100).toString()
|
||||||
|
: "";
|
||||||
|
maxAmountStr.value =
|
||||||
|
sf.filters.maxAmount != null && sf.filters.maxAmount > 0
|
||||||
|
? (sf.filters.maxAmount / 100).toString()
|
||||||
|
: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteSaved(id: number) {
|
async function handleDeleteSaved(id: number) {
|
||||||
try {
|
try {
|
||||||
await filterStore.deleteFilter(id)
|
await filterStore.deleteFilter(id);
|
||||||
savedFilters.value = filterStore.savedFilters
|
savedFilters.value = filterStore.savedFilters;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -211,7 +286,7 @@ async function handleDeleteSaved(id: number) {
|
|||||||
.filter-panel {
|
.filter-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
background: #FFFFFF;
|
background: #ffffff;
|
||||||
border-radius: 40rpx 40rpx 0 0;
|
border-radius: 40rpx 40rpx 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -224,13 +299,13 @@ async function handleDeleteSaved(id: number) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 32rpx 40rpx 24rpx;
|
padding: 32rpx 40rpx 24rpx;
|
||||||
border-bottom: 2rpx solid #F0E0D6;
|
border-bottom: 2rpx solid #f0e0d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-title {
|
.filter-title {
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: #2d1b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-close {
|
.filter-close {
|
||||||
@@ -240,13 +315,15 @@ async function handleDeleteSaved(id: number) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #F0E0D6;
|
background: #f0e0d6;
|
||||||
&:active { background: #E0D6D0; }
|
&:active {
|
||||||
|
background: #e0d6d0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-close-text {
|
.filter-close-text {
|
||||||
font-size: 36rpx;
|
font-size: 36rpx;
|
||||||
color: #8B7E7E;
|
color: #8b7e7e;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +332,7 @@ async function handleDeleteSaved(id: number) {
|
|||||||
padding: 24rpx 40rpx;
|
padding: 24rpx 40rpx;
|
||||||
max-height: 55vh;
|
max-height: 55vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-section {
|
.filter-section {
|
||||||
@@ -266,7 +344,7 @@ async function handleDeleteSaved(id: number) {
|
|||||||
.section-label {
|
.section-label {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: #2d1b1b;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: 16rpx;
|
||||||
}
|
}
|
||||||
@@ -281,23 +359,28 @@ async function handleDeleteSaved(id: number) {
|
|||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
padding: 12rpx 24rpx;
|
padding: 12rpx 24rpx;
|
||||||
background: #FFF8F0;
|
background: #fff8f0;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid #f0e0d6;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #FFE8E0;
|
background: #ffe8e0;
|
||||||
border-color: #FF8C69;
|
border-color: #ff8c69;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active { opacity: 0.7; }
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-text {
|
.chip-text {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #2D1B1B;
|
color: #2d1b1b;
|
||||||
.active & { color: #FF8C69; font-weight: 600; }
|
.active & {
|
||||||
|
color: #ff8c69;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-row {
|
.date-row {
|
||||||
@@ -312,22 +395,24 @@ async function handleDeleteSaved(id: number) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 20rpx;
|
||||||
background: #FFF8F0;
|
background: #fff8f0;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid #f0e0d6;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-text {
|
.date-text {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #2D1B1B;
|
color: #2d1b1b;
|
||||||
&.placeholder { color: #BFB3B3; }
|
&.placeholder {
|
||||||
|
color: #bfb3b3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-sep {
|
.date-sep {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #8B7E7E;
|
color: #8b7e7e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-row {
|
.amount-row {
|
||||||
@@ -342,27 +427,27 @@ async function handleDeleteSaved(id: number) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 20rpx;
|
||||||
background: #FFF8F0;
|
background: #fff8f0;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid #f0e0d6;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #2D1B1B;
|
color: #2d1b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-sep {
|
.amount-sep {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #8B7E7E;
|
color: #8b7e7e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-input {
|
.keyword-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 20rpx;
|
||||||
background: #FFF8F0;
|
background: #fff8f0;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid #f0e0d6;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #2D1B1B;
|
color: #2d1b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-item {
|
.saved-item {
|
||||||
@@ -370,16 +455,18 @@ async function handleDeleteSaved(id: number) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16rpx 20rpx;
|
padding: 16rpx 20rpx;
|
||||||
background: #FFF8F0;
|
background: #fff8f0;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: 8rpx;
|
||||||
|
|
||||||
&:active { background: #F0E0D6; }
|
&:active {
|
||||||
|
background: #f0e0d6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-name {
|
.saved-name {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #2D1B1B;
|
color: #2d1b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-delete {
|
.saved-delete {
|
||||||
@@ -388,19 +475,21 @@ async function handleDeleteSaved(id: number) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
&:active { opacity: 0.6; }
|
&:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-delete-text {
|
.saved-delete-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #BFB3B3;
|
color: #bfb3b3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-footer {
|
.filter-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
padding: 24rpx 40rpx 40rpx;
|
padding: 24rpx 40rpx 40rpx;
|
||||||
border-top: 2rpx solid #F0E0D6;
|
border-top: 2rpx solid #f0e0d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-btn {
|
.footer-btn {
|
||||||
@@ -411,18 +500,34 @@ async function handleDeleteSaved(id: number) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&.reset { background: #FFF8F0; border: 2rpx solid #F0E0D6; }
|
&.reset {
|
||||||
&.save { background: #FFF0E6; border: 2rpx solid #FF8C69; }
|
background: #fff8f0;
|
||||||
&.apply { background: linear-gradient(135deg, #FF8C69, #E67355); }
|
border: 2rpx solid #f0e0d6;
|
||||||
|
}
|
||||||
|
&.save {
|
||||||
|
background: #fff0e6;
|
||||||
|
border: 2rpx solid #ff8c69;
|
||||||
|
}
|
||||||
|
&.apply {
|
||||||
|
background: linear-gradient(135deg, #ff8c69, #e67355);
|
||||||
|
}
|
||||||
|
|
||||||
&:active { opacity: 0.8; }
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-btn-text {
|
.footer-btn-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
&.reset-text { color: #8B7E7E; }
|
&.reset-text {
|
||||||
&.save-text { color: #FF8C69; }
|
color: #8b7e7e;
|
||||||
&.apply-text { color: #FFFFFF; }
|
}
|
||||||
|
&.save-text {
|
||||||
|
color: #ff8c69;
|
||||||
|
}
|
||||||
|
&.apply-text {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ async function loadTx(reset = false, silent = false) {
|
|||||||
if (filterType.value !== 'all') params.type = filterType.value
|
if (filterType.value !== 'all') params.type = filterType.value
|
||||||
// 合并高级筛选参数
|
// 合并高级筛选参数
|
||||||
const af = advancedFilters.value
|
const af = advancedFilters.value
|
||||||
if (af.category_ids?.length === 1) params.category_id = af.category_ids[0]
|
if (af.category_ids?.length) params.category_ids = af.category_ids.join(',')
|
||||||
if (af.startDate) params.startDate = af.startDate
|
if (af.startDate) params.startDate = af.startDate
|
||||||
if (af.endDate) params.endDate = af.endDate
|
if (af.endDate) params.endDate = af.endDate
|
||||||
if (af.minAmount) params.minAmount = af.minAmount
|
if (af.minAmount) params.minAmount = af.minAmount
|
||||||
|
|||||||
@@ -116,19 +116,22 @@ function syncBudgetData() {
|
|||||||
|
|
||||||
/** 获取个人支出(不传 group_id,始终只算自己的消费) */
|
/** 获取个人支出(不传 group_id,始终只算自己的消费) */
|
||||||
async function fetchMyExpense() {
|
async function fetchMyExpense() {
|
||||||
|
try {
|
||||||
const data = await getOverview({ month: currentMonth, group_id: null })
|
const data = await getOverview({ month: currentMonth, group_id: null })
|
||||||
monthExpense.value = Number(data?.expense) || 0
|
monthExpense.value = Number(data?.expense) || 0
|
||||||
|
} catch {
|
||||||
|
monthExpense.value = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await budgetStore.fetchBudget(currentMonth)
|
||||||
budgetStore.fetchBudget(currentMonth),
|
|
||||||
fetchMyExpense()
|
|
||||||
])
|
|
||||||
syncBudgetData()
|
syncBudgetData()
|
||||||
|
// 非关键:支出数据失败不影响预算显示
|
||||||
|
fetchMyExpense()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Budget page load error:', e)
|
console.error('Budget page load error:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -141,11 +144,9 @@ onMounted(async () => {
|
|||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
if (!initialLoaded.value) return
|
if (!initialLoaded.value) return
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await budgetStore.fetchBudget(currentMonth)
|
||||||
budgetStore.fetchBudget(currentMonth),
|
|
||||||
fetchMyExpense()
|
|
||||||
])
|
|
||||||
syncBudgetData()
|
syncBudgetData()
|
||||||
|
fetchMyExpense()
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -419,7 +420,7 @@ function goBack() {
|
|||||||
.preview-skeleton {
|
.preview-skeleton {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
.cursor {
|
.input-cursor {
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,26 @@
|
|||||||
<Icon name="edit" :size="48" color="#BFB3B3" />
|
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||||
<text class="state-text">还没有记录,快去记一笔吧</text>
|
<text class="state-text">还没有记录,快去记一笔吧</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 强提醒公告弹窗 -->
|
||||||
|
<view class="modal-mask" v-if="urgentNotice" @tap="dismissUrgent">
|
||||||
|
<view class="urgent-modal" @tap.stop>
|
||||||
|
<view class="urgent-header">
|
||||||
|
<Icon name="bell" :size="32" color="#FF8C69" />
|
||||||
|
<text class="urgent-title">{{ urgentNotice.title }}</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="urgent-body" scroll-y>
|
||||||
|
<image v-if="urgentNotice.image_url" class="urgent-image" :src="urgentNotice.image_url" mode="widthFix" />
|
||||||
|
<text class="urgent-content">{{ urgentNotice.content }}</text>
|
||||||
|
<view v-if="urgentNotice.link_url" class="urgent-link" @tap="openLink(urgentNotice.link_url)">
|
||||||
|
<text class="urgent-link-text">查看详情</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="urgent-footer" @tap="dismissUrgent">
|
||||||
|
<text class="urgent-btn-text">我知道了</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -160,6 +180,27 @@ const greeting = computed(() => {
|
|||||||
return '晚上好'
|
return '晚上好'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 强提醒公告:取第一条未读的
|
||||||
|
const urgentNotice = computed(() => notifStore.urgentNotifications[0] || null)
|
||||||
|
|
||||||
|
function dismissUrgent() {
|
||||||
|
if (urgentNotice.value) {
|
||||||
|
notifStore.markRead(urgentNotice.value.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink(url: string) {
|
||||||
|
// #ifdef H5
|
||||||
|
window.open(url, '_blank')
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: url,
|
||||||
|
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData(silent = false) {
|
async function loadData(silent = false) {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
try {
|
try {
|
||||||
@@ -167,15 +208,21 @@ async function loadData(silent = false) {
|
|||||||
loadError.value = false
|
loadError.value = false
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
// 关键数据:任一失败则显示错误
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
statsStore.fetchOverview(),
|
statsStore.fetchOverview(),
|
||||||
budgetStore.fetchBudget(),
|
budgetStore.fetchBudget(),
|
||||||
txStore.fetchTransactions({ page: 1 }),
|
txStore.fetchTransactions({ page: 1 }),
|
||||||
fetchTodayData(today),
|
fetchTodayData(today)
|
||||||
userStore.fetchUserInfo(),
|
|
||||||
notifStore.fetchUnreadCount()
|
|
||||||
])
|
])
|
||||||
recentTx.value = txStore.transactions.slice(0, 5)
|
recentTx.value = txStore.transactions.slice(0, 5)
|
||||||
|
|
||||||
|
// 非关键数据:失败不影响页面显示
|
||||||
|
Promise.allSettled([
|
||||||
|
userStore.fetchUserInfo(),
|
||||||
|
notifStore.fetchUnreadCount(),
|
||||||
|
notifStore.fetchUrgentNotifications()
|
||||||
|
])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Load error:', e)
|
console.error('Load error:', e)
|
||||||
if (!silent) loadError.value = true
|
if (!silent) loadError.value = true
|
||||||
@@ -516,4 +563,85 @@ function goBills() {
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
color: #BFB3B3;
|
color: #BFB3B3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 强提醒公告弹窗 */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 300;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-modal {
|
||||||
|
width: 80%;
|
||||||
|
max-height: 70vh;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 32rpx 32rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2D1B1B;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 32rpx 24rpx;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-image {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-content {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #2D1B1B;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-link {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-link-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #FF8C69;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-footer {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
border-top: 2rpx solid #F0E0D6;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:active { background: #FFF8F0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-btn-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FF8C69;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,14 +33,30 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="notification-list">
|
<view v-else class="notification-list">
|
||||||
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ unread: !item.is_read }" @tap="handleTap(item)">
|
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ unread: !item.is_read, pinned: item.is_pinned }" @tap="handleTap(item)">
|
||||||
<view class="notif-icon" :style="{ background: getNotifBg(item.type) }">
|
<view class="notif-icon" :style="{ background: getNotifBg(item.type) }">
|
||||||
<Icon :name="getIconName(item.type)" :size="28" :color="getIconColor(item.type)" />
|
<Icon :name="getIconName(item.type)" :size="28" :color="getIconColor(item.type)" />
|
||||||
</view>
|
</view>
|
||||||
<view class="notif-content">
|
<view class="notif-content">
|
||||||
|
<view class="notif-title-row">
|
||||||
|
<text v-if="item.is_pinned" class="pin-badge">📌</text>
|
||||||
<text class="notif-title">{{ item.title }}</text>
|
<text class="notif-title">{{ item.title }}</text>
|
||||||
|
</view>
|
||||||
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
|
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
|
||||||
|
<image v-if="item.image_url" class="notif-image" :src="item.image_url" mode="widthFix" />
|
||||||
|
<view class="notif-meta">
|
||||||
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
|
<text 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>
|
||||||
<view v-if="!item.is_read" class="unread-dot"></view>
|
<view v-if="!item.is_read" class="unread-dot"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -56,13 +72,16 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
||||||
import { useNotificationStore } from '@/stores/notification'
|
import { useNotificationStore } from '@/stores/notification'
|
||||||
import { getNotifications, markRead, markAllRead } from '@/api/notification'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { getNotifications, markRead, markAllRead, deleteNotification, updateNotification } from '@/api/notification'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import type { Notification } from '@/api/notification'
|
import type { Notification } from '@/api/notification'
|
||||||
|
|
||||||
const notifStore = useNotificationStore()
|
const notifStore = useNotificationStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const isAdmin = computed(() => userStore.userInfo?.role === 'admin')
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'all', label: '全部' },
|
{ key: 'all', label: '全部' },
|
||||||
@@ -174,6 +193,48 @@ async function handleMarkAllRead() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLink(url: string) {
|
||||||
|
// #ifdef H5
|
||||||
|
window.open(url, '_blank')
|
||||||
|
// #endif
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: url,
|
||||||
|
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员:切换置顶状态 */
|
||||||
|
async function handleTogglePin(item: Notification) {
|
||||||
|
try {
|
||||||
|
await updateNotification(item.id, { is_pinned: !item.is_pinned })
|
||||||
|
item.is_pinned = item.is_pinned ? 0 : 1
|
||||||
|
uni.showToast({ title: item.is_pinned ? '已置顶' : '已取消置顶', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员:删除公告 */
|
||||||
|
function handleDeleteNotif(item: Notification) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除公告',
|
||||||
|
content: `确定删除「${item.title}」?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await deleteNotification(item.id)
|
||||||
|
list.value = list.value.filter(n => n.id !== item.id)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
@@ -301,6 +362,16 @@ onReachBottom(() => {
|
|||||||
|
|
||||||
.notif-content { flex: 1; min-width: 0; }
|
.notif-content { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.notif-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-badge {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.notif-title {
|
.notif-title {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -318,11 +389,22 @@ onReachBottom(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notif-image {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.notif-time {
|
.notif-time {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #BFB3B3;
|
color: #BFB3B3;
|
||||||
display: block;
|
|
||||||
margin-top: 8rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.unread-dot {
|
.unread-dot {
|
||||||
@@ -334,6 +416,17 @@ onReachBottom(() => {
|
|||||||
margin-top: 8rpx;
|
margin-top: 8rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notif-link {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #FF8C69;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned {
|
||||||
|
border-color: #FF8C69;
|
||||||
|
background: #FFFAF7;
|
||||||
|
}
|
||||||
|
|
||||||
.no-more {
|
.no-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 32rpx 0;
|
padding: 32rpx 0;
|
||||||
@@ -343,4 +436,25 @@ onReachBottom(() => {
|
|||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #BFB3B3;
|
color: #BFB3B3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn {
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
background: #FFF0E6;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
|
||||||
|
&.danger { background: #FFE8E8; }
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #FF8C69;
|
||||||
|
&.danger-text { color: #FF6B6B; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ async function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.p-skeleton {
|
.qs-skeleton {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
:height="420"
|
:height="420"
|
||||||
/>
|
/>
|
||||||
<view class="trend-list">
|
<view class="trend-list">
|
||||||
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
|
<view v-for="point in trendData" :key="point.date" class="trend-item">
|
||||||
<text class="trend-date">{{ point.date.slice(8) }}日</text>
|
<text class="trend-date">{{ point.date.slice(8) }}日</text>
|
||||||
<view class="trend-bar-bg">
|
<view class="trend-bar-bg">
|
||||||
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
|
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
|
||||||
@@ -119,6 +119,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { useStatsStore } from '@/stores/stats'
|
import { useStatsStore } from '@/stores/stats'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
@@ -136,9 +137,7 @@ const groupStore = useGroupStore()
|
|||||||
|
|
||||||
const currentMonth = ref(getCurrentMonth())
|
const currentMonth = ref(getCurrentMonth())
|
||||||
const tabType = ref<'expense' | 'income'>('expense')
|
const tabType = ref<'expense' | 'income'>('expense')
|
||||||
const overview = computed(() => statsStore.overview)
|
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
|
||||||
const categoryStats = computed(() => statsStore.categoryStats)
|
|
||||||
const trendData = computed(() => statsStore.trendData)
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
const maxSingle = ref(0)
|
const maxSingle = ref(0)
|
||||||
|
|||||||
@@ -50,19 +50,19 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
groups.value = await api.getGroups()
|
groups.value = await api.getGroups()
|
||||||
} catch {
|
|
||||||
groups.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 currentGroupId 是否仍有效(群组可能已被解散或退出)
|
// 仅在成功获取后验证 currentGroupId 是否仍有效
|
||||||
if (currentGroupId.value !== null) {
|
if (currentGroupId.value !== null) {
|
||||||
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
||||||
if (!stillValid) {
|
if (!stillValid) {
|
||||||
switchToPersonal()
|
switchToPersonal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// 网络失败时保留上次数据,不丢失群组选择
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建群组 */
|
/** 创建群组 */
|
||||||
|
|||||||
@@ -1,24 +1,34 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import * as api from '@/api/notification'
|
import * as api from '@/api/notification'
|
||||||
|
import type { Notification } from '@/api/notification'
|
||||||
|
|
||||||
export const useNotificationStore = defineStore('notification', () => {
|
export const useNotificationStore = defineStore('notification', () => {
|
||||||
const unreadCount = ref(0)
|
const unreadCount = ref(0)
|
||||||
|
const urgentNotifications = ref<Notification[]>([])
|
||||||
|
|
||||||
async function fetchUnreadCount() {
|
async function fetchUnreadCount() {
|
||||||
const data = await api.getUnreadCount()
|
const data = await api.getUnreadCount()
|
||||||
unreadCount.value = data.count
|
unreadCount.value = data.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取未读的强提醒公告(首页弹窗用) */
|
||||||
|
async function fetchUrgentNotifications() {
|
||||||
|
urgentNotifications.value = await api.getPinnedNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
async function markRead(id: number) {
|
async function markRead(id: number) {
|
||||||
await api.markRead(id)
|
await api.markRead(id)
|
||||||
if (unreadCount.value > 0) unreadCount.value--
|
if (unreadCount.value > 0) unreadCount.value--
|
||||||
|
// 从强提醒列表中移除
|
||||||
|
urgentNotifications.value = urgentNotifications.value.filter(n => n.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllRead() {
|
async function markAllRead() {
|
||||||
await api.markAllRead()
|
await api.markAllRead()
|
||||||
unreadCount.value = 0
|
unreadCount.value = 0
|
||||||
|
urgentNotifications.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
return { unreadCount, fetchUnreadCount, markRead, markAllRead }
|
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ export const useTransactionStore = defineStore('transaction', () => {
|
|||||||
total.value = data.total
|
total.value = data.total
|
||||||
return data
|
return data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
transactions.value = []
|
// 不清空数据,保留上次成功的状态,避免并发请求失败时丢失已有数据
|
||||||
total.value = 0
|
|
||||||
throw err
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|||||||
@@ -69,13 +69,18 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
|
|
||||||
if (!isRedirectingToLogin) {
|
if (!isRedirectingToLogin) {
|
||||||
isRedirectingToLogin = true
|
isRedirectingToLogin = true
|
||||||
const retries = [...pendingRetries]
|
|
||||||
pendingRetries = []
|
|
||||||
|
|
||||||
reLogin()
|
reLogin()
|
||||||
.then(() => retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject)))
|
.then(() => {
|
||||||
|
// 处理快照时已入队的 + reLogin 期间新入队的请求
|
||||||
|
const retries = [...pendingRetries]
|
||||||
|
pendingRetries = []
|
||||||
|
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||||
|
const retries = [...pendingRetries]
|
||||||
|
pendingRetries = []
|
||||||
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
||||||
})
|
})
|
||||||
.finally(() => { isRedirectingToLogin = false })
|
.finally(() => { isRedirectingToLogin = false })
|
||||||
|
|||||||
@@ -134,6 +134,20 @@ async function runMigrations(conn: mysql.Connection) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公告系统增强:扩展 notifications 表
|
||||||
|
const hasPinned = await columnExists(conn, 'notifications', 'is_pinned')
|
||||||
|
if (!hasPinned) {
|
||||||
|
console.log('[DB] Migrating: extending notifications table')
|
||||||
|
await conn.query('ALTER TABLE notifications ADD COLUMN is_pinned TINYINT(1) DEFAULT 0 AFTER is_read')
|
||||||
|
await conn.query('ALTER TABLE notifications ADD COLUMN is_urgent TINYINT(1) DEFAULT 0 AFTER is_pinned')
|
||||||
|
await conn.query('ALTER TABLE notifications ADD COLUMN publish_at TIMESTAMP NULL AFTER is_urgent')
|
||||||
|
await conn.query('ALTER TABLE notifications ADD COLUMN expire_at TIMESTAMP NULL AFTER publish_at')
|
||||||
|
await conn.query('ALTER TABLE notifications ADD COLUMN image_url VARCHAR(500) DEFAULT \'\' AFTER expire_at')
|
||||||
|
await conn.query('ALTER TABLE notifications ADD COLUMN link_url VARCHAR(500) DEFAULT \'\' AFTER image_url')
|
||||||
|
await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)')
|
||||||
|
await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initDatabase() {
|
export async function initDatabase() {
|
||||||
|
|||||||
@@ -82,9 +82,17 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||||||
title VARCHAR(100) NOT NULL,
|
title VARCHAR(100) NOT NULL,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
is_read TINYINT(1) DEFAULT 0,
|
is_read TINYINT(1) DEFAULT 0,
|
||||||
|
is_pinned TINYINT(1) DEFAULT 0 COMMENT '是否置顶',
|
||||||
|
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
|
||||||
|
publish_at TIMESTAMP NULL COMMENT '定时发布时间(NULL=立即发布)',
|
||||||
|
expire_at TIMESTAMP NULL COMMENT '过期时间(NULL=永不过期)',
|
||||||
|
image_url VARCHAR(500) DEFAULT '' COMMENT '公告配图',
|
||||||
|
link_url VARCHAR(500) DEFAULT '' COMMENT '公告链接',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX idx_user_read (user_id, is_read),
|
INDEX idx_user_read (user_id, is_read),
|
||||||
INDEX idx_type (type)
|
INDEX idx_type (type),
|
||||||
|
INDEX idx_pinned (is_pinned, created_at),
|
||||||
|
INDEX idx_expire (expire_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS saved_filters (
|
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
|
|||||||
const userInfo = (userRows as any[])[0]
|
const userInfo = (userRows as any[])[0]
|
||||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Auth] Demo login failed:', err.message)
|
console.error('[Auth] Demo login failed:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -72,7 +72,7 @@ router.post('/login', async (req: Request, res: Response) => {
|
|||||||
const userInfo = (userRows as any[])[0]
|
const userInfo = (userRows as any[])[0]
|
||||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Auth] Login failed:', err.message)
|
console.error('[Auth] Login failed:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { Router, Response } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
import pool from '../db/connection'
|
import pool from '../db/connection'
|
||||||
import { AuthRequest } from '../middleware/auth'
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
/** 管理员权限检查 */
|
||||||
|
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||||
|
const user = (rows as any[])[0]
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取通知列表 */
|
/** 获取通知列表 */
|
||||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -12,7 +22,10 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
const pSize = Math.min(50, Math.max(1, parseInt(pageSize as string) || 20))
|
const pSize = Math.min(50, Math.max(1, parseInt(pageSize as string) || 20))
|
||||||
const offset = (pNum - 1) * pSize
|
const offset = (pNum - 1) * pSize
|
||||||
|
|
||||||
let where = 'WHERE (n.user_id = ? OR (n.type = \'system\' AND n.user_id IS NULL) OR (n.type = \'group\' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))'
|
// 基础可见条件:个人通知 + 系统公告 + 群组通知
|
||||||
|
let where = `WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
||||||
|
AND (n.expire_at IS NULL OR n.expire_at > NOW())
|
||||||
|
AND (n.publish_at IS NULL OR n.publish_at <= NOW())`
|
||||||
const params: any[] = [req.userId, req.userId]
|
const params: any[] = [req.userId, req.userId]
|
||||||
|
|
||||||
if (type && ['system', 'group', 'personal'].includes(type as string)) {
|
if (type && ['system', 'group', 'personal'].includes(type as string)) {
|
||||||
@@ -21,10 +34,11 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id
|
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id,
|
||||||
|
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
${where}
|
${where}
|
||||||
ORDER BY n.created_at DESC
|
ORDER BY n.is_pinned DESC, n.created_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[...params, pSize, offset]
|
[...params, pSize, offset]
|
||||||
)
|
)
|
||||||
@@ -49,12 +63,37 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 获取置顶/强提醒公告(首页弹窗用) */
|
||||||
|
router.get('/pinned', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at,
|
||||||
|
n.is_pinned, n.is_urgent, n.image_url, n.link_url
|
||||||
|
FROM notifications n
|
||||||
|
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
||||||
|
AND n.is_urgent = 1 AND n.is_read = 0
|
||||||
|
AND (n.expire_at IS NULL OR n.expire_at > NOW())
|
||||||
|
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT 5`,
|
||||||
|
[req.userId, req.userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: rows })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] pinned error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/** 获取未读数量 */
|
/** 获取未读数量 */
|
||||||
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
|
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT COUNT(*) as count FROM notifications
|
`SELECT COUNT(*) as count FROM notifications
|
||||||
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))) AND is_read = 0`,
|
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
||||||
|
AND is_read = 0
|
||||||
|
AND (expire_at IS NULL OR expire_at > NOW())
|
||||||
|
AND (publish_at IS NULL OR publish_at <= NOW())`,
|
||||||
[req.userId, req.userId]
|
[req.userId, req.userId]
|
||||||
)
|
)
|
||||||
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
|
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
|
||||||
@@ -68,7 +107,8 @@ router.get('/unread-count', async (req: AuthRequest, res: Response) => {
|
|||||||
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
|
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
'UPDATE notifications SET is_read = 1 WHERE id = ? AND (user_id = ? OR (type = \'system\' AND user_id IS NULL) OR (type = \'group\' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))',
|
`UPDATE notifications SET is_read = 1 WHERE id = ?
|
||||||
|
AND (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))`,
|
||||||
[req.params.id, req.userId, req.userId]
|
[req.params.id, req.userId, req.userId]
|
||||||
)
|
)
|
||||||
if ((result as any).affectedRows === 0) {
|
if ((result as any).affectedRows === 0) {
|
||||||
@@ -86,8 +126,11 @@ router.put('/read-all', async (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE notifications SET is_read = 1
|
`UPDATE notifications SET is_read = 1
|
||||||
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL)) AND is_read = 0`,
|
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
||||||
[req.userId]
|
AND is_read = 0
|
||||||
|
AND (expire_at IS NULL OR expire_at > NOW())
|
||||||
|
AND (publish_at IS NULL OR publish_at <= NOW())`,
|
||||||
|
[req.userId, req.userId]
|
||||||
)
|
)
|
||||||
res.json({ code: 0 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -96,17 +139,11 @@ router.put('/read-all', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 发布系统公告(管理员) */
|
/** 发布公告(管理员/群主) */
|
||||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// 验证管理员权限
|
const { title, content, type = 'system', group_id, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
|
||||||
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
|
||||||
const user = (userRows as any[])[0]
|
|
||||||
if (!user || user.role !== 'admin') {
|
|
||||||
return res.status(403).json({ code: 40300, message: '仅管理员可发布公告' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { title, content } = req.body
|
|
||||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||||
return res.status(400).json({ code: 40001, message: '标题不能为空' })
|
return res.status(400).json({ code: 40001, message: '标题不能为空' })
|
||||||
}
|
}
|
||||||
@@ -114,16 +151,99 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
|
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
// 系统公告需要管理员权限
|
||||||
'INSERT INTO notifications (type, title, content) VALUES (?, ?, ?)',
|
if (type === 'system') {
|
||||||
['system', title.trim(), content || '']
|
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||||
|
const user = (userRows as any[])[0]
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ code: 40300, message: '仅管理员可发布系统公告' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 群组公告需要群主权限
|
||||||
|
if (type === 'group' && group_id) {
|
||||||
|
const [groupRows] = await pool.query('SELECT created_by FROM `groups` WHERE id = ?', [group_id])
|
||||||
|
const group = (groupRows as any[])[0]
|
||||||
|
if (!group || group.created_by !== req.userId) {
|
||||||
|
return res.status(403).json({ code: 40300, message: '仅群主可发布群组公告' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO notifications (type, title, content, user_id, group_id, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
type,
|
||||||
|
title.trim(),
|
||||||
|
content || '',
|
||||||
|
type === 'personal' ? req.userId : null,
|
||||||
|
type === 'group' ? group_id : null,
|
||||||
|
is_pinned ? 1 : 0,
|
||||||
|
is_urgent ? 1 : 0,
|
||||||
|
publish_at || null,
|
||||||
|
expire_at || null,
|
||||||
|
image_url || '',
|
||||||
|
link_url || ''
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
res.json({ code: 0 })
|
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Notification] POST error:', err)
|
console.error('[Notification] POST error:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 编辑公告(管理员) */
|
||||||
|
router.put('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { title, content, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
|
||||||
|
|
||||||
|
const updates: string[] = []
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
if (title !== undefined) { updates.push('title = ?'); params.push(title.trim()) }
|
||||||
|
if (content !== undefined) { updates.push('content = ?'); params.push(content) }
|
||||||
|
if (is_pinned !== undefined) { updates.push('is_pinned = ?'); params.push(is_pinned ? 1 : 0) }
|
||||||
|
if (is_urgent !== undefined) { updates.push('is_urgent = ?'); params.push(is_urgent ? 1 : 0) }
|
||||||
|
if (publish_at !== undefined) { updates.push('publish_at = ?'); params.push(publish_at || null) }
|
||||||
|
if (expire_at !== undefined) { updates.push('expire_at = ?'); params.push(expire_at || null) }
|
||||||
|
if (image_url !== undefined) { updates.push('image_url = ?'); params.push(image_url) }
|
||||||
|
if (link_url !== undefined) { updates.push('link_url = ?'); params.push(link_url) }
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(req.params.id)
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`UPDATE notifications SET ${updates.join(', ')} WHERE id = ?`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '公告不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] PUT error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 删除公告(管理员) */
|
||||||
|
router.delete('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query('DELETE FROM notifications WHERE id = ?', [req.params.id])
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '公告不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] DELETE error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, minAmount, maxAmount, keyword } = req.query
|
const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, category_ids, minAmount, maxAmount, keyword } = req.query
|
||||||
const pSize = safeInt(pageSize, 20, 1, 100)
|
const pSize = safeInt(pageSize, 20, 1, 100)
|
||||||
const pNum = safeInt(page, 1, 1, 99999)
|
const pNum = safeInt(page, 1, 1, 99999)
|
||||||
const offset = (pNum - 1) * pSize
|
const offset = (pNum - 1) * pSize
|
||||||
@@ -79,7 +79,14 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
if (endDate && DATE_REGEX.test(endDate as string)) {
|
if (endDate && DATE_REGEX.test(endDate as string)) {
|
||||||
where += ' AND t.date <= ?'; params.push(endDate)
|
where += ' AND t.date <= ?'; params.push(endDate)
|
||||||
}
|
}
|
||||||
if (category_id) {
|
// 支持单个 category_id 或多个 category_ids
|
||||||
|
if (category_ids) {
|
||||||
|
const ids = String(category_ids).split(',').map(Number).filter(n => !isNaN(n))
|
||||||
|
if (ids.length > 0) {
|
||||||
|
where += ` AND t.category_id IN (${ids.map(() => '?').join(',')})`
|
||||||
|
params.push(...ids)
|
||||||
|
}
|
||||||
|
} else if (category_id) {
|
||||||
where += ' AND t.category_id = ?'; params.push(category_id)
|
where += ' AND t.category_id = ?'; params.push(category_id)
|
||||||
}
|
}
|
||||||
if (minAmount) {
|
if (minAmount) {
|
||||||
|
|||||||
Reference in New Issue
Block a user