Compare commits

...

2 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
9 changed files with 147 additions and 54 deletions

View File

@@ -25,6 +25,8 @@
:fixed="true"
size="large"
:auto-focus="true"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="save"
/>
@@ -55,7 +57,12 @@
</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
v-model:visible="showSuccess"
@@ -99,6 +106,8 @@ const showSuccess = ref(false)
const scrollToCat = ref('')
const todayStr = localToday
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
// Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
@@ -301,9 +310,46 @@ function goBack() {
display: flex;
flex-direction: column;
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
// 同时留出保存按钮的位置,确保内容不被遮挡
$numpad-h: 464rpx;
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
$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));
}
// 底部保存按钮栏
.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 {

View File

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

View File

@@ -67,9 +67,18 @@
size="large"
unit="元"
:auto-focus="false"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="onConfirm"
/>
</view>
<!-- 底部保存按钮键盘收起时显示 -->
<view class="save-bar" v-if="!keyboardVisible">
<view class="save-btn" @tap="onConfirm">
<text class="save-btn-text">保存预算</text>
</view>
</view>
</view>
</template>
@@ -98,6 +107,7 @@ const loading = ref(true)
const initialLoaded = ref(false)
const amountStr = ref('')
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
const presets = [1000, 2000, 3000, 5000, 10000]
@@ -197,10 +207,11 @@ function goBack() {
.page {
min-height: 100vh;
background: $bg;
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
// AmountEditor fixed Numpad 占位 + 保存按钮占位
$numpad-h: 464rpx;
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
$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 {
@@ -372,6 +383,40 @@ function goBack() {
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) {

View File

@@ -55,7 +55,7 @@ export const useGroupStore = defineStore('group', () => {
if (currentGroupId.value !== null) {
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
if (!stillValid) {
switchToPersonal()
await switchToPersonal()
}
}
} catch {
@@ -83,7 +83,7 @@ export const useGroupStore = defineStore('group', () => {
async function leaveGroup(groupId: number) {
await api.leaveGroup(groupId)
if (currentGroupId.value === groupId) {
switchToPersonal()
await switchToPersonal()
}
await fetchGroups()
}
@@ -92,7 +92,7 @@ export const useGroupStore = defineStore('group', () => {
async function deleteGroup(groupId: number) {
await api.deleteGroup(groupId)
if (currentGroupId.value === groupId) {
switchToPersonal()
await switchToPersonal()
}
await fetchGroups()
}

View File

@@ -36,7 +36,11 @@ export function formatDate(date: string | Date): string {
if (diff === 1) 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 {

View File

@@ -124,9 +124,9 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
await conn.beginTransaction()
// 验证源分类存在且属于当前用户
// 验证源分类存在且属于当前用户(仅允许迁移自定义分类)
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]
)
const sourceCategory = (sourceRows as any[])[0]

View File

@@ -46,6 +46,7 @@ const upload = multer({
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
try {
const [rows] = await pool.query(
'SELECT type, group_id FROM notifications WHERE id = ?',
[req.params.id]
@@ -75,6 +76,10 @@ async function requireNotificationEditAuth(req: AuthRequest, res: Response, next
}
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)
return ['http:', 'https:'].includes(parsed.protocol)
} catch {
// 相对路径也允许
return url.startsWith('/') || url.startsWith('./')
// 非标准 URL 格式,小程序不支持相对路径,拒绝
return false
}
}

View File

@@ -177,21 +177,10 @@ router.post('/sync', async (req, res) => {
}
}
// 更新 next_date 到下一周期
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())
}
// 更新 next_date:循环结束后 next 已推进到今天之后的下一周期,直接使用
await pool.execute(
'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) {
const oldPath = path.join(AVATAR_DIR, oldUrl)
if (fs.existsSync(oldPath)) {
try {
fs.unlinkSync(oldPath)
} catch (e) {
console.warn('[User] 删除旧头像失败:', e)
}
}
}