feat: v1.5 安全加固与体验优化
- 后端添加 express-rate-limit 限流(登录 10次/分钟,API 200次/分钟) - H5 演示登录:自动获取 demo token,401 时自动重试 - 分类删除前查询关联记录数,提示用户影响范围 - 分类添加支持 8 色选择器,替代随机颜色 - 首页 overview card 改为日均+收入+笔数,避免重复显示支出 - 请求模块 401 处理优化:H5 自动刷新 token 并重试原请求
This commit is contained in:
@@ -24,6 +24,25 @@ onLaunch(() => {
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
if (!token) {
|
||||
// 同步等待 token,避免页面首次请求因无 token 而 401
|
||||
request<{ token: string; userId: number }>({
|
||||
url: '/auth/demo-login',
|
||||
method: 'POST'
|
||||
}).then((data) => {
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
console.log('[Auth] H5 demo login success')
|
||||
}).catch((e) => {
|
||||
console.error('[Auth] H5 demo login failed:', e)
|
||||
})
|
||||
} else {
|
||||
console.log('[Auth] H5 token exists')
|
||||
}
|
||||
// #endif
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -33,8 +33,17 @@
|
||||
<text class="add-title">添加自定义分类</text>
|
||||
<view class="add-form">
|
||||
<input class="add-input" v-model="newName" placeholder="分类名称" maxlength="10" />
|
||||
<view class="add-btn" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
|
||||
</view>
|
||||
<view class="color-picker">
|
||||
<view
|
||||
v-for="c in colorOptions" :key="c"
|
||||
class="color-dot"
|
||||
:class="{ selected: selectedColor === c }"
|
||||
:style="{ background: c }"
|
||||
@tap="selectedColor = c"
|
||||
/>
|
||||
</view>
|
||||
<view class="add-btn" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -49,6 +58,8 @@ import { statusBarHeight } from '@/utils/system'
|
||||
const catStore = useCategoryStore()
|
||||
const tabType = ref<'expense' | 'income'>('expense')
|
||||
const newName = ref('')
|
||||
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
|
||||
const selectedColor = ref(colorOptions[0])
|
||||
|
||||
const currentCategories = computed(() => catStore.getByType(tabType.value))
|
||||
|
||||
@@ -58,14 +69,11 @@ async function onAdd() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
|
||||
const colors = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
|
||||
const color = colors[Math.floor(Math.random() * colors.length)]
|
||||
|
||||
try {
|
||||
await catStore.addCategory({
|
||||
name,
|
||||
icon: name[0],
|
||||
color,
|
||||
color: selectedColor.value,
|
||||
type: tabType.value
|
||||
})
|
||||
newName.value = ''
|
||||
@@ -76,20 +84,44 @@ async function onAdd() {
|
||||
}
|
||||
|
||||
async function onDelete(cat: any) {
|
||||
uni.showModal({
|
||||
title: '删除分类',
|
||||
content: `确定删除「${cat.name}」?该分类下的记录将变为"未分类"。`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await catStore.deleteCategory(cat.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
try {
|
||||
// 查询关联记录数
|
||||
const { request: req } = await import('@/utils/request')
|
||||
const { count } = await req<{ count: number }>({ url: `/categories/${cat.id}/transaction-count` })
|
||||
const tip = count > 0
|
||||
? `该分类下有 ${count} 条记录,删除后会变为"未分类"。`
|
||||
: '删除后不可恢复。'
|
||||
uni.showModal({
|
||||
title: '删除分类',
|
||||
content: `确定删除「${cat.name}」?${tip}`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await catStore.deleteCategory(cat.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
// 查询失败时仍允许删除
|
||||
uni.showModal({
|
||||
title: '删除分类',
|
||||
content: `确定删除「${cat.name}」?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await catStore.deleteCategory(cat.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -198,6 +230,7 @@ async function onDelete(cat: any) {
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.add-input {
|
||||
@@ -209,8 +242,29 @@ async function onDelete(cat: any) {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid transparent;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.selected {
|
||||
border-color: #2D1B1B;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 160rpx;
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||
border-radius: 20rpx;
|
||||
|
||||
@@ -23,15 +23,20 @@
|
||||
<BudgetBar v-if="!loading" :total="budget?.amount || DEFAULT_BUDGET" :used="overview.expense" />
|
||||
<Skeleton v-else width="100%" height="16rpx" variant="text" />
|
||||
<view class="mini-stats">
|
||||
<view class="mini-stat">
|
||||
<text class="mini-label">日均</text>
|
||||
<text class="mini-value" v-if="!loading">{{ formatAmount(overview.daily) }}</text>
|
||||
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
|
||||
</view>
|
||||
<view class="mini-stat">
|
||||
<text class="mini-label">收入</text>
|
||||
<text class="mini-value income" v-if="!loading">+{{ formatAmount(overview.income) }}</text>
|
||||
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
|
||||
</view>
|
||||
<view class="mini-stat">
|
||||
<text class="mini-label">支出</text>
|
||||
<text class="mini-value expense" v-if="!loading">-{{ formatAmount(overview.expense) }}</text>
|
||||
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
|
||||
<text class="mini-label">笔数</text>
|
||||
<text class="mini-value" v-if="!loading">{{ overview.count }}笔</text>
|
||||
<Skeleton v-else width="80rpx" height="40rpx" variant="rect" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -33,19 +33,29 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
uni.removeStorageSync('xc:userId')
|
||||
if (!isRedirectingToLogin) {
|
||||
isRedirectingToLogin = true
|
||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
// #ifdef H5
|
||||
window.location.reload()
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
// 重新触发 wx.login
|
||||
reLogin()
|
||||
// #endif
|
||||
isRedirectingToLogin = false
|
||||
}, 1500)
|
||||
// H5 环境自动获取 demo token 并重试
|
||||
if (typeof window !== 'undefined') {
|
||||
request<{ token: string; userId: number }>({
|
||||
url: '/auth/demo-login',
|
||||
method: 'POST'
|
||||
}).then((data) => {
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
// 重试原请求
|
||||
request(options).then(resolve).catch(reject)
|
||||
}).catch(() => {
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
}).finally(() => { isRedirectingToLogin = false })
|
||||
} else {
|
||||
// 小程序环境重新触发 wx.login
|
||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
||||
setTimeout(() => { reLogin(); isRedirectingToLogin = false }, 1500)
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
}
|
||||
} else {
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
}
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user