chenkun 2 days ago
parent
commit
b8b8164c08
4 changed files with 986 additions and 8 deletions
  1. 39 8
      src/layouts/MainLayout.vue
  2. 12 0
      src/router/index.ts
  3. 802 0
      src/views/documents/Index.vue
  4. 133 0
      src/views/documents/KnowledgeBase.vue

+ 39 - 8
src/layouts/MainLayout.vue

@@ -106,8 +106,29 @@ const router = useRouter()
 const route = useRoute()
 const authStore = useAuthStore()
 
+// 接口定义
+interface MenuItem {
+  id: string | number
+  name: string
+  title: string
+  path: string
+  icon?: string
+  menu_type?: string
+  is_hidden?: boolean
+  is_active?: boolean
+  children?: MenuItem[]
+  parent_id?: string | number | null
+}
+
+interface ApiResponse<T = any> {
+  code: number
+  message: string
+  data: T
+  timestamp: string
+}
+
 // 响应式数据
-const userMenus = ref<any[]>([])
+const userMenus = ref<MenuItem[]>([])
 const menuLoading = ref(false)
 
 const activeMenu = computed(() => {
@@ -128,20 +149,20 @@ const getIconComponent = (iconName: string) => {
 }
 
 // 检查是否有菜单类型的子项
-const hasMenuChildren = (menu: any) => {
-  return menu.children && menu.children.some((child: any) => child.menu_type === 'menu')
+const hasMenuChildren = (menu: MenuItem) => {
+  return menu.children && menu.children.some((child: MenuItem) => child.menu_type === 'menu')
 }
 
 // 获取菜单类型的子项
-const getMenuChildren = (menu: any) => {
-  return menu.children ? menu.children.filter((child: any) => child.menu_type === 'menu') : []
+const getMenuChildren = (menu: MenuItem) => {
+  return menu.children ? menu.children.filter((child: MenuItem) => child.menu_type === 'menu') : []
 }
 
 // 获取用户菜单
 const loadUserMenus = async () => {
   menuLoading.value = true
   try {
-    const result = await request.get('/api/v1/user/menus')
+    const result = await request.get<any, ApiResponse<MenuItem[]>>('/api/v1/user/menus')
     if (result.code === 0) {
       userMenus.value = result.data
       console.log('用户菜单加载成功:', result.data)
@@ -160,8 +181,8 @@ const loadUserMenus = async () => {
 }
 
 // 获取默认菜单(兜底方案)
-const getDefaultMenus = () => {
-  const defaultMenus = [
+const getDefaultMenus = (): MenuItem[] => {
+  const defaultMenus: MenuItem[] = [
     {
       id: 'dashboard',
       name: 'dashboard',
@@ -190,6 +211,16 @@ const getDefaultMenus = () => {
   
   // 如果是管理员,添加系统管理菜单
   if (authStore.isAdmin) {
+    // 管理员默认菜单中包含文档中心
+    defaultMenus.push({
+      id: 'documents',
+      name: 'documents',
+      title: '文档管理中心',
+      path: '/admin/documents',
+      icon: 'Document',
+      children: []
+    })
+
     defaultMenus.push({
       id: 'admin',
       name: 'admin',

+ 12 - 0
src/router/index.ts

@@ -91,6 +91,18 @@ const routes: RouteRecordRaw[] = [
         name: 'AdminSettings',
         component: () => import('@/views/admin/Settings.vue'),
         meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/documents',
+        name: 'Documents',
+        component: () => import('@/views/documents/Index.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/documents/kb',
+        name: 'KnowledgeBase',
+        component: () => import('@/views/documents/KnowledgeBase.vue'),
+        meta: { requiresAdmin: true }
       }
     ]
   },

+ 802 - 0
src/views/documents/Index.vue

@@ -0,0 +1,802 @@
+<template>
+  <div class="documents-container">
+    <div class="header-section">
+      <div class="title-info">
+        <h2>{{ currentTitle }}</h2>
+        <div class="statistics-bar">
+          <span class="stat-item">
+            <el-icon><Document /></el-icon>
+            全部数据: <span class="stat-value">{{ statistics.allTotal }}</span>
+          </span>
+          <span class="stat-item">
+            <el-icon><CircleCheck /></el-icon>
+            已入库: <span class="stat-value success">{{ statistics.totalEntered }}</span>
+          </span>
+          <span class="stat-item" v-if="searchQuery.keyword || searchQuery.primaryCategoryId || searchQuery.year">
+            <el-icon><Search /></el-icon>
+            检索结果: <span class="stat-value">{{ total }}</span>
+          </span>
+        </div>
+      </div>
+      <div class="action-buttons">
+        <el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
+          <el-icon><Delete /></el-icon> 批量删除
+        </el-button>
+        <el-button type="warning" :disabled="selectedIds.length === 0" @click="handleBatchEnter">
+          <el-icon><CircleCheck /></el-icon> 批量入库
+        </el-button>
+        <el-button type="success" class="upload-btn" @click="handleUpload">
+          <el-icon><Upload /></el-icon> 上传文档
+        </el-button>
+      </div>
+    </div>
+
+    <!-- 板块切换 Tabs -->
+    <el-tabs v-model="activeTable" class="table-tabs" @tab-change="handleTableChange">
+      <el-tab-pane label="编制依据" name="basis" />
+      <el-tab-pane label="施工方案" name="work" />
+      <el-tab-pane label="办公制度" name="job" />
+    </el-tabs>
+
+    <el-card class="search-card">
+      <div class="search-bar">
+        <el-input
+          v-model="searchQuery.keyword"
+          placeholder="搜索文档标题、正文内容或标准号..."
+          class="search-input"
+          clearable
+          @keyup.enter="handleSearch"
+        >
+          <template #prefix>
+            <el-icon><Search /></el-icon>
+          </template>
+        </el-input>
+
+        <div class="filter-group">
+          <el-select v-model="searchQuery.primaryCategoryId" placeholder="一级分类" clearable @change="handlePrimaryCategoryChange" class="filter-select">
+            <el-option label="全部一级分类" :value="null" />
+            <el-option
+              v-for="item in primaryCategories"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
+          </el-select>
+
+          <el-select v-model="searchQuery.secondaryCategoryId" placeholder="二级分类" clearable :disabled="!searchQuery.primaryCategoryId" class="filter-select">
+            <el-option label="全部二级分类" :value="null" />
+            <el-option
+              v-for="item in secondaryCategories"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
+          </el-select>
+
+          <el-select v-model="searchQuery.year" placeholder="年份" clearable class="filter-select-year">
+            <el-option label="全部年份" :value="null" />
+            <el-option
+              v-for="year in yearOptions"
+              :key="year"
+              :label="year"
+              :value="year"
+            />
+          </el-select>
+
+          <el-select v-model="searchQuery.whether_to_enter" placeholder="入库状态" clearable class="filter-select">
+            <el-option label="全部状态" :value="null" />
+            <el-option label="未入库" :value="0" />
+            <el-option label="已入库" :value="1" />
+          </el-select>
+
+          <el-button type="primary" @click="handleSearch" class="search-btn">
+            <el-icon><Filter /></el-icon> 检索
+          </el-button>
+        </div>
+      </div>
+    </el-card>
+
+    <div class="content-section" v-loading="loading">
+      <el-empty v-if="documents.length === 0" description="暂无文档数据" />
+      <el-table 
+        v-else 
+        :data="documents" 
+        style="width: 100%" 
+        border 
+        stripe 
+        @selection-change="handleSelectionChange"
+        :row-class-name="tableRowClassName"
+      >
+        <el-table-column type="selection" width="55" :selectable="canSelect" />
+        <el-table-column prop="title" label="标题" min-width="250" show-overflow-tooltip />
+        <el-table-column prop="standard_no" label="标准号" width="150" show-overflow-tooltip />
+        <el-table-column prop="document_type" label="标准类型" width="120" show-overflow-tooltip />
+        <el-table-column prop="professional_field" label="专业领域" width="120" show-overflow-tooltip />
+        <el-table-column prop="year" label="年份" width="80" align="center" />
+        <el-table-column prop="release_date" label="发布日期" width="120">
+          <template #default="scope">
+            {{ formatSimpleDate(scope.row.release_date) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="whether_to_enter" label="状态" width="100">
+          <template #default="scope">
+            <el-tag :type="isEntered(scope.row.whether_to_enter) ? 'success' : 'info'">
+              {{ isEntered(scope.row.whether_to_enter) ? '已入库' : '未入库' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="转换进度" width="200">
+          <template #default="scope">
+            <div class="conversion-progress-wrapper">
+              <el-progress 
+                :percentage="scope.row.conversion_progress || 0" 
+                :stroke-width="20" 
+                :text-inside="true"
+                :status="getProgressStatus(scope.row)"
+              />
+              <div v-if="scope.row.conversion_status === 3" class="error-msg-text" :title="scope.row.conversion_error">
+                <el-icon><Warning /></el-icon>
+                <span>失败原因: {{ scope.row.conversion_error }}</span>
+              </div>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="220" fixed="right">
+          <template #default="scope">
+            <el-button link type="primary" @click="handleView(scope.row)">查看详情</el-button>
+            <el-button 
+              link 
+              type="warning" 
+              @click="handleConvert(scope.row)"
+              :disabled="scope.row.conversion_status === 1 || scope.row.conversion_status === 2"
+            >
+              {{ scope.row.conversion_status === 1 ? '转换中' : (scope.row.conversion_status === 2 ? '已转换' : '开始转换') }}
+            </el-button>
+            <el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+...
+    <!-- 文档预览对话框 -->
+    <el-dialog v-model="previewVisible" :title="previewTitle" width="85%" top="5vh" custom-class="preview-dialog" @closed="previewUrl = ''">
+      <div v-loading="previewLoading" class="preview-content">
+        <iframe 
+          v-if="previewUrl" 
+          :src="proxyPreviewUrl" 
+          width="100%" 
+          height="100%" 
+          frameborder="0"
+          allow="fullscreen"
+          @load="previewLoading = false"
+        ></iframe>
+      </div>
+    </el-dialog>
+
+      <div class="pagination-container" v-if="total > 0">
+        <el-pagination
+          v-model:current-page="searchQuery.page"
+          v-model:page-size="searchQuery.size"
+          :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          :total="total"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </div>
+
+    <!-- 上传文档对话框 -->
+    <el-dialog v-model="uploadDialogVisible" title="上传文档" width="500px">
+      <el-form :model="uploadForm" label-width="100px">
+        <el-form-item label="文档标题" required>
+          <el-input v-model="uploadForm.title" placeholder="请输入文档标题" />
+        </el-form-item>
+        <el-form-item label="一级分类" required>
+          <el-select v-model="uploadForm.primary_category_id" placeholder="请选择一级分类" @change="handleUploadPrimaryChange">
+            <el-option v-for="item in primaryCategories" :key="item.id" :label="item.name" :value="item.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="二级分类">
+          <el-select v-model="uploadForm.secondary_category_id" placeholder="请选择二级分类">
+            <el-option v-for="item in uploadSecondaryCategories" :key="item.id" :label="item.name" :value="item.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="年份">
+          <el-select v-model="uploadForm.year" placeholder="请选择年份">
+            <el-option v-for="year in yearOptions" :key="year" :label="year" :value="year" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="文档内容">
+          <el-input v-model="uploadForm.content" type="textarea" :rows="4" placeholder="请输入文档内容" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="uploadDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitUpload" :loading="submitting">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Search, Filter, Upload, CircleCheck, Delete, Document, Warning } from '@element-plus/icons-vue'
+import request from '@/api/request'
+import { useAuthStore } from '@/stores/auth'
+import dayjs from 'dayjs'
+
+// 接口定义
+interface Category {
+  id: string | number
+  name: string
+  parent_id?: string | number | null
+}
+
+interface DocumentItem {
+  id: number
+  title: string
+  content: string
+  document_type: string | null
+  professional_field: string | null
+  year: number | null
+  release_date: string | null
+  standard_no: string | null
+  status: string | null
+  whether_to_enter: number
+  file_url: string | null
+  conversion_status: number | null
+  conversion_progress: number | null
+  conversion_error: string | null
+  created_at: string
+  updated_at: string
+}
+
+interface ApiResponse<T = any> {
+  code: number
+  message: string
+  data: T
+  timestamp: string
+}
+
+interface PageResult<T> {
+  items: T[]
+  total: number
+  page: number
+  size: number
+  all_total?: number
+  total_entered?: number
+}
+
+// 状态变量
+const loading = ref(false)
+const submitting = ref(false)
+const uploadDialogVisible = ref(false)
+const previewVisible = ref(false)
+const previewLoading = ref(false)
+const previewTitle = ref('')
+const previewUrl = ref('')
+const total = ref(0)
+const statistics = ref({
+  allTotal: 0,
+  totalEntered: 0
+})
+const authStore = useAuthStore()
+const activeTable = ref('basis')
+const documents = ref<DocumentItem[]>([])
+
+const currentTitle = computed(() => {
+  const titles: Record<string, string> = {
+    basis: '编制依据文档中心',
+    work: '施工方案文档中心',
+    job: '办公制度文档管理中心'
+  }
+  return titles[activeTable.value] || '文档管理中心'
+})
+const selectedIds = ref<number[]>([])
+const primaryCategories = ref<Category[]>([])
+const secondaryCategories = ref<Category[]>([])
+const uploadSecondaryCategories = ref<Category[]>([])
+
+const searchQuery = reactive({
+  keyword: '',
+  primaryCategoryId: null as string | number | null,
+  secondaryCategoryId: null as string | number | null,
+  year: null as number | null,
+  whether_to_enter: null as number | null,
+  page: 1,
+  size: 20
+})
+
+const uploadForm = reactive({
+  title: '',
+  content: '',
+  primary_category_id: null as string | number | null,
+  secondary_category_id: null as string | number | null,
+  year: new Date().getFullYear()
+})
+
+const yearOptions = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - i)
+
+// 计算属性
+const proxyPreviewUrl = computed(() => {
+  if (!previewUrl.value) return ''
+  // 使用后端代理接口查看外部网页,附加 token 以通过认证
+  const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
+  return `${baseUrl}/api/v1/documents/proxy-view?url=${encodeURIComponent(previewUrl.value)}&token=${authStore.token}`
+})
+
+// 方法
+const formatSimpleDate = (date: string | null) => {
+  if (!date) return '-'
+  return dayjs(date).format('YYYY-MM-DD')
+}
+
+const formatDate = (date: string) => {
+  return date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : '-'
+}
+
+// 为已入库的行添加特定类名
+const tableRowClassName = ({ row }: { row: DocumentItem }) => {
+  if (isEntered(row.whether_to_enter)) {
+    return 'row-entered'
+  }
+  return ''
+}
+
+// 判断是否可以勾选(已入库的数据不可勾选)
+const canSelect = (row: DocumentItem) => {
+  return !isEntered(row.whether_to_enter)
+}
+
+const handleSelectionChange = (selection: DocumentItem[]) => {
+  selectedIds.value = selection.map(item => item.id)
+}
+
+const handleBatchEnter = async () => {
+  if (selectedIds.value.length === 0) return
+  
+  try {
+    const res = await request.post<any, ApiResponse>('/api/v1/documents/batch-enter', { 
+      ids: selectedIds.value,
+      table_type: activeTable.value
+    })
+    if (res.code === 0) {
+      ElMessage.success(res.message || '入库成功')
+      fetchDocuments()
+    } else {
+      ElMessage.error(res.message || '入库失败')
+    }
+  } catch (error) {
+    console.error('批量入库失败:', error)
+    ElMessage.error('网络错误')
+  }
+}
+
+const handleDelete = async (row: DocumentItem) => {
+  try {
+    await ElMessageBox.confirm(
+      `确定要删除文档 "${row.title}" 吗?此操作不可恢复。`,
+      '确认删除',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+      }
+    )
+    
+    const res = await request.post<any, ApiResponse>('/api/v1/documents/batch-delete', {
+      ids: [row.id],
+      table_type: activeTable.value
+    })
+    
+    if (res.code === 0) {
+      ElMessage.success('删除成功')
+      fetchDocuments()
+    } else {
+      ElMessage.error(res.message || '删除失败')
+    }
+  } catch (error: any) {
+    if (error !== 'cancel') {
+      console.error('删除文档失败:', error)
+      ElMessage.error('删除失败')
+    }
+  }
+}
+
+const handleBatchDelete = async () => {
+  if (selectedIds.value.length === 0) return
+  
+  try {
+    await ElMessageBox.confirm(
+      `确定要批量删除选中的 ${selectedIds.value.length} 条文档吗?此操作不可恢复。`,
+      '确认批量删除',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning',
+      }
+    )
+    
+    const res = await request.post<any, ApiResponse>('/api/v1/documents/batch-delete', {
+      ids: selectedIds.value,
+      table_type: activeTable.value
+    })
+    
+    if (res.code === 0) {
+      ElMessage.success(res.message || '批量删除成功')
+      selectedIds.value = []
+      fetchDocuments()
+    } else {
+      ElMessage.error(res.message || '批量删除失败')
+    }
+  } catch (error: any) {
+    if (error !== 'cancel') {
+      console.error('批量删除失败:', error)
+      ElMessage.error('操作失败')
+    }
+  }
+}
+
+const handleView = (row: DocumentItem) => {
+  if (!row.file_url) {
+    ElMessage.warning('该文档暂无详情链接')
+    return
+  }
+  previewTitle.value = row.title
+  previewUrl.value = row.file_url
+  previewLoading.value = true
+  previewVisible.value = true
+}
+
+const fetchPrimaryCategories = async () => {
+  try {
+    const res = await request.get<any, ApiResponse<Category[]>>('/api/v1/documents/categories/primary')
+    if (res.code === 0) {
+      primaryCategories.value = res.data
+    }
+  } catch (error) {
+    console.error('获取一级分类失败:', error)
+  }
+}
+
+const fetchSecondaryCategories = async (primaryId: string | number, isUpload = false) => {
+  try {
+    const res = await request.get<any, ApiResponse<Category[]>>(`/api/v1/documents/categories/secondary?primaryId=${primaryId}`)
+    if (res.code === 0) {
+      if (isUpload) {
+        uploadSecondaryCategories.value = res.data
+      } else {
+        secondaryCategories.value = res.data
+      }
+    }
+  } catch (error) {
+    console.error('获取二级分类失败:', error)
+  }
+}
+
+const handlePrimaryCategoryChange = (val: string | number | null) => {
+  searchQuery.secondaryCategoryId = null
+  if (val) {
+    fetchSecondaryCategories(val)
+  } else {
+    secondaryCategories.value = []
+  }
+}
+
+const handleUploadPrimaryChange = (val: string | number | null) => {
+  uploadForm.secondary_category_id = null
+  if (val) {
+    fetchSecondaryCategories(val, true)
+  } else {
+    uploadSecondaryCategories.value = []
+  }
+}
+
+const handleTableChange = (val: string) => {
+  activeTable.value = val as string
+  searchQuery.page = 1
+  fetchDocuments()
+}
+
+const fetchDocuments = async () => {
+  loading.value = true
+  try {
+    let url = '/api/v1/documents/list'
+    if (searchQuery.keyword) {
+      url = '/api/v1/documents/search'
+    }
+
+    const res = await request.get<any, ApiResponse<PageResult<DocumentItem>>>(url, { 
+      params: {
+        ...searchQuery,
+        table_type: activeTable.value
+      } 
+    })
+    if (res.code === 0) {
+        documents.value = res.data.items
+        total.value = res.data.total
+        statistics.value = {
+          allTotal: res.data.all_total || 0,
+          totalEntered: res.data.total_entered || 0
+        }
+        
+        // 自动检查是否需要开启轮询
+        const hasConverting = documents.value.some(doc => doc.conversion_status === 1)
+        if (hasConverting) {
+          startPolling()
+        } else {
+          stopPolling()
+        }
+      } else {
+      ElMessage.error(res.message || '获取文档列表失败')
+    }
+  } catch (error) {
+    console.error('获取文档列表失败:', error)
+    ElMessage.error('网络错误,请稍后再试')
+  } finally {
+    loading.value = false
+  }
+}
+
+const isEntered = (val: any) => {
+  if (val === null || val === undefined) return false
+  if (typeof val === 'number') return val === 1
+  if (typeof val === 'string') return val === '1' || val.toLowerCase() === 'true'
+  if (typeof val === 'boolean') return val === true
+  return !!val
+}
+
+const getProgressStatus = (row: DocumentItem) => {
+  if (row.conversion_status === 1) return 'warning'   // 转换中
+  if (row.conversion_status === 2) return 'success'   // 转换完成 (100)
+  if (row.conversion_status === 3) return 'exception' // 转换失败
+  return '' // 未开始或默认 (蓝色)
+}
+
+const handleSearch = async () => {
+  searchQuery.page = 1
+  fetchDocuments()
+}
+
+const handleSizeChange = (val: number) => {
+  searchQuery.size = val
+  fetchDocuments()
+}
+
+const handleCurrentChange = (val: number) => {
+  searchQuery.page = val
+  fetchDocuments()
+}
+
+const handleUpload = () => {
+  uploadForm.title = ''
+  uploadForm.content = ''
+  uploadForm.primary_category_id = null
+  uploadForm.secondary_category_id = null
+  uploadDialogVisible.value = true
+}
+
+const submitUpload = async () => {
+  if (!uploadForm.title) return ElMessage.warning('请输入标题')
+  if (!uploadForm.primary_category_id) return ElMessage.warning('请选择一级分类')
+
+  submitting.value = true
+  try {
+    const res = await request.post<any, ApiResponse>('/api/v1/documents/add', {
+      ...uploadForm,
+      table_type: activeTable.value
+    })
+    if (res.code === 0) {
+      ElMessage.success('上传成功')
+      uploadDialogVisible.value = false
+      fetchDocuments()
+    } else {
+      ElMessage.error(res.message || '上传失败')
+    }
+  } catch (error) {
+    console.error('上传文档失败:', error)
+    ElMessage.error('网络错误')
+  } finally {
+    submitting.value = false
+  }
+}
+
+const handleDownload = (row: DocumentItem) => {
+  ElMessage.info(`下载文档: ${row.title}`)
+}
+
+const handleConvert = async (row: DocumentItem) => {
+  try {
+    const res = await request.post<any, ApiResponse>('/api/v1/documents/convert', {
+      id: row.id,
+      table_type: activeTable.value
+    })
+    if (res.code === 0) {
+      ElMessage.success('转换任务已启动')
+      fetchDocuments() // 立即刷新一次状态
+      startPolling()   // 确保开启轮询
+    } else {
+      ElMessage.error(res.message || '启动转换失败')
+    }
+  } catch (error) {
+    console.error('转换失败:', error)
+    ElMessage.error('网络错误')
+  }
+}
+
+// 轮询转换状态
+let pollingTimer: any = null
+const startPolling = () => {
+  if (pollingTimer) return
+  pollingTimer = setInterval(() => {
+    fetchDocuments()
+  }, 1000)
+}
+
+const stopPolling = () => {
+  if (pollingTimer) {
+    clearInterval(pollingTimer)
+    pollingTimer = null
+  }
+}
+
+onMounted(() => {
+  fetchPrimaryCategories()
+  fetchDocuments()
+})
+
+onUnmounted(() => {
+  stopPolling()
+})
+</script>
+
+<style scoped>
+.documents-container {
+  padding: 20px;
+}
+
+.header-section {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.title-info h2 {
+  margin: 0;
+  font-size: 24px;
+  color: #303133;
+}
+
+.statistics-bar {
+  display: flex;
+  gap: 20px;
+  margin-top: 8px;
+}
+
+.conversion-progress-wrapper {
+  width: 100%;
+  padding: 0 5px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.error-msg-text {
+  font-size: 12px;
+  color: #f56c6c;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-top: 2px;
+}
+
+.stat-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 14px;
+  color: #606266;
+}
+
+.stat-item .el-icon {
+  font-size: 16px;
+  color: #909399;
+}
+
+.stat-value {
+  font-weight: bold;
+  color: #303133;
+}
+
+.stat-value.success {
+  color: #67c23a;
+}
+
+.action-buttons {
+  display: flex;
+  gap: 15px;
+}
+
+.upload-btn {
+  background-color: #67c23a;
+  border-color: #67c23a;
+}
+
+.search-card {
+  margin-bottom: 20px;
+  background-color: #f8f9fa;
+}
+
+.search-bar {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.search-input :deep(.el-input__wrapper) {
+  padding: 8px 12px;
+  font-size: 16px;
+}
+
+.filter-group {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  flex-wrap: wrap;
+}
+
+.filter-select {
+  width: 200px;
+}
+
+.filter-select-year {
+  width: 140px;
+}
+
+.search-btn {
+  padding: 0 30px;
+  height: 40px;
+  font-size: 15px;
+  font-weight: bold;
+  margin-left: auto;
+}
+
+.content-section {
+  background: #fff;
+  border-radius: 4px;
+  min-height: 400px;
+}
+
+.pagination-container {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.preview-content {
+  height: 75vh;
+  overflow: hidden;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+}
+
+.preview-content iframe {
+  display: block;
+}
+
+:deep(.preview-dialog) {
+  .el-dialog__body {
+    padding: 10px 20px 20px;
+  }
+}
+
+/* 隐藏已入库行的复选框 */
+:deep(.row-entered .el-table-column--selection .el-checkbox) {
+  display: none;
+}
+</style>

+ 133 - 0
src/views/documents/KnowledgeBase.vue

@@ -0,0 +1,133 @@
+<template>
+  <div class="kb-container">
+    <div class="header-section">
+      <div class="title-info">
+        <h2>知识库管理中心</h2>
+        <p class="subtitle">管理和查看已转换的知识库内容</p>
+      </div>
+      <div class="action-buttons">
+        <el-button type="primary" @click="handleRefresh">
+          <el-icon><Refresh /></el-icon> 刷新数据
+        </el-button>
+      </div>
+    </div>
+
+    <el-card class="status-card">
+      <template #header>
+        <div class="card-header">
+          <span>知识库概览</span>
+        </div>
+      </template>
+      <el-row :gutter="20">
+        <el-col :span="8">
+          <div class="stat-box">
+            <div class="label">总条目</div>
+            <div class="value">{{ stats.total }}</div>
+          </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="stat-box">
+            <div class="label">已解析</div>
+            <div class="value success">{{ stats.parsed }}</div>
+          </div>
+        </el-col>
+        <el-col :span="8">
+          <div class="stat-box">
+            <div class="label">待解析</div>
+            <div class="value warning">{{ stats.pending }}</div>
+          </div>
+        </el-col>
+      </el-row>
+    </el-card>
+
+    <div class="content-section">
+      <el-empty description="知识库功能开发中..." />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { Refresh } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+
+const stats = reactive({
+  total: 0,
+  parsed: 0,
+  pending: 0
+})
+
+const handleRefresh = () => {
+  ElMessage.success('刷新成功')
+}
+
+onMounted(() => {
+  // 初始化逻辑
+})
+</script>
+
+<style scoped>
+.kb-container {
+  padding: 20px;
+}
+
+.header-section {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.title-info h2 {
+  margin: 0;
+  font-size: 24px;
+  color: #303133;
+}
+
+.subtitle {
+  margin: 8px 0 0;
+  color: #909399;
+  font-size: 14px;
+}
+
+.status-card {
+  margin-bottom: 20px;
+}
+
+.stat-box {
+  text-align: center;
+  padding: 20px;
+  background-color: #f8f9fa;
+  border-radius: 8px;
+}
+
+.stat-box .label {
+  font-size: 14px;
+  color: #606266;
+  margin-bottom: 8px;
+}
+
+.stat-box .value {
+  font-size: 28px;
+  font-weight: bold;
+  color: #303133;
+}
+
+.stat-box .value.success {
+  color: #67c23a;
+}
+
+.stat-box .value.warning {
+  color: #e6a23c;
+}
+
+.content-section {
+  background: #fff;
+  border-radius: 4px;
+  min-height: 400px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border: 1px border #ebeef5;
+}
+</style>