fix: v1.4 Bug 修复与体验优化
- 修复所有日期相关逻辑使用 UTC 导致时区偏移问题 - 首页今日数据改用 stats API 聚合查询,消除 100 条上限 - 账单分页加载添加请求序号,防止并发覆盖 - 统计页 maxSingle 改为按金额排序 - 统计页 tab 切换时刷新月对比数据 - 首页 onShow 跳过首次加载,避免双重请求 - 今日卡片同时展示支出/收入,骨架屏对齐 - 账单页统一左滑删除,去掉长按 - 后端 transaction 支持 sortBy 参数 - 后端 stats overview 支持 startDate/endDate 查询
This commit is contained in:
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
const m = (month as string) || getCurrentMonth()
|
|
||||||
const range = getMonthRange(m)
|
let startDate: string
|
||||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
let endDate: string
|
||||||
const { startDate, endDate } = range
|
let elapsedDays = 1
|
||||||
|
|
||||||
|
if (qStart && qEnd) {
|
||||||
|
// 按日期范围查询(用于今日等场景)
|
||||||
|
startDate = qStart as string
|
||||||
|
endDate = qEnd as string
|
||||||
|
elapsedDays = 1
|
||||||
|
} else {
|
||||||
|
const m = (month as string) || getCurrentMonth()
|
||||||
|
const range = getMonthRange(m)
|
||||||
|
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||||
|
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({
|
||||||
|
|||||||
@@ -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]
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user