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:
@@ -75,8 +75,10 @@ client/src/
|
|||||||
├── components/ # 公共组件
|
├── components/ # 公共组件
|
||||||
│ ├── BudgetBar/ # 预算进度条
|
│ ├── BudgetBar/ # 预算进度条
|
||||||
│ ├── CategoryIcon/ # 分类图标 (首字+颜色)
|
│ ├── CategoryIcon/ # 分类图标 (首字+颜色)
|
||||||
|
│ ├── ChartWrapper/ # uCharts 图表封装
|
||||||
│ ├── Icon/ # 图标组件 (PNG 映射)
|
│ ├── Icon/ # 图标组件 (PNG 映射)
|
||||||
│ ├── Numpad/ # 数字键盘
|
│ ├── Numpad/ # 数字键盘
|
||||||
|
│ ├── SaveSuccess/ # 保存成功动画
|
||||||
│ ├── Skeleton/ # 骨架屏
|
│ ├── Skeleton/ # 骨架屏
|
||||||
│ └── TransactionItem/ # 交易列表项
|
│ └── TransactionItem/ # 交易列表项
|
||||||
├── stores/ # Pinia stores
|
├── stores/ # Pinia stores
|
||||||
@@ -89,9 +91,12 @@ client/src/
|
|||||||
├── api/ # API 封装
|
├── api/ # API 封装
|
||||||
│ ├── auth.ts # 登录接口
|
│ ├── auth.ts # 登录接口
|
||||||
│ ├── transaction.ts # 交易接口
|
│ ├── transaction.ts # 交易接口
|
||||||
|
│ ├── category.ts # 分类接口
|
||||||
|
│ ├── budget.ts # 预算接口
|
||||||
│ ├── stats.ts # 统计接口
|
│ ├── stats.ts # 统计接口
|
||||||
│ ├── user.ts # 用户信息接口 (含头像上传)
|
│ ├── user.ts # 用户信息接口 (含头像上传)
|
||||||
│ └── group.ts # 群组接口
|
│ ├── group.ts # 群组接口
|
||||||
|
│ └── index.ts # barrel 导出
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── format.ts # 金额/日期格式化
|
│ ├── format.ts # 金额/日期格式化
|
||||||
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
|
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { request } from '@/utils/request'
|
|||||||
export interface Overview {
|
export interface Overview {
|
||||||
expense: number
|
expense: number
|
||||||
income: number
|
income: number
|
||||||
|
expenseCount: number
|
||||||
|
incomeCount: number
|
||||||
count: number
|
count: number
|
||||||
daily: number
|
daily: number
|
||||||
|
dailyIncome: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分类统计 */
|
/** 分类统计 */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { request } from '@/utils/request'
|
|||||||
/** 交易记录 */
|
/** 交易记录 */
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: number
|
id: number
|
||||||
|
user_id: number
|
||||||
amount: number
|
amount: number
|
||||||
type: 'expense' | 'income'
|
type: 'expense' | 'income'
|
||||||
category_id: number
|
category_id: number
|
||||||
@@ -13,6 +14,7 @@ export interface Transaction {
|
|||||||
category_color?: string
|
category_color?: string
|
||||||
group_id?: number | null
|
group_id?: number | null
|
||||||
creator_nickname?: string
|
creator_nickname?: string
|
||||||
|
creator_avatar?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 交易列表响应 */
|
/** 交易列表响应 */
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const sizeMap = { sm: 64, md: 88, lg: 112 }
|
|||||||
const fontMap = { sm: 28, md: 36, lg: 48 }
|
const fontMap = { sm: 28, md: 36, lg: 48 }
|
||||||
|
|
||||||
const iconStyle = computed(() => ({
|
const iconStyle = computed(() => ({
|
||||||
background: props.bgColor || (props.color + '15'),
|
background: props.selected ? '#FFE8E0' : (props.bgColor || (props.color + '15')),
|
||||||
width: (sizeMap[props.size || 'md']) + 'rpx',
|
width: (sizeMap[props.size || 'md']) + 'rpx',
|
||||||
height: (sizeMap[props.size || 'md']) + 'rpx',
|
height: (sizeMap[props.size || 'md']) + 'rpx',
|
||||||
}))
|
}))
|
||||||
@@ -43,6 +43,5 @@ const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
|
|||||||
|
|
||||||
.selected {
|
.selected {
|
||||||
border-color: #FF8C69;
|
border-color: #FF8C69;
|
||||||
background: #FFE8E0 !important;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function buildConfig(ctx: any, canvas2d: boolean, cssWidth: number, cssHeight: n
|
|||||||
height: cssHeight,
|
height: cssHeight,
|
||||||
animation: true,
|
animation: true,
|
||||||
background: '#FFFFFF',
|
background: '#FFFFFF',
|
||||||
padding: [20, 20, 20, 20],
|
padding: [10, 10, 10, 10],
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontColor: '#8B7E7E',
|
fontColor: '#8B7E7E',
|
||||||
title: { show: false },
|
title: { show: false },
|
||||||
|
|||||||
@@ -37,4 +37,10 @@ const style = computed(() => ({
|
|||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.skeleton {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
@touchmove="!disableSwipe && onTouchMove($event)"
|
@touchmove="!disableSwipe && onTouchMove($event)"
|
||||||
@touchend="!disableSwipe && onTouchEnd()"
|
@touchend="!disableSwipe && onTouchEnd()"
|
||||||
>
|
>
|
||||||
|
<!-- 头像(群组模式下显示他人创建者) -->
|
||||||
|
<view v-if="isOtherCreator" class="creator-avatar">
|
||||||
|
<image v-if="creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-img" mode="aspectFill" />
|
||||||
|
<text v-else class="creator-initial">{{ item.creator_nickname?.[0] }}</text>
|
||||||
|
</view>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
|
v-else
|
||||||
:label="item.category_name?.[0] || '?'"
|
:label="item.category_name?.[0] || '?'"
|
||||||
:color="item.category_color || '#BFB3B3'"
|
:color="item.category_color || '#BFB3B3'"
|
||||||
:bg-color="(item.category_color || '#BFB3B3') + '20'"
|
:bg-color="(item.category_color || '#BFB3B3') + '20'"
|
||||||
@@ -16,7 +22,10 @@
|
|||||||
/>
|
/>
|
||||||
<view class="info">
|
<view class="info">
|
||||||
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
|
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
|
||||||
<text class="meta">{{ item.category_name || '未分类' }}{{ hideDate ? '' : ' · ' + formatDate(item.date) }}</text>
|
<text class="meta">
|
||||||
|
<text v-if="isOtherCreator" class="creator-name">{{ item.creator_nickname }} · </text>
|
||||||
|
{{ item.category_name || '未分类' }}{{ hideDate ? '' : ' · ' + formatDate(item.date) }}
|
||||||
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
|
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -27,14 +36,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { formatAmount, formatDate } from '@/utils/format'
|
import { formatAmount, formatDate } from '@/utils/format'
|
||||||
|
import { API_BASE } from '@/config'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
item: {
|
item: {
|
||||||
id: number
|
id: number
|
||||||
|
user_id: number
|
||||||
amount: number
|
amount: number
|
||||||
type: 'expense' | 'income'
|
type: 'expense' | 'income'
|
||||||
note?: string
|
note?: string
|
||||||
@@ -42,14 +53,33 @@ withDefaults(defineProps<{
|
|||||||
category_name?: string
|
category_name?: string
|
||||||
category_icon?: string
|
category_icon?: string
|
||||||
category_color?: string
|
category_color?: string
|
||||||
|
creator_nickname?: string
|
||||||
|
creator_avatar?: string
|
||||||
}
|
}
|
||||||
hideDate?: boolean
|
hideDate?: boolean
|
||||||
/** 禁用滑动删除(首页预览等场景) */
|
|
||||||
disableSwipe?: boolean
|
disableSwipe?: boolean
|
||||||
}>(), { hideDate: false, disableSwipe: false })
|
/** 是否显示创建者信息(群组模式) */
|
||||||
|
showCreator?: boolean
|
||||||
|
/** 当前用户 ID,用于判断是否为本人记录 */
|
||||||
|
currentUserId?: number
|
||||||
|
}>(), { hideDate: false, disableSwipe: false, showCreator: false, currentUserId: 0 })
|
||||||
|
|
||||||
|
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
|
||||||
|
const isOtherCreator = computed(() => {
|
||||||
|
if (!props.showCreator || !props.item.creator_nickname) return false
|
||||||
|
return !props.currentUserId || props.item.user_id !== props.currentUserId
|
||||||
|
})
|
||||||
|
|
||||||
defineEmits(['item-tap', 'delete'])
|
defineEmits(['item-tap', 'delete'])
|
||||||
|
|
||||||
|
const creatorAvatarUrl = computed(() => {
|
||||||
|
if (!props.item.creator_avatar) return ''
|
||||||
|
if (props.item.creator_avatar.startsWith('http')) return props.item.creator_avatar
|
||||||
|
// 截取服务端根地址(去掉 /api)
|
||||||
|
const base = API_BASE.replace(/\/api$/, '')
|
||||||
|
return base + '/api/user/avatar/' + props.item.creator_avatar
|
||||||
|
})
|
||||||
|
|
||||||
const startX = ref(0)
|
const startX = ref(0)
|
||||||
const offsetX = ref(0)
|
const offsetX = ref(0)
|
||||||
const showDelete = ref(false)
|
const showDelete = ref(false)
|
||||||
@@ -121,6 +151,29 @@ function onTouchEnd() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.creator-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #FFF0E6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-initial {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FF8C69;
|
||||||
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -143,6 +196,11 @@ function onTouchEnd() {
|
|||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.creator-name {
|
||||||
|
color: #FF8C69;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.amount {
|
.amount {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 32rpx;
|
font-size: 32rpx;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
||||||
</view>
|
</view>
|
||||||
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
|
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
|
||||||
<view style="width: 88rpx;"></view>
|
<view :style="{ width: capsuleRight + 88 + 'px' }"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -57,11 +57,12 @@
|
|||||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
import { useCategoryStore } from '@/stores/category'
|
import { useCategoryStore } from '@/stores/category'
|
||||||
import { useTransactionStore } from '@/stores/transaction'
|
import { useTransactionStore } from '@/stores/transaction'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
|
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
|
|
||||||
const catStore = useCategoryStore()
|
const catStore = useCategoryStore()
|
||||||
const txStore = useTransactionStore()
|
const txStore = useTransactionStore()
|
||||||
@@ -114,6 +115,7 @@ watch(currentCategories, (cats) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
await catStore.fetchCategories()
|
await catStore.fetchCategories()
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
const page = pages[pages.length - 1] as any
|
const page = pages[pages.length - 1] as any
|
||||||
@@ -154,8 +156,8 @@ function onDateChange(e: any) {
|
|||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (saving.value) return
|
if (saving.value) return
|
||||||
const amount = Math.round(parseFloat(amountStr.value) * 100)
|
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||||
if (amount <= 0) {
|
if (isNaN(amount) || amount <= 0) {
|
||||||
uni.showToast({ title: '请输入金额', icon: 'none' })
|
uni.showToast({ title: '请输入金额', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -188,6 +190,8 @@ async function save() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
|
// 保存成功后直接返回,不弹确认框
|
||||||
|
if (showSuccess.value) return
|
||||||
const hasInput = amountStr.value !== '0' || note.value
|
const hasInput = amountStr.value !== '0' || note.value
|
||||||
if (hasInput) {
|
if (hasInput) {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
@@ -365,4 +369,11 @@ function goBack() {
|
|||||||
.extra-input { font-size: 28rpx; color: #2D1B1B; flex: 1; }
|
.extra-input { font-size: 28rpx; color: #2D1B1B; flex: 1; }
|
||||||
|
|
||||||
.placeholder { color: #BFB3B3; }
|
.placeholder { color: #BFB3B3; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cursor {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
:item="item"
|
:item="item"
|
||||||
:hideDate="true"
|
:hideDate="true"
|
||||||
|
:showCreator="groupStore.isGroupMode"
|
||||||
|
:currentUserId="userStore.userInfo?.id"
|
||||||
@item-tap="onEdit(item)"
|
@item-tap="onEdit(item)"
|
||||||
@delete="onDelete(item)"
|
@delete="onDelete(item)"
|
||||||
/>
|
/>
|
||||||
@@ -73,6 +75,8 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { useTransactionStore } from '@/stores/transaction'
|
import { useTransactionStore } from '@/stores/transaction'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
@@ -81,6 +85,8 @@ import { statusBarHeight } from '@/utils/system'
|
|||||||
import { formatAmount, formatDate } from '@/utils/format'
|
import { formatAmount, formatDate } from '@/utils/format'
|
||||||
|
|
||||||
const txStore = useTransactionStore()
|
const txStore = useTransactionStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
const filterType = ref('all')
|
const filterType = ref('all')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
@@ -151,10 +157,18 @@ async function loadTx(reset = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onEdit(item: any) {
|
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}` })
|
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete(item: any) {
|
async function onDelete(item: any) {
|
||||||
|
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
||||||
|
uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '删除记录',
|
title: '删除记录',
|
||||||
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}?`,
|
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}?`,
|
||||||
|
|||||||
@@ -62,8 +62,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { useBudgetStore } from '@/stores/budget'
|
import { useBudgetStore } from '@/stores/budget'
|
||||||
import { useStatsStore } from '@/stores/stats'
|
import { useStatsStore } from '@/stores/stats'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { formatAmount, getCurrentMonth } from '@/utils/format'
|
import { formatAmount, getCurrentMonth } from '@/utils/format'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
||||||
@@ -86,6 +88,7 @@ function formatPreset(val: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
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) {
|
function selectPreset(val: number) {
|
||||||
amountStr.value = String(val)
|
amountStr.value = String(val)
|
||||||
}
|
}
|
||||||
@@ -330,4 +345,14 @@ function goBack() {
|
|||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
50% { opacity: 0; }
|
50% { opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.preview-skeleton {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.cursor {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
||||||
</view>
|
</view>
|
||||||
<text class="title">分类管理</text>
|
<text class="title">分类管理</text>
|
||||||
<view style="width: 88rpx;"></view>
|
<view :style="{ width: capsuleRight + 88 + 'px' }"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
<!-- 浮动添加按钮 -->
|
<!-- 浮动添加按钮 -->
|
||||||
<view class="fab" @tap="showAddModal = true">
|
<view class="fab" @tap="showAddModal = true">
|
||||||
<text class="fab-icon">+</text>
|
<Icon name="plus" :size="48" color="#FFFFFF" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 添加弹窗 -->
|
<!-- 添加弹窗 -->
|
||||||
@@ -112,10 +112,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useCategoryStore, type Category } from '@/stores/category'
|
import { useCategoryStore, type Category } from '@/stores/category'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getCategoryTransactionCount } from '@/api/category'
|
import { getCategoryTransactionCount } from '@/api/category'
|
||||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
|
|
||||||
const catStore = useCategoryStore()
|
const catStore = useCategoryStore()
|
||||||
const tabType = ref<'expense' | 'income'>('expense')
|
const tabType = ref<'expense' | 'income'>('expense')
|
||||||
@@ -143,7 +144,10 @@ const migrateTargets = computed(() => {
|
|||||||
return currentCategories.value.filter(c => c.id !== deletingCat.value?.id)
|
return currentCategories.value.filter(c => c.id !== deletingCat.value?.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => catStore.fetchCategories())
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
|
catStore.fetchCategories()
|
||||||
|
})
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
@@ -331,6 +335,9 @@ async function onMigrateConfirm() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: 16rpx;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-name { font-size: 28rpx; color: #2D1B1B; font-weight: 500; }
|
.cat-name { font-size: 28rpx; color: #2D1B1B; font-weight: 500; }
|
||||||
@@ -349,8 +356,8 @@ async function onMigrateConfirm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 64rpx;
|
width: 80rpx;
|
||||||
height: 64rpx;
|
height: 80rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -376,13 +383,6 @@ async function onMigrateConfirm() {
|
|||||||
&:active { transform: scale(0.95); }
|
&:active { transform: scale(0.95); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab-icon {
|
|
||||||
font-size: 56rpx;
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker {
|
.color-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||||
</view>
|
</view>
|
||||||
<text class="nav-title">一起记</text>
|
<text class="nav-title">一起记</text>
|
||||||
<view style="width: 72rpx;"></view>
|
<view :style="{ width: capsuleRight + 72 + 'px' }"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -77,15 +77,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useGroupStore } from '@/stores/group'
|
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'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
const newGroupName = ref('')
|
const newGroupName = ref('')
|
||||||
const inviteCode = ref('')
|
const inviteCode = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
groupStore.fetchGroups()
|
groupStore.fetchGroups()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -101,11 +104,13 @@ function copyInviteCode(code: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
|
if (saving.value) return
|
||||||
const name = newGroupName.value.trim()
|
const name = newGroupName.value.trim()
|
||||||
if (!name) {
|
if (!name) {
|
||||||
uni.showToast({ title: '请输入群组名称', icon: 'none' })
|
uni.showToast({ title: '请输入群组名称', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const result = await groupStore.createGroup(name)
|
const result = await groupStore.createGroup(name)
|
||||||
newGroupName.value = ''
|
newGroupName.value = ''
|
||||||
@@ -121,21 +126,27 @@ async function handleCreate() {
|
|||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
|
uni.showToast({ title: e.message || '创建失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleJoin() {
|
async function handleJoin() {
|
||||||
|
if (saving.value) return
|
||||||
const code = inviteCode.value.trim().toUpperCase()
|
const code = inviteCode.value.trim().toUpperCase()
|
||||||
if (!code) {
|
if (!code) {
|
||||||
uni.showToast({ title: '请输入邀请码', icon: 'none' })
|
uni.showToast({ title: '请输入邀请码', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const result = await groupStore.joinGroup(code)
|
const result = await groupStore.joinGroup(code)
|
||||||
inviteCode.value = ''
|
inviteCode.value = ''
|
||||||
uni.showToast({ title: `已加入「${result.name}」`, icon: 'success' })
|
uni.showToast({ title: `已加入「${result.name}」`, icon: 'success' })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
|
uni.showToast({ title: e.message || '加入失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,11 +208,13 @@ function handleDissolve(group: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 72rpx;
|
width: 88rpx;
|
||||||
height: 72rpx;
|
height: 88rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
@@ -336,6 +349,8 @@ function handleDissolve(group: any) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 8rpx;
|
margin-top: 8rpx;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
min-height: 48rpx;
|
||||||
gap: 4rpx;
|
gap: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,11 +378,17 @@ function handleDissolve(group: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-btn {
|
.group-btn {
|
||||||
padding: 12rpx 24rpx;
|
padding: 16rpx 28rpx;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
|
min-height: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
&.leave { background: #FFF0E6; }
|
&.leave { background: #FFF0E6; }
|
||||||
&.dissolve { background: #FFE8E8; }
|
&.dissolve { background: #FFE8E8; }
|
||||||
|
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-btn-text {
|
.group-btn-text {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="tx-list" v-if="!loading && !loadError && recentTx.length > 0">
|
<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>
|
||||||
|
|
||||||
<view class="tx-list" v-if="loading">
|
<view class="tx-list" v-if="loading">
|
||||||
@@ -214,6 +214,10 @@ function showNotification() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTxTap(item: any) {
|
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}` })
|
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,11 +276,13 @@ function goBills() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bell {
|
.bell {
|
||||||
width: 72rpx;
|
width: 88rpx;
|
||||||
height: 72rpx;
|
height: 88rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-card {
|
.overview-card {
|
||||||
@@ -442,7 +448,8 @@ function goBills() {
|
|||||||
.section-more {
|
.section-more {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #FF8C69;
|
color: #FF8C69;
|
||||||
padding: 8rpx 0;
|
padding: 16rpx 24rpx;
|
||||||
|
margin: -16rpx -24rpx;
|
||||||
|
|
||||||
&:active { opacity: 0.6; }
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
@@ -51,7 +52,8 @@ const previewUrl = ref('')
|
|||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
formNickname.value = userStore.nickname
|
formNickname.value = userStore.nickname
|
||||||
previewUrl.value = userStore.avatarUrl
|
previewUrl.value = userStore.avatarUrl
|
||||||
})
|
})
|
||||||
@@ -125,11 +127,13 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 72rpx;
|
width: 88rpx;
|
||||||
height: 72rpx;
|
height: 88rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
@@ -141,11 +145,13 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-save {
|
.nav-save {
|
||||||
width: 72rpx;
|
width: 88rpx;
|
||||||
height: 72rpx;
|
height: 88rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-text {
|
.save-text {
|
||||||
@@ -180,8 +186,8 @@ async function saveProfile() {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 48rpx;
|
width: 64rpx;
|
||||||
height: 48rpx;
|
height: 64rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
<text class="about-version">v1.0.0</text>
|
<text class="about-version">v1.0.0</text>
|
||||||
<text class="about-desc">温暖 x 轻松的个人记账小程序</text>
|
<text class="about-desc">温暖 x 轻松的个人记账小程序</text>
|
||||||
<text class="about-desc">让记账从负担变成享受</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 class="modal-btn confirm" @tap="showAbout = false">知道了</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -275,6 +275,7 @@ async function exportData() {
|
|||||||
} while (allList.length < total)
|
} while (allList.length < total)
|
||||||
|
|
||||||
if (allList.length === 0) {
|
if (allList.length === 0) {
|
||||||
|
uni.hideLoading()
|
||||||
uni.showToast({ title: '暂无数据', icon: 'none' })
|
uni.showToast({ title: '暂无数据', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -361,6 +362,8 @@ async function exportData() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 32rpx;
|
gap: 32rpx;
|
||||||
|
|
||||||
|
&:active { opacity: 0.85; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-avatar {
|
.p-avatar {
|
||||||
@@ -443,6 +446,12 @@ async function exportData() {
|
|||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.p-skeleton {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.menu-card {
|
.menu-card {
|
||||||
margin: 40rpx 40rpx 0;
|
margin: 40rpx 40rpx 0;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
@@ -514,6 +523,10 @@ async function exportData() {
|
|||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-btns-top {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-btn {
|
.modal-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
@@ -546,6 +559,8 @@ async function exportData() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
|
|
||||||
|
&:active { background: #FFF0E6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-info {
|
.id-info {
|
||||||
@@ -646,8 +661,10 @@ async function exportData() {
|
|||||||
|
|
||||||
.id-manage {
|
.id-manage {
|
||||||
margin-top: 16rpx;
|
margin-top: 16rpx;
|
||||||
padding: 20rpx;
|
padding: 24rpx 20rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
&:active { background: #FFF0E6; border-radius: 16rpx; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-manage-text {
|
.id-manage-text {
|
||||||
|
|||||||
@@ -30,12 +30,12 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="ov-item">
|
<view class="ov-item">
|
||||||
<text class="ov-label">日均</text>
|
<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" />
|
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
|
||||||
</view>
|
</view>
|
||||||
<view class="ov-item">
|
<view class="ov-item">
|
||||||
<text class="ov-label">笔数</text>
|
<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" />
|
<Skeleton v-else width="80rpx" height="40rpx" variant="rect" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
canvasId="categoryChart"
|
canvasId="categoryChart"
|
||||||
type="pie"
|
type="pie"
|
||||||
:chartData="categoryChartData"
|
:chartData="categoryChartData"
|
||||||
:width="650"
|
:width="690"
|
||||||
:height="400"
|
:height="450"
|
||||||
/>
|
/>
|
||||||
<view class="chart-legend">
|
<view class="chart-legend">
|
||||||
<view v-for="item in categoryStats" :key="item.id" class="legend-item">
|
<view v-for="item in categoryStats" :key="item.id" class="legend-item">
|
||||||
@@ -90,8 +90,8 @@
|
|||||||
canvasId="trendChart"
|
canvasId="trendChart"
|
||||||
type="line"
|
type="line"
|
||||||
:chartData="trendChartData"
|
:chartData="trendChartData"
|
||||||
:width="650"
|
:width="690"
|
||||||
:height="400"
|
:height="420"
|
||||||
/>
|
/>
|
||||||
<view class="trend-list">
|
<view class="trend-list">
|
||||||
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
|
<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 loading = ref(true)
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
const maxSingle = ref(0)
|
const maxSingle = ref(0)
|
||||||
|
let loadSeq = 0 // 请求序号,防止并发覆盖
|
||||||
const prevMonthData = ref<any>(null)
|
const prevMonthData = ref<any>(null)
|
||||||
|
|
||||||
const isCurrentMonth = computed(() => currentMonth.value === getCurrentMonth())
|
const isCurrentMonth = computed(() => currentMonth.value === getCurrentMonth())
|
||||||
@@ -151,9 +152,9 @@ const categoryChartData = computed(() => {
|
|||||||
data: categoryStats.value.map(item => ({
|
data: categoryStats.value.map(item => ({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
value: item.amount / 100
|
value: item.amount / 100
|
||||||
})),
|
}))
|
||||||
color: categoryStats.value.map(item => item.color)
|
|
||||||
}],
|
}],
|
||||||
|
color: categoryStats.value.map(item => item.color),
|
||||||
extra: {
|
extra: {
|
||||||
pie: {
|
pie: {
|
||||||
activeOpacity: 0.5,
|
activeOpacity: 0.5,
|
||||||
@@ -238,6 +239,7 @@ watch(currentMonth, () => loadData())
|
|||||||
watch(tabType, () => loadTabData())
|
watch(tabType, () => loadTabData())
|
||||||
|
|
||||||
async function loadData(silent = false) {
|
async function loadData(silent = false) {
|
||||||
|
const seq = ++loadSeq
|
||||||
try {
|
try {
|
||||||
if (!silent) loading.value = true
|
if (!silent) loading.value = true
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
@@ -248,16 +250,20 @@ async function loadData(silent = false) {
|
|||||||
fetchMaxSingle(),
|
fetchMaxSingle(),
|
||||||
fetchPrevMonthData()
|
fetchPrevMonthData()
|
||||||
])
|
])
|
||||||
|
if (seq !== loadSeq) return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== loadSeq) return
|
||||||
console.error('Stats load error:', e)
|
console.error('Stats load error:', e)
|
||||||
loadError.value = true
|
loadError.value = true
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch all data when tab switches
|
// Re-fetch all data when tab switches
|
||||||
async function loadTabData() {
|
async function loadTabData() {
|
||||||
|
const seq = ++loadSeq
|
||||||
|
loadError.value = false
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
statsStore.fetchOverview(currentMonth.value),
|
statsStore.fetchOverview(currentMonth.value),
|
||||||
@@ -266,8 +272,11 @@ async function loadTabData() {
|
|||||||
fetchMaxSingle(),
|
fetchMaxSingle(),
|
||||||
fetchPrevMonthData()
|
fetchPrevMonthData()
|
||||||
])
|
])
|
||||||
|
if (seq !== loadSeq) return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== loadSeq) return
|
||||||
console.error('Stats tab load error:', e)
|
console.error('Stats tab load error:', e)
|
||||||
|
loadError.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +374,8 @@ function getBarWidth(amount: number) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&.disabled { opacity: 0.5; }
|
&.disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-text {
|
.month-text {
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ export const useBudgetStore = defineStore('budget', () => {
|
|||||||
try {
|
try {
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
budget.value = await api.getBudget(month || getCurrentMonth(), groupStore.currentGroupId)
|
budget.value = await api.getBudget(month || getCurrentMonth(), groupStore.currentGroupId)
|
||||||
} catch {
|
} catch (err) {
|
||||||
budget.value = null
|
budget.value = null
|
||||||
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ export type CategoryStat = api.CategoryStat
|
|||||||
export type TrendPoint = api.TrendPoint
|
export type TrendPoint = api.TrendPoint
|
||||||
|
|
||||||
export const useStatsStore = defineStore('stats', () => {
|
export const useStatsStore = defineStore('stats', () => {
|
||||||
const overview = ref<api.Overview>({ expense: 0, income: 0, count: 0, daily: 0 })
|
const overview = ref<api.Overview>({ expense: 0, income: 0, expenseCount: 0, incomeCount: 0, count: 0, daily: 0, dailyIncome: 0 })
|
||||||
const categoryStats = ref<api.CategoryStat[]>([])
|
const categoryStats = ref<api.CategoryStat[]>([])
|
||||||
const trendData = ref<api.TrendPoint[]>([])
|
const trendData = ref<api.TrendPoint[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
async function fetchOverview(month?: string) {
|
async function fetchOverview(month?: string) {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const data = await api.getOverview({
|
const data = await api.getOverview({
|
||||||
@@ -24,11 +26,17 @@ export const useStatsStore = defineStore('stats', () => {
|
|||||||
overview.value = {
|
overview.value = {
|
||||||
expense: Number(data?.expense) || 0,
|
expense: Number(data?.expense) || 0,
|
||||||
income: Number(data?.income) || 0,
|
income: Number(data?.income) || 0,
|
||||||
|
expenseCount: Number(data?.expenseCount) || 0,
|
||||||
|
incomeCount: Number(data?.incomeCount) || 0,
|
||||||
count: Number(data?.count) || 0,
|
count: Number(data?.count) || 0,
|
||||||
daily: Number(data?.daily) || 0
|
daily: Number(data?.daily) || 0,
|
||||||
|
dailyIncome: Number(data?.dailyIncome) || 0
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
overview.value = { expense: 0, income: 0, count: 0, daily: 0 }
|
overview.value = { expense: 0, income: 0, expenseCount: 0, incomeCount: 0, count: 0, daily: 0, dailyIncome: 0 }
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,8 +49,9 @@ export const useStatsStore = defineStore('stats', () => {
|
|||||||
amount: Number(item.amount) || 0,
|
amount: Number(item.amount) || 0,
|
||||||
count: Number(item.count) || 0
|
count: Number(item.count) || 0
|
||||||
})) : []
|
})) : []
|
||||||
} catch {
|
} catch (err) {
|
||||||
categoryStats.value = []
|
categoryStats.value = []
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +63,11 @@ export const useStatsStore = defineStore('stats', () => {
|
|||||||
...item,
|
...item,
|
||||||
amount: Number(item.amount) || 0
|
amount: Number(item.amount) || 0
|
||||||
})) : []
|
})) : []
|
||||||
} catch {
|
} catch (err) {
|
||||||
trendData.value = []
|
trendData.value = []
|
||||||
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend }
|
return { overview, categoryStats, trendData, loading, fetchOverview, fetchCategoryStats, fetchTrend }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ export const useTransactionStore = defineStore('transaction', () => {
|
|||||||
transactions.value = data.list
|
transactions.value = data.list
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
currentPage.value = data.page
|
currentPage.value = data.page
|
||||||
} catch {
|
} catch (err) {
|
||||||
transactions.value = []
|
transactions.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
|
throw err
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[UserStore] fetchUserInfo error:', e)
|
console.error('[UserStore] fetchUserInfo error:', e)
|
||||||
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
|
|||||||
router.post('/login', async (req: Request, res: Response) => {
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.body
|
const { code } = req.body
|
||||||
if (!code) {
|
if (!code || typeof code !== 'string' || code.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(code)) {
|
||||||
return res.status(400).json({ code: 40001, message: '缺少code参数' })
|
return res.status(400).json({ code: 40001, message: 'code参数无效' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用微信 code2session 接口
|
// 调用微信 code2session 接口
|
||||||
|
|||||||
@@ -4,8 +4,23 @@ import { backupDatabase, getBackupList } from '../utils/backup'
|
|||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
// 管理员用户 ID 列表(逗号分隔),未配置时默认仅 ID=1
|
||||||
|
const ADMIN_IDS = process.env.ADMIN_USER_IDS
|
||||||
|
? process.env.ADMIN_USER_IDS.split(',').map(Number)
|
||||||
|
: [1]
|
||||||
|
|
||||||
|
/** 检查是否为管理员,非管理员返回 403 */
|
||||||
|
function requireAdmin(req: AuthRequest, res: Response): boolean {
|
||||||
|
if (!req.userId || !ADMIN_IDS.includes(req.userId)) {
|
||||||
|
res.status(403).json({ code: 40300, message: '无权限' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// 手动触发备份
|
// 手动触发备份
|
||||||
router.post('/', async (_req: AuthRequest, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
if (!requireAdmin(req, res)) return
|
||||||
try {
|
try {
|
||||||
const filepath = await backupDatabase()
|
const filepath = await backupDatabase()
|
||||||
res.json({ code: 0, data: { filepath } })
|
res.json({ code: 0, data: { filepath } })
|
||||||
@@ -16,7 +31,8 @@ router.post('/', async (_req: AuthRequest, res: Response) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 获取备份列表
|
// 获取备份列表
|
||||||
router.get('/', async (_req: AuthRequest, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
if (!requireAdmin(req, res)) return
|
||||||
try {
|
try {
|
||||||
const list = getBackupList()
|
const list = getBackupList()
|
||||||
res.json({ code: 0, data: list })
|
res.json({ code: 0, data: list })
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
if (!name || !icon || !color || !type) {
|
if (!name || !icon || !color || !type) {
|
||||||
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
||||||
}
|
}
|
||||||
|
if (typeof name !== 'string' || name.length > 50) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '分类名称不能超过50字' })
|
||||||
|
}
|
||||||
|
if (typeof color !== 'string' || color.length > 20) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '颜色值无效' })
|
||||||
|
}
|
||||||
if (!['expense', 'income'].includes(type)) {
|
if (!['expense', 'income'].includes(type)) {
|
||||||
return res.status(400).json({ code: 40002, message: '类型无效' })
|
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||||
}
|
}
|
||||||
@@ -45,6 +51,12 @@ router.put('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
if (!name || !color) {
|
if (!name || !color) {
|
||||||
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
||||||
}
|
}
|
||||||
|
if (typeof name !== 'string' || name.length > 50) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '分类名称不能超过50字' })
|
||||||
|
}
|
||||||
|
if (typeof color !== 'string' || color.length > 20) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '颜色值无效' })
|
||||||
|
}
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
'UPDATE categories SET name = ?, color = ? WHERE id = ? AND is_custom = 1 AND user_id = ?',
|
'UPDATE categories SET name = ?, color = ? WHERE id = ? AND is_custom = 1 AND user_id = ?',
|
||||||
[name, color, req.params.id, req.userId]
|
[name, color, req.params.id, req.userId]
|
||||||
@@ -121,16 +133,21 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
|
|||||||
router.put('/sort', async (req: AuthRequest, res: Response) => {
|
router.put('/sort', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { ids } = req.body // [id1, id2, id3, ...]
|
const { ids } = req.body // [id1, id2, id3, ...]
|
||||||
if (!Array.isArray(ids) || ids.length === 0) {
|
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '参数无效' })
|
||||||
|
}
|
||||||
|
// 校验每个元素必须是正整数
|
||||||
|
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
|
||||||
return res.status(400).json({ code: 40001, message: '参数无效' })
|
return res.status(400).json({ code: 40001, message: '参数无效' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建 CASE WHEN 语句批量更新
|
// 使用参数化查询构建 CASE WHEN 语句
|
||||||
const cases = ids.map((id: number, index: number) => `WHEN ${Number(id)} THEN ${index}`).join(' ')
|
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
|
||||||
const idList = ids.map((id: number) => Number(id)).join(',')
|
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
|
||||||
|
const placeholders = ids.map(() => '?').join(',')
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE categories SET sort_order = CASE id ${cases} END WHERE id IN (${idList}) AND (user_id = 0 OR user_id = ?)`,
|
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
|
||||||
[req.userId]
|
[...whenParams, ...ids, req.userId]
|
||||||
)
|
)
|
||||||
res.json({ code: 0 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
router.post('/join', async (req: AuthRequest, res: Response) => {
|
router.post('/join', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { invite_code } = req.body
|
const { invite_code } = req.body
|
||||||
if (!invite_code || typeof invite_code !== 'string') {
|
if (!invite_code || typeof invite_code !== 'string' || invite_code.length > 10) {
|
||||||
return res.status(400).json({ code: 40001, message: '邀请码不能为空' })
|
return res.status(400).json({ code: 40001, message: '邀请码无效' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const [groupRows] = await pool.query(
|
const [groupRows] = await pool.query(
|
||||||
@@ -142,19 +142,14 @@ router.post('/join', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(404).json({ code: 40400, message: '邀请码无效' })
|
return res.status(404).json({ code: 40400, message: '邀请码无效' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已是成员
|
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
|
||||||
const [existing] = await pool.query(
|
const [result] = await pool.query(
|
||||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
||||||
[group.id, req.userId]
|
|
||||||
)
|
|
||||||
if ((existing as any[]).length > 0) {
|
|
||||||
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
|
||||||
[group.id, req.userId, 'member']
|
[group.id, req.userId, 'member']
|
||||||
)
|
)
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ code: 0, data: { group_id: group.id, name: group.name } })
|
res.json({ code: 0, data: { group_id: group.id, name: group.name } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
|
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
|
||||||
c.name as category_name, c.icon as category_icon, c.color as category_color,
|
c.name as category_name, c.icon as category_icon, c.color as category_color,
|
||||||
u.nickname as creator_nickname
|
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
||||||
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
|
||||||
LEFT JOIN users u ON t.user_id = u.id
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
@@ -85,7 +85,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
|
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
|
||||||
c.name as category_name, c.icon as category_icon, c.color as category_color,
|
c.name as category_name, c.icon as category_icon, c.color as category_color,
|
||||||
u.nickname as creator_nickname
|
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
||||||
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
|
||||||
LEFT JOIN users u ON t.user_id = u.id
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
|
|||||||
Reference in New Issue
Block a user