Explorar o código

dev:标签管理基本完成

ZengChao hai 1 mes
pai
achega
30d6ff8cae
Modificáronse 3 ficheiros con 375 adicións e 140 borrados
  1. 4 4
      src/api/request.ts
  2. 150 0
      src/api/tag.ts
  3. 221 136
      src/views/admin/Tag.vue

+ 4 - 4
src/api/request.ts

@@ -31,10 +31,10 @@ request.interceptors.request.use(
 // 响应拦截器
 request.interceptors.response.use(
   (response: AxiosResponse) => {
-    const { code, message, data } = response.data
-    
-    // 成功响应
-    if (code === 0) {
+    const { code, message } = response.data ?? {}
+
+    // 成功响应(兼容 code=0 和 code=200,以及无 code 的纯数据/Blob 返回)
+    if (code === 0 || code === 200 || typeof code === 'undefined') {
       return response.data
     }
     

+ 150 - 0
src/api/tag.ts

@@ -0,0 +1,150 @@
+import request from './request'
+
+const prefix = '/api/v1'
+
+// 标签分类相关类型定义
+export interface TagCategory {
+  id: number
+  parent_id: number
+  name: string
+  path: string
+  level: number
+  type: 'category' | 'label'
+  sort_no: number
+  status: number
+  remark?: string
+  created_at: string
+  updated_at: string
+  created_by: string
+  created_by_name?: string
+  updated_by?: string
+  updated_by_name?: string
+  children?: TagCategory[]
+}
+
+export interface TagCategoryCreate {
+  name: string
+  parent_id: number
+  type?: 'category' | 'label'
+  sort_no?: number
+  status?: number
+  remark?: string
+}
+
+export interface TagCategoryUpdate {
+  name?: string
+  parent_id?: number
+  type?: 'category' | 'label'
+  sort_no?: number
+  status?: number
+  remark?: string
+}
+
+export interface TagCategoryListParams {
+  parent_id?: number
+  status?: number
+  page?: number
+  page_size?: number
+}
+
+export interface TagCategoryBatchDeleteRequest {
+  ids: number[]
+}
+
+export interface TagCategoryMoveRequest {
+  parent_id: number
+}
+
+// API响应类型
+export interface ApiResponse<T = any> {
+  code: number
+  message: string
+  data: T
+  timestamp?: string
+}
+
+export interface PaginatedResponse<T = any> {
+  code: number
+  message: string
+  data: T[]
+  meta: {
+    page: number
+    page_size: number
+    total: number
+    total_pages?: number
+  }
+  timestamp?: string
+}
+
+// 标签分类API
+export const tagApi = {
+  // 创建标签分类
+  createCategory(data: TagCategoryCreate): Promise<ApiResponse<TagCategory>> {
+    return request.post(`${prefix}/sample/tag/create`, data)
+  },
+
+  // 获取标签分类详情
+  getCategoryDetail(categoryId: number): Promise<ApiResponse<TagCategory>> {
+    return request.get(`${prefix}/sample/tag/detail/${categoryId}`)
+  },
+
+  // 获取标签分类列表
+  getCategoryList(params: TagCategoryListParams): Promise<PaginatedResponse<TagCategory>> {
+    return request.get(`${prefix}/sample/tag/list`, { params })
+  },
+
+  // 更新标签分类
+  updateCategory(categoryId: number, data: TagCategoryUpdate): Promise<ApiResponse<TagCategory>> {
+    return request.post(`${prefix}/sample/tag/update/${categoryId}`, data)
+  },
+
+  // 删除标签分类
+  deleteCategory(categoryId: number, softDelete: boolean = true): Promise<ApiResponse> {
+    return request.post(`${prefix}/sample/tag/delete/${categoryId}`, null, {
+      params: { soft_delete: softDelete }
+    })
+  },
+
+  // 获取标签分类树
+  getCategoryTree(includeDisabled: boolean = false): Promise<ApiResponse<TagCategory[]>> {
+    return request.get(`${prefix}/sample/tag/tree`, {
+      params: { include_disabled: includeDisabled }
+    })
+  },
+
+  // 获取面包屑路径
+  getCategoryBreadcrumb(categoryId: number): Promise<ApiResponse<TagCategory[]>> {
+    return request.get(`${prefix}/sample/tag/breadcrumb/${categoryId}`)
+  },
+
+  // 批量删除标签分类
+  batchDeleteCategories(
+    ids: number[], 
+    softDelete: boolean = true
+  ): Promise<ApiResponse<{ success_count: number; total: number }>> {
+    return request.post(`${prefix}/sample/tag/batch/delete`, { ids }, {
+      params: { soft_delete: softDelete }
+    })
+  },
+
+  // 移动分类到新的父分类
+  moveCategory(categoryId: number, parentId: number): Promise<ApiResponse<TagCategory>> {
+    return request.post(`${prefix}/sample/tag/move/${categoryId}`, { parent_id: parentId })
+  },
+
+  // 重新排序分类
+  reorderCategory(categoryId: number, sortNo: number): Promise<ApiResponse<TagCategory>> {
+    return request.post(`${prefix}/sample/tag/reorder/${categoryId}`, null, {
+      params: { sort_no: sortNo }
+    })
+  },
+
+  // 获取子分类数量
+  getChildrenCount(categoryId: number, recursive: boolean = false): Promise<ApiResponse<{ count: number }>> {
+    return request.get(`${prefix}/sample/tag/children-count/${categoryId}`, {
+      params: { recursive }
+    })
+  }
+}
+
+export default tagApi

+ 221 - 136
src/views/admin/Tag.vue

@@ -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 {