|
|
@@ -5,38 +5,26 @@
|
|
|
<p>管理系统标签分类和标签列表</p>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 操作栏 -->
|
|
|
- <div class="toolbar">
|
|
|
- <div class="toolbar-left">
|
|
|
- <el-button type="primary" @click="showCreateDialog = true">
|
|
|
- <el-icon><Plus /></el-icon>
|
|
|
- 创建标签
|
|
|
- </el-button>
|
|
|
- <el-button @click="refreshTags">
|
|
|
- <el-icon><Refresh /></el-icon>
|
|
|
- 刷新
|
|
|
- </el-button>
|
|
|
- </div>
|
|
|
- <div class="toolbar-right">
|
|
|
- <el-input
|
|
|
- v-model="searchKeyword"
|
|
|
- placeholder="搜索标签名称..."
|
|
|
- style="width: 300px"
|
|
|
- @input="handleSearch"
|
|
|
- >
|
|
|
- <template #prefix>
|
|
|
- <el-icon><Search /></el-icon>
|
|
|
- </template>
|
|
|
- </el-input>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
<!-- 主容器:树 + 列表 -->
|
|
|
<div class="content-container">
|
|
|
<!-- 左侧:标签树 -->
|
|
|
<div class="tree-panel">
|
|
|
<div class="tree-header">
|
|
|
<h3>标签分类</h3>
|
|
|
+ <div class="tree-actions">
|
|
|
+ <el-button type="primary" size="small" @click="openCreateCategoryDialog">
|
|
|
+ <template #icon><Plus /></template>
|
|
|
+ 新增
|
|
|
+ </el-button>
|
|
|
+ <el-button type="warning" size="small" @click="openEditCategoryDialog" :disabled="!selectedCategory">
|
|
|
+ <template #icon><Edit /></template>
|
|
|
+ 修改
|
|
|
+ </el-button>
|
|
|
+ <el-button type="danger" size="small" @click="deleteCategoryConfirm" :disabled="!selectedCategory">
|
|
|
+ <template #icon><Delete /></template>
|
|
|
+ 删除
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<el-tree
|
|
|
ref="treeRef"
|
|
|
@@ -53,7 +41,10 @@
|
|
|
<div class="list-panel">
|
|
|
<div v-if="selectedCategory" class="list-header">
|
|
|
<h3>{{ selectedCategory.name }} 中的标签</h3>
|
|
|
- <el-tag type="info">共 {{ total }} 个</el-tag>
|
|
|
+ <el-button type="primary" size="small" @click="openCreateTagDialog">
|
|
|
+ <template #icon><Plus /></template>
|
|
|
+ 新增
|
|
|
+ </el-button>
|
|
|
</div>
|
|
|
<div v-else class="list-header">
|
|
|
<h3>请在左侧选择分类</h3>
|
|
|
@@ -67,16 +58,9 @@
|
|
|
style="width: 100%"
|
|
|
@selection-change="handleSelectionChange"
|
|
|
>
|
|
|
- <el-table-column type="selection" width="55" />
|
|
|
- <el-table-column prop="name" label="标签名称" min-width="150" />
|
|
|
- <el-table-column prop="level" label="等级" width="80" align="center">
|
|
|
- <template #default="{ row }">
|
|
|
- <el-tag type="info">{{ row.level }}</el-tag>
|
|
|
- </template>
|
|
|
- </el-table-column>
|
|
|
- <el-table-column prop="path" label="路径" min-width="150" show-overflow-tooltip />
|
|
|
- <el-table-column prop="sort_no" label="排序" width="80" align="center" />
|
|
|
- <el-table-column prop="status" label="状态" width="100" align="center">
|
|
|
+ <el-table-column prop="name" label="标签名称" min-width="120" />
|
|
|
+ <el-table-column prop="parent_name" label="分类" width="130" align="center" />
|
|
|
+ <el-table-column prop="status" label="状态" width="110" align="center">
|
|
|
<template #default="{ row }">
|
|
|
<el-switch
|
|
|
v-model="row.status"
|
|
|
@@ -86,16 +70,22 @@
|
|
|
/>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
- <el-table-column prop="created_by_name" label="创建者" width="120" />
|
|
|
- <el-table-column prop="created_at" label="创建时间" width="180">
|
|
|
+ <el-table-column prop="created_by_name" label="创建者" width="110" />
|
|
|
+ <el-table-column prop="created_at" label="创建时间" width="110">
|
|
|
<template #default="{ row }">
|
|
|
- {{ formatDateTime(row.created_at) }}
|
|
|
+ {{ formatDate(row.created_at) }}
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
- <el-table-column label="操作" width="200" fixed="right">
|
|
|
+ <el-table-column prop="updated_by_name" label="更新者" width="110" />
|
|
|
+ <el-table-column prop="updated_at" label="更新时间" width="110">
|
|
|
<template #default="{ row }">
|
|
|
- <el-button type="primary" size="small" @click="editTag(row)">
|
|
|
- 编辑
|
|
|
+ {{ formatDate(row.updated_at) }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="150" fixed="right">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button type="warning" size="small" @click="editTag(row)">
|
|
|
+ 修改
|
|
|
</el-button>
|
|
|
<el-button type="danger" size="small" @click="deleteTag(row)">
|
|
|
删除
|
|
|
@@ -126,7 +116,7 @@
|
|
|
<!-- 创建/编辑标签对话框 -->
|
|
|
<el-dialog
|
|
|
v-model="showCreateDialog"
|
|
|
- :title="editingTag ? '编辑标签' : '创建标签'"
|
|
|
+ :title="editingTag ? (tagForm.type === 'category' ? '编辑分类' : '编辑标签') : (tagForm.type === 'category' ? '创建分类' : '创建标签')"
|
|
|
width="600px"
|
|
|
@close="resetForm"
|
|
|
>
|
|
|
@@ -186,8 +176,9 @@
|
|
|
<script setup lang="ts">
|
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
-import { Plus, Search, Refresh } from '@element-plus/icons-vue'
|
|
|
+import { Plus, Search, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
|
|
import request from '@/api/request'
|
|
|
+import tagApi, { type TagCategory, type TagCategoryCreate, type TagCategoryUpdate } from '@/api/tag'
|
|
|
|
|
|
// 响应式数据
|
|
|
const loading = ref(false)
|
|
|
@@ -210,6 +201,7 @@ const editingTag = ref<any>(null)
|
|
|
const tagForm = reactive({
|
|
|
name: '',
|
|
|
parent_id: 0,
|
|
|
+ type: 'label' as 'category' | 'label',
|
|
|
sort_no: 0,
|
|
|
status: 1,
|
|
|
remark: ''
|
|
|
@@ -229,45 +221,34 @@ const tagRules = {
|
|
|
const tagFormRef = ref()
|
|
|
const treeRef = ref()
|
|
|
|
|
|
+// 过滤树节点,仅保留指定类型
|
|
|
+const filterTreeByType = (nodes: TagCategory[] = [], type: 'category' | 'label'): TagCategory[] => {
|
|
|
+ return nodes
|
|
|
+ .filter((node) => node.type === type)
|
|
|
+ .map((node): TagCategory => {
|
|
|
+ const children = node.children ? filterTreeByType(node.children as TagCategory[], type) : undefined
|
|
|
+ return {
|
|
|
+ ...node,
|
|
|
+ children: children && children.length > 0 ? children : undefined
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
// 加载标签分类树
|
|
|
const loadCategoryTree = async () => {
|
|
|
treeLoading.value = true
|
|
|
try {
|
|
|
- // TODO: 调用获取标签树的API
|
|
|
- // const response = await request.get('/api/tags/tree')
|
|
|
- // categoryTree.value = response.data.data
|
|
|
+ const response: any = await tagApi.getCategoryTree(true)
|
|
|
+ console.log('分类树响应:', response)
|
|
|
|
|
|
- // 模拟数据
|
|
|
- const mockData = {
|
|
|
- code: 200,
|
|
|
- message: '获取分类树成功',
|
|
|
- data: [
|
|
|
- {
|
|
|
- id: 1,
|
|
|
- parent_id: 0,
|
|
|
- name: '测试',
|
|
|
- path: '/',
|
|
|
- level: 1,
|
|
|
- sort_no: 0,
|
|
|
- status: 1,
|
|
|
- children: [
|
|
|
- {
|
|
|
- id: 2,
|
|
|
- parent_id: 1,
|
|
|
- name: '用户管理',
|
|
|
- path: '/1/',
|
|
|
- level: 2,
|
|
|
- sort_no: 0,
|
|
|
- status: 1,
|
|
|
- children: null
|
|
|
- }
|
|
|
- ]
|
|
|
- }
|
|
|
- ],
|
|
|
- timestamp: new Date().toISOString()
|
|
|
- }
|
|
|
+ // tree接口返回格式: { code: 200, message: '...', data: [...] }
|
|
|
+ // data是一个数组,包含所有根节点及其嵌套的children
|
|
|
+ const treeData = response.data || []
|
|
|
|
|
|
- categoryTree.value = mockData.data
|
|
|
+ // 过滤只显示type为category的节点
|
|
|
+ const filtered = filterTreeByType(treeData, 'category')
|
|
|
+ console.log('过滤后的分类树:', filtered)
|
|
|
+ categoryTree.value = filtered
|
|
|
} catch (error) {
|
|
|
console.error('加载分类树失败:', error)
|
|
|
ElMessage.error('加载分类树失败')
|
|
|
@@ -285,51 +266,24 @@ const loadTags = async () => {
|
|
|
|
|
|
loading.value = true
|
|
|
try {
|
|
|
- // TODO: 调用获取标签列表的API
|
|
|
- // const response = await request.get('/api/tags', {
|
|
|
- // params: {
|
|
|
- // parent_id: selectedCategory.value.id,
|
|
|
- // page: currentPage.value,
|
|
|
- // page_size: pageSize.value,
|
|
|
- // search: searchKeyword.value
|
|
|
- // }
|
|
|
- // })
|
|
|
- // tags.value = response.data.data
|
|
|
- // total.value = response.data.meta.total
|
|
|
+ // 使用 list 接口获取指定分类下的标签
|
|
|
+ const response: any = await tagApi.getCategoryList({
|
|
|
+ parent_id: selectedCategory.value.id,
|
|
|
+ page: currentPage.value,
|
|
|
+ page_size: pageSize.value
|
|
|
+ })
|
|
|
|
|
|
- // 模拟数据
|
|
|
- const mockData = {
|
|
|
- code: 200,
|
|
|
- message: '获取标签分类列表成功',
|
|
|
- data: [
|
|
|
- {
|
|
|
- created_at: '2026-01-20T07:55:51',
|
|
|
- updated_at: '2026-01-20T07:55:51',
|
|
|
- id: 1,
|
|
|
- is_deleted: 0,
|
|
|
- parent_id: 0,
|
|
|
- name: '测试',
|
|
|
- path: '/',
|
|
|
- level: 1,
|
|
|
- sort_no: 0,
|
|
|
- status: 1,
|
|
|
- created_by: 'ed6a79d3-0083-4d81-8b48-fc522f686f74',
|
|
|
- created_by_name: 'admin',
|
|
|
- updated_by: null,
|
|
|
- updated_by_name: null
|
|
|
- }
|
|
|
- ],
|
|
|
- timestamp: new Date().toISOString(),
|
|
|
- meta: {
|
|
|
- page: currentPage.value,
|
|
|
- page_size: pageSize.value,
|
|
|
- total: 1,
|
|
|
- total_pages: 0
|
|
|
- }
|
|
|
- }
|
|
|
+ console.log('标签列表响应(来自list):', response)
|
|
|
+
|
|
|
+ // list接口返回格式: { code: 200, message: '...', data: [...], meta: {...} }
|
|
|
+ const listData = response.data || []
|
|
|
+
|
|
|
+ // 筛选 type 为 label 的节点
|
|
|
+ const labelList = listData.filter((item: any) => item.type === 'label')
|
|
|
|
|
|
- tags.value = mockData.data
|
|
|
- total.value = mockData.meta.total
|
|
|
+ console.log('过滤后的标签列表:', labelList)
|
|
|
+ tags.value = labelList
|
|
|
+ total.value = response.meta?.total || labelList.length
|
|
|
} catch (error) {
|
|
|
console.error('加载标签列表失败:', error)
|
|
|
ElMessage.error('加载标签列表失败')
|
|
|
@@ -371,9 +325,14 @@ const handleSelectionChange = (selection: any[]) => {
|
|
|
// 状态切换
|
|
|
const handleStatusChange = async (tag: any) => {
|
|
|
try {
|
|
|
- // TODO: 调用更新标签状态的API
|
|
|
- // await request.patch(`/api/tags/${tag.id}`, { status: tag.status })
|
|
|
- ElMessage.success('标签状态更新成功')
|
|
|
+ const response = await tagApi.updateCategory(tag.id, { status: tag.status })
|
|
|
+ if (response.code === 200) {
|
|
|
+ ElMessage.success('标签状态更新成功')
|
|
|
+ } else {
|
|
|
+ ElMessage.error(response.message || '更新标签状态失败')
|
|
|
+ // 恢复原状态
|
|
|
+ tag.status = tag.status === 1 ? 0 : 1
|
|
|
+ }
|
|
|
} catch (error) {
|
|
|
console.error('更新标签状态失败:', error)
|
|
|
ElMessage.error('更新标签状态失败')
|
|
|
@@ -388,6 +347,7 @@ const editTag = (tag: any) => {
|
|
|
Object.assign(tagForm, {
|
|
|
name: tag.name,
|
|
|
parent_id: tag.parent_id,
|
|
|
+ type: tag.type || 'label',
|
|
|
sort_no: tag.sort_no,
|
|
|
status: tag.status,
|
|
|
remark: tag.remark || ''
|
|
|
@@ -408,12 +368,16 @@ const deleteTag = async (tag: any) => {
|
|
|
}
|
|
|
)
|
|
|
|
|
|
- // TODO: 调用删除标签的API
|
|
|
- // await request.delete(`/api/tags/${tag.id}`)
|
|
|
- ElMessage.success('标签删除成功')
|
|
|
- loadTags()
|
|
|
+ const response = await tagApi.deleteCategory(tag.id, true)
|
|
|
+ if (response.code === 200) {
|
|
|
+ ElMessage.success('标签删除成功')
|
|
|
+ loadTags()
|
|
|
+ } else {
|
|
|
+ ElMessage.error(response.message || '删除标签失败')
|
|
|
+ }
|
|
|
} catch (error) {
|
|
|
if (error !== 'cancel') {
|
|
|
+ console.error('删除标签失败:', error)
|
|
|
ElMessage.error('删除标签失败')
|
|
|
}
|
|
|
}
|
|
|
@@ -426,17 +390,27 @@ const saveTag = async () => {
|
|
|
|
|
|
saving.value = true
|
|
|
|
|
|
+ let response
|
|
|
if (editingTag.value) {
|
|
|
- // TODO: 调用更新标签的API
|
|
|
- // await request.patch(`/api/tags/${editingTag.value.id}`, tagForm)
|
|
|
- ElMessage.success('标签更新成功')
|
|
|
+ response = await tagApi.updateCategory(editingTag.value.id, tagForm)
|
|
|
+ if (response.code === 200) {
|
|
|
+ ElMessage.success('标签更新成功')
|
|
|
+ } else {
|
|
|
+ ElMessage.error(response.message || '标签更新失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
} else {
|
|
|
- // TODO: 调用创建标签的API
|
|
|
- // await request.post('/api/tags', tagForm)
|
|
|
- ElMessage.success('标签创建成功')
|
|
|
+ response = await tagApi.createCategory(tagForm)
|
|
|
+ if (response.code === 200) {
|
|
|
+ ElMessage.success('标签创建成功')
|
|
|
+ } else {
|
|
|
+ ElMessage.error(response.message || '标签创建失败')
|
|
|
+ return
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
showCreateDialog.value = false
|
|
|
+ loadCategoryTree()
|
|
|
loadTags()
|
|
|
} catch (error) {
|
|
|
console.error('保存标签失败:', error)
|
|
|
@@ -458,6 +432,7 @@ const resetForm = () => {
|
|
|
Object.assign(tagForm, {
|
|
|
name: '',
|
|
|
parent_id: 0,
|
|
|
+ type: 'label' as 'category' | 'label',
|
|
|
sort_no: 0,
|
|
|
status: 1,
|
|
|
remark: ''
|
|
|
@@ -465,6 +440,105 @@ const resetForm = () => {
|
|
|
tagFormRef.value?.clearValidate()
|
|
|
}
|
|
|
|
|
|
+// 打开新增分类对话框
|
|
|
+const openCreateCategoryDialog = () => {
|
|
|
+ editingTag.value = null
|
|
|
+ Object.assign(tagForm, {
|
|
|
+ name: '',
|
|
|
+ parent_id: selectedCategory.value?.id || 0,
|
|
|
+ type: 'category' as 'category' | 'label',
|
|
|
+ sort_no: 0,
|
|
|
+ status: 1,
|
|
|
+ remark: ''
|
|
|
+ })
|
|
|
+ showCreateDialog.value = true
|
|
|
+}
|
|
|
+
|
|
|
+// 打开新增标签对话框
|
|
|
+const openCreateTagDialog = () => {
|
|
|
+ editingTag.value = null
|
|
|
+ Object.assign(tagForm, {
|
|
|
+ name: '',
|
|
|
+ parent_id: selectedCategory.value?.id || 0,
|
|
|
+ type: 'label' as 'category' | 'label',
|
|
|
+ sort_no: 0,
|
|
|
+ status: 1,
|
|
|
+ remark: ''
|
|
|
+ })
|
|
|
+ showCreateDialog.value = true
|
|
|
+}
|
|
|
+
|
|
|
+// 打开编辑分类对话框
|
|
|
+const openEditCategoryDialog = async () => {
|
|
|
+ if (!selectedCategory.value) {
|
|
|
+ ElMessage.warning('请先选择一个分类')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await tagApi.getCategoryDetail(selectedCategory.value.id)
|
|
|
+ if (response.code === 200) {
|
|
|
+ editingTag.value = response.data
|
|
|
+ Object.assign(tagForm, {
|
|
|
+ name: response.data.name,
|
|
|
+ parent_id: response.data.parent_id,
|
|
|
+ type: response.data.type || 'category',
|
|
|
+ sort_no: response.data.sort_no,
|
|
|
+ status: response.data.status,
|
|
|
+ remark: response.data.remark || ''
|
|
|
+ })
|
|
|
+ showCreateDialog.value = true
|
|
|
+ } else {
|
|
|
+ ElMessage.error(response.message || '获取分类详情失败')
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取分类详情失败:', error)
|
|
|
+ ElMessage.error('获取分类详情失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 删除分类确认
|
|
|
+const deleteCategoryConfirm = async () => {
|
|
|
+ if (!selectedCategory.value) {
|
|
|
+ ElMessage.warning('请先选择一个分类')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ `确定要删除分类 "${selectedCategory.value.name}" 吗?此操作不可恢复。`,
|
|
|
+ '确认删除',
|
|
|
+ {
|
|
|
+ confirmButtonText: '确定',
|
|
|
+ cancelButtonText: '取消',
|
|
|
+ type: 'warning'
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ const response = await tagApi.deleteCategory(selectedCategory.value.id, true)
|
|
|
+ if (response.code === 200) {
|
|
|
+ ElMessage.success('分类删除成功')
|
|
|
+ loadCategoryTree()
|
|
|
+ selectedCategory.value = null
|
|
|
+ tags.value = []
|
|
|
+ } else {
|
|
|
+ ElMessage.error(response.message || '删除分类失败')
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (error !== 'cancel') {
|
|
|
+ console.error('删除分类失败:', error)
|
|
|
+ ElMessage.error('删除分类失败')
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化日期时间
|
|
|
+// 格式化日期(只显示日期)
|
|
|
+const formatDate = (dateTime: string) => {
|
|
|
+ if (!dateTime) return '-'
|
|
|
+ return new Date(dateTime).toLocaleDateString('zh-CN')
|
|
|
+}
|
|
|
+
|
|
|
// 格式化日期时间
|
|
|
const formatDateTime = (dateTime: string) => {
|
|
|
if (!dateTime) return '-'
|
|
|
@@ -522,11 +596,12 @@ onMounted(() => {
|
|
|
.content-container {
|
|
|
display: flex;
|
|
|
gap: 20px;
|
|
|
- min-height: 600px;
|
|
|
+ min-height: 700px;
|
|
|
+ overflow-x: hidden;
|
|
|
}
|
|
|
|
|
|
.tree-panel {
|
|
|
- flex: 0 0 250px;
|
|
|
+ flex: 0 0 350px;
|
|
|
background: #fff;
|
|
|
border-radius: 8px;
|
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
@@ -538,6 +613,9 @@ onMounted(() => {
|
|
|
margin-bottom: 16px;
|
|
|
padding-bottom: 12px;
|
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
}
|
|
|
|
|
|
.tree-header h3 {
|
|
|
@@ -546,14 +624,21 @@ onMounted(() => {
|
|
|
color: #333;
|
|
|
}
|
|
|
|
|
|
+.tree-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 0px;
|
|
|
+}
|
|
|
+
|
|
|
.list-panel {
|
|
|
flex: 1;
|
|
|
+ min-width: 0;
|
|
|
background: #fff;
|
|
|
border-radius: 8px;
|
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
padding: 16px;
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
+ overflow-x: auto;
|
|
|
}
|
|
|
|
|
|
.list-header {
|