Compare commits
2 Commits
f586f9e117
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 324a3ec5c9 | |||
| 9f5802d634 |
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -46,35 +46,40 @@ const upload = multer({
|
||||
|
||||
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
|
||||
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT type, group_id FROM notifications WHERE id = ?',
|
||||
[req.params.id]
|
||||
)
|
||||
const notification = (rows as any[])[0]
|
||||
if (!notification) {
|
||||
return res.status(404).json({ code: 40400, message: '公告不存在' })
|
||||
}
|
||||
|
||||
// 检查是否是管理员
|
||||
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 next()
|
||||
}
|
||||
|
||||
// 群组公告:检查是否是群主
|
||||
if (notification.type === 'group' && notification.group_id) {
|
||||
const [groupRows] = await pool.query(
|
||||
'SELECT created_by FROM `groups` WHERE id = ?',
|
||||
[notification.group_id]
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT type, group_id FROM notifications WHERE id = ?',
|
||||
[req.params.id]
|
||||
)
|
||||
const group = (groupRows as any[])[0]
|
||||
if (group && group.created_by === req.userId) {
|
||||
const notification = (rows as any[])[0]
|
||||
if (!notification) {
|
||||
return res.status(404).json({ code: 40400, message: '公告不存在' })
|
||||
}
|
||||
|
||||
// 检查是否是管理员
|
||||
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 next()
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
|
||||
// 群组公告:检查是否是群主
|
||||
if (notification.type === 'group' && notification.group_id) {
|
||||
const [groupRows] = await pool.query(
|
||||
'SELECT created_by FROM `groups` WHERE id = ?',
|
||||
[notification.group_id]
|
||||
)
|
||||
const group = (groupRows as any[])[0]
|
||||
if (group && group.created_by === req.userId) {
|
||||
return 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,11 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
|
||||
if (oldUrl) {
|
||||
const oldPath = path.join(AVATAR_DIR, oldUrl)
|
||||
if (fs.existsSync(oldPath)) {
|
||||
fs.unlinkSync(oldPath)
|
||||
try {
|
||||
fs.unlinkSync(oldPath)
|
||||
} catch (e) {
|
||||
console.warn('[User] 删除旧头像失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user