Files
xiaocai/client/src/pages/category-manage/index.vue
wangxiaogang 4f42f2c342 refactor: UI/UX 全面重构 — 设计系统统一
设计系统:
- uni.scss 扩展:间距/圆角/字体/阴影/动画 token 体系
- 新建 styles/mixins.scss:20+ 公共 mixin

页面重构(12 个页面):
- 首页/记账/统计/账单/个人中心/预算/分类管理
- 群组管理/通知中心/管理员/用户管理/编辑资料

所有硬编码替换为设计 token:
- 颜色:#FFF8F0→$bg, #FFFFFF→$surface, #FF8C69→$primary 等
- 间距:40rpx→$space-xl, 24rpx→$space-md 等
- 字体:28rpx→$font-lg, 24rpx→$font-md 等
- 圆角:20rpx→$radius-lg, 40rpx→$radius-2xl 等
- 阴影:统一使用 $shadow-md/$shadow-sm

重复样式提取为 mixin:
- page-base, sticky-header, nav-bar, card, modal-mask
- tabs, tab, state-box, shimmer, flex-center 等
2026-06-06 22:37:46 +08:00

506 lines
13 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: capsuleRight + 88 + 'px' }"></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">
<Icon name="plus" :size="48" color="#FFFFFF" />
</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 { onShow } from '@dcloudio/uni-app'
import { useCategoryStore, type Category } from '@/stores/category'
import { waitForReady } from '@/utils/app-ready'
import { getCategoryTransactionCount } from '@/api/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import { statusBarHeight, capsuleRight } 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)
})
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
await catStore.fetchCategories()
initialLoaded.value = true
})
// 返回页面时静默刷新
onShow(() => {
if (initialLoaded.value) 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>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed {
@include sticky-header;
}
.status-bar { @include status-bar; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-sm $space-xl;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.title { font-size: $font-xl; font-weight: 600; color: $text; }
.tabs-wrap { display: flex; justify-content: center; margin: $space-sm 0 $space-lg; }
.tabs {
display: inline-flex;
background: $surface-warm;
border-radius: $space-md;
padding: $space-xs;
}
.tab {
padding: $space-sm $space-xl;
border-radius: $radius-lg;
font-size: $font-lg;
color: $text-sec;
transition: all $transition-normal;
&.active {
background: $surface;
color: $primary;
font-weight: 600;
box-shadow: $shadow-sm;
}
}
.cat-list { padding: 0 $space-xl; }
.cat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-md 0;
border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; }
}
.cat-info {
display: flex;
align-items: center;
gap: $space-sm;
flex: 1;
padding: $space-xs 0;
&:active { opacity: 0.6; }
}
.cat-name { font-size: $font-lg; color: $text; font-weight: 500; }
.cat-badge {
font-size: $font-xs;
color: $primary;
background: $primary-light;
padding: 4rpx 12rpx;
border-radius: $radius-xs;
}
.cat-actions {
display: flex;
gap: $space-xs;
}
.action-btn {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:active { background: $border; }
}
.fab {
position: fixed;
right: $space-xl;
bottom: 120rpx;
width: 112rpx;
height: 112rpx;
background: linear-gradient(135deg, $primary, #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); }
}
.color-picker {
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
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: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
display: block;
text-align: center;
margin-bottom: $space-lg;
}
.modal-desc {
font-size: $font-lg;
color: $text-sec;
display: block;
margin-bottom: $space-md;
}
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.migrate-list {
max-height: 400rpx;
margin-bottom: $space-md;
}
.migrate-item {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-lg $space-sm;
border-radius: $radius-md;
margin-bottom: $space-xs;
transition: background $transition-normal;
&.selected {
background: $primary-light;
}
&:active { background: $surface-warm; }
}
.migrate-name {
font-size: $font-lg;
color: $text;
font-weight: 500;
}
.modal-btns {
display: flex;
gap: $space-md;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
font-weight: 600;
&.cancel {
background: $bg;
color: $text-sec;
}
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style>