fix: 全面修复 22 项问题

严重 Bug:
- NaN 金额校验绕过(add 页面 parseFloat 空字符串)
- 4 个页面补充 waitForReady(add/budget/category-manage/group-manage)
- 导出 Loading 遮罩不消失(profile exportData 缺 hideLoading)
- Store 吞掉请求错误不 re-throw(transaction/stats/budget/user)
- 分类排序 SQL 拼接改参数化查询(category PUT /sort)
- 备份接口添加管理员权限检查(backup requireAdmin)

中等问题:
- 群组加入竞态条件改为 INSERT IGNORE
- 统计页切月/切Tab添加 loadSeq 防竞态
- stats store 补全 loading 状态 + Overview 初始值补全 7 字段
- 保存成功后返回不再弹「放弃修改」确认框
- 群组管理创建/加入添加 saving 双重提交保护
- 预算页添加 onShow 刷新
- 服务端输入校验(name/color/invite_code/微信code)

UI/样式:
- 触摸目标: bell/nav-back/nav-save→88rpx, avatar-badge→64rpx, action-btn→80rpx
- :active 反馈: bell/profile-card/identity-card/id-manage/arrow/action-btn/group-btn/cat-info
- 内联样式改动态绑定(4处 spacer 使用 capsuleRight)
- FAB 文字+改用 Icon 组件
- prefers-reduced-motion 动画降级(Skeleton/profile/budget/add)
- 胶囊适配(add/category-manage/group-manage)
- CategoryIcon !important 改为 iconStyle 计算属性

文档:
- CLAUDE.md 补充 SaveSuccess/ChartWrapper 组件 + category/budget/index API
This commit is contained in:
2026-06-04 17:06:02 +08:00
parent 21f4d81959
commit e110cd5c5d
25 changed files with 314 additions and 88 deletions

View File

@@ -7,7 +7,7 @@
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
<view style="width: 88rpx;"></view>
<view :style="{ width: capsuleRight + 88 + 'px' }"></view>
</view>
</view>
@@ -57,11 +57,12 @@
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready'
import Numpad from '@/components/Numpad/Numpad.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
import { statusBarHeight } from '@/utils/system'
import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore()
const txStore = useTransactionStore()
@@ -114,6 +115,7 @@ watch(currentCategories, (cats) => {
})
onMounted(async () => {
await waitForReady()
await catStore.fetchCategories()
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
@@ -154,8 +156,8 @@ function onDateChange(e: any) {
async function save() {
if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value) * 100)
if (amount <= 0) {
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' })
return
}
@@ -188,6 +190,8 @@ async function save() {
}
function goBack() {
// 保存成功后直接返回,不弹确认框
if (showSuccess.value) return
const hasInput = amountStr.value !== '0' || note.value
if (hasInput) {
uni.showModal({
@@ -365,4 +369,11 @@ function goBack() {
.extra-input { font-size: 28rpx; color: #2D1B1B; flex: 1; }
.placeholder { color: #BFB3B3; }
@media (prefers-reduced-motion: reduce) {
.cursor {
animation: none;
opacity: 1;
}
}
</style>

View File

@@ -27,6 +27,8 @@
:key="item.id"
:item="item"
:hideDate="true"
:showCreator="groupStore.isGroupMode"
:currentUserId="userStore.userInfo?.id"
@item-tap="onEdit(item)"
@delete="onDelete(item)"
/>
@@ -73,6 +75,8 @@
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { waitForReady } from '@/utils/app-ready'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
@@ -81,6 +85,8 @@ import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
const txStore = useTransactionStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const filterType = ref('all')
const page = ref(1)
const pageSize = 20
@@ -151,10 +157,18 @@ async function loadTx(reset = false) {
}
function onEdit(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
async function onDelete(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
return
}
uni.showModal({
title: '删除记录',
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}`,

View File

@@ -62,8 +62,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useBudgetStore } from '@/stores/budget'
import { useStatsStore } from '@/stores/stats'
import { waitForReady } from '@/utils/app-ready'
import { formatAmount, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
@@ -86,6 +88,7 @@ function formatPreset(val: number) {
}
onMounted(async () => {
await waitForReady()
loading.value = true
try {
await Promise.all([
@@ -104,6 +107,18 @@ onMounted(async () => {
}
})
// 返回页面时刷新预算数据
onShow(async () => {
try {
await Promise.all([
budgetStore.fetchBudget(currentMonth),
statsStore.fetchOverview(currentMonth)
])
budget.value = budgetStore.budget
monthExpense.value = statsStore.overview.expense
} catch {}
})
function selectPreset(val: number) {
amountStr.value = String(val)
}
@@ -330,4 +345,14 @@ function goBack() {
@keyframes blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.preview-skeleton {
animation: none;
}
.cursor {
animation: none;
opacity: 1;
}
}
</style>

View File

@@ -7,7 +7,7 @@
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">分类管理</text>
<view style="width: 88rpx;"></view>
<view :style="{ width: capsuleRight + 88 + 'px' }"></view>
</view>
</view>
@@ -38,7 +38,7 @@
<!-- 浮动添加按钮 -->
<view class="fab" @tap="showAddModal = true">
<text class="fab-icon">+</text>
<Icon name="plus" :size="48" color="#FFFFFF" />
</view>
<!-- 添加弹窗 -->
@@ -112,10 +112,11 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useCategoryStore, type Category } from '@/stores/category'
import { waitForReady } from '@/utils/app-ready'
import { getCategoryTransactionCount } from '@/api/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import { statusBarHeight } from '@/utils/system'
import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense')
@@ -143,7 +144,10 @@ const migrateTargets = computed(() => {
return currentCategories.value.filter(c => c.id !== deletingCat.value?.id)
})
onMounted(() => catStore.fetchCategories())
onMounted(async () => {
await waitForReady()
catStore.fetchCategories()
})
function goBack() {
uni.navigateBack()
@@ -331,6 +335,9 @@ async function onMigrateConfirm() {
align-items: center;
gap: 16rpx;
flex: 1;
padding: 8rpx 0;
&:active { opacity: 0.6; }
}
.cat-name { font-size: 28rpx; color: #2D1B1B; font-weight: 500; }
@@ -349,8 +356,8 @@ async function onMigrateConfirm() {
}
.action-btn {
width: 64rpx;
height: 64rpx;
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
@@ -376,13 +383,6 @@ async function onMigrateConfirm() {
&:active { transform: scale(0.95); }
}
.fab-icon {
font-size: 56rpx;
color: #FFFFFF;
font-weight: 300;
line-height: 1;
}
.color-picker {
display: flex;
gap: 20rpx;

View File

@@ -7,7 +7,7 @@
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">一起记</text>
<view style="width: 72rpx;"></view>
<view :style="{ width: capsuleRight + 72 + 'px' }"></view>
</view>
</view>
@@ -77,15 +77,18 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useGroupStore } from '@/stores/group'
import { statusBarHeight } from '@/utils/system'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
const groupStore = useGroupStore()
const newGroupName = ref('')
const inviteCode = ref('')
const saving = ref(false)
onMounted(() => {
onMounted(async () => {
await waitForReady()
groupStore.fetchGroups()
})
@@ -101,11 +104,13 @@ function copyInviteCode(code: string) {
}
async function handleCreate() {
if (saving.value) return
const name = newGroupName.value.trim()
if (!name) {
uni.showToast({ title: '请输入群组名称', icon: 'none' })
return
}
saving.value = true
try {
const result = await groupStore.createGroup(name)
newGroupName.value = ''
@@ -121,21 +126,27 @@ async function handleCreate() {
})
} catch (e: any) {
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
} finally {
saving.value = false
}
}
async function handleJoin() {
if (saving.value) return
const code = inviteCode.value.trim().toUpperCase()
if (!code) {
uni.showToast({ title: '请输入邀请码', icon: 'none' })
return
}
saving.value = true
try {
const result = await groupStore.joinGroup(code)
inviteCode.value = ''
uni.showToast({ title: `已加入「${result.name}`, icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
} finally {
saving.value = false
}
}
@@ -197,11 +208,13 @@ function handleDissolve(group: any) {
}
.nav-back {
width: 72rpx;
height: 72rpx;
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.nav-title {
@@ -336,6 +349,8 @@ function handleDissolve(group: any) {
display: flex;
align-items: center;
margin-top: 8rpx;
padding: 8rpx 0;
min-height: 48rpx;
gap: 4rpx;
}
@@ -363,11 +378,17 @@ function handleDissolve(group: any) {
}
.group-btn {
padding: 12rpx 24rpx;
padding: 16rpx 28rpx;
border-radius: 16rpx;
min-height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&.leave { background: #FFF0E6; }
&.dissolve { background: #FFE8E8; }
&:active { opacity: 0.7; }
}
.group-btn-text {

View File

@@ -86,7 +86,7 @@
</view>
<view class="tx-list" v-if="!loading && !loadError && recentTx.length > 0">
<TransactionItem v-for="item in recentTx" :key="item.id" :item="item" :disableSwipe="true" @item-tap="onTxTap(item)" />
<TransactionItem v-for="item in recentTx" :key="item.id" :item="item" :disableSwipe="true" :showCreator="groupStore.isGroupMode" :currentUserId="userStore.userInfo?.id" @item-tap="onTxTap(item)" />
</view>
<view class="tx-list" v-if="loading">
@@ -214,6 +214,10 @@ function showNotification() {
}
function onTxTap(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
@@ -272,11 +276,13 @@ function goBills() {
}
.bell {
width: 72rpx;
height: 72rpx;
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.overview-card {
@@ -442,7 +448,8 @@ function goBills() {
.section-more {
font-size: 24rpx;
color: #FF8C69;
padding: 8rpx 0;
padding: 16rpx 24rpx;
margin: -16rpx -24rpx;
&:active { opacity: 0.6; }
}

View File

@@ -42,6 +42,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
@@ -51,7 +52,8 @@ const previewUrl = ref('')
const uploading = ref(false)
const saving = ref(false)
onMounted(() => {
onMounted(async () => {
await waitForReady()
formNickname.value = userStore.nickname
previewUrl.value = userStore.avatarUrl
})
@@ -125,11 +127,13 @@ async function saveProfile() {
}
.nav-back {
width: 72rpx;
height: 72rpx;
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.nav-title {
@@ -141,11 +145,13 @@ async function saveProfile() {
}
.nav-save {
width: 72rpx;
height: 72rpx;
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.save-text {
@@ -180,8 +186,8 @@ async function saveProfile() {
position: absolute;
bottom: 0;
right: 0;
width: 48rpx;
height: 48rpx;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.4);
display: flex;

View File

@@ -71,7 +71,7 @@
<text class="about-version">v1.0.0</text>
<text class="about-desc">温暖 x 轻松的个人记账小程序</text>
<text class="about-desc">让记账从负担变成享受</text>
<view class="modal-btns" style="margin-top: 40rpx;">
<view class="modal-btns modal-btns-top">
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
</view>
</view>
@@ -275,6 +275,7 @@ async function exportData() {
} while (allList.length < total)
if (allList.length === 0) {
uni.hideLoading()
uni.showToast({ title: '暂无数据', icon: 'none' })
return
}
@@ -361,6 +362,8 @@ async function exportData() {
display: flex;
align-items: center;
gap: 32rpx;
&:active { opacity: 0.85; }
}
.p-avatar {
@@ -443,6 +446,12 @@ async function exportData() {
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.p-skeleton {
animation: none;
}
}
.menu-card {
margin: 40rpx 40rpx 0;
background: #FFFFFF;
@@ -514,6 +523,10 @@ async function exportData() {
gap: 24rpx;
}
.modal-btns-top {
margin-top: 40rpx;
}
.modal-btn {
flex: 1;
height: 88rpx;
@@ -546,6 +559,8 @@ async function exportData() {
display: flex;
align-items: center;
gap: 20rpx;
&:active { background: #FFF0E6; }
}
.id-info {
@@ -646,8 +661,10 @@ async function exportData() {
.id-manage {
margin-top: 16rpx;
padding: 20rpx;
padding: 24rpx 20rpx;
text-align: center;
&:active { background: #FFF0E6; border-radius: 16rpx; }
}
.id-manage-text {

View File

@@ -30,12 +30,12 @@
</view>
<view class="ov-item">
<text class="ov-label">日均</text>
<text class="ov-value" v-if="!loading">{{ formatAmount(overview.daily) }}</text>
<text class="ov-value" v-if="!loading">{{ formatAmount(tabType === 'expense' ? overview.daily : overview.dailyIncome) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
<view class="ov-item">
<text class="ov-label">笔数</text>
<text class="ov-value" v-if="!loading">{{ overview.count }}</text>
<text class="ov-value" v-if="!loading">{{ (tabType === 'expense' ? overview.expenseCount : overview.incomeCount) }}</text>
<Skeleton v-else width="80rpx" height="40rpx" variant="rect" />
</view>
</view>
@@ -57,8 +57,8 @@
canvasId="categoryChart"
type="pie"
:chartData="categoryChartData"
:width="650"
:height="400"
:width="690"
:height="450"
/>
<view class="chart-legend">
<view v-for="item in categoryStats" :key="item.id" class="legend-item">
@@ -90,8 +90,8 @@
canvasId="trendChart"
type="line"
:chartData="trendChartData"
:width="650"
:height="400"
:width="690"
:height="420"
/>
<view class="trend-list">
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
@@ -140,6 +140,7 @@ const trendData = computed(() => statsStore.trendData)
const loading = ref(true)
const loadError = ref(false)
const maxSingle = ref(0)
let loadSeq = 0 // 请求序号,防止并发覆盖
const prevMonthData = ref<any>(null)
const isCurrentMonth = computed(() => currentMonth.value === getCurrentMonth())
@@ -151,9 +152,9 @@ const categoryChartData = computed(() => {
data: categoryStats.value.map(item => ({
name: item.name,
value: item.amount / 100
})),
color: categoryStats.value.map(item => item.color)
}))
}],
color: categoryStats.value.map(item => item.color),
extra: {
pie: {
activeOpacity: 0.5,
@@ -238,6 +239,7 @@ watch(currentMonth, () => loadData())
watch(tabType, () => loadTabData())
async function loadData(silent = false) {
const seq = ++loadSeq
try {
if (!silent) loading.value = true
loadError.value = false
@@ -248,16 +250,20 @@ async function loadData(silent = false) {
fetchMaxSingle(),
fetchPrevMonthData()
])
if (seq !== loadSeq) return
} catch (e) {
if (seq !== loadSeq) return
console.error('Stats load error:', e)
loadError.value = true
} finally {
loading.value = false
if (seq === loadSeq) loading.value = false
}
}
// Re-fetch all data when tab switches
async function loadTabData() {
const seq = ++loadSeq
loadError.value = false
try {
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
@@ -266,8 +272,11 @@ async function loadTabData() {
fetchMaxSingle(),
fetchPrevMonthData()
])
if (seq !== loadSeq) return
} catch (e) {
if (seq !== loadSeq) return
console.error('Stats tab load error:', e)
loadError.value = true
}
}
@@ -365,6 +374,8 @@ function getBarWidth(amount: number) {
justify-content: center;
&.disabled { opacity: 0.5; }
&:active { opacity: 0.6; }
}
.month-text {