Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 6m47s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
700 lines
15 KiB
Vue
700 lines
15 KiB
Vue
<template>
|
||
<div class="page-container-flex">
|
||
<van-nav-bar
|
||
:title="navTitle"
|
||
left-text="返回"
|
||
left-arrow
|
||
placeholder
|
||
@click-left="handleBack"
|
||
/>
|
||
|
||
<div class="scroll-content">
|
||
<!-- 第一层:选择交易类型 -->
|
||
<div
|
||
v-if="currentLevel === 0"
|
||
class="level-container"
|
||
>
|
||
<van-cell-group inset>
|
||
<van-cell
|
||
v-for="type in typeOptions"
|
||
:key="type.value"
|
||
:title="type.label"
|
||
is-link
|
||
@click="handleSelectType(type.value)"
|
||
/>
|
||
</van-cell-group>
|
||
</div>
|
||
|
||
<!-- 第二层:分类列表 -->
|
||
<div
|
||
v-else
|
||
class="level-container"
|
||
>
|
||
<!-- 面包屑导航 -->
|
||
<div class="breadcrumb">
|
||
<van-tag
|
||
type="primary"
|
||
closeable
|
||
style="margin-left: 16px"
|
||
@close="handleBackToRoot"
|
||
>
|
||
{{ currentTypeName }}
|
||
</van-tag>
|
||
</div>
|
||
|
||
<!-- 分类列表 -->
|
||
<van-empty
|
||
v-if="categories.length === 0"
|
||
description="暂无分类"
|
||
/>
|
||
|
||
<van-cell-group
|
||
v-else
|
||
inset
|
||
>
|
||
<van-swipe-cell
|
||
v-for="category in categories"
|
||
:key="category.id"
|
||
>
|
||
<van-cell :title="category.name">
|
||
<template #icon>
|
||
<div
|
||
v-if="category.icon"
|
||
class="category-icon"
|
||
v-html="parseIcon(category.icon)"
|
||
/>
|
||
</template>
|
||
<template #default>
|
||
<div class="category-actions">
|
||
<van-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
@click="handleIconSelect(category)"
|
||
>
|
||
选择图标
|
||
</van-button>
|
||
<van-button
|
||
size="small"
|
||
@click="handleEditOld(category)"
|
||
>
|
||
编辑
|
||
</van-button>
|
||
</div>
|
||
</template>
|
||
</van-cell>
|
||
<template #right>
|
||
<van-button
|
||
square
|
||
type="danger"
|
||
text="删除"
|
||
@click="handleDelete(category)"
|
||
/>
|
||
</template>
|
||
</van-swipe-cell>
|
||
</van-cell-group>
|
||
</div>
|
||
|
||
<!-- 底部安全距离 -->
|
||
<div style="height: calc(55px + env(safe-area-inset-bottom, 0px))" />
|
||
|
||
<div class="bottom-button">
|
||
<!-- 新增分类按钮 -->
|
||
<van-button
|
||
type="primary"
|
||
size="large"
|
||
icon="plus"
|
||
@click="handleAddCategory"
|
||
>
|
||
新增分类
|
||
</van-button>
|
||
</div>
|
||
|
||
<!-- 新增分类对话框 -->
|
||
<van-dialog
|
||
v-model:show="showAddDialog"
|
||
title="新增分类"
|
||
@confirm="handleConfirmAdd"
|
||
@cancel="resetAddForm"
|
||
>
|
||
<van-form ref="addFormRef">
|
||
<van-field
|
||
v-model="addForm.name"
|
||
name="name"
|
||
label="分类名称"
|
||
placeholder="请输入分类名称"
|
||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||
/>
|
||
</van-form>
|
||
</van-dialog>
|
||
|
||
<!-- 编辑分类对话框 -->
|
||
<van-dialog
|
||
v-model:show="showEditDialog"
|
||
title="编辑分类"
|
||
show-cancel-button
|
||
@confirm="handleConfirmEdit"
|
||
>
|
||
<van-form ref="editFormRef">
|
||
<van-field
|
||
v-model="editForm.name"
|
||
name="name"
|
||
label="分类名称"
|
||
placeholder="请输入分类名称"
|
||
:rules="[{ required: true, message: '请输入分类名称' }]"
|
||
/>
|
||
</van-form>
|
||
</van-dialog>
|
||
|
||
<!-- 删除确认对话框 -->
|
||
<van-dialog
|
||
v-model:show="showDeleteConfirm"
|
||
title="删除分类"
|
||
message="删除后无法恢复,确定要删除吗?"
|
||
@confirm="handleConfirmDelete"
|
||
/>
|
||
|
||
<!-- 图标选择对话框 -->
|
||
<van-dialog
|
||
v-model:show="showIconDialog"
|
||
title="选择图标"
|
||
show-cancel-button
|
||
@confirm="handleConfirmIconSelect"
|
||
>
|
||
<div class="icon-selector">
|
||
<div
|
||
v-if="currentCategory && currentCategory.icon"
|
||
class="icon-list"
|
||
>
|
||
<div
|
||
v-for="(icon, index) in parseIconArray(currentCategory.icon)"
|
||
:key="index"
|
||
class="icon-item"
|
||
:class="{ active: selectedIconIndex === index }"
|
||
@click="selectedIconIndex = index"
|
||
>
|
||
<div
|
||
class="icon-preview"
|
||
v-html="icon"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="empty-icons"
|
||
>
|
||
<van-empty description="暂无图标" />
|
||
</div>
|
||
|
||
<div class="icon-actions">
|
||
<van-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="isGeneratingIcon"
|
||
:disabled="isGeneratingIcon"
|
||
@click="handleGenerateIcon"
|
||
>
|
||
{{ isGeneratingIcon ? 'AI生成中...' : '生成新图标' }}
|
||
</van-button>
|
||
</div>
|
||
</div>
|
||
</van-dialog>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { showSuccessToast, showToast, showLoadingToast, closeToast } from 'vant'
|
||
import {
|
||
getCategoryList,
|
||
createCategory,
|
||
deleteCategory,
|
||
updateCategory,
|
||
generateIcon,
|
||
updateSelectedIcon
|
||
} from '@/api/transactionCategory'
|
||
|
||
const router = useRouter()
|
||
|
||
// 交易类型选项
|
||
const typeOptions = [
|
||
{ value: 0, label: '支出' },
|
||
{ value: 1, label: '收入' },
|
||
{ value: 2, label: '不计收支' }
|
||
]
|
||
|
||
// 层级状态
|
||
const currentLevel = ref(0) // 0=类型选择, 1=分类管理
|
||
const currentType = ref(null) // 当前选中的交易类型
|
||
const currentTypeName = computed(() => {
|
||
const type = typeOptions.find((t) => t.value === currentType.value)
|
||
return type ? type.label : ''
|
||
})
|
||
|
||
// 分类数据
|
||
const categories = ref([])
|
||
|
||
// 编辑对话框
|
||
const showAddDialog = ref(false)
|
||
const addFormRef = ref(null)
|
||
const addForm = ref({
|
||
name: ''
|
||
})
|
||
|
||
// 删除确认
|
||
const showDeleteConfirm = ref(false)
|
||
const deleteTarget = ref(null)
|
||
|
||
// 编辑对话框
|
||
const showEditDialog = ref(false)
|
||
const editFormRef = ref(null)
|
||
const editForm = ref({
|
||
id: 0,
|
||
name: ''
|
||
})
|
||
|
||
// 图标选择对话框
|
||
const showIconDialog = ref(false)
|
||
const currentCategory = ref(null) // 当前正在编辑图标的分类
|
||
const selectedIconIndex = ref(0)
|
||
const isGeneratingIcon = ref(false)
|
||
|
||
// 计算导航栏标题
|
||
const navTitle = computed(() => {
|
||
if (currentLevel.value === 0) {
|
||
return '编辑分类'
|
||
}
|
||
return currentTypeName.value
|
||
})
|
||
|
||
/**
|
||
* 选择交易类型,进入分类管理
|
||
*/
|
||
const handleSelectType = async (type) => {
|
||
currentType.value = type
|
||
currentLevel.value = 1
|
||
await loadCategories()
|
||
}
|
||
|
||
/**
|
||
* 加载分类列表
|
||
*/
|
||
const loadCategories = async () => {
|
||
try {
|
||
showLoadingToast({
|
||
message: '加载中...',
|
||
forbidClick: true,
|
||
duration: 0
|
||
})
|
||
|
||
const { success, data } = await getCategoryList(currentType.value)
|
||
if (success) {
|
||
categories.value = data || []
|
||
} else {
|
||
showToast('加载分类失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('加载分类错误:', error)
|
||
showToast('加载分类失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
closeToast()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 返回上一级
|
||
*/
|
||
const handleBack = () => {
|
||
if (currentLevel.value === 1) {
|
||
currentLevel.value = 0
|
||
currentType.value = null
|
||
categories.value = []
|
||
} else {
|
||
if (window.history.length > 1) {
|
||
router.back()
|
||
} else {
|
||
router.replace('/')
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 返回到根目录(类型选择)
|
||
*/
|
||
const handleBackToRoot = () => {
|
||
currentLevel.value = 0
|
||
currentType.value = null
|
||
categories.value = []
|
||
}
|
||
|
||
/**
|
||
* 新增分类
|
||
*/
|
||
const handleAddCategory = () => {
|
||
addForm.value = {
|
||
name: ''
|
||
}
|
||
showAddDialog.value = true
|
||
}
|
||
|
||
/**
|
||
* 确认新增
|
||
*/
|
||
const handleConfirmAdd = async () => {
|
||
try {
|
||
// 表单验证
|
||
await addFormRef.value?.validate()
|
||
|
||
showLoadingToast({
|
||
message: '创建中...',
|
||
forbidClick: true,
|
||
duration: 0
|
||
})
|
||
|
||
const { success, message } = await createCategory({
|
||
name: addForm.value.name,
|
||
type: currentType.value
|
||
})
|
||
|
||
if (success) {
|
||
showSuccessToast('创建成功')
|
||
showAddDialog.value = false
|
||
resetAddForm()
|
||
await loadCategories()
|
||
} else {
|
||
showToast(message || '创建失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('创建失败:', error)
|
||
showToast('创建失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
closeToast()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 编辑分类
|
||
*/
|
||
const handleEdit = (category) => {
|
||
editForm.value = {
|
||
id: category.id,
|
||
name: category.name
|
||
}
|
||
showEditDialog.value = true
|
||
}
|
||
|
||
/**
|
||
* 打开图标选择器
|
||
*/
|
||
const handleIconSelect = (category) => {
|
||
currentCategory.value = category
|
||
selectedIconIndex.value = 0
|
||
showIconDialog.value = true
|
||
}
|
||
|
||
/**
|
||
* 生成新图标
|
||
*/
|
||
const handleGenerateIcon = async () => {
|
||
if (!currentCategory.value) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
isGeneratingIcon.value = true
|
||
showLoadingToast({
|
||
message: 'AI正在生成图标...',
|
||
forbidClick: true,
|
||
duration: 0
|
||
})
|
||
|
||
const { success, data, message } = await generateIcon(currentCategory.value.id)
|
||
|
||
if (success) {
|
||
showSuccessToast('图标生成成功')
|
||
// 重新加载分类列表以获取最新的图标
|
||
await loadCategories()
|
||
// 更新当前分类引用
|
||
const updated = categories.value.find((c) => c.id === currentCategory.value.id)
|
||
if (updated) {
|
||
currentCategory.value = updated
|
||
}
|
||
} else {
|
||
showToast(message || '生成图标失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('生成图标失败:', error)
|
||
showToast('生成图标失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
isGeneratingIcon.value = false
|
||
closeToast()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确认选择图标
|
||
*/
|
||
const handleConfirmIconSelect = async () => {
|
||
if (!currentCategory.value) {return}
|
||
|
||
try {
|
||
showLoadingToast({
|
||
message: '保存中...',
|
||
forbidClick: true,
|
||
duration: 0
|
||
})
|
||
|
||
const { success, message } = await updateSelectedIcon(
|
||
currentCategory.value.id,
|
||
selectedIconIndex.value
|
||
)
|
||
|
||
if (success) {
|
||
showSuccessToast('图标保存成功')
|
||
showIconDialog.value = false
|
||
await loadCategories()
|
||
} else {
|
||
showToast(message || '保存失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('保存图标失败:', error)
|
||
showToast('保存图标失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
closeToast()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 编辑分类
|
||
*/
|
||
const handleEditOld = (category) => {
|
||
editForm.value = {
|
||
id: category.id,
|
||
name: category.name
|
||
}
|
||
showEditDialog.value = true
|
||
}
|
||
|
||
/**
|
||
* 确认编辑
|
||
*/
|
||
const handleConfirmEdit = async () => {
|
||
try {
|
||
await editFormRef.value?.validate()
|
||
|
||
showLoadingToast({
|
||
message: '保存中...',
|
||
forbidClick: true,
|
||
duration: 0
|
||
})
|
||
|
||
const { success, message } = await updateCategory({
|
||
id: editForm.value.id,
|
||
name: editForm.value.name
|
||
})
|
||
|
||
if (success) {
|
||
showSuccessToast('保存成功')
|
||
showEditDialog.value = false
|
||
await loadCategories()
|
||
} else {
|
||
showToast(message || '保存失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('保存失败:', error)
|
||
showToast('保存失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
closeToast()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除分类
|
||
*/
|
||
const handleDelete = async (category) => {
|
||
deleteTarget.value = category
|
||
showDeleteConfirm.value = true
|
||
}
|
||
|
||
/**
|
||
* 确认删除
|
||
*/
|
||
const handleConfirmDelete = async () => {
|
||
if (!deleteTarget.value) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
showLoadingToast({
|
||
message: '删除中...',
|
||
forbidClick: true,
|
||
duration: 0
|
||
})
|
||
|
||
const { success, message } = await deleteCategory(deleteTarget.value.id)
|
||
|
||
if (success) {
|
||
showSuccessToast('删除成功')
|
||
showDeleteConfirm.value = false
|
||
deleteTarget.value = null
|
||
await loadCategories()
|
||
} else {
|
||
showToast(message || '删除失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('删除失败:', error)
|
||
showToast('删除失败: ' + (error.message || '未知错误'))
|
||
} finally {
|
||
closeToast()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重置新增表单
|
||
*/
|
||
const resetAddForm = () => {
|
||
addForm.value = {
|
||
name: ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析图标数组(第一个图标为当前选中的)
|
||
*/
|
||
const parseIcon = (iconJson) => {
|
||
if (!iconJson) {return ''}
|
||
try {
|
||
const icons = JSON.parse(iconJson)
|
||
return Array.isArray(icons) && icons.length > 0 ? icons[0] : ''
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析图标数组为完整数组
|
||
*/
|
||
const parseIconArray = (iconJson) => {
|
||
if (!iconJson) {return []}
|
||
try {
|
||
const icons = JSON.parse(iconJson)
|
||
return Array.isArray(icons) ? icons : []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 初始化时显示类型选择
|
||
currentLevel.value = 0
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.level-container {
|
||
min-height: calc(100vh - 50px);
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.breadcrumb {
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.breadcrumb .van-tag {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.category-icon {
|
||
width: 24px;
|
||
height: 24px;
|
||
margin-right: 12px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.category-icon :deep(svg) {
|
||
width: 100%;
|
||
height: 100%;
|
||
fill: currentColor;
|
||
}
|
||
|
||
.category-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.icon-selector {
|
||
padding: 16px;
|
||
}
|
||
|
||
.icon-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.icon-item {
|
||
width: 60px;
|
||
height: 60px;
|
||
border: 2px solid var(--van-border-color);
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.icon-item:hover {
|
||
border-color: var(--van-primary-color);
|
||
background-color: var(--van-primary-color-light);
|
||
}
|
||
|
||
.icon-item.active {
|
||
border-color: var(--van-primary-color);
|
||
background-color: var(--van-primary-color-light);
|
||
box-shadow: 0 2px 8px rgba(25, 137, 250, 0.3);
|
||
}
|
||
|
||
.icon-preview {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.icon-preview :deep(svg) {
|
||
width: 100%;
|
||
height: 100%;
|
||
fill: currentColor;
|
||
}
|
||
|
||
.empty-icons {
|
||
padding: 20px 0;
|
||
}
|
||
|
||
.icon-actions {
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--van-border-color);
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* 深色模式 */
|
||
/* @media (prefers-color-scheme: dark) {
|
||
.level-container {
|
||
background: var(--van-background);
|
||
}
|
||
} */
|
||
|
||
/* 设置页面容器背景色 */
|
||
:deep(.van-nav-bar) {
|
||
background: transparent !important;
|
||
}
|
||
</style>
|