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:
wangxiaogang
2026-06-06 22:02:28 +08:00
parent 83571de723
commit a35689cdda
20 changed files with 1058 additions and 202 deletions

View File

@@ -149,11 +149,13 @@ onMounted(async () => {
if (currentCategories.value.length > 0 && !selectedCat.value) {
selectedCat.value = currentCategories.value[0].id
}
initialLoaded.value = true
})
// 返回页面时刷新分类(可能新增了分类)
const initialLoaded = ref(false)
onShow(() => {
catStore.fetchCategories()
if (initialLoaded.value) catStore.fetchCategories()
})
function onDateChange(e: any) {

View File

@@ -70,6 +70,74 @@
</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>
@@ -99,33 +167,56 @@ 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() {
uni.showModal({
title: '发布公告',
editable: true,
placeholderText: '输入公告标题',
success: async (res) => {
if (res.confirm && res.content) {
const content = res.content
uni.showModal({
title: '公告内容',
editable: true,
placeholderText: '输入公告内容(可选)',
success: async (res2) => {
if (res2.confirm !== undefined) {
try {
await publishNotification({ title: content.trim(), content: res2.content || '' })
uni.showToast({ title: '已发布', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '发布失败', icon: 'none' })
}
}
}
})
}
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' })
}
}
</script>
<style lang="scss" scoped>
@@ -284,4 +375,222 @@ function goNotifications() {
font-weight: 500;
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>

View File

@@ -13,7 +13,13 @@
<view class="filter-section">
<text class="section-label">分类</text>
<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>
</view>
</view>
@@ -23,16 +29,30 @@
<view class="filter-section">
<text class="section-label">日期范围</text>
<view class="date-row">
<picker mode="date" :value="localFilters.startDate || ''" @change="onStartDateChange">
<view class="date-picker">
<text class="date-text" :class="{ placeholder: !localFilters.startDate }">{{ localFilters.startDate || '开始日期' }}</text>
</view>
<picker
class="date-picker"
mode="date"
:value="localFilters.startDate || ''"
@change="onStartDateChange"
>
<text
class="date-text"
:class="{ placeholder: !localFilters.startDate }"
>{{ localFilters.startDate || "开始日期" }}
</text>
</picker>
<text class="date-sep"></text>
<picker mode="date" :value="localFilters.endDate || ''" @change="onEndDateChange">
<view class="date-picker">
<text class="date-text" :class="{ placeholder: !localFilters.endDate }">{{ localFilters.endDate || '结束日期' }}</text>
</view>
<picker
class="date-picker"
mode="date"
:value="localFilters.endDate || ''"
@change="onEndDateChange"
>
<text
class="date-text"
:class="{ placeholder: !localFilters.endDate }"
>{{ localFilters.endDate || "结束日期" }}
</text>
</picker>
</view>
</view>
@@ -41,22 +61,41 @@
<view class="filter-section">
<text class="section-label">金额区间</text>
<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>
<input class="amount-input" type="digit" v-model="maxAmountStr" placeholder="最大金额" />
<input
class="amount-input"
type="digit"
v-model="maxAmountStr"
placeholder="最大金额"
/>
</view>
</view>
<!-- 关键词 -->
<view class="filter-section">
<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 class="filter-section" v-if="savedFilters.length > 0">
<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>
<view class="saved-delete" @tap.stop="handleDeleteSaved(sf.id)">
<text class="saved-delete-text">×</text>
@@ -81,116 +120,152 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { useFilterStore } from '@/stores/filter'
import type { FilterParams, SavedFilter } from '@/api/filter'
import { ref, reactive, onMounted } from "vue";
import { useCategoryStore } from "@/stores/category";
import { useFilterStore } from "@/stores/filter";
import type { FilterParams, SavedFilter } from "@/api/filter";
const emit = defineEmits<{
close: []
apply: [filters: FilterParams]
}>()
close: [];
apply: [filters: FilterParams];
}>();
const props = defineProps<{
initialFilters?: FilterParams
}>()
initialFilters?: FilterParams;
}>();
const categoryStore = useCategoryStore()
const filterStore = useFilterStore()
const categoryStore = useCategoryStore();
const filterStore = useFilterStore();
const categories = ref<any[]>([])
const savedFilters = ref<SavedFilter[]>([])
const minAmountStr = ref('')
const maxAmountStr = ref('')
const categories = ref<any[]>([]);
const savedFilters = ref<SavedFilter[]>([]);
const minAmountStr = ref("");
const maxAmountStr = ref("");
const localFilters = reactive<FilterParams>({
type: '',
type: "",
category_ids: [],
startDate: '',
endDate: '',
startDate: "",
endDate: "",
minAmount: 0,
maxAmount: 0,
keyword: '',
})
keyword: "",
});
onMounted(async () => {
categories.value = [...categoryStore.getByType('expense'), ...categoryStore.getByType('income')]
await filterStore.fetchSavedFilters()
savedFilters.value = filterStore.savedFilters
initCategorys();
initSavedFilters();
// 应用初始筛选条件
if (props.initialFilters) {
Object.assign(localFilters, props.initialFilters)
if (localFilters.minAmount != null && localFilters.minAmount > 0) minAmountStr.value = (localFilters.minAmount / 100).toString()
if (localFilters.maxAmount != null && localFilters.maxAmount > 0) maxAmountStr.value = (localFilters.maxAmount / 100).toString()
Object.assign(localFilters, props.initialFilters);
if (localFilters.minAmount != null && localFilters.minAmount > 0)
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) {
if (!localFilters.category_ids) localFilters.category_ids = []
const idx = localFilters.category_ids.indexOf(id)
if (!localFilters.category_ids) localFilters.category_ids = [];
const idx = localFilters.category_ids.indexOf(id);
if (idx >= 0) {
localFilters.category_ids.splice(idx, 1)
localFilters.category_ids.splice(idx, 1);
} else {
localFilters.category_ids.push(id)
localFilters.category_ids.push(id);
}
}
function onStartDateChange(e: any) {
localFilters.startDate = e.detail.value
localFilters.startDate = e.detail.value;
}
function onEndDateChange(e: any) {
localFilters.endDate = e.detail.value
localFilters.endDate = e.detail.value;
}
function handleReset() {
Object.assign(localFilters, { type: '', category_ids: [], startDate: '', endDate: '', minAmount: 0, maxAmount: 0, keyword: '' })
minAmountStr.value = ''
maxAmountStr.value = ''
Object.assign(localFilters, {
type: "",
category_ids: [],
startDate: "",
endDate: "",
minAmount: 0,
maxAmount: 0,
keyword: "",
});
minAmountStr.value = "";
maxAmountStr.value = "";
}
function handleApply() {
const filters: FilterParams = { ...localFilters }
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
emit('apply', filters)
filters.minAmount = minAmountStr.value
? Math.round(parseFloat(minAmountStr.value) * 100)
: 0;
filters.maxAmount = maxAmountStr.value
? Math.round(parseFloat(maxAmountStr.value) * 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
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;
uni.showModal({
title: '保存方案',
title: "保存方案",
editable: true,
placeholderText: '输入方案名称',
placeholderText: "输入方案名称",
success: async (res) => {
if (res.confirm && res.content) {
try {
await filterStore.saveFilter(res.content.trim(), filters)
savedFilters.value = filterStore.savedFilters
uni.showToast({ title: '已保存', icon: 'success' })
await filterStore.saveFilter(res.content.trim(), filters);
savedFilters.value = filterStore.savedFilters;
uni.showToast({ title: "已保存", icon: "success" });
} catch (e: any) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
uni.showToast({ title: e.message || "保存失败", icon: "none" });
}
}
}
})
},
});
}
function applySaved(sf: SavedFilter) {
Object.assign(localFilters, sf.filters)
minAmountStr.value = (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() : ''
Object.assign(localFilters, sf.filters);
minAmountStr.value =
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) {
try {
await filterStore.deleteFilter(id)
savedFilters.value = filterStore.savedFilters
await filterStore.deleteFilter(id);
savedFilters.value = filterStore.savedFilters;
} catch {}
}
</script>
@@ -211,7 +286,7 @@ async function handleDeleteSaved(id: number) {
.filter-panel {
width: 100%;
max-height: 80vh;
background: #FFFFFF;
background: #ffffff;
border-radius: 40rpx 40rpx 0 0;
display: flex;
flex-direction: column;
@@ -224,13 +299,13 @@ async function handleDeleteSaved(id: number) {
align-items: center;
justify-content: space-between;
padding: 32rpx 40rpx 24rpx;
border-bottom: 2rpx solid #F0E0D6;
border-bottom: 2rpx solid #f0e0d6;
}
.filter-title {
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
color: #2d1b1b;
}
.filter-close {
@@ -240,13 +315,15 @@ async function handleDeleteSaved(id: number) {
align-items: center;
justify-content: center;
border-radius: 50%;
background: #F0E0D6;
&:active { background: #E0D6D0; }
background: #f0e0d6;
&:active {
background: #e0d6d0;
}
}
.filter-close-text {
font-size: 36rpx;
color: #8B7E7E;
color: #8b7e7e;
line-height: 1;
}
@@ -255,6 +332,7 @@ async function handleDeleteSaved(id: number) {
padding: 24rpx 40rpx;
max-height: 55vh;
overflow-x: hidden;
box-sizing: border-box;
}
.filter-section {
@@ -266,7 +344,7 @@ async function handleDeleteSaved(id: number) {
.section-label {
font-size: 26rpx;
font-weight: 600;
color: #2D1B1B;
color: #2d1b1b;
display: block;
margin-bottom: 16rpx;
}
@@ -281,23 +359,28 @@ async function handleDeleteSaved(id: number) {
.chip {
padding: 12rpx 24rpx;
background: #FFF8F0;
background: #fff8f0;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
border: 2rpx solid #f0e0d6;
transition: all 0.2s;
&.active {
background: #FFE8E0;
border-color: #FF8C69;
background: #ffe8e0;
border-color: #ff8c69;
}
&:active { opacity: 0.7; }
&:active {
opacity: 0.7;
}
}
.chip-text {
font-size: 24rpx;
color: #2D1B1B;
.active & { color: #FF8C69; font-weight: 600; }
color: #2d1b1b;
.active & {
color: #ff8c69;
font-weight: 600;
}
}
.date-row {
@@ -312,22 +395,24 @@ async function handleDeleteSaved(id: number) {
flex: 1;
height: 72rpx;
padding: 0 20rpx;
background: #FFF8F0;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #F0E0D6;
border: 2rpx solid #f0e0d6;
display: flex;
align-items: center;
}
.date-text {
font-size: 26rpx;
color: #2D1B1B;
&.placeholder { color: #BFB3B3; }
color: #2d1b1b;
&.placeholder {
color: #bfb3b3;
}
}
.date-sep {
font-size: 26rpx;
color: #8B7E7E;
color: #8b7e7e;
}
.amount-row {
@@ -342,27 +427,27 @@ async function handleDeleteSaved(id: number) {
flex: 1;
height: 72rpx;
padding: 0 20rpx;
background: #FFF8F0;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #F0E0D6;
border: 2rpx solid #f0e0d6;
font-size: 26rpx;
color: #2D1B1B;
color: #2d1b1b;
}
.amount-sep {
font-size: 26rpx;
color: #8B7E7E;
color: #8b7e7e;
}
.keyword-input {
width: 100%;
height: 72rpx;
padding: 0 20rpx;
background: #FFF8F0;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #F0E0D6;
border: 2rpx solid #f0e0d6;
font-size: 26rpx;
color: #2D1B1B;
color: #2d1b1b;
}
.saved-item {
@@ -370,16 +455,18 @@ async function handleDeleteSaved(id: number) {
align-items: center;
justify-content: space-between;
padding: 16rpx 20rpx;
background: #FFF8F0;
background: #fff8f0;
border-radius: 16rpx;
margin-bottom: 8rpx;
&:active { background: #F0E0D6; }
&:active {
background: #f0e0d6;
}
}
.saved-name {
font-size: 26rpx;
color: #2D1B1B;
color: #2d1b1b;
}
.saved-delete {
@@ -388,19 +475,21 @@ async function handleDeleteSaved(id: number) {
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
&:active {
opacity: 0.6;
}
}
.saved-delete-text {
font-size: 28rpx;
color: #BFB3B3;
color: #bfb3b3;
}
.filter-footer {
display: flex;
gap: 16rpx;
padding: 24rpx 40rpx 40rpx;
border-top: 2rpx solid #F0E0D6;
border-top: 2rpx solid #f0e0d6;
}
.footer-btn {
@@ -411,18 +500,34 @@ async function handleDeleteSaved(id: number) {
align-items: center;
justify-content: center;
&.reset { background: #FFF8F0; border: 2rpx solid #F0E0D6; }
&.save { background: #FFF0E6; border: 2rpx solid #FF8C69; }
&.apply { background: linear-gradient(135deg, #FF8C69, #E67355); }
&.reset {
background: #fff8f0;
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 {
font-size: 28rpx;
font-weight: 600;
&.reset-text { color: #8B7E7E; }
&.save-text { color: #FF8C69; }
&.apply-text { color: #FFFFFF; }
&.reset-text {
color: #8b7e7e;
}
&.save-text {
color: #ff8c69;
}
&.apply-text {
color: #ffffff;
}
}
</style>

View File

@@ -166,7 +166,7 @@ async function loadTx(reset = false, silent = false) {
if (filterType.value !== 'all') params.type = filterType.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.endDate) params.endDate = af.endDate
if (af.minAmount) params.minAmount = af.minAmount

View File

@@ -116,19 +116,22 @@ function syncBudgetData() {
/** 获取个人支出(不传 group_id始终只算自己的消费 */
async function fetchMyExpense() {
const data = await getOverview({ month: currentMonth, group_id: null })
monthExpense.value = Number(data?.expense) || 0
try {
const data = await getOverview({ month: currentMonth, group_id: null })
monthExpense.value = Number(data?.expense) || 0
} catch {
monthExpense.value = 0
}
}
onMounted(async () => {
await waitForReady()
loading.value = true
try {
await Promise.all([
budgetStore.fetchBudget(currentMonth),
fetchMyExpense()
])
await budgetStore.fetchBudget(currentMonth)
syncBudgetData()
// 非关键:支出数据失败不影响预算显示
fetchMyExpense()
} catch (e) {
console.error('Budget page load error:', e)
} finally {
@@ -141,11 +144,9 @@ onMounted(async () => {
onShow(async () => {
if (!initialLoaded.value) return
try {
await Promise.all([
budgetStore.fetchBudget(currentMonth),
fetchMyExpense()
])
await budgetStore.fetchBudget(currentMonth)
syncBudgetData()
fetchMyExpense()
} catch {}
})
@@ -419,7 +420,7 @@ function goBack() {
.preview-skeleton {
animation: none;
}
.cursor {
.input-cursor {
animation: none;
opacity: 1;
}

View File

@@ -112,6 +112,26 @@
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text>
</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>
</template>
@@ -160,6 +180,27 @@ const greeting = computed(() => {
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) {
await waitForReady()
try {
@@ -167,15 +208,21 @@ async function loadData(silent = false) {
loadError.value = false
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
// 关键数据:任一失败则显示错误
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 }),
fetchTodayData(today),
userStore.fetchUserInfo(),
notifStore.fetchUnreadCount()
fetchTodayData(today)
])
recentTx.value = txStore.transactions.slice(0, 5)
// 非关键数据:失败不影响页面显示
Promise.allSettled([
userStore.fetchUserInfo(),
notifStore.fetchUnreadCount(),
notifStore.fetchUrgentNotifications()
])
} catch (e) {
console.error('Load error:', e)
if (!silent) loadError.value = true
@@ -516,4 +563,85 @@ function goBills() {
font-size: 28rpx;
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>

View File

@@ -33,14 +33,30 @@
</view>
<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) }">
<Icon :name="getIconName(item.type)" :size="28" :color="getIconColor(item.type)" />
</view>
<view class="notif-content">
<text class="notif-title">{{ item.title }}</text>
<view class="notif-title-row">
<text v-if="item.is_pinned" class="pin-badge">📌</text>
<text class="notif-title">{{ item.title }}</text>
</view>
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
<text class="notif-time">{{ formatTime(item.created_at) }}</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 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>
@@ -56,13 +72,16 @@
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
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 { 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: '全部' },
@@ -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() {
uni.navigateBack()
}
@@ -301,6 +362,16 @@ onReachBottom(() => {
.notif-content { flex: 1; min-width: 0; }
.notif-title-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.pin-badge {
font-size: 24rpx;
}
.notif-title {
font-size: 28rpx;
font-weight: 600;
@@ -318,11 +389,22 @@ onReachBottom(() => {
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 {
font-size: 22rpx;
color: #BFB3B3;
display: block;
margin-top: 8rpx;
}
.unread-dot {
@@ -334,6 +416,17 @@ onReachBottom(() => {
margin-top: 8rpx;
}
.notif-link {
font-size: 22rpx;
color: #FF8C69;
text-decoration: underline;
}
.pinned {
border-color: #FF8C69;
background: #FFFAF7;
}
.no-more {
text-align: center;
padding: 32rpx 0;
@@ -343,4 +436,25 @@ onReachBottom(() => {
font-size: 24rpx;
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>

View File

@@ -477,7 +477,7 @@ async function exportData() {
}
@media (prefers-reduced-motion: reduce) {
.p-skeleton {
.qs-skeleton {
animation: none;
}
}

View File

@@ -94,7 +94,7 @@
:height="420"
/>
<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>
<view class="trend-bar-bg">
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
@@ -119,6 +119,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats'
import { useGroupStore } from '@/stores/group'
@@ -136,9 +137,7 @@ const groupStore = useGroupStore()
const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense')
const overview = computed(() => statsStore.overview)
const categoryStats = computed(() => statsStore.categoryStats)
const trendData = computed(() => statsStore.trendData)
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
const loading = ref(true)
const loadError = ref(false)
const maxSingle = ref(0)