fix: v1.4 Bug 修复与体验优化

- 修复所有日期相关逻辑使用 UTC 导致时区偏移问题
- 首页今日数据改用 stats API 聚合查询,消除 100 条上限
- 账单分页加载添加请求序号,防止并发覆盖
- 统计页 maxSingle 改为按金额排序
- 统计页 tab 切换时刷新月对比数据
- 首页 onShow 跳过首次加载,避免双重请求
- 今日卡片同时展示支出/收入,骨架屏对齐
- 账单页统一左滑删除,去掉长按
- 后端 transaction 支持 sortBy 参数
- 后端 stats overview 支持 startDate/endDate 查询
This commit is contained in:
2026-05-29 17:33:24 +08:00
parent 418d5b24f9
commit 4737f160b7
7 changed files with 94 additions and 45 deletions

View File

@@ -67,12 +67,14 @@ const type = ref<'expense' | 'income'>('expense')
const amountStr = ref('0') const amountStr = ref('0')
const selectedCat = ref<number | null>(null) const selectedCat = ref<number | null>(null)
const note = ref('') const note = ref('')
const selectedDate = ref(new Date().toISOString().slice(0, 10)) const now = new Date()
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const selectedDate = ref(localToday)
const saving = ref(false) const saving = ref(false)
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const showSuccess = ref(false) const showSuccess = ref(false)
const scrollToCat = ref('') const scrollToCat = ref('')
const todayStr = new Date().toISOString().slice(0, 10) const todayStr = localToday
// 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 }

View File

@@ -26,7 +26,6 @@
:item="item" :item="item"
:hideDate="true" :hideDate="true"
@tap="onEdit(item)" @tap="onEdit(item)"
@longpress="onDelete(item)"
@delete="onDelete(item)" @delete="onDelete(item)"
/> />
</view> </view>
@@ -85,6 +84,7 @@ const pageSize = 20
const txList = ref<any[]>([]) const txList = ref<any[]>([])
const total = ref(0) const total = ref(0)
const loadError = ref(false) const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖
const noMore = computed(() => txList.value.length >= total.value && total.value > 0) const noMore = computed(() => txList.value.length >= total.value && total.value > 0)
@@ -116,11 +116,14 @@ async function loadTx(reset = false) {
page.value = 1 page.value = 1
txList.value = [] txList.value = []
} }
const seq = ++loadSeq
const params: any = { page: page.value, pageSize } const params: any = { page: page.value, pageSize }
if (filterType.value !== 'all') params.type = filterType.value if (filterType.value !== 'all') params.type = filterType.value
try { try {
loadError.value = false loadError.value = false
await txStore.fetchTransactions(params) await txStore.fetchTransactions(params)
// 丢弃过期请求的结果
if (seq !== loadSeq) return
if (reset) { if (reset) {
txList.value = txStore.transactions txList.value = txStore.transactions
} else { } else {
@@ -128,6 +131,7 @@ async function loadTx(reset = false) {
} }
total.value = txStore.total total.value = txStore.total
} catch (e) { } catch (e) {
if (seq !== loadSeq) return
console.error('Load bills error:', e) console.error('Load bills error:', e)
loadError.value = true loadError.value = true
} }

View File

@@ -37,17 +37,28 @@
</view> </view>
<view class="today-card" v-if="!loading"> <view class="today-card" v-if="!loading">
<view class="today-left"> <view class="today-item">
<text class="today-label">今日支出</text> <text class="today-label">今日支出</text>
<text class="today-amount">{{ formatAmount(todayExpense) }}</text> <text class="today-amount expense">{{ formatAmount(todayExpense) }}</text>
</view> </view>
<view class="today-right"> <view class="today-divider"></view>
<text class="today-count">{{ todayCount }}</text> <view class="today-item">
<text class="today-label">今日收入</text>
<text class="today-amount income">{{ formatAmount(todayIncome) }}</text>
</view> </view>
<view class="today-count">{{ todayCount }}</view>
</view> </view>
<view class="today-card" v-else> <view class="today-card" v-else>
<Skeleton width="200rpx" height="28rpx" variant="text" /> <view class="today-item">
<Skeleton width="160rpx" height="48rpx" variant="rect" /> <Skeleton width="96rpx" height="24rpx" variant="text" />
<Skeleton width="140rpx" height="36rpx" variant="rect" />
</view>
<view class="today-divider"></view>
<view class="today-item">
<Skeleton width="96rpx" height="24rpx" variant="text" />
<Skeleton width="140rpx" height="36rpx" variant="rect" />
</view>
<Skeleton width="48rpx" height="22rpx" variant="text" />
</view> </view>
<view class="quick-actions"> <view class="quick-actions">
@@ -118,6 +129,7 @@ const recentTx = ref<any[]>([])
const loading = ref(true) const loading = ref(true)
const loadError = ref(false) const loadError = ref(false)
const todayExpense = ref(0) const todayExpense = ref(0)
const todayIncome = ref(0)
const todayCount = ref(0) const todayCount = ref(0)
const greeting = computed(() => { const greeting = computed(() => {
@@ -133,16 +145,17 @@ async function loadData() {
try { try {
loading.value = true loading.value = true
loadError.value = false loadError.value = false
const today = new Date().toISOString().slice(0, 10) const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
await Promise.all([ await Promise.all([
statsStore.fetchOverview(), statsStore.fetchOverview(),
budgetStore.fetchBudget(), budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 }), txStore.fetchTransactions({ page: 1 }),
fetchTodayExpense(today) fetchTodayData(today)
]) ])
overview.value = statsStore.overview overview.value = statsStore.overview
budget.value = budgetStore.budget budget.value = budgetStore.budget
recentTx.value = txStore.transactions.slice(0, 10) recentTx.value = txStore.transactions.slice(0, 5)
} catch (e) { } catch (e) {
console.error('Load error:', e) console.error('Load error:', e)
loadError.value = true loadError.value = true
@@ -151,26 +164,28 @@ async function loadData() {
} }
} }
async function fetchTodayExpense(today: string) { async function fetchTodayData(today: string) {
try { try {
const { request } = await import('@/utils/request') const { request } = await import('@/utils/request')
const data = await request<any>({ const data = await request<any>({ url: '/stats/overview', data: { startDate: today, endDate: today } })
url: '/transactions', todayExpense.value = data.expense || 0
data: { page: 1, pageSize: 100, type: 'expense', startDate: today, endDate: today } todayIncome.value = data.income || 0
}) todayCount.value = data.count || 0
todayExpense.value = data.list.reduce((sum: number, t: any) => sum + t.amount, 0)
todayCount.value = data.list.length
} catch { } catch {
todayExpense.value = 0 todayExpense.value = 0
todayIncome.value = 0
todayCount.value = 0 todayCount.value = 0
} }
} }
onMounted(() => loadData()) const initialLoaded = ref(false)
onMounted(() => {
loadData().finally(() => { initialLoaded.value = true })
})
// Refresh data when returning from other pages (e.g., after adding a transaction) // Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => { onShow(() => {
if (!loading.value) loadData() if (initialLoaded.value && !loading.value) loadData()
}) })
onPullDownRefresh(() => { onPullDownRefresh(() => {
@@ -311,11 +326,12 @@ function goBills() {
border: 2rpx solid #F0E0D6; border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08); box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 24rpx;
} }
.today-left { .today-item {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8rpx; gap: 8rpx;
@@ -328,18 +344,24 @@ function goBills() {
.today-amount { .today-amount {
font-family: 'Fredoka', sans-serif; font-family: 'Fredoka', sans-serif;
font-size: 36rpx; font-size: 32rpx;
font-weight: 600; font-weight: 600;
color: #FF6B6B;
&.expense { color: #FF6B6B; }
&.income { color: #7BC67E; }
} }
.today-right { .today-divider {
text-align: right; width: 2rpx;
height: 56rpx;
background: #F0E0D6;
flex-shrink: 0;
} }
.today-count { .today-count {
font-size: 24rpx; font-size: 22rpx;
color: #8B7E7E; color: #BFB3B3;
flex-shrink: 0;
} }
.quick-actions { .quick-actions {

View File

@@ -109,6 +109,11 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' }) uni.navigateTo({ url: '/pages/category-manage/index' })
} }
function getLocalDateStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function exportData() { async function exportData() {
try { try {
const data = await request<any>({ url: '/transactions', data: { page: 1, pageSize: 9999 } }) const data = await request<any>({ url: '/transactions', data: { page: 1, pageSize: 9999 } })
@@ -141,7 +146,7 @@ async function exportData() {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `小菜记账_${new Date().toISOString().slice(0, 10)}.csv` a.download = `小菜记账_${getLocalDateStr()}.csv`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
uni.showToast({ title: '导出成功', icon: 'success' }) uni.showToast({ title: '导出成功', icon: 'success' })
@@ -149,7 +154,7 @@ async function exportData() {
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager() const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${new Date().toISOString().slice(0, 10)}.csv` const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
fs.writeFileSync(filePath, csv, 'utf-8') fs.writeFileSync(filePath, csv, 'utf-8')
uni.openDocument({ uni.openDocument({
filePath, filePath,

View File

@@ -240,7 +240,9 @@ async function loadTabData() {
try { try {
await Promise.all([ await Promise.all([
statsStore.fetchCategoryStats(currentMonth.value, tabType.value), statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value) statsStore.fetchTrend(currentMonth.value, tabType.value),
fetchMaxSingle(),
fetchPrevMonthData()
]) ])
categoryStats.value = statsStore.categoryStats categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData trendData.value = statsStore.trendData
@@ -258,7 +260,7 @@ async function fetchMaxSingle() {
const { request } = await import('@/utils/request') const { request } = await import('@/utils/request')
const data = await request<any>({ const data = await request<any>({
url: '/transactions', url: '/transactions',
data: { page: 1, pageSize: 1, type: tabType.value, startDate, endDate } data: { page: 1, pageSize: 1, type: tabType.value, startDate, endDate, sortBy: 'amount' }
}) })
maxSingle.value = data.list?.[0]?.amount || 0 maxSingle.value = data.list?.[0]?.amount || 0
} catch { } catch {

View File

@@ -7,11 +7,28 @@ const router = Router()
router.get('/overview', async (req: AuthRequest, res: Response) => { router.get('/overview', async (req: AuthRequest, res: Response) => {
try { try {
const { month } = req.query const { month, startDate: qStart, endDate: qEnd } = req.query
let startDate: string
let endDate: string
let elapsedDays = 1
if (qStart && qEnd) {
// 按日期范围查询(用于今日等场景)
startDate = qStart as string
endDate = qEnd as string
elapsedDays = 1
} else {
const m = (month as string) || getCurrentMonth() const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m) const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' }) if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range startDate = range.startDate
endDate = range.endDate
const now = new Date()
const [year, mon] = m.split('-').map(Number)
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
}
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT `SELECT
@@ -22,11 +39,6 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
[req.userId, startDate, endDate] [req.userId, startDate, endDate]
) )
const row = (rows as any[])[0] const row = (rows as any[])[0]
const now = new Date()
const [year, mon] = m.split('-').map(Number)
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
const elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0 const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0
res.json({ res.json({

View File

@@ -35,7 +35,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
router.get('/', async (req: AuthRequest, res: Response) => { router.get('/', async (req: AuthRequest, res: Response) => {
try { try {
const { page, pageSize, type, startDate, endDate } = req.query const { page, pageSize, type, startDate, endDate, sortBy } = req.query
const pSize = safeInt(pageSize, 20, 1, 100) const pSize = safeInt(pageSize, 20, 1, 100)
const pNum = safeInt(page, 1, 1, 99999) const pNum = safeInt(page, 1, 1, 99999)
const offset = (pNum - 1) * pSize const offset = (pNum - 1) * pSize
@@ -53,12 +53,14 @@ router.get('/', async (req: AuthRequest, res: Response) => {
where += ' AND t.date <= ?'; params.push(endDate) where += ' AND t.date <= ?'; params.push(endDate)
} }
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color `SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color
FROM transactions t FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN categories c ON t.category_id = c.id
${where} ${where}
ORDER BY t.date DESC, t.created_at DESC ORDER BY ${orderBy}
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
[...params, pSize, offset] [...params, pSize, offset]
) )