Sfoglia il codice sorgente

feat:标签管理页面编写完成

ZengChao 1 mese fa
parent
commit
c7b3b1c218
2 ha cambiato i file con 619 aggiunte e 0 eliminazioni
  1. 6 0
      src/router/index.ts
  2. 613 0
      src/views/admin/Tag.vue

+ 6 - 0
src/router/index.ts

@@ -92,6 +92,12 @@ const routes: RouteRecordRaw[] = [
         component: () => import('@/views/admin/Settings.vue'),
         meta: { requiresAdmin: true }
       },
+      {
+        path: 'admin/tags',
+        name: 'AdminTags',
+        component: () => import('@/views/admin/Tag.vue'),
+        meta: { requiresAdmin: true }
+      },
       {
         path: 'admin/documents',
         name: 'Documents',

+ 613 - 0
src/views/admin/Tag.vue

@@ -0,0 +1,613 @@
+<template>
+  <div class="tag-management">
+    <div class="page-header">
+      <h2>标签管理</h2>
+      <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>
+        <el-tree
+          ref="treeRef"
+          :data="categoryTree"
+          node-key="id"
+          :props="{ children: 'children', label: 'name' }"
+          default-expand-all
+          @node-click="handleTreeNodeClick"
+          v-loading="treeLoading"
+        />
+      </div>
+
+      <!-- 右侧:标签列表 -->
+      <div class="list-panel">
+        <div v-if="selectedCategory" class="list-header">
+          <h3>{{ selectedCategory.name }} 中的标签</h3>
+          <el-tag type="info">共 {{ total }} 个</el-tag>
+        </div>
+        <div v-else class="list-header">
+          <h3>请在左侧选择分类</h3>
+        </div>
+
+        <!-- 标签列表 -->
+        <el-table
+          v-if="selectedCategory"
+          v-loading="loading"
+          :data="tags"
+          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">
+            <template #default="{ row }">
+              <el-switch
+                v-model="row.status"
+                :active-value="1"
+                :inactive-value="0"
+                @change="handleStatusChange(row)"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column prop="created_by_name" label="创建者" width="120" />
+          <el-table-column prop="created_at" label="创建时间" width="180">
+            <template #default="{ row }">
+              {{ formatDateTime(row.created_at) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="200" fixed="right">
+            <template #default="{ row }">
+              <el-button type="primary" size="small" @click="editTag(row)">
+                编辑
+              </el-button>
+              <el-button type="danger" size="small" @click="deleteTag(row)">
+                删除
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <div v-else class="empty-state">
+          <el-empty description="请在左侧选择一个分类" />
+        </div>
+
+        <!-- 分页 -->
+        <div v-if="selectedCategory" class="pagination">
+          <el-pagination
+            v-model:current-page="currentPage"
+            v-model:page-size="pageSize"
+            :page-sizes="[10, 20, 50, 100]"
+            :total="total"
+            layout="total, sizes, prev, pager, next, jumper"
+            @size-change="handleSizeChange"
+            @current-change="handleCurrentChange"
+          />
+        </div>
+      </div>
+    </div>
+
+    <!-- 创建/编辑标签对话框 -->
+    <el-dialog
+      v-model="showCreateDialog"
+      :title="editingTag ? '编辑标签' : '创建标签'"
+      width="600px"
+      @close="resetForm"
+    >
+      <el-form
+        ref="tagFormRef"
+        :model="tagForm"
+        :rules="tagRules"
+        label-width="100px"
+      >
+        <el-form-item label="标签名称" prop="name">
+          <el-input v-model="tagForm.name" placeholder="请输入标签名称" />
+        </el-form-item>
+        <el-form-item label="上级标签" prop="parent_id">
+          <el-tree-select
+            v-model="tagForm.parent_id"
+            :data="categoryTree"
+            node-key="id"
+            :props="{ children: 'children', label: 'name' }"
+            placeholder="请选择上级标签"
+            check-strictly
+          />
+        </el-form-item>
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="排序" prop="sort_no">
+              <el-input-number v-model="tagForm.sort_no" :min="0" controls-position="right" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="状态" prop="status">
+              <el-select v-model="tagForm.status" placeholder="请选择状态">
+                <el-option label="启用" :value="1" />
+                <el-option label="禁用" :value="0" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <el-form-item label="备注">
+          <el-input
+            v-model="tagForm.remark"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入标签备注(可选)"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showCreateDialog = false">取消</el-button>
+        <el-button type="primary" @click="saveTag" :loading="saving">
+          {{ editingTag ? '更新' : '创建' }}
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<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 request from '@/api/request'
+
+// 响应式数据
+const loading = ref(false)
+const saving = ref(false)
+const treeLoading = ref(false)
+const tags = ref<any[]>([])
+const selectedTags = ref<any[]>([])
+const categoryTree = ref<any[]>([])
+const selectedCategory = ref<any>(null)
+const searchKeyword = ref('')
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 对话框状态
+const showCreateDialog = ref(false)
+const editingTag = ref<any>(null)
+
+// 表单数据
+const tagForm = reactive({
+  name: '',
+  parent_id: 0,
+  sort_no: 0,
+  status: 1,
+  remark: ''
+})
+
+// 表单验证规则
+const tagRules = {
+  name: [
+    { required: true, message: '请输入标签名称', trigger: 'blur' }
+  ],
+  parent_id: [
+    { required: true, message: '请选择上级标签', trigger: 'change' }
+  ]
+}
+
+// 引用
+const tagFormRef = ref()
+const treeRef = ref()
+
+// 加载标签分类树
+const loadCategoryTree = async () => {
+  treeLoading.value = true
+  try {
+    // TODO: 调用获取标签树的API
+    // const response = await request.get('/api/tags/tree')
+    // categoryTree.value = response.data.data
+    
+    // 模拟数据
+    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()
+    }
+    
+    categoryTree.value = mockData.data
+  } catch (error) {
+    console.error('加载分类树失败:', error)
+    ElMessage.error('加载分类树失败')
+  } finally {
+    treeLoading.value = false
+  }
+}
+
+// 加载标签列表
+const loadTags = async () => {
+  if (!selectedCategory.value) {
+    tags.value = []
+    return
+  }
+
+  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
+    
+    // 模拟数据
+    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
+      }
+    }
+    
+    tags.value = mockData.data
+    total.value = mockData.meta.total
+  } catch (error) {
+    console.error('加载标签列表失败:', error)
+    ElMessage.error('加载标签列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+// 树节点点击处理
+const handleTreeNodeClick = (data: any) => {
+  selectedCategory.value = data
+  currentPage.value = 1
+  loadTags()
+}
+
+// 搜索处理
+const handleSearch = () => {
+  currentPage.value = 1
+  loadTags()
+}
+
+// 分页处理
+const handleSizeChange = (size: number) => {
+  pageSize.value = size
+  currentPage.value = 1
+  loadTags()
+}
+
+const handleCurrentChange = (page: number) => {
+  currentPage.value = page
+  loadTags()
+}
+
+// 选择处理
+const handleSelectionChange = (selection: any[]) => {
+  selectedTags.value = selection
+}
+
+// 状态切换
+const handleStatusChange = async (tag: any) => {
+  try {
+    // TODO: 调用更新标签状态的API
+    // await request.patch(`/api/tags/${tag.id}`, { status: tag.status })
+    ElMessage.success('标签状态更新成功')
+  } catch (error) {
+    console.error('更新标签状态失败:', error)
+    ElMessage.error('更新标签状态失败')
+    // 恢复原状态
+    tag.status = tag.status === 1 ? 0 : 1
+  }
+}
+
+// 编辑标签
+const editTag = (tag: any) => {
+  editingTag.value = tag
+  Object.assign(tagForm, {
+    name: tag.name,
+    parent_id: tag.parent_id,
+    sort_no: tag.sort_no,
+    status: tag.status,
+    remark: tag.remark || ''
+  })
+  showCreateDialog.value = true
+}
+
+// 删除标签
+const deleteTag = async (tag: any) => {
+  try {
+    await ElMessageBox.confirm(
+      `确定要删除标签 "${tag.name}" 吗?此操作不可恢复。`,
+      '确认删除',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }
+    )
+    
+    // TODO: 调用删除标签的API
+    // await request.delete(`/api/tags/${tag.id}`)
+    ElMessage.success('标签删除成功')
+    loadTags()
+  } catch (error) {
+    if (error !== 'cancel') {
+      ElMessage.error('删除标签失败')
+    }
+  }
+}
+
+// 保存标签
+const saveTag = async () => {
+  try {
+    await tagFormRef.value.validate()
+    
+    saving.value = true
+    
+    if (editingTag.value) {
+      // TODO: 调用更新标签的API
+      // await request.patch(`/api/tags/${editingTag.value.id}`, tagForm)
+      ElMessage.success('标签更新成功')
+    } else {
+      // TODO: 调用创建标签的API
+      // await request.post('/api/tags', tagForm)
+      ElMessage.success('标签创建成功')
+    }
+    
+    showCreateDialog.value = false
+    loadTags()
+  } catch (error) {
+    console.error('保存标签失败:', error)
+    ElMessage.error('保存标签失败')
+  } finally {
+    saving.value = false
+  }
+}
+
+// 刷新标签
+const refreshTags = () => {
+  loadCategoryTree()
+  loadTags()
+}
+
+// 重置表单
+const resetForm = () => {
+  editingTag.value = null
+  Object.assign(tagForm, {
+    name: '',
+    parent_id: 0,
+    sort_no: 0,
+    status: 1,
+    remark: ''
+  })
+  tagFormRef.value?.clearValidate()
+}
+
+// 格式化日期时间
+const formatDateTime = (dateTime: string) => {
+  if (!dateTime) return '-'
+  return new Date(dateTime).toLocaleString('zh-CN')
+}
+
+// 组件挂载
+onMounted(() => {
+  loadCategoryTree()
+})
+</script>
+
+<style scoped>
+.tag-management {
+  padding: 20px;
+}
+
+.page-header {
+  margin-bottom: 20px;
+}
+
+.page-header h2 {
+  margin: 0 0 8px 0;
+  color: #333;
+}
+
+.page-header p {
+  margin: 0;
+  color: #666;
+  font-size: 14px;
+}
+
+.toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+  padding: 16px;
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.toolbar-left {
+  display: flex;
+  gap: 12px;
+}
+
+.toolbar-right {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+.content-container {
+  display: flex;
+  gap: 20px;
+  min-height: 600px;
+}
+
+.tree-panel {
+  flex: 0 0 250px;
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  padding: 16px;
+  overflow-y: auto;
+}
+
+.tree-header {
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.tree-header h3 {
+  margin: 0;
+  font-size: 16px;
+  color: #333;
+}
+
+.list-panel {
+  flex: 1;
+  background: #fff;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+}
+
+.list-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.list-header h3 {
+  margin: 0;
+  font-size: 16px;
+  color: #333;
+}
+
+.empty-state {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 300px;
+}
+
+.pagination {
+  margin-top: 20px;
+  display: flex;
+  justify-content: center;
+}
+
+:deep(.el-tree) {
+  background: transparent;
+  border: none;
+}
+
+:deep(.el-tree-node__content) {
+  height: 36px;
+  padding: 0 4px;
+}
+
+:deep(.el-tree-node) {
+  position: relative;
+}
+
+:deep(.el-tree-node__children) {
+  padding-left: 16px;
+}
+
+:deep(.el-table) {
+  background: transparent;
+  border: none;
+}
+
+:deep(.el-dialog__body) {
+  padding: 20px;
+}
+</style>