Compare commits

...

4 Commits

Author SHA1 Message Date
324a3ec5c9 fix: 键盘收起后添加底部保存按钮
- 记账页面添加底部保存按钮,键盘收起时显示
- 预算页面添加底部保存按钮,键盘收起时显示
- 按钮根据编辑/新增模式显示不同文案
- 正确处理底部安全区适配
2026-06-15 16:45:18 +08:00
9f5802d634 fix: 修复3个高优先级Bug + 5个中低优先级问题
BUG-01: recurring /sync next_date 更新逻辑错误(应用循环后的 next)
BUG-02: bills onReachBottom loading 缺 .value
BUG-03: group store switchToPersonal 缺少 await (3处)
OPT-04: notification requireNotificationEditAuth 加 try-catch
OPT-05: category 迁移源分类条件改为 is_custom=1 AND user_id=?
OPT-06: user 旧头像删除加 try-catch + 日志
CODE-02: format.ts 跨年日期显示年份
CODE-05: notification isValidUrl 去掉相对路径允许
2026-06-10 15:59:12 +08:00
f586f9e117 fix: 金额编辑器小数位溢出挤位 + 编辑页自动聚焦 + 周期账单移除冗余聚焦 2026-06-09 16:54:13 +08:00
6b26951b1b fix: 金额输入框样式优化 + 预算页键盘遮挡修复 + admin 样式修复
- AmountEditor: 未聚焦时透明,聚焦时粘土卡片浮起;¥/单位 baseline 对齐
- budget: 补充 Numpad 占位高度,auto-focus 改为 false 缩短页面
- admin/feedback: 补充 modal-mask mixin
- admin/track: filter-tabs 与 scroll-view 分离 + 格式化
2026-06-09 14:44:25 +08:00
14 changed files with 297 additions and 125 deletions

View File

@@ -128,60 +128,54 @@ defineExpose({ focus, blur })
width: 100%; width: 100%;
} }
// 金额显示区 — 未聚焦时低调,聚焦时粘土卡片浮起
.amount-display { .amount-display {
min-height: 156rpx;
padding: $space-lg $space-xl;
background: $surface;
border: 2rpx solid $border;
border-radius: $radius-2xl;
box-shadow: $shadow-clay;
display: flex; display: flex;
align-items: center; align-items: baseline;
justify-content: center; justify-content: center;
gap: $space-xs; gap: 8rpx;
padding: $space-lg $space-xl $space-md;
border-radius: $radius-xl;
border: 2rpx solid transparent;
background: transparent;
transition: background $transition-normal, border-color $transition-normal, box-shadow $transition-normal;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
transition: border-color $transition-normal, box-shadow $transition-normal, transform $transition-fast;
&:active {
transform: scale(0.99);
}
} }
.amount-editor--focused .amount-display { .amount-editor--focused .amount-display {
border-color: $primary-light; background: $surface;
box-shadow: $shadow-clay, 0 8rpx 24rpx rgba(255, 140, 105, 0.16); border-color: $border;
box-shadow: $shadow-clay;
} }
.currency, // ¥ 符号
.currency {
flex-shrink: 0;
font-family: 'Fredoka', sans-serif;
font-weight: 500;
color: $text-muted;
line-height: 1.2;
}
// 单位(元)
.unit { .unit {
flex-shrink: 0; flex-shrink: 0;
color: $text-sec;
font-weight: 600; font-weight: 600;
color: $text-sec;
line-height: 1.2;
} }
.currency { // 金额字符容器
align-self: flex-start;
margin-top: 12rpx;
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
}
.unit {
align-self: flex-end;
margin-bottom: 14rpx;
font-size: $font-md;
}
.amount-value { .amount-value {
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: baseline;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
font-family: 'Fredoka', sans-serif; font-family: 'Fredoka', sans-serif;
font-weight: 700; font-weight: 700;
color: $text; color: $text;
line-height: 1.1; line-height: 1.2;
word-break: break-all; word-break: break-all;
} }
@@ -189,7 +183,6 @@ defineExpose({ focus, blur })
.placeholder { .placeholder {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 1.1em;
} }
.amount-char { .amount-char {
@@ -197,9 +190,10 @@ defineExpose({ focus, blur })
} }
.placeholder { .placeholder {
color: $text-muted; color: #D0C4C4;
} }
// 光标
.cursor { .cursor {
width: 4rpx; width: 4rpx;
height: 1em; height: 1em;
@@ -209,11 +203,11 @@ defineExpose({ focus, blur })
animation: cursor-blink 1s steps(2, start) infinite; animation: cursor-blink 1s steps(2, start) infinite;
} }
// ===== 尺寸变体 =====
.amount-editor--small { .amount-editor--small {
.amount-display { .amount-display {
min-height: 112rpx;
padding: $space-md $space-lg; padding: $space-md $space-lg;
border-radius: $radius-xl;
} }
.amount-value { .amount-value {
@@ -221,14 +215,17 @@ defineExpose({ focus, blur })
} }
.currency { .currency {
margin-top: 6rpx;
font-size: 28rpx; font-size: 28rpx;
} }
.unit {
font-size: $font-sm;
}
} }
.amount-editor--medium { .amount-editor--medium {
.amount-display { .amount-display {
min-height: 132rpx; padding: $space-md $space-xl;
} }
.amount-value { .amount-value {
@@ -238,12 +235,24 @@ defineExpose({ focus, blur })
.currency { .currency {
font-size: 34rpx; font-size: 34rpx;
} }
.unit {
font-size: $font-md;
}
} }
.amount-editor--large { .amount-editor--large {
.amount-value { .amount-value {
font-size: 88rpx; font-size: 88rpx;
} }
.currency {
font-size: 40rpx;
}
.unit {
font-size: $font-lg;
}
} }
@keyframes cursor-blink { @keyframes cursor-blink {

View File

@@ -48,9 +48,19 @@ export function insertAmountKey(
} }
const normalizedInsert = normalizeInsert(value, safeCursor, key) const normalizedInsert = normalizeInsert(value, safeCursor, key)
const next = value.slice(0, safeCursor) + normalizedInsert + value.slice(safeCursor) let next = value.slice(0, safeCursor) + normalizedInsert + value.slice(safeCursor)
if (!isValidAmountInput(next, options)) { if (!isValidAmountInput(next, options)) {
// 小数位超出上限时,从右侧挤出一个字符(小数部分左移)
const dotIdx = next.indexOf('.')
if (dotIdx >= 0 && next.length - dotIdx - 1 > MAX_DECIMAL_PLACES) {
next = next.slice(0, -1)
const newCursor = Math.min(safeCursor + normalizedInsert.length, next.length)
if (!isValidAmountInput(next, options)) {
return { value, cursorIndex: safeCursor, accepted: false }
}
return { value: next, cursorIndex: newCursor, accepted: true }
}
return { value, cursorIndex: safeCursor, accepted: false } return { value, cursorIndex: safeCursor, accepted: false }
} }

View File

@@ -25,6 +25,8 @@
:fixed="true" :fixed="true"
size="large" size="large"
:auto-focus="true" :auto-focus="true"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="save" @confirm="save"
/> />
@@ -55,7 +57,12 @@
</view> </view>
</view> </view>
<!-- 底部保存按钮键盘收起时显示 -->
<view class="save-bar" v-if="!showSuccess && !keyboardVisible">
<view class="save-btn" @tap="save">
<text class="save-btn-text">{{ editId ? '保存修改' : '保存' }}</text>
</view>
</view>
<SaveSuccess <SaveSuccess
v-model:visible="showSuccess" v-model:visible="showSuccess"
@@ -99,6 +106,8 @@ const showSuccess = ref(false)
const scrollToCat = ref('') const scrollToCat = ref('')
const todayStr = localToday const todayStr = localToday
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null) const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
// Remember last selected category per type // Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null } const lastCatByType: Record<string, number | null> = { expense: null, income: null }
@@ -195,6 +204,7 @@ onMounted(async () => {
// 等待分类列表渲染完成后滚动到选中项 // 等待分类列表渲染完成后滚动到选中项
nextTick(() => { nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200) setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
amountEditorRef.value?.focus()
}) })
} else { } else {
uni.showToast({ title: '记录不存在', icon: 'none' }) uni.showToast({ title: '记录不存在', icon: 'none' })
@@ -300,9 +310,46 @@ function goBack() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx // AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
// 同时留出保存按钮的位置,确保内容不被遮挡
$numpad-h: 464rpx; $numpad-h: 464rpx;
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom)); $save-bar-h: 144rpx;
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom)); padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
}
// 底部保存按钮栏
.save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: $space-md $space-xl;
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
background: $surface;
border-top: 2rpx solid $border;
z-index: 50;
}
.save-btn {
height: 96rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
.save-btn-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
} }
.header-fixed { .header-fixed {

View File

@@ -607,4 +607,9 @@ function goBack() { uni.navigateBack() }
.skip & { color: $text-sec; } .skip & { color: $text-sec; }
.confirm & { color: $surface; } .confirm & { color: $surface; }
} }
.modal-mask {
@include modal-mask;
}
</style> </style>

View File

@@ -61,13 +61,19 @@
<view class="query-section"> <view class="query-section">
<view class="query-header"> <view class="query-header">
<text class="section-title">事件记录</text> <text class="section-title">事件记录</text>
<scroll-view class="filter-tabs" scroll-x> <scroll-view scroll-x>
<view class="filter-tabs">
<view class="filter-tab" :class="{ active: !filterEvent }" @tap="filterEvent = ''">全部</view> <view class="filter-tab" :class="{ active: !filterEvent }" @tap="filterEvent = ''">全部</view>
<view class="filter-tab" :class="{ active: filterEvent === 'page_view' }" @tap="filterEvent = 'page_view'">页面</view> <view class="filter-tab" :class="{ active: filterEvent === 'page_view' }" @tap="filterEvent = 'page_view'">
<view class="filter-tab" :class="{ active: filterEvent === 'action' }" @tap="filterEvent = 'action'">操作</view> 页面</view>
<view class="filter-tab" :class="{ active: filterEvent === 'action' }" @tap="filterEvent = 'action'">操作
</view>
<view class="filter-tab" :class="{ active: filterEvent === 'error' }" @tap="filterEvent = 'error'">错误</view> <view class="filter-tab" :class="{ active: filterEvent === 'error' }" @tap="filterEvent = 'error'">错误</view>
<view class="filter-tab" :class="{ active: filterEvent === 'api_error' }" @tap="filterEvent = 'api_error'">API错误</view> <view class="filter-tab" :class="{ active: filterEvent === 'api_error' }" @tap="filterEvent = 'api_error'">
<view class="filter-tab" :class="{ active: filterEvent === 'app_launch' }" @tap="filterEvent = 'app_launch'">启动</view> API错误</view>
<view class="filter-tab" :class="{ active: filterEvent === 'app_launch' }"
@tap="filterEvent = 'app_launch'">启动</view>
</view>
</scroll-view> </scroll-view>
</view> </view>
<view class="filter-row"> <view class="filter-row">
@@ -95,7 +101,8 @@
</view> </view>
<text class="record-page" v-if="item.page">{{ item.page }}</text> <text class="record-page" v-if="item.page">{{ item.page }}</text>
<text class="record-user" v-if="item.nickname">{{ item.nickname }}</text> <text class="record-user" v-if="item.nickname">{{ item.nickname }}</text>
<text class="record-data" v-if="item.data && Object.keys(item.data).length > 0">{{ JSON.stringify(item.data) }}</text> <text class="record-data" v-if="item.data && Object.keys(item.data).length > 0">{{ JSON.stringify(item.data)
}}</text>
</view> </view>
<view v-if="loading" class="loading-box"> <view v-if="loading" class="loading-box">
@@ -274,7 +281,9 @@ function goBack() {
@include sticky-header; @include sticky-header;
} }
.status-bar { @include status-bar; } .status-bar {
@include status-bar;
}
.nav-bar { .nav-bar {
display: flex; display: flex;
@@ -282,7 +291,9 @@ function goBack() {
padding: $space-sm $space-xl $space-md; padding: $space-sm $space-xl $space-md;
} }
.nav-back { @include nav-back; } .nav-back {
@include nav-back;
}
.nav-title { .nav-title {
flex: 1; flex: 1;
@@ -292,7 +303,9 @@ function goBack() {
color: $text; color: $text;
} }
.nav-placeholder { width: 88rpx; } .nav-placeholder {
width: 88rpx;
}
.stats-card { .stats-card {
margin: $space-md $space-xl; margin: $space-md $space-xl;
@@ -333,7 +346,9 @@ function goBack() {
gap: $space-sm; gap: $space-sm;
padding: $space-xs 0; padding: $space-xs 0;
&:active { opacity: 0.7; } &:active {
opacity: 0.7;
}
} }
.event-tag { .event-tag {
@@ -341,18 +356,34 @@ function goBack() {
border-radius: $radius-xs; border-radius: $radius-xs;
flex-shrink: 0; flex-shrink: 0;
&.page { background: #E3F2FD; } &.page {
&.action { background: #E8F5E9; } background: #E3F2FD;
&.error { background: #FFEBEE; } }
&.action {
background: #E8F5E9;
}
&.error {
background: #FFEBEE;
}
} }
.event-tag-text { .event-tag-text {
font-size: $font-sm; font-size: $font-sm;
font-weight: 500; font-weight: 500;
.page & { color: #1976D2; } .page & {
.action & { color: #388E3C; } color: #1976D2;
.error & { color: #D32F2F; } }
.action & {
color: #388E3C;
}
.error & {
color: #D32F2F;
}
} }
.event-bar { .event-bar {
@@ -472,7 +503,9 @@ function goBack() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&:active { opacity: 0.8; } &:active {
opacity: 0.8;
}
} }
.search-btn-text { .search-btn-text {
@@ -491,7 +524,6 @@ function goBack() {
display: flex; display: flex;
gap: $space-xs; gap: $space-xs;
white-space: nowrap; white-space: nowrap;
width: 100%;
} }
.filter-tab { .filter-tab {
@@ -520,7 +552,9 @@ function goBack() {
padding: $space-md; padding: $space-md;
border-bottom: 1rpx solid $border; border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; } &:last-child {
border-bottom: none;
}
} }
.record-header { .record-header {
@@ -534,18 +568,34 @@ function goBack() {
padding: 2rpx 12rpx; padding: 2rpx 12rpx;
border-radius: $radius-xs; border-radius: $radius-xs;
&.page { background: #E3F2FD; } &.page {
&.action { background: #E8F5E9; } background: #E3F2FD;
&.error { background: #FFEBEE; } }
&.action {
background: #E8F5E9;
}
&.error {
background: #FFEBEE;
}
} }
.record-event-text { .record-event-text {
font-size: $font-xs; font-size: $font-xs;
font-weight: 500; font-weight: 500;
.page & { color: #1976D2; } .page & {
.action & { color: #388E3C; } color: #1976D2;
.error & { color: #D32F2F; } }
.action & {
color: #388E3C;
}
.error & {
color: #D32F2F;
}
} }
.record-time { .record-time {
@@ -578,7 +628,8 @@ function goBack() {
border-radius: $radius-sm; border-radius: $radius-sm;
} }
.loading-box, .empty-box { .loading-box,
.empty-box {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -601,6 +652,8 @@ function goBack() {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.bar-fill { transition: none; } .bar-fill {
transition: none;
}
} }
</style> </style>

View File

@@ -242,7 +242,7 @@ async function onDelete(item: any) {
// 触底加载更多 // 触底加载更多
onReachBottom(() => { onReachBottom(() => {
if (noMore.value || loading) return if (noMore.value || loading.value) return
page.value++ page.value++
loadTx(false) loadTx(false)
}) })

View File

@@ -66,10 +66,19 @@
:fixed="true" :fixed="true"
size="large" size="large"
unit="元" unit="元"
:auto-focus="true" :auto-focus="false"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="onConfirm" @confirm="onConfirm"
/> />
</view> </view>
<!-- 底部保存按钮键盘收起时显示 -->
<view class="save-bar" v-if="!keyboardVisible">
<view class="save-btn" @tap="onConfirm">
<text class="save-btn-text">保存预算</text>
</view>
</view>
</view> </view>
</template> </template>
@@ -98,6 +107,7 @@ const loading = ref(true)
const initialLoaded = ref(false) const initialLoaded = ref(false)
const amountStr = ref('') const amountStr = ref('')
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null) const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
const presets = [1000, 2000, 3000, 5000, 10000] const presets = [1000, 2000, 3000, 5000, 10000]
@@ -197,7 +207,11 @@ function goBack() {
.page { .page {
min-height: 100vh; min-height: 100vh;
background: $bg; background: $bg;
padding-bottom: 120rpx; // AmountEditor fixed Numpad 占位 + 保存按钮占位
$numpad-h: 464rpx;
$save-bar-h: 144rpx;
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
} }
.header-fixed { .header-fixed {
@@ -369,6 +383,40 @@ function goBack() {
color: $text; color: $text;
} }
// 底部保存按钮栏
.save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: $space-md $space-xl;
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
background: $surface;
border-top: 2rpx solid $border;
z-index: 50;
}
.save-btn {
height: 96rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
.save-btn-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -153,7 +153,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed, onMounted } from 'vue'
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 { formatAmount } from '@/utils/format' import { formatAmount } from '@/utils/format'
@@ -233,7 +233,6 @@ function openAddModal() {
if (cats.length > 0) form.value.category_id = cats[0].id if (cats.length > 0) form.value.category_id = cats[0].id
amountStr.value = '' amountStr.value = ''
showModal.value = true showModal.value = true
nextTick(() => amountEditorRef.value?.focus())
} }
function openEditModal(item: RecurringTemplate) { function openEditModal(item: RecurringTemplate) {
@@ -248,7 +247,6 @@ function openEditModal(item: RecurringTemplate) {
} }
amountStr.value = (item.amount / 100).toString() amountStr.value = (item.amount / 100).toString()
showModal.value = true showModal.value = true
nextTick(() => amountEditorRef.value?.focus())
} }
async function toggleActive(item: RecurringTemplate) { async function toggleActive(item: RecurringTemplate) {

View File

@@ -55,7 +55,7 @@ export const useGroupStore = defineStore('group', () => {
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() await switchToPersonal()
} }
} }
} catch { } catch {
@@ -83,7 +83,7 @@ export const useGroupStore = defineStore('group', () => {
async function leaveGroup(groupId: number) { async function leaveGroup(groupId: number) {
await api.leaveGroup(groupId) await api.leaveGroup(groupId)
if (currentGroupId.value === groupId) { if (currentGroupId.value === groupId) {
switchToPersonal() await switchToPersonal()
} }
await fetchGroups() await fetchGroups()
} }
@@ -92,7 +92,7 @@ export const useGroupStore = defineStore('group', () => {
async function deleteGroup(groupId: number) { async function deleteGroup(groupId: number) {
await api.deleteGroup(groupId) await api.deleteGroup(groupId)
if (currentGroupId.value === groupId) { if (currentGroupId.value === groupId) {
switchToPersonal() await switchToPersonal()
} }
await fetchGroups() await fetchGroups()
} }

View File

@@ -36,7 +36,11 @@ export function formatDate(date: string | Date): string {
if (diff === 1) return '昨天' if (diff === 1) return '昨天'
if (diff === 2) return '前天' if (diff === 2) return '前天'
return `${d.getMonth() + 1}${d.getDate()}` // 跨年显示年份
const isCurrentYear = d.getFullYear() === now.getFullYear()
return isCurrentYear
? `${d.getMonth() + 1}${d.getDate()}`
: `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
} }
export function formatMonth(date: string): string { export function formatMonth(date: string): string {

View File

@@ -124,9 +124,9 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
await conn.beginTransaction() await conn.beginTransaction()
// 验证源分类存在且属于当前用户 // 验证源分类存在且属于当前用户(仅允许迁移自定义分类)
const [sourceRows] = await conn.query( const [sourceRows] = await conn.query(
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)', 'SELECT id, type FROM categories WHERE id = ? AND is_custom = 1 AND user_id = ?',
[req.params.id, req.userId] [req.params.id, req.userId]
) )
const sourceCategory = (sourceRows as any[])[0] const sourceCategory = (sourceRows as any[])[0]

View File

@@ -46,6 +46,7 @@ const upload = multer({
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */ /** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) { async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
try {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT type, group_id FROM notifications WHERE id = ?', 'SELECT type, group_id FROM notifications WHERE id = ?',
[req.params.id] [req.params.id]
@@ -75,6 +76,10 @@ async function requireNotificationEditAuth(req: AuthRequest, res: Response, next
} }
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' }) return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
} catch (err) {
console.error('[Notification] editAuth error:', err)
return res.status(500).json({ code: 50000, message: '服务器错误' })
}
} }
/** 获取通知列表 */ /** 获取通知列表 */
@@ -283,8 +288,8 @@ function isValidUrl(url: string): boolean {
const parsed = new URL(url) const parsed = new URL(url)
return ['http:', 'https:'].includes(parsed.protocol) return ['http:', 'https:'].includes(parsed.protocol)
} catch { } catch {
// 相对路径也允许 // 非标准 URL 格式,小程序不支持相对路径,拒绝
return url.startsWith('/') || url.startsWith('./') return false
} }
} }

View File

@@ -177,21 +177,10 @@ router.post('/sync', async (req, res) => {
} }
} }
// 更新 next_date 到下一周期 // 更新 next_date:循环结束后 next 已推进到今天之后的下一周期,直接使用
let newNext: Date
if (t.frequency === 'weekly') {
newNext = new Date(new Date(t.next_date).getTime() + 7 * 86400000)
} else if (t.frequency === 'monthly') {
const d = new Date(t.next_date)
newNext = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate())
} else {
const d = new Date(t.next_date)
newNext = new Date(d.getFullYear() + 1, d.getMonth(), d.getDate())
}
await pool.execute( await pool.execute(
'UPDATE recurring_templates SET next_date = ? WHERE id = ?', 'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
[newNext.toISOString().slice(0, 10), t.id] [next.toISOString().slice(0, 10), t.id]
) )
} }

View File

@@ -132,7 +132,11 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
if (oldUrl) { if (oldUrl) {
const oldPath = path.join(AVATAR_DIR, oldUrl) const oldPath = path.join(AVATAR_DIR, oldUrl)
if (fs.existsSync(oldPath)) { if (fs.existsSync(oldPath)) {
try {
fs.unlinkSync(oldPath) fs.unlinkSync(oldPath)
} catch (e) {
console.warn('[User] 删除旧头像失败:', e)
}
} }
} }