Files
xiaocai/client/src/pages/category-manage/index.vue
wangxiaogang db81ad8eb2 refactor: API 请求整合与 Bug 修复
前端重构:
- 新增 api/ 目录统一管理所有 API 请求
- 所有 stores 和 pages 改为使用 api 模块
- 修复日期格式化函数,支持 Date 对象和 ISO 字符串
- 修复 TransactionItem 使用 CategoryIcon 组件
- 优化 ChartWrapper 区分 H5 和小程序环境

后端修复:
- 修复 auth 中间件 PUBLIC_PATHS 缺少 demo-login
- 修复备份工具命令注入漏洞,改用 execFile
- 修复 stats 路由 type 参数验证
- 优化分类排序为批量 SQL 更新
- 合并迁移和删除为数据库事务原子操作
- 修复交易和统计日期返回格式
- 新增自动备份功能(启动时备份)
2026-06-02 17:41:35 +08:00

506 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">分类管理</text>
<view style="width: 88rpx;"></view>
</view>
</view>
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: tabType === 'expense' }" @tap="tabType = 'expense'">支出</view>
<view class="tab" :class="{ active: tabType === 'income' }" @tap="tabType = 'income'">收入</view>
</view>
</view>
<view class="cat-list">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-row">
<view class="cat-info" @tap="cat.is_custom && onEdit(cat)">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
<text class="cat-name">{{ cat.name }}</text>
<text class="cat-badge" v-if="cat.is_custom">自定义</text>
</view>
<view class="cat-actions" v-if="cat.is_custom">
<view class="action-btn" @tap="onEdit(cat)">
<Icon name="edit" :size="24" color="#8B7E7E" />
</view>
<view class="action-btn" @tap="onDelete(cat)">
<Icon name="trash" :size="24" color="#FF6B6B" />
</view>
</view>
</view>
</view>
<!-- 浮动添加按钮 -->
<view class="fab" @tap="showAddModal = true">
<text class="fab-icon">+</text>
</view>
<!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">添加自定义分类</text>
<input class="modal-input" v-model="newName" placeholder="分类名称" maxlength="10" />
<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="modal-btns">
<view class="modal-btn cancel" @tap="showAddModal = false">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
<!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">编辑分类</text>
<input class="modal-input" v-model="editName" placeholder="分类名称" maxlength="10" />
<view class="color-picker">
<view
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: editColor === c }"
:style="{ background: c }"
@tap="editColor = c"
/>
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
</view>
</view>
</view>
<!-- 迁移弹窗 -->
<view class="modal-mask" v-if="showMigrateModal" @tap="showMigrateModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">迁移记录</text>
<text class="modal-desc">{{ deletingCat?.name }}下有 {{ migrateCount }} 条记录请选择迁移到</text>
<scroll-view class="migrate-list" scroll-y>
<view
v-for="cat in migrateTargets"
:key="cat.id"
class="migrate-item"
:class="{ selected: migrateTargetId === cat.id }"
@tap="migrateTargetId = cat.id"
>
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
<text class="migrate-name">{{ cat.name }}</text>
</view>
</scroll-view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showMigrateModal = false">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !migrateTargetId }" @tap="onMigrateConfirm">确认迁移并删除</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useCategoryStore, type Category } from '@/stores/category'
import { getCategoryTransactionCount } from '@/api/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import { statusBarHeight } from '@/utils/system'
const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense')
const showAddModal = ref(false)
const newName = ref('')
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const selectedColor = ref(colorOptions[0])
// 编辑相关
const showEditModal = ref(false)
const editingCat = ref<Category | null>(null)
const editName = ref('')
const editColor = ref('')
// 迁移相关
const showMigrateModal = ref(false)
const deletingCat = ref<Category | null>(null)
const migrateCount = ref(0)
const migrateTargetId = ref<number | null>(null)
const currentCategories = computed(() => catStore.getByType(tabType.value))
// 迁移目标列表(同类型且非当前删除的分类)
const migrateTargets = computed(() => {
return currentCategories.value.filter(c => c.id !== deletingCat.value?.id)
})
onMounted(() => catStore.fetchCategories())
function goBack() {
uni.navigateBack()
}
async function onAdd() {
const name = newName.value.trim()
if (!name) return
try {
await catStore.addCategory({
name,
icon: name[0],
color: selectedColor.value,
type: tabType.value
})
newName.value = ''
showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
} catch {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
function onEdit(cat: Category) {
editingCat.value = cat
editName.value = cat.name
editColor.value = cat.color
showEditModal.value = true
}
async function onEditConfirm() {
if (!editingCat.value) return
const name = editName.value.trim()
if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' })
return
}
try {
await catStore.updateCategory(editingCat.value.id, { name, color: editColor.value })
showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' })
} catch {
uni.showToast({ title: '修改失败', icon: 'none' })
}
}
async function onDelete(cat: Category) {
try {
const { count } = await getCategoryTransactionCount(cat.id)
if (count > 0) {
// 有关联记录,需要迁移
deletingCat.value = cat
migrateCount.value = count
migrateTargetId.value = null
showMigrateModal.value = true
} else {
// 无关联记录,直接删除
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' })
}
}
}
})
}
} 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' })
}
}
}
})
}
}
async function onMigrateConfirm() {
if (!deletingCat.value || !migrateTargetId.value) return
try {
// 服务端原子操作:迁移记录 + 删除分类
await catStore.migrateCategory(deletingCat.value.id, migrateTargetId.value)
showMigrateModal.value = false
// 刷新分类列表
await catStore.fetchCategories()
uni.showToast({ title: '已迁移并删除', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar { background: #FFF8F0; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 40rpx;
}
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:active { background: #F0E0D6; }
}
.title { font-size: 32rpx; font-weight: 600; color: #2D1B1B; }
.tabs-wrap { display: flex; justify-content: center; margin: 16rpx 0 32rpx; }
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
&.active {
background: #FFFFFF;
color: #FF8C69;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
}
}
.cat-list { padding: 0 40rpx; }
.cat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #F0E0D6;
&:last-child { border-bottom: none; }
}
.cat-info {
display: flex;
align-items: center;
gap: 16rpx;
flex: 1;
}
.cat-name { font-size: 28rpx; color: #2D1B1B; font-weight: 500; }
.cat-badge {
font-size: 20rpx;
color: #FF8C69;
background: #FFE8E0;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.cat-actions {
display: flex;
gap: 8rpx;
}
.action-btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:active { background: #F0E0D6; }
}
.fab {
position: fixed;
right: 40rpx;
bottom: 120rpx;
width: 112rpx;
height: 112rpx;
background: linear-gradient(135deg, #FF8C69, #E67355);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 8rpx 8rpx 24rpx rgba(255, 140, 105, 0.4);
z-index: 50;
&:active { transform: scale(0.95); }
}
.fab-icon {
font-size: 56rpx;
color: #FFFFFF;
font-weight: 300;
line-height: 1;
}
.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; }
}
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
width: 600rpx;
background: #FFFFFF;
border-radius: 32rpx;
padding: 48rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.modal-desc {
font-size: 28rpx;
color: #8B7E7E;
display: block;
margin-bottom: 24rpx;
}
.modal-input {
width: 100%;
height: 96rpx;
background: #FFF8F0;
border-radius: 24rpx;
padding: 0 32rpx;
font-size: 28rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.migrate-list {
max-height: 400rpx;
margin-bottom: 24rpx;
}
.migrate-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 16rpx;
border-radius: 16rpx;
margin-bottom: 8rpx;
transition: background 0.2s;
&.selected {
background: #FFE8E0;
}
&:active { background: #FFF0E6; }
}
.migrate-name {
font-size: 28rpx;
color: #2D1B1B;
font-weight: 500;
}
.modal-btns {
display: flex;
gap: 24rpx;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
&.cancel {
background: #FFF8F0;
color: #8B7E7E;
}
&.confirm {
background: linear-gradient(135deg, #FF8C69, #E67355);
color: #FFFFFF;
}
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style>