3 Revize c76dbc7f47 ... 20884b35f1

Autor SHA1 Zpráva Datum
  chenkun 20884b35f1 chore: add gitignore for vite project před 3 týdny
  chenkun 34d76a79fb Revert "v0.6" před 3 týdny
  chenkun 2901c5f58c v0.6 před 3 týdny

+ 17 - 12
.gitignore

@@ -1,16 +1,21 @@
-# Python cache
-__pycache__/
-*.py[oc]
-build/
+# Dependencies
+node_modules/
+pnpm-lock.yaml
+
+# Vite
+.vite/
 dist/
-wheels/
-*.egg-info
-*.log
-# Environment variables
+*.local
+
+# Environment
 .env
-src/app/config/.env
+.env.local
+.env.*.local
 
-# cursor
-.cursorrules
+# IDE
+.vscode/
+.idea/
 
-venv/
+# OS
+.DS_Store
+Thumbs.db

+ 139 - 0
src/api/document.ts

@@ -0,0 +1,139 @@
+import request from './request'
+
+// --- 类型定义 ---
+
+export interface DocumentItem {
+  id: string
+  title: string
+  note?: string
+  content?: string // 兼容旧代码
+  source_type: 'basis' | 'work' | 'job'
+  table_type?: 'basis' | 'work' | 'job' // 兼容旧代码
+  primary_category_id: string
+  secondary_category_id: string
+  year: number
+  file_url: string
+  file_extension: string
+  whether_to_enter: number
+  conversion_status: number
+  conversion_progress: number
+  md_url?: string
+  json_url?: string
+  md_display_name?: string
+  json_display_name?: string
+  conversion_error?: string
+  error_message?: string // 兼容旧代码
+  created_by?: string
+  updated_by?: string
+  creator_name?: string
+  updater_name?: string
+  created_time: string
+  updated_time: string
+  created_at?: string // 兼容旧代码
+  updated_at?: string // 兼容旧代码
+  // 基础信息扩展字段
+  standard_no?: string
+  issuing_authority?: string
+  release_date?: string
+  document_type?: string
+  professional_field?: string
+  validity?: string
+  project_name?: string
+  project_section?: string
+  compilation_basis?: string
+  plan_summary?: string
+  plan_category?: string
+  level_1_classification?: string
+  level_2_classification?: string
+  level_3_classification?: string
+  level_4_classification?: string
+}
+
+export interface DocumentQueryParams {
+  page?: number
+  size?: number
+  keyword?: string
+  table_type?: string | null
+  whether_to_enter?: number | null
+}
+
+export interface ApiResponse<T = any> {
+  code: number
+  message: string
+  data: T
+  timestamp: string
+}
+
+export interface PageResult<T> {
+  items: T[]
+  total: number
+  page: number
+  size: number
+  all_total?: number
+  total_entered?: number
+}
+
+// --- API 方法 ---
+
+const prefix = '/api/v1/sample'
+
+export const documentApi = {
+  // 获取文档列表
+  getList(params: DocumentQueryParams, silent: boolean = false): Promise<ApiResponse<PageResult<DocumentItem>>> {
+    const config: any = { params }
+    if (silent) {
+      config.headers = { 'Skip-Error-Message': 'true' }
+    }
+    return request.get(`${prefix}/documents/list`, config)
+  },
+
+  // 获取文档详情
+  getDetail(id: string): Promise<ApiResponse<DocumentItem>> {
+    return request.get(`${prefix}/documents/detail/${id}`)
+  },
+
+  // 添加文档
+  add(data: Partial<DocumentItem>): Promise<ApiResponse<{ id: string }>> {
+    return request.post(`${prefix}/documents/add`, data)
+  },
+
+  // 编辑文档
+  edit(data: Partial<DocumentItem>): Promise<ApiResponse<null>> {
+    return request.post(`${prefix}/documents/edit`, data)
+  },
+
+  // 文档入库
+  enter(id: string): Promise<ApiResponse<null>> {
+    return request.post(`${prefix}/documents/enter`, { id })
+  },
+
+  // 批量入库
+  batchEnter(ids: string[]): Promise<ApiResponse<null>> {
+    return request.post(`${prefix}/documents/batch-enter`, { ids })
+  },
+
+  // 批量删除
+  batchDelete(ids: string[]): Promise<ApiResponse<null>> {
+    return request.post(`${prefix}/documents/batch-delete`, { ids })
+  },
+
+  // 启动转换
+  convert(id: string, tableType?: string): Promise<ApiResponse<null>> {
+    return request.post(`${prefix}/documents/convert`, { id, table_type: tableType })
+  },
+
+  // 代理查看内容
+  proxyView(url: string, token?: string): Promise<any> {
+    return request.get(`${prefix}/documents/proxy-view`, {
+      params: { url, token }
+    })
+  },
+
+  // 获取上传预签名 URL
+  getUploadUrl(filename: string, contentType: string): Promise<ApiResponse<{ upload_url: string, file_url: string }>> {
+    return request.post(`${prefix}/documents/upload-url`, {
+      filename,
+      content_type: contentType
+    })
+  }
+}

+ 97 - 0
src/api/image.ts

@@ -0,0 +1,97 @@
+import request from './request'
+
+// --- 类型定义 ---
+
+export interface ImageCategory {
+  id: string
+  type_name: string
+  parent_id: string
+  remark?: string
+  children?: ImageCategory[]
+  created_time: string
+  updated_time: string
+}
+
+export interface ImageItem {
+  id: string
+  image_name: string
+  image_url: string
+  image_type: string
+  description?: string
+  category_name?: string
+  creator_name?: string
+  created_by: string
+  updated_by?: string
+  created_time: string
+  updated_time: string
+}
+
+export interface ImageQueryParams {
+  category_id?: string
+  page?: number
+  page_size?: number
+}
+
+export interface ApiResponse<T = any> {
+  code: number
+  message: string
+  data: T
+  timestamp: string
+}
+
+export interface PageResult<T> {
+  list: T[]
+  total: number
+  page: number
+  page_size: number
+}
+
+// --- API 方法 ---
+
+const prefix = '/api/v1/images'
+
+export const imageApi = {
+  // --- 分类管理 ---
+  
+  // 获取全部分类树
+  getCategories(): Promise<ApiResponse<ImageCategory[]>> {
+    return request.get(`${prefix}/categories`)
+  },
+
+  // 新增分类
+  addCategory(data: Partial<ImageCategory>): Promise<ApiResponse<{ id: string }>> {
+    return request.post(`${prefix}/categories`, data)
+  },
+
+  // 更新分类
+  updateCategory(id: string, data: Partial<ImageCategory>): Promise<ApiResponse<null>> {
+    return request.put(`${prefix}/categories/${id}`, data)
+  },
+
+  // 删除分类
+  deleteCategory(id: string): Promise<ApiResponse<null>> {
+    return request.delete(`${prefix}/categories/${id}`)
+  },
+
+  // --- 图片管理 ---
+
+  // 获取图片列表
+  getList(params: ImageQueryParams): Promise<ApiResponse<PageResult<ImageItem>>> {
+    return request.get(prefix, { params })
+  },
+
+  // 保存图片信息
+  add(data: Partial<ImageItem>): Promise<ApiResponse<null>> {
+    return request.post(prefix, data)
+  },
+
+  // 删除图片
+  delete(id: string): Promise<ApiResponse<null>> {
+    return request.delete(`${prefix}/${id}`)
+  },
+
+  // 获取上传链接
+  getUploadUrl(filename: string, contentType: string): Promise<ApiResponse<{ upload_url: string, file_url: string, object_name: string }>> {
+    return request.post(`${prefix}/upload-url`, { filename, content_type: contentType })
+  }
+}

+ 227 - 0
src/router/index.ts

@@ -0,0 +1,227 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import type { RouteRecordRaw } from 'vue-router'
+import { useAuthStore } from '@/stores/auth'
+import MainLayout from '@/layouts/MainLayout.vue'
+
+const routes: RouteRecordRaw[] = [
+  {
+    path: '/',
+    redirect: '/dashboard'
+  },
+  {
+    path: '/login',
+    name: 'Login',
+    component: () => import('@/views/auth/Login.vue'),
+    meta: { requiresGuest: true }
+  },
+  {
+    path: '/register',
+    name: 'Register', 
+    component: () => import('@/views/auth/Register.vue'),
+    meta: { requiresGuest: true }
+  },
+  {
+    path: '/oauth/callback',
+    name: 'OAuthCallback',
+    component: () => import('@/views/auth/OAuthCallback.vue')
+  },
+  {
+    path: '/',
+    component: MainLayout,
+    meta: { requiresAuth: true },
+    children: [
+      {
+        path: 'dashboard',
+        name: 'Dashboard',
+        component: () => import('@/views/dashboard/Index.vue')
+      },
+      {
+        path: 'profile',
+        name: 'Profile',
+        component: () => import('@/views/user/Profile.vue')
+      },
+      {
+        path: 'admin',
+        redirect: '/admin/dashboard'
+      },
+      {
+        path: 'admin/dashboard',
+        name: 'AdminDashboard',
+        component: () => import('@/views/admin/Dashboard.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/users',
+        name: 'AdminUsers',
+        component: () => import('@/views/admin/Users.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/roles',
+        name: 'AdminRoles',
+        component: () => import('@/views/admin/Roles.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/menus',
+        name: 'AdminMenus',
+        component: () => import('@/views/admin/Menus.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/tasks',
+        name: 'AdminTasks',
+        component: () => import('@/views/admin/TaskManagement.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/permissions',
+        name: 'AdminPermissions',
+        component: () => import('@/views/admin/Permissions.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/apps',
+        name: 'AdminApps',
+        component: () => import('@/views/admin/Apps.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/logs',
+        name: 'AdminLogs',
+        component: () => import('@/views/admin/Logs.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/settings',
+        name: 'AdminSettings',
+        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',
+        component: () => import('@/views/documents/Index.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/images',
+        name: 'Images',
+        component: () => import('@/views/images/Index.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/documents/kb',
+        name: 'KnowledgeBase',
+        component: () => import('@/views/documents/KnowledgeBase.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/documents/snippet',
+        name: 'KnowledgeSnippet',
+        component: () => import('@/views/documents/KnowledgeSnippet.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/documents/search-engine',
+        name: 'SearchEngine',
+        component: () => import('@/views/documents/SearchEngine.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/basic-info/basis',
+        name: 'BasicInfoBasis',
+        component: () => import('@/views/basic-info/Index.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/basic-info/work',
+        name: 'BasicInfoWork',
+        component: () => import('@/views/basic-info/Index.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'admin/basic-info/job',
+        name: 'BasicInfoJob',
+        component: () => import('@/views/basic-info/Index.vue'),
+        meta: { requiresAdmin: true }
+      }
+    ]
+  },
+  {
+    path: '/unauthorized',
+    name: 'Unauthorized',
+    component: () => import('@/views/error/403.vue')
+  },
+  {
+    path: '/:pathMatch(.*)*',
+    name: 'NotFound',
+    component: () => import('@/views/error/404.vue')
+  }
+]
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes
+})
+
+// 路由守卫
+router.beforeEach(async (to, from, next) => {
+  const authStore = useAuthStore()
+  
+  // 如果有token但没有用户信息,先尝试获取用户信息
+  if (authStore.token && !authStore.user && !authStore.loading) {
+    try {
+      await authStore.checkAuth()
+    } catch (error) {
+      console.error('路由守卫检查认证状态失败:', error)
+    }
+  }
+  
+  // 检查是否需要认证
+  if (to.meta.requiresAuth) {
+    if (!authStore.isAuthenticated) {
+      // 未登录,重定向到登录页
+      next({
+        name: 'Login',
+        query: { redirect: to.fullPath }
+      })
+      return
+    }
+    
+    // 检查是否需要管理员权限
+    if (to.meta.requiresAdmin) {
+      // For admin routes, check permissions
+      if (authStore.isAdmin) {
+        // Super admin or admin users can access all admin routes
+        next()
+        return
+      } else {
+        // For non-admin users, check if they have menu access to this specific path
+        if (authStore.hasPathAccess(to.path)) {
+          next()
+          return
+        } else {
+          next({ name: 'Unauthorized' })
+          return
+        }
+      }
+    }
+  }
+  
+  // 检查是否需要访客权限(已登录用户不能访问登录/注册页)
+  if (to.meta.requiresGuest && authStore.isAuthenticated) {
+    next({ name: 'Dashboard' })
+    return
+  }
+  
+  next()
+})
+
+export default router

+ 173 - 0
src/stores/auth.ts

@@ -0,0 +1,173 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { User, LoginForm, TokenResponse } from '@/types/auth'
+import { authApi } from '@/api/auth'
+import { userApi, type UserProfile } from '@/api/user'
+import { getToken, setToken, removeToken } from '@/utils/auth'
+import request from '@/api/request'
+
+export const useAuthStore = defineStore('auth', () => {
+  const user = ref<UserProfile | null>(null)
+  const token = ref<string | null>(getToken())
+  const loading = ref(false)
+  const userMenus = ref<any[]>([])
+
+  // 计算属性
+  const isAuthenticated = computed(() => !!token.value && !!user.value)
+  const isAdmin = computed(() => {
+    if (!user.value) return false
+    // Check if user is superuser OR has admin/super_admin roles
+    return user.value.is_superuser || 
+           (user.value.roles && user.value.roles.some((role: string) => 
+             role === 'admin' || role === 'super_admin'
+           ))
+  })
+
+  // 检查用户是否有访问特定路径的权限
+  const hasPathAccess = (targetPath: string): boolean => {
+    if (isAdmin.value) return true // Admin users have access to all paths
+    
+    const checkMenuAccess = (menus: any[], path: string): boolean => {
+      for (const menu of menus) {
+        if (menu.path === path && menu.menu_type === 'menu') {
+          return true
+        }
+        if (menu.children && checkMenuAccess(menu.children, path)) {
+          return true
+        }
+      }
+      return false
+    }
+    
+    return checkMenuAccess(userMenus.value, targetPath)
+  }
+
+  // 登录
+  const login = async (loginForm: LoginForm): Promise<void> => {
+    loading.value = true
+    try {
+      const response = await authApi.login(loginForm)
+      const tokenData: TokenResponse = response.data
+      
+      // 保存token
+      token.value = tokenData.access_token
+      setToken(tokenData.access_token, tokenData.refresh_token)
+      
+      // 获取用户信息
+      await fetchUserInfo()
+    } finally {
+      loading.value = false
+    }
+  }
+
+  // 登出
+  const logout = async (): Promise<void> => {
+    try {
+      if (token.value) {
+        await authApi.logout()
+      }
+    } catch (error) {
+      console.error('登出失败:', error)
+    } finally {
+      // 清除本地数据
+      user.value = null
+      token.value = null
+      userMenus.value = []
+      removeToken()
+    }
+  }
+
+  // 获取用户信息
+  const fetchUserInfo = async (): Promise<void> => {
+    if (!token.value) return
+    
+    try {
+      const response = await userApi.getProfile()
+      user.value = response.data
+      
+      // 同时获取用户菜单权限
+      await fetchUserMenus()
+    } catch (error) {
+      console.error('获取用户信息失败:', error)
+      // 如果获取用户信息失败,可能token已过期,清除登录状态
+      await logout()
+      throw error
+    }
+  }
+
+  // 获取用户菜单权限
+  const fetchUserMenus = async (): Promise<void> => {
+    if (!token.value) return
+    
+    try {
+      const response: any = await request.get('/api/v1/system/user/menus')
+      if (response.code === 0) {
+        userMenus.value = response.data
+      }
+    } catch (error) {
+      console.error('获取用户菜单失败:', error)
+      userMenus.value = []
+    }
+  }
+
+  // 检查认证状态
+  const checkAuth = async (): Promise<void> => {
+    const storedToken = getToken()
+    if (storedToken && !user.value) {
+      token.value = storedToken // 确保token被设置
+      try {
+        await fetchUserInfo()
+      } catch (error) {
+        // 认证失败,清除token
+        await logout()
+        throw error
+      }
+    }
+  }
+
+  // 刷新token
+  const refreshToken = async (): Promise<boolean> => {
+    try {
+      const response = await authApi.refreshToken()
+      const tokenData: TokenResponse = response.data
+      
+      token.value = tokenData.access_token
+      setToken(tokenData.access_token, tokenData.refresh_token)
+      
+      return true
+    } catch (error) {
+      console.error('刷新token失败:', error)
+      await logout()
+      return false
+    }
+  }
+
+  // 更新用户信息
+  const updateProfile = async (profileData: any): Promise<void> => {
+    try {
+      await userApi.updateProfile(profileData)
+      // 重新获取用户信息
+      await fetchUserInfo()
+    } catch (error) {
+      console.error('更新用户信息失败:', error)
+      throw error
+    }
+  }
+
+  return {
+    user,
+    token,
+    loading,
+    userMenus,
+    isAuthenticated,
+    isAdmin,
+    hasPathAccess,
+    login,
+    logout,
+    fetchUserInfo,
+    fetchUserMenus,
+    checkAuth,
+    refreshToken,
+    updateProfile
+  }
+})

+ 1134 - 0
src/views/basic-info/Index.vue

@@ -0,0 +1,1134 @@
+<template>
+  <div class="basic-info-container">
+    <el-card class="box-card search-card">
+      <div class="search-header">
+        <span class="title">{{ pageTitle }}</span>
+        <el-button type="primary" :icon="Plus" @click="handleAdd">新增{{ moduleName }}</el-button>
+      </div>
+      
+      <el-form :model="searchForm" label-width="80px" class="search-form" label-position="top">
+        <el-row :gutter="20">
+          <!-- 共通字段: 名称 -->
+          <el-col :span="6">
+            <el-form-item :label="titleLabel">
+              <el-input v-model="searchForm.title" :placeholder="'请输入' + titleLabel" clearable />
+            </el-form-item>
+          </el-col>
+          
+          <!-- Basis 专用: 标准编号 -->
+          <el-col :span="6" v-if="infoType === 'basis'">
+            <el-form-item label="标准编号">
+              <el-input v-model="searchForm.standard_no" placeholder="请输入标准编号" clearable />
+            </el-form-item>
+          </el-col>
+          
+          <!-- Basis/Job 专用: 文件类型 -->
+          <el-col :span="6" v-if="infoType === 'basis' || infoType === 'job'">
+            <el-form-item label="文件类型">
+              <el-select v-model="searchForm.document_type" placeholder="请选择文件类型" clearable style="width: 100%">
+                <el-option v-for="item in documentTypeOptions" :key="item" :label="item" :value="item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          
+          <!-- Basis 专用: 专业领域 -->
+          <el-col :span="6" v-if="infoType === 'basis'">
+            <el-form-item label="专业领域">
+              <el-select v-model="searchForm.professional_field" placeholder="请选择专业领域" clearable style="width: 100%">
+                <el-option v-for="item in professionalFieldOptions" :key="item" :label="item" :value="item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+
+          <!-- Row 2 -->
+          <!-- Basis 专用: 时效性 -->
+          <el-col :span="6" v-if="infoType === 'basis'">
+            <el-form-item label="时效性">
+              <el-select v-model="searchForm.validity" placeholder="请选择时效性" clearable style="width: 100%">
+                <el-option label="现行" value="现行" />
+                <el-option label="已废止" value="已废止" />
+                <el-option label="被替代" value="被替代" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          
+          <el-col :span="6" v-if="infoType === 'work'">
+            <el-form-item label="方案类别">
+              <el-select v-model="searchForm.plan_category" placeholder="请选择方案类别" clearable style="width: 100%">
+                <el-option v-for="item in planCategoryOptions" :key="item" :label="item" :value="item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="6" v-if="infoType === 'work'">
+            <el-form-item label="一级分类">
+              <el-input v-model="searchForm.level_1_classification" placeholder="请输入一级分类" clearable />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="6" v-if="infoType === 'work'">
+            <el-form-item label="二级分类">
+              <el-select v-model="searchForm.level_2_classification" placeholder="请选择二级分类" clearable style="width: 100%">
+                <el-option v-for="item in level2Options" :key="item" :label="item" :value="item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="6" v-if="infoType === 'work'">
+            <el-form-item label="三级分类">
+              <el-select v-model="searchForm.level_3_classification" placeholder="请选择三级分类" clearable style="width: 100%">
+                <el-option v-for="item in level3Options" :key="item" :label="item" :value="item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="6" v-if="infoType === 'work'">
+            <el-form-item label="四级分类">
+              <el-select v-model="searchForm.level_4_classification" placeholder="请选择四级分类" clearable style="width: 100%">
+                <el-option v-for="item in level4Options" :key="item" :label="item" :value="item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          
+          <!-- 共通字段: 发布单位/编制单位/发布部门 -->
+          <el-col :span="6">
+            <el-form-item :label="authorityLabel">
+              <el-input v-model="searchForm.issuing_authority" :placeholder="'请输入' + authorityLabel" clearable />
+            </el-form-item>
+          </el-col>
+          
+          <!-- 共通字段: 日期范围 -->
+          <el-col :span="6">
+            <el-form-item label="发布开始日期">
+              <el-date-picker
+                v-model="searchForm.release_date_start"
+                type="date"
+                placeholder="选择开始日期"
+                style="width: 100%"
+                value-format="YYYY-MM-DD"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="6">
+            <el-form-item label="发布结束日期">
+              <el-date-picker
+                v-model="searchForm.release_date_end"
+                type="date"
+                placeholder="选择结束日期"
+                style="width: 100%"
+                value-format="YYYY-MM-DD"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        
+        <div class="search-buttons">
+          <el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
+          <el-button :icon="Refresh" @click="resetSearch">重置</el-button>
+        </div>
+      </el-form>
+    </el-card>
+
+    <el-card class="box-card table-card">
+      <el-table :data="tableData" v-loading="loading" style="width: 100%" border stripe>
+        <el-table-column prop="title" :label="titleLabel" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="standard_no" label="标准编号" width="150" show-overflow-tooltip v-if="infoType === 'basis'" />
+        <el-table-column prop="issuing_authority" :label="authorityLabel" width="180" show-overflow-tooltip />
+        <el-table-column prop="release_date" :label="dateLabel" width="120">
+          <template #default="scope">
+            {{ formatDate(scope.row.release_date) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="document_type" label="文档类型" width="120" v-if="infoType === 'basis' || infoType === 'job'" />
+        <el-table-column prop="plan_category" label="方案类别" width="120" v-if="infoType === 'work'" />
+        <el-table-column prop="level_1_classification" label="一级分类" width="120" v-if="infoType === 'work'" />
+        <el-table-column prop="level_2_classification" label="二级分类" width="120" v-if="infoType === 'work'" />
+        <el-table-column prop="level_3_classification" label="三级分类" width="120" v-if="infoType === 'work'" />
+        <el-table-column prop="level_4_classification" label="四级分类" width="120" v-if="infoType === 'work'" />
+        <el-table-column prop="professional_field" label="专业领域" width="120" v-if="infoType === 'basis'" />
+        <el-table-column label="参编单位" min-width="150" v-if="infoType === 'basis'">
+          <template #default="scope">
+            <template v-if="scope.row.participating_units">
+              <el-popover
+                placement="top"
+                :width="350"
+                trigger="hover"
+                popper-class="tag-popover"
+              >
+                <template #reference>
+                  <div class="tag-group">
+                    <el-tag 
+                      v-for="(unit, idx) in scope.row.participating_units.split(';').slice(0, 2)" 
+                      :key="idx" 
+                      size="small" 
+                      class="info-tag"
+                    >
+                      {{ unit }}
+                    </el-tag>
+                    <el-tag v-if="scope.row.participating_units.split(';').length > 2" size="small" type="info" class="info-tag">
+                      +{{ scope.row.participating_units.split(';').length - 2 }}
+                    </el-tag>
+                  </div>
+                </template>
+                <div class="popover-tag-list">
+                  <el-tag 
+                    v-for="(unit, idx) in scope.row.participating_units.split(';')" 
+                    :key="idx" 
+                    size="small" 
+                    class="popover-info-tag"
+                  >
+                    {{ unit }}
+                  </el-tag>
+                </div>
+              </el-popover>
+            </template>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="参考依据" min-width="150" v-if="infoType === 'basis'">
+          <template #default="scope">
+            <template v-if="scope.row.reference_basis">
+              <el-popover
+                placement="top"
+                :width="350"
+                trigger="hover"
+                popper-class="tag-popover"
+              >
+                <template #reference>
+                  <div class="tag-group">
+                    <el-tag 
+                      v-for="(basis, idx) in scope.row.reference_basis.split(';').slice(0, 2)" 
+                      :key="idx" 
+                      size="small" 
+                      type="info"
+                      class="info-tag"
+                    >
+                      {{ basis }}
+                    </el-tag>
+                    <el-tag v-if="scope.row.reference_basis.split(';').length > 2" size="small" type="info" class="info-tag">
+                      +{{ scope.row.reference_basis.split(';').length - 2 }}
+                    </el-tag>
+                  </div>
+                </template>
+                <div class="popover-tag-list">
+                  <el-tag 
+                    v-for="(basis, idx) in scope.row.reference_basis.split(';')" 
+                    :key="idx" 
+                    size="small" 
+                    type="info"
+                    class="popover-info-tag"
+                  >
+                    {{ basis }}
+                  </el-tag>
+                </div>
+              </el-popover>
+            </template>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="validity" label="时效性" width="100" v-if="infoType === 'basis'">
+          <template #default="scope">
+            <el-tag :type="getValidityType(scope.row.validity)">
+              {{ scope.row.validity || '现行' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="备注" min-width="150" show-overflow-tooltip>
+          <template #default="scope">
+            {{ scope.row.note || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="creator_name" label="创建人" width="100" />
+        <el-table-column prop="created_time" label="创建时间" width="180">
+          <template #default="scope">
+            {{ formatDateTime(scope.row.created_time) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="updater_name" label="修改人" width="100" />
+        <el-table-column prop="updated_time" label="修改时间" width="180">
+          <template #default="scope">
+            {{ formatDateTime(scope.row.updated_time) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="180" fixed="right" align="center">
+          <template #default="scope">
+            <div class="action-buttons">
+              <el-tooltip content="详情" placement="top">
+                <el-button link type="primary" @click="handleAction('view', scope.row)">
+                  <el-icon><View /></el-icon>
+                </el-button>
+              </el-tooltip>
+              <el-tooltip content="下载" placement="top" v-if="scope.row.file_url">
+                <el-button link type="success" @click="handleAction('download', scope.row)">
+                  <el-icon><Download /></el-icon>
+                </el-button>
+              </el-tooltip>
+              <el-tooltip content="编辑" placement="top">
+                <el-button link type="primary" @click="handleAction('edit', scope.row)">
+                  <el-icon><Edit /></el-icon>
+                </el-button>
+              </el-tooltip>
+              <el-tooltip content="删除" placement="top">
+                <el-button link type="danger" @click="handleAction('delete', scope.row)">
+                  <el-icon><Delete /></el-icon>
+                </el-button>
+              </el-tooltip>
+            </div>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-container">
+        <el-pagination
+          v-model:current-page="currentPage"
+          v-model:page-size="pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          layout="total, sizes, prev, pager, next, jumper"
+          :total="total"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑对话框 -->
+    <el-dialog v-model="formDialogVisible" :title="formTitle" width="800px">
+      <el-form :model="editForm" label-width="110px">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item :label="titleLabel" required>
+              <el-input v-model="editForm.title" :placeholder="'请输入' + titleLabel" />
+            </el-form-item>
+          </el-col>
+          
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="英文名称">
+              <el-input v-model="editForm.english_name" placeholder="请输入英文名称" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="标准编号">
+              <el-input v-model="editForm.standard_no" placeholder="请输入标准编号" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12">
+            <el-form-item :label="authorityLabel">
+              <el-input v-model="editForm.issuing_authority" :placeholder="'请输入' + authorityLabel" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12">
+            <el-form-item :label="dateLabel">
+              <el-date-picker v-model="editForm.release_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="实施日期">
+              <el-date-picker v-model="editForm.implementation_date" type="date" placeholder="选择实施日期" value-format="YYYY-MM-DD" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis' || infoType === 'job'">
+            <el-form-item label="文件类型">
+              <el-select v-model="editForm.document_type" placeholder="请选择文件类型" style="width: 100%">
+                <el-option v-for="item in documentTypeOptions" :key="item" :label="item" :value="item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="专业领域">
+              <el-select v-model="editForm.professional_field" placeholder="请选择专业领域" style="width: 100%">
+                <el-option v-for="item in professionalFieldOptions" :key="item" :label="item" :value="item" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="时效性">
+              <el-select v-model="editForm.validity" placeholder="请选择时效性" style="width: 100%">
+                <el-option label="现行" value="现行" />
+                <el-option label="已废止" value="已废止" />
+                <el-option label="被替代" value="被替代" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="起草单位">
+              <el-input v-model="editForm.drafting_unit" placeholder="请输入起草单位" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="批准部门">
+              <el-input v-model="editForm.approving_department" placeholder="请输入批准部门" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24" v-if="infoType === 'basis'">
+            <el-form-item label="参编单位">
+              <div v-for="(unit, index) in participatingUnitsList" :key="index" class="dynamic-input-row">
+                <el-input v-model="participatingUnitsList[index]" placeholder="请输入参编单位" />
+                <div class="row-actions">
+                  <el-button :icon="Plus" circle size="small" @click="addListItem(participatingUnitsList)" />
+                  <el-button :icon="Minus" circle size="small" type="danger" @click="removeListItem(participatingUnitsList, index)" />
+                </div>
+              </div>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="工程阶段">
+              <el-input v-model="editForm.engineering_phase" placeholder="请输入工程阶段" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24" v-if="infoType === 'basis'">
+            <el-form-item label="引用依据">
+              <div v-for="(basis, index) in referenceBasisList" :key="index" class="dynamic-input-row">
+                <el-input v-model="referenceBasisList[index]" placeholder="请输入引用依据" />
+                <div class="row-actions">
+                  <el-button :icon="Plus" circle size="small" @click="addListItem(referenceBasisList)" />
+                  <el-button :icon="Minus" circle size="small" type="danger" @click="removeListItem(referenceBasisList, index)" />
+                </div>
+              </div>
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="12" v-if="infoType === 'basis'">
+            <el-form-item label="来源链接">
+              <el-input v-model="editForm.source_url" placeholder="请输入来源链接" />
+            </el-form-item>
+          </el-col>
+
+          <template v-if="infoType === 'work'">
+            <el-col :span="12">
+              <el-form-item label="项目名称">
+                <el-input v-model="editForm.project_name" placeholder="请输入项目名称" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="项目标段">
+                <el-input v-model="editForm.project_section" placeholder="请输入项目标段" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="方案类别">
+                <el-select v-model="editForm.plan_category" placeholder="请选择方案类别" style="width: 100%">
+                  <el-option v-for="item in planCategoryOptions" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="一级分类">
+                <el-input v-model="editForm.level_1_classification" disabled />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="二级分类">
+                <el-select v-model="editForm.level_2_classification" placeholder="请选择二级分类" style="width: 100%">
+                  <el-option v-for="item in level2Options" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="三级分类">
+                <el-select v-model="editForm.level_3_classification" placeholder="请选择三级分类" style="width: 100%">
+                  <el-option v-for="item in level3Options" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="四级分类">
+                <el-select v-model="editForm.level_4_classification" placeholder="请选择四级分类" style="width: 100%">
+                  <el-option v-for="item in level4Options" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="24">
+              <el-form-item label="方案摘要">
+                <el-input v-model="editForm.plan_summary" type="textarea" :rows="3" placeholder="请输入方案摘要" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="24">
+              <el-form-item label="施工标准规范">
+                <div v-for="(basis, index) in compilationBasisList" :key="index" class="dynamic-input-row">
+                  <el-input v-model="compilationBasisList[index]" placeholder="请输入施工标准规范" />
+                  <div class="row-actions">
+                    <el-button :icon="Plus" circle size="small" @click="addListItem(compilationBasisList)" />
+                    <el-button :icon="Minus" circle size="small" type="danger" @click="removeListItem(compilationBasisList, index)" />
+                  </div>
+                </div>
+              </el-form-item>
+            </el-col>
+          </template>
+
+          <template v-if="infoType === 'job'">
+            <el-col :span="12">
+              <el-form-item label="生效日期">
+                <el-date-picker v-model="editForm.effective_start_date" type="date" placeholder="选择生效日期" value-format="YYYY-MM-DD" style="width: 100%" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="失效日期">
+                <el-date-picker v-model="editForm.effective_end_date" type="date" placeholder="选择失效日期" value-format="YYYY-MM-DD" style="width: 100%" />
+              </el-form-item>
+            </el-col>
+          </template>
+
+          <el-col :span="24">
+            <el-form-item label="备注">
+              <el-input v-model="editForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
+            </el-form-item>
+          </el-col>
+
+          <el-col :span="24">
+            <el-form-item label="文件预览链接" v-if="false">
+              <el-input v-model="editForm.file_url" placeholder="请输入文件预览链接 (URL)" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+      <template #footer>
+        <el-button @click="formDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 查看详情对话框 -->
+    <el-dialog v-model="detailDialogVisible" title="详情信息" width="600px">
+      <el-descriptions :column="1" border>
+        <el-descriptions-item :label="titleLabel">{{ currentItem?.title }}</el-descriptions-item>
+        <el-descriptions-item label="标准编号" v-if="infoType === 'basis'">{{ currentItem?.standard_no || '-' }}</el-descriptions-item>
+        <el-descriptions-item :label="authorityLabel">{{ currentItem?.issuing_authority || '-' }}</el-descriptions-item>
+        <el-descriptions-item :label="dateLabel">{{ formatDate(currentItem?.release_date) }}</el-descriptions-item>
+        <el-descriptions-item label="文档类型" v-if="infoType === 'basis' || infoType === 'job'">{{ currentItem?.document_type || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="专业领域" v-if="infoType === 'basis'">{{ currentItem?.professional_field || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="参编单位" v-if="infoType === 'basis'">
+          <div v-if="currentItem?.participating_units">
+            <div v-for="(unit, idx) in currentItem.participating_units.split(';')" :key="idx">{{ unit }}</div>
+          </div>
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="引用依据" v-if="infoType === 'basis'">
+          <div v-if="currentItem?.reference_basis">
+            <div v-for="(basis, idx) in currentItem.reference_basis.split(';')" :key="idx">{{ basis }}</div>
+          </div>
+          <span v-else>-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="时效性" v-if="infoType === 'basis'">
+          <el-tag :type="getValidityType(currentItem?.validity)">{{ currentItem?.validity || '现行' }}</el-tag>
+        </el-descriptions-item>
+        <template v-if="infoType === 'work'">
+          <el-descriptions-item label="项目名称">{{ currentItem?.project_name || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="项目标段">{{ currentItem?.project_section || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="方案类别">{{ currentItem?.plan_category || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="一级分类">{{ currentItem?.level_1_classification || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="二级分类">{{ currentItem?.level_2_classification || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="三级分类">{{ currentItem?.level_3_classification || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="四级分类">{{ currentItem?.level_4_classification || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="方案摘要">{{ currentItem?.plan_summary || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="施工标准规范">
+            <div v-if="currentItem?.compilation_basis">
+              <div v-for="(basis, idx) in currentItem.compilation_basis.split(';')" :key="idx">{{ basis }}</div>
+            </div>
+            <span v-else>-</span>
+          </el-descriptions-item>
+        </template>
+        <el-descriptions-item label="创建人">{{ currentItem?.creator_name || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="创建时间">{{ formatDateTime(currentItem?.created_time) }}</el-descriptions-item>
+        <el-descriptions-item label="修改人">{{ currentItem?.updater_name || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="修改时间">{{ formatDateTime(currentItem?.updated_time) }}</el-descriptions-item>
+      </el-descriptions>
+      <template #footer>
+        <el-button @click="detailDialogVisible = false">关闭</el-button>
+        <el-button type="primary" @click="handleAction('edit', currentItem)">编辑</el-button>
+        <el-button type="success" @click="handleAction('preview', currentItem)" v-if="currentItem?.file_url">预览文件</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 预览对话框 -->
+    <el-dialog v-model="previewVisible" :title="previewTitle" width="85%" top="5vh" custom-class="preview-dialog" @closed="previewUrl = ''">
+      <template #header>
+        <div class="dialog-header-custom">
+          <span>{{ previewTitle }}</span>
+          <div class="header-actions">
+            <el-button type="primary" link @click="openInNewWindow">
+              <el-icon><Monitor /></el-icon> 在新窗口打开
+            </el-button>
+          </div>
+        </div>
+      </template>
+      <div v-loading="previewLoading" class="preview-content" style="height: 70vh;">
+        <iframe 
+          v-if="previewUrl" 
+          :src="proxyPreviewUrl" 
+          width="100%" 
+          height="100%" 
+          frameborder="0"
+          @load="previewLoading = false"
+        ></iframe>
+        <div v-else-if="!previewLoading" class="no-preview">
+          <el-empty description="无法预览该文件" />
+        </div>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, computed, watch, reactive } from 'vue'
+import { useRoute } from 'vue-router'
+import { Search, View, Monitor, Download, Edit, Delete, Refresh, Plus, Minus, Document } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import request from '@/api/request'
+import type { ApiResponse } from '@/types/auth'
+import dayjs from 'dayjs'
+import { downloadFile } from '@/utils/download'
+import { getFileExtension } from '@/utils/file'
+import { useAuthStore } from '@/stores/auth'
+
+const route = useRoute()
+const authStore = useAuthStore()
+const loading = ref(false)
+const submitting = ref(false)
+const tableData = ref([])
+const total = ref(0)
+const currentPage = ref(1)
+const pageSize = ref(20)
+
+// 对话框状态
+const formDialogVisible = ref(false)
+const detailDialogVisible = ref(false)
+const previewVisible = ref(false)
+const previewLoading = ref(false)
+const previewUrl = ref('')
+const previewTitle = ref('')
+
+// 动态列表字段存储
+const participatingUnitsList = ref<string[]>([''])
+const referenceBasisList = ref<string[]>([''])
+const compilationBasisList = ref<string[]>([''])
+
+const addListItem = (list: string[]) => {
+  list.push('')
+}
+
+const removeListItem = (list: string[], index: number) => {
+  if (list.length > 1) {
+    list.splice(index, 1)
+  } else {
+    list[0] = ''
+  }
+}
+
+// 当前操作的数据
+const currentItem = ref<any>(null)
+const editForm = reactive<any>({
+  id: null,
+  title: '',
+  standard_no: '',
+  issuing_authority: '',
+  release_date: '',
+  document_type: '',
+  professional_field: '',
+  validity: '现行',
+  project_name: '',
+  project_section: '',
+  note: '',
+  file_url: '',
+  // 新增字段 - Basis
+  english_name: '',
+  implementation_date: '',
+  drafting_unit: '',
+  approving_department: '',
+  participating_units: '',
+  engineering_phase: '',
+  reference_basis: '',
+  source_url: '',
+  // 新增字段 - Work
+  plan_summary: '',
+  compilation_basis: '',
+  plan_category: '',
+  level_1_classification: '施工方案',
+  level_2_classification: '',
+  level_3_classification: '',
+  level_4_classification: '',
+  // 新增字段 - Job
+  effective_start_date: '',
+  effective_end_date: ''
+})
+
+const formTitle = computed(() => editForm.id ? `编辑${moduleName.value}` : `新增${moduleName.value}`)
+
+// 检索表单接口
+interface SearchForm {
+  title: string
+  standard_no: string
+  document_type: string
+  professional_field: string
+  validity: string
+  issuing_authority: string
+  release_date_start: string
+  release_date_end: string
+  plan_category: string
+  level_1_classification: string
+  level_2_classification: string
+  level_3_classification: string
+  level_4_classification: string
+  [key: string]: string
+}
+
+// 检索表单
+const searchForm = reactive<SearchForm>({
+  title: '',
+  standard_no: '',
+  document_type: '',
+  professional_field: '',
+  validity: '',
+  issuing_authority: '',
+  release_date_start: '',
+  release_date_end: '',
+  plan_category: '',
+  level_1_classification: '',
+  level_2_classification: '',
+  level_3_classification: '',
+  level_4_classification: ''
+})
+
+// 选项配置
+const documentTypeOptions = ['国家标准', '行业标准', '企业标准', '地方标准', '管理制度', '技术规范']
+const professionalFieldOptions = ['建筑工程', '市政工程', '机电安装', '路桥工程', '装饰装修', '其他']
+const planCategoryOptions = ['超危大方案', '超危大方案较大II级', '超危大方案特大IV级', '超危大方案一般I级', '超危大方案重大III级', '危大方案', '一般方案']
+const level2Options = ['临建工程', '路基工程', '其他', '桥梁工程', '隧道工程']
+const level3Options = ['/', 'TBM施工', '拌和站安、拆施工', '不良地质隧道施工', '常规桥梁', '挡土墙工程类', '辅助坑道施工', '复杂洞口工程施工', '钢筋加工场安、拆', '钢栈桥施工', '拱桥', '涵洞工程类', '滑坡体处理类', '路堤', '路堑', '其他', '深基坑', '隧道总体施工', '特殊结构隧道', '斜拉桥', '悬索桥']
+const level4Options = ['/', '挡土墙', '顶管', '断层破碎带及软弱围岩', '钢筋砼箱涵', '高填路堤', '抗滑桩', '其他', '软岩大变形隧道', '上部结构', '深基坑开挖与支护', '深挖路堑', '隧道TBM', '隧道进洞', '隧道竖井', '隧道斜井', '特种设备', '瓦斯隧道', '下部结构', '小净距隧道', '岩爆隧道', '岩溶隧道', '涌水突泥隧道', '桩基础']
+
+// 根据路由路径判断类型
+const infoType = computed(() => {
+  const path = route.path
+  if (path.includes('/basis')) return 'basis'
+  if (path.includes('/work')) return 'work'
+  if (path.includes('/job')) return 'job'
+  return 'basis'
+})
+
+const pageTitle = computed(() => {
+  switch (infoType.value) {
+    case 'basis': return '施工标准规范管理'
+    case 'work': return '施工方案管理'
+    case 'job': return '办公制度管理'
+    default: return '基本信息管理'
+  }
+})
+
+const moduleName = computed(() => {
+  switch (infoType.value) {
+    case 'basis': return '施工标准规范'
+    case 'work': return '施工方案'
+    case 'job': return '办公制度'
+    default: return ''
+  }
+})
+
+const titleLabel = computed(() => {
+  switch (infoType.value) {
+    case 'basis': return '文档名称'
+    case 'work': return '方案名称'
+    case 'job': return '文件名称'
+    default: return '名称'
+  }
+})
+
+const authorityLabel = computed(() => {
+  switch (infoType.value) {
+    case 'basis': return '发布单位'
+    case 'work': return '编制单位'
+    case 'job': return '发布部门'
+    default: return '单位'
+  }
+})
+
+const dateLabel = computed(() => {
+  switch (infoType.value) {
+    case 'basis': return '发布日期'
+    case 'work': return '编制日期'
+    case 'job': return '发布日期'
+    default: return '日期'
+  }
+})
+
+// 获取列表数据
+const loadData = async () => {
+  loading.value = true
+  try {
+    const response = await request.get<ApiResponse<{
+      items: any[]
+      total: number
+      page: number
+      size: number
+    }>>('/api/v1/sample/basic-info/list', {
+      params: {
+        type: infoType.value,
+        page: currentPage.value,
+        size: pageSize.value,
+        ...searchForm
+      }
+    })
+    
+    // axios 返回的是 AxiosResponse,但在拦截器中我们返回了 response.data
+    // 如果 TS 还是报错,可以显式转换
+    const resData = (response as unknown) as ApiResponse<{
+      items: any[]
+      total: number
+      page: number
+      size: number
+    }>
+
+    if (resData.code === 0) {
+      tableData.value = resData.data.items as any
+      total.value = resData.data.total
+    } else {
+      ElMessage.error(resData.message || '获取数据失败')
+    }
+  } catch (error) {
+    console.error('获取数据失败:', error)
+    ElMessage.error('网络错误,请稍后重试')
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleSearch = () => {
+  currentPage.value = 1
+  loadData()
+}
+
+const resetSearch = () => {
+  Object.keys(searchForm).forEach(key => {
+    searchForm[key] = ''
+  })
+  handleSearch()
+}
+
+const handleSizeChange = (val: number) => {
+  pageSize.value = val
+  loadData()
+}
+
+const handleCurrentChange = (val: number) => {
+  currentPage.value = val
+  loadData()
+}
+
+const formatDate = (date: string | undefined | null) => {
+  if (!date) return '-'
+  return dayjs(date).format('YYYY-MM-DD')
+}
+
+const formatDateTime = (date: string | undefined | null) => {
+  if (!date) return '-'
+  return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
+}
+
+const getValidityType = (validity: string) => {
+  switch (validity) {
+    case '现行': return 'success'
+    case '已废止': return 'danger'
+    case '被替代': return 'warning'
+    default: return 'success'
+  }
+}
+
+const proxyPreviewUrl = computed(() => {
+  if (!previewUrl.value) return ''
+  // 使用后端代理接口查看外部网页,附加 token 以通过认证
+  let baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
+  if (baseUrl.includes('localhost') && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
+    baseUrl = baseUrl.replace('localhost', window.location.hostname)
+  }
+  return `${baseUrl}/api/v1/sample/documents/proxy-view?url=${encodeURIComponent(previewUrl.value)}&token=${authStore.token}`
+})
+
+const handleAction = async (action: string, row: any) => {
+  currentItem.value = row
+  switch (action) {
+    case 'view':
+      if (row.file_url) {
+        // 如果有文件链接,直接进入预览
+        previewTitle.value = row.title
+        previewUrl.value = row.file_url
+        previewVisible.value = true
+        previewLoading.value = true
+      } else {
+        // 否则进入详情对话框
+        detailDialogVisible.value = true
+      }
+      break
+    case 'preview':
+      if (row.file_url) {
+        previewTitle.value = row.title
+        previewUrl.value = row.file_url
+        previewVisible.value = true
+        previewLoading.value = true
+      } else {
+        ElMessage.warning('该文档暂无预览链接')
+      }
+      break
+    case 'download':
+      if (row.file_url) {
+        const ext = getFileExtension(row)
+        const filename = row.title.endsWith(ext) ? row.title : `${row.title}${ext}`
+        downloadFile(row.file_url, filename)
+      } else {
+        ElMessage.warning('该文档暂无下载链接')
+      }
+      break
+    case 'edit':
+      Object.keys(editForm).forEach(key => {
+        editForm[key] = row[key] || ''
+      })
+      // 处理动态列表字段
+      participatingUnitsList.value = row.participating_units ? row.participating_units.split(';') : ['']
+      referenceBasisList.value = row.reference_basis ? row.reference_basis.split(';') : ['']
+      compilationBasisList.value = row.compilation_basis ? row.compilation_basis.split(';') : ['']
+      
+      editForm.id = row.id
+      formDialogVisible.value = true
+      break
+    case 'delete':
+      ElMessageBox.confirm('确定要删除该条信息吗?', '提示', {
+        type: 'warning'
+      }).then(async () => {
+        try {
+          const res = await request.post<ApiResponse>(`/api/v1/sample/basic-info/delete?type=${infoType.value}&id=${row.id}`)
+          const resData = (res as unknown) as ApiResponse
+          if (resData.code === 0) {
+            ElMessage.success('删除成功')
+            loadData()
+          } else {
+            ElMessage.error(resData.message || '删除失败')
+          }
+        } catch (error) {
+          console.error('删除失败:', error)
+          ElMessage.error('网络错误')
+        }
+      }).catch(() => {})
+      break
+  }
+}
+
+const handleAdd = () => {
+  Object.keys(editForm).forEach(key => {
+    if (key === 'validity') {
+      editForm[key] = '现行'
+    } else if (key === 'level_1_classification') {
+      editForm[key] = '施工方案'
+    } else {
+      editForm[key] = ''
+    }
+  })
+  // 初始化动态列表字段
+   participatingUnitsList.value = ['']
+   referenceBasisList.value = ['']
+   compilationBasisList.value = ['']
+  
+  editForm.id = null
+  formDialogVisible.value = true
+}
+
+const submitForm = async () => {
+  if (!editForm.title) {
+    return ElMessage.warning('请输入名称')
+  }
+  
+  // 提交前合并动态列表字段,过滤掉空行并用分号连接
+  editForm.participating_units = participatingUnitsList.value.filter(item => item.trim() !== '').join(';')
+  editForm.reference_basis = referenceBasisList.value.filter(item => item.trim() !== '').join(';')
+  editForm.compilation_basis = compilationBasisList.value.filter(item => item.trim() !== '').join(';')
+  
+  submitting.value = true
+  try {
+    const isEdit = !!editForm.id
+    const url = isEdit ? `/api/v1/sample/basic-info/edit?type=${infoType.value}&id=${editForm.id}` : `/api/v1/sample/basic-info/add?type=${infoType.value}`
+    
+    const res = await request.post<ApiResponse>(url, editForm)
+    const resData = (res as unknown) as ApiResponse
+    
+    if (resData.code === 0) {
+      ElMessage.success(isEdit ? '更新成功' : '新增成功')
+      formDialogVisible.value = false
+      loadData()
+    } else {
+      ElMessage.error(resData.message || '操作失败')
+    }
+  } catch (error) {
+    console.error('操作失败:', error)
+    ElMessage.error('网络错误')
+  } finally {
+    submitting.value = false
+  }
+}
+
+const openInNewWindow = () => {
+  if (proxyPreviewUrl.value) {
+    window.open(proxyPreviewUrl.value, '_blank')
+  }
+}
+
+// 监听路由变化,切换类型时重新加载数据
+watch(() => route.path, () => {
+  currentPage.value = 1
+  resetSearch()
+})
+
+onMounted(() => {
+  loadData()
+})
+</script>
+
+<style scoped>
+.basic-info-container {
+  padding: 20px;
+  background-color: #f5f7fa;
+  min-height: calc(100vh - 84px);
+}
+
+.search-card {
+  margin-bottom: 20px;
+}
+
+.search-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.search-header .title {
+  font-size: 18px;
+  font-weight: bold;
+}
+
+.search-form {
+  padding: 0 10px;
+}
+
+:deep(.el-form-item__label) {
+  font-weight: normal;
+  color: #606266;
+  padding-bottom: 4px;
+}
+
+.search-buttons {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  margin-top: 10px;
+}
+
+.dynamic-input-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+  width: 100%;
+}
+
+.dynamic-input-row:last-child {
+  margin-bottom: 0;
+}
+
+.row-actions {
+  display: flex;
+  gap: 5px;
+  margin-left: 10px;
+}
+
+.table-card {
+  padding: 10px;
+}
+
+.pagination-container {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.info-tag {
+  margin-right: 4px;
+  margin-bottom: 2px;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.tag-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.action-buttons {
+  display: flex;
+  justify-content: center;
+  gap: 4px;
+}
+
+.action-buttons .el-button {
+  padding: 4px;
+  margin: 0;
+}
+
+.popover-tag-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  padding: 4px 0;
+}
+
+.popover-info-tag {
+  width: 100%;
+  justify-content: flex-start;
+  height: auto;
+  padding: 4px 8px;
+  white-space: normal;
+  word-break: break-all;
+  line-height: 1.4;
+}
+
+:deep(.el-table .cell) {
+  white-space: nowrap;
+}
+
+.preview-content {
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.no-preview {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100%;
+  background-color: #f5f7fa;
+}
+
+.dialog-header-custom {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+  padding-right: 30px;
+}
+
+.header-actions {
+  display: flex;
+  gap: 10px;
+}
+</style>

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

@@ -0,0 +1,1638 @@
+<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.table_type || searchQuery.whether_to_enter !== null">
+            <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-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.table_type" placeholder="所属知识库" clearable class="filter-select">
+            <el-option label="全部知识库" :value="null" />
+            <el-option label="施工标准规范知识库" value="basis" />
+            <el-option label="施工方案知识库" value="work" />
+            <el-option label="办公制度知识库" value="job" />
+          </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 
+        size="small"
+        @selection-change="handleSelectionChange"
+        :row-class-name="tableRowClassName"
+      >
+        <el-table-column type="selection" width="40" :selectable="canSelect" />
+        
+        <el-table-column prop="title" label="文档名称" min-width="280" show-overflow-tooltip>
+          <template #default="scope">
+            <div class="file-info-cell">
+              <div class="file-icon-mini" :class="getFileIconClass(scope.row)">
+                <el-icon v-if="getFileIcon(scope.row) === 'pdf'"><Document /></el-icon>
+                <el-icon v-else-if="getFileIcon(scope.row) === 'word'"><Document /></el-icon>
+                <el-icon v-else-if="getFileIcon(scope.row) === 'excel'"><Grid /></el-icon>
+                <el-icon v-else-if="getFileIcon(scope.row) === 'ppt'"><DataAnalysis /></el-icon>
+                <el-icon v-else><Document /></el-icon>
+              </div>
+              <div class="file-info-content">
+                <span class="file-name-link" @click="handleView(scope.row)">
+                  {{ scope.row.title }}{{ getFileExtension(scope.row) }}
+                </span>
+                <span v-if="scope.row.note" class="file-note">
+                  {{ scope.row.note }}
+                </span>
+              </div>
+            </div>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="转换状态" min-width="180">
+          <template #default="scope">
+            <div class="conversion-cell">
+              <el-tag 
+                :type="getConversionStatusTag(scope.row)"
+                size="small"
+                effect="light"
+                class="status-tag"
+              >
+                {{ getConversionStatusText(scope.row) }}
+              </el-tag>
+              
+              <div class="converted-file-links" v-if="scope.row.conversion_status === 2">
+                 <div class="converted-file-name" v-if="scope.row.md_url">
+                   <el-link type="primary" :underline="false" @click="handleDownloadConverted(scope.row)">
+                     <el-icon><Link /></el-icon> {{ scope.row.md_display_name || '下载 Markdown' }}
+                   </el-link>
+                 </div>
+                 <div class="converted-file-name" v-if="scope.row.json_url">
+                   <el-link type="success" :underline="false" @click="handleDownloadJson(scope.row)">
+                     <el-icon><Link /></el-icon> {{ scope.row.json_display_name || '下载 JSON' }}
+                   </el-link>
+                 </div>
+               </div>
+            </div>
+          </template>
+        </el-table-column>
+
+        <el-table-column label="知识库" min-width="120">
+          <template #default="scope">
+            <el-tag size="small" effect="plain" :type="getKBTagType(scope.row.source_type)">
+              {{ getKnowledgeBaseShortName(scope.row.source_type) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+
+        <el-table-column prop="whether_to_enter" label="入库" width="80" align="center">
+          <template #default="scope">
+            <el-tooltip :content="isEntered(scope.row.whether_to_enter) ? '已入库' : '未入库'" placement="top">
+              <el-icon :class="isEntered(scope.row.whether_to_enter) ? 'status-icon-success' : 'status-icon-info'">
+                <CircleCheck v-if="isEntered(scope.row.whether_to_enter)" />
+                <Warning v-else />
+              </el-icon>
+            </el-tooltip>
+          </template>
+        </el-table-column>
+        <el-table-column prop="creator_name" label="上传人" min-width="100" show-overflow-tooltip />
+
+        <el-table-column label="上传时间" min-width="150" prop="created_time">
+          <template #default="scope">
+            {{ formatDate(scope.row.created_time) }}
+          </template>
+        </el-table-column>
+
+        <el-table-column prop="updater_name" label="修改人" min-width="100" show-overflow-tooltip />
+
+        <el-table-column label="修改时间" min-width="150" prop="updated_time">
+          <template #default="scope">
+            {{ formatDate(scope.row.updated_time) }}
+          </template>
+        </el-table-column>
+
+        <el-table-column label="操作" width="260" fixed="right" align="center">
+          <template #default="scope">
+            <div class="action-buttons">
+              <el-tooltip content="编辑" placement="top">
+                <el-button 
+                  link 
+                  type="primary" 
+                  @click="handleEdit(scope.row)"
+                  class="action-btn-icon"
+                >
+                  <el-icon><Edit /></el-icon>
+                </el-button>
+              </el-tooltip>
+              
+              <el-tooltip content="下载" placement="top" v-if="scope.row.file_url">
+                <el-button 
+                  link 
+                  type="success" 
+                  @click="handleDownload(scope.row)"
+                  class="action-btn-icon"
+                >
+                  <el-icon><Download /></el-icon>
+                </el-button>
+              </el-tooltip>
+              
+              <el-tooltip 
+                :content="scope.row.conversion_status === 1 ? '转换中' : (scope.row.conversion_status === 2 ? '重新转换' : '开始转换')" 
+                placement="top"
+              >
+                <el-button 
+                  link 
+                  type="warning" 
+                  @click="handleConvert(scope.row)"
+                  :disabled="scope.row.conversion_status === 1"
+                  class="action-btn-icon"
+                >
+                  <el-icon><Switch /></el-icon>
+                </el-button>
+              </el-tooltip>
+
+              <el-tooltip content="删除" placement="top">
+                <el-button 
+                  link 
+                  type="danger" 
+                  @click="handleDelete(scope.row)"
+                  class="action-btn-icon"
+                >
+                  <el-icon><Delete /></el-icon>
+                </el-button>
+              </el-tooltip>
+            </div>
+          </template>
+        </el-table-column>
+      </el-table>
+
+    <!-- 文档预览对话框 -->
+    <el-dialog v-model="previewVisible" :title="previewTitle" width="85%" top="5vh" custom-class="preview-dialog" @closed="previewUrl = ''">
+      <template #header>
+        <div class="dialog-header-custom">
+          <span>{{ previewTitle }}</span>
+          <div class="header-actions">
+            <el-button type="primary" link @click="openInNewWindow">
+              <el-icon><TopRight /></el-icon>在新窗口打开原链接
+            </el-button>
+          </div>
+        </div>
+      </template>
+      <div v-loading="previewLoading" class="preview-content">
+        <!-- Office 文档未转换提示 -->
+        <div v-if="isOfficeDoc && previewDocType === 'original' && !previewLoading" class="unsupported-preview">
+          <el-result
+            icon="warning"
+            title="该格式暂不支持直接预览"
+            sub-title="Word/Excel/PPT 等 Office 文档需要转换后才能在浏览器中直接预览。"
+          >
+            <template #extra>
+              <div class="unsupported-actions">
+                <el-button type="primary" @click="handleConvert(currentDoc!)">
+                  <el-icon><Switch /></el-icon> 立即转换
+                </el-button>
+                <el-button type="success" @click="handleDownload(currentDoc!)">
+                  <el-icon><Download /></el-icon> 下载原文档
+                </el-button>
+              </div>
+            </template>
+          </el-result>
+        </div>
+
+        <div v-if="isHtmlPage && !previewLoading && !isOfficeDoc" class="preview-tip">
+          <el-alert
+            title="网页预览提示"
+            type="info"
+            description="由于外部网站的安全限制,某些网页可能无法在此处正常预览。如果显示异常,请点击右上角按钮在新窗口中查看。"
+            show-icon
+            :closable="false"
+          />
+        </div>
+        <iframe 
+          v-if="previewUrl && !(isOfficeDoc && previewDocType === 'original')" 
+          :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-select v-model="uploadForm.table_type" placeholder="请选择知识库">
+            <el-option label="施工标准规范知识库" value="basis" />
+            <el-option label="施工方案知识库" value="work" />
+            <el-option label="办公制度知识库" value="job" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="文档标题" required>
+          <el-input v-model="uploadForm.title" placeholder="请输入文档标题" />
+        </el-form-item>
+        <el-form-item label="文档链接" v-if="false">
+          <el-input v-model="uploadForm.file_url" placeholder="请输入文档链接 (URL) 或通过下方上传文件" />
+        </el-form-item>
+        <el-form-item label="文件上传">
+          <el-upload
+            ref="uploadRef"
+            class="upload-demo"
+            action="#"
+            :http-request="customUpload"
+            :limit="1"
+            :on-exceed="handleExceed"
+            :before-upload="beforeUpload"
+          >
+            <el-button type="primary">点击上传</el-button>
+            <template #tip>
+              <div class="el-upload__tip">
+                支持 PDF, Word, Excel, PPT, TXT 等文件,最大 50MB
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
+        <el-form-item label="文档备注">
+          <el-input v-model="uploadForm.note" 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>
+
+    <!-- 编辑文档对话框 -->
+    <el-dialog v-model="editDialogVisible" title="编辑文档" width="700px">
+      <el-form :model="editForm" label-width="120px">
+        <el-divider content-position="left">基础信息</el-divider>
+        <el-form-item label="所属知识库" required>
+          <el-select v-model="editForm.table_type" disabled placeholder="请选择知识库" style="width: 100%">
+            <el-option label="施工标准规范知识库" value="basis" />
+            <el-option label="施工方案知识库" value="work" />
+            <el-option label="办公制度知识库" value="job" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="文档标题" required>
+          <el-input v-model="editForm.title" placeholder="请输入文档标题" />
+        </el-form-item>
+        <el-form-item label="文档链接" v-if="false">
+          <el-input v-model="editForm.file_url" placeholder="请输入文档链接 (URL)" />
+        </el-form-item>
+        
+        <el-divider content-position="left">专业属性补全</el-divider>
+        
+        <!-- 施工标准规范特有字段 -->
+        <template v-if="editForm.table_type === 'basis'">
+          <el-form-item label="标准编号">
+            <el-input v-model="editForm.standard_no" placeholder="如:GB/T 50001-2017" />
+          </el-form-item>
+          <el-form-item label="发布单位">
+            <el-input v-model="editForm.issuing_authority" placeholder="请输入发布单位" />
+          </el-form-item>
+          <el-form-item label="发布日期">
+            <el-date-picker v-model="editForm.release_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%" />
+          </el-form-item>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="文件类型">
+                <el-input v-model="editForm.document_type" placeholder="如:国家标准" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="时效性">
+                <el-select v-model="editForm.validity" placeholder="请选择" style="width: 100%">
+                  <el-option label="现行" value="现行" />
+                  <el-option label="已废止" value="已废止" />
+                  <el-option label="被替代" value="被替代" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-form-item label="专业领域">
+            <el-input v-model="editForm.professional_field" placeholder="如:建筑工程" />
+          </el-form-item>
+        </template>
+
+        <!-- 施工方案特有字段 -->
+        <template v-else-if="editForm.table_type === 'work'">
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="工程名称">
+                <el-input v-model="editForm.project_name" placeholder="请输入项目名称" />
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="工程标段">
+                <el-input v-model="editForm.project_section" placeholder="请输入项目标段" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="方案类别">
+                <el-select v-model="editForm.plan_category" placeholder="请选择方案类别" style="width: 100%">
+                  <el-option v-for="item in planCategoryOptions" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="一级分类">
+                <el-input v-model="editForm.level_1_classification" disabled />
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="二级分类">
+                <el-select v-model="editForm.level_2_classification" placeholder="请选择二级分类" style="width: 100%">
+                  <el-option v-for="item in level2Options" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="三级分类">
+                <el-select v-model="editForm.level_3_classification" placeholder="请选择三级分类" style="width: 100%">
+                  <el-option v-for="item in level3Options" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="四级分类">
+                <el-select v-model="editForm.level_4_classification" placeholder="请选择四级分类" style="width: 100%">
+                  <el-option v-for="item in level4Options" :key="item" :label="item" :value="item" />
+                </el-select>
+              </el-form-item>
+            </el-col>
+            <el-col :span="12">
+              <el-form-item label="编制单位">
+                <el-input v-model="editForm.issuing_authority" placeholder="请输入编制单位" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-row :gutter="20">
+            <el-col :span="12">
+              <el-form-item label="编制日期">
+                <el-date-picker v-model="editForm.release_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%" />
+              </el-form-item>
+            </el-col>
+          </el-row>
+          <el-form-item label="方案摘要">
+            <el-input v-model="editForm.plan_summary" type="textarea" :rows="3" placeholder="请输入方案摘要" />
+          </el-form-item>
+          <el-form-item label="施工标准规范">
+            <el-input v-model="editForm.compilation_basis" type="textarea" :rows="3" placeholder="请输入施工标准规范,多个依据请用逗号分隔" />
+          </el-form-item>
+        </template>
+
+        <!-- 办公制度特有字段 -->
+        <template v-else-if="editForm.table_type === 'job'">
+          <el-form-item label="发布部门">
+            <el-input v-model="editForm.issuing_authority" placeholder="请输入发布部门" />
+          </el-form-item>
+          <el-form-item label="制度类型">
+            <el-input v-model="editForm.document_type" placeholder="如:行政管理" />
+          </el-form-item>
+          <el-form-item label="发布日期">
+            <el-date-picker v-model="editForm.release_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" style="width: 100%" />
+          </el-form-item>
+        </template>
+
+        <el-divider content-position="left">文档备注</el-divider>
+        <el-form-item label="文档备注">
+          <el-input v-model="editForm.note" type="textarea" :rows="6" placeholder="请输入文档备注" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="editDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitEdit" :loading="submitting">确定保存</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 文档详情对话框 -->
+    <el-dialog v-model="detailDialogVisible" title="文档详情" width="600px">
+      <el-descriptions :column="1" border>
+        <el-descriptions-item label="名称">{{ currentDoc?.title }}</el-descriptions-item>
+        <el-descriptions-item label="知识库">{{ getKnowledgeBaseName(currentDoc?.source_type) }}</el-descriptions-item>
+        <el-descriptions-item label="上传人">{{ currentDoc?.creator_name || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="上传时间">{{ formatDate(currentDoc?.created_time) }}</el-descriptions-item>
+        <el-descriptions-item label="最后修改人" v-if="currentDoc?.updater_name">{{ currentDoc?.updater_name }}</el-descriptions-item>
+        <el-descriptions-item label="修改时间" v-if="currentDoc?.updated_time">{{ formatDate(currentDoc?.updated_time) }}</el-descriptions-item>
+        <el-descriptions-item label="入库状态">
+          <el-tag :type="isEntered(currentDoc?.whether_to_enter) ? 'success' : 'info'">
+            {{ isEntered(currentDoc?.whether_to_enter) ? '已入库' : '未入库' }}
+          </el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="文档备注">
+          <div class="content-preview">{{ currentDoc?.note || '暂无备注' }}</div>
+        </el-descriptions-item>
+        
+        <!-- 施工方案特有详情 -->
+        <template v-if="currentDoc?.source_type === 'work'">
+          <el-descriptions-item label="工程名称">{{ currentDoc?.project_name || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="工程标段">{{ currentDoc?.project_section || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="方案类别">{{ currentDoc?.plan_category || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="一级分类">{{ currentDoc?.level_1_classification || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="二级分类">{{ currentDoc?.level_2_classification || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="三级分类">{{ currentDoc?.level_3_classification || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="四级分类">{{ currentDoc?.level_4_classification || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="编制单位">{{ currentDoc?.issuing_authority || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="编制日期">{{ formatDate(currentDoc?.release_date) }}</el-descriptions-item>
+          <el-descriptions-item label="方案摘要">
+            <div class="content-preview">{{ currentDoc?.plan_summary || '-' }}</div>
+          </el-descriptions-item>
+          <el-descriptions-item label="施工标准规范">
+            <div class="content-preview">{{ currentDoc?.compilation_basis || '-' }}</div>
+          </el-descriptions-item>
+        </template>
+      </el-descriptions>
+      <template #footer>
+        <div class="detail-footer">
+          <div class="download-group" v-if="currentDoc">
+            <el-button type="success" plain size="small" @click="handleDownload(currentDoc)" v-if="currentDoc.file_url">
+              <el-icon><Download /></el-icon> 下载原文件
+            </el-button>
+            <el-button type="primary" plain size="small" @click="handleDownloadConverted(currentDoc)" v-if="currentDoc.md_url">
+              <el-icon><Download /></el-icon> 下载 MD
+            </el-button>
+            <el-button type="warning" plain size="small" @click="handleDownloadJson(currentDoc)" v-if="currentDoc.json_url">
+              <el-icon><Download /></el-icon> 下载 JSON
+            </el-button>
+          </div>
+          <div class="action-group">
+            <el-button @click="detailDialogVisible = false">关闭</el-button>
+            <el-button type="primary" @click="handleEditFromDetail" v-if="currentDoc">
+              <el-icon><Edit /></el-icon> 编辑文档
+            </el-button>
+            <el-button type="success" @click="handleSingleEnter(currentDoc)" v-if="currentDoc && !isEntered(currentDoc.whether_to_enter)">加入知识库</el-button>
+            <el-button type="primary" @click="handlePreview(currentDoc)" v-if="currentDoc?.file_url">预览原文档</el-button>
+          </div>
+        </div>
+      </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, TopRight, Grid, DataAnalysis, Link, View, Switch, Edit, User, Download } from '@element-plus/icons-vue'
+import request from '@/api/request'
+import axios from 'axios'
+import { downloadFile } from '@/utils/download'
+import { getFileExtension } from '@/utils/file'
+import { useAuthStore } from '@/stores/auth'
+import dayjs from 'dayjs'
+import { documentApi, type DocumentItem } from '@/api/document'
+
+// 接口定义已移至 @/api/document
+
+// 状态变量
+const loading = ref(false)
+const submitting = ref(false)
+const uploadRef = ref<any>(null)
+const uploadDialogVisible = ref(false)
+const editDialogVisible = ref(false)
+const detailDialogVisible = ref(false)
+const previewVisible = ref(false)
+const previewLoading = ref(false)
+const previewTitle = ref('')
+const previewUrl = ref('')
+const previewDocType = ref('') // 'original' or 'md'
+const isOfficeDoc = ref(false)
+const total = ref(0)
+const statistics = ref({
+  allTotal: 0,
+  totalEntered: 0
+})
+const authStore = useAuthStore()
+const documents = ref<DocumentItem[]>([])
+const currentDoc = ref<DocumentItem | null>(null)
+
+const editForm = reactive({
+  id: '',
+  title: '',
+  note: '',
+  table_type: 'basis' as 'basis' | 'work' | 'job',
+  year: new Date().getFullYear(),
+  // 扩展字段 (子表特有属性)
+  standard_no: '',
+  issuing_authority: '',
+  release_date: '',
+  document_type: '',
+  professional_field: '',
+  validity: '',
+  project_name: '',
+  project_section: '',
+  plan_category: '',
+  level_1_classification: '施工方案',
+  level_2_classification: '',
+  level_3_classification: '',
+  level_4_classification: '',
+  plan_summary: '',
+  compilation_basis: '',
+  file_url: ''
+})
+
+const planCategoryOptions = ['超危大方案', '超危大方案较大II级', '超危大方案特大IV级', '超危大方案一般I级', '超危大方案重大III级', '危大方案', '一般方案']
+const level2Options = ['临建工程', '路基工程', '其他', '桥梁工程', '隧道工程']
+const level3Options = ['/', 'TBM施工', '拌和站安、拆施工', '不良地质隧道施工', '常规桥梁', '挡土墙工程类', '辅助坑道施工', '复杂洞口工程施工', '钢筋加工场安、拆', '钢栈桥施工', '拱桥', '涵洞工程类', '滑坡体处理类', '路堤', '路堑', '其他', '深基坑', '隧道总体施工', '特殊结构隧道', '斜拉桥', '悬索桥']
+const level4Options = ['/', '挡土墙', '顶管', '断层破碎带及软弱围岩', '钢筋砼箱涵', '高填路堤', '抗滑桩', '其他', '软岩大变形隧道', '上部结构', '深基坑开挖与支护', '深挖路堑', '隧道TBM', '隧道进洞', '隧道竖井', '隧道斜井', '特种设备', '瓦斯隧道', '下部结构', '小净距隧道', '岩爆隧道', '岩溶隧道', '涌水突泥隧道', '桩基础']
+
+const currentTitle = computed(() => {
+  return '文档管理中心'
+})
+const selectedIds = ref<string[]>([])
+
+const searchQuery = reactive({
+  keyword: '',
+  table_type: null as string | null,
+  whether_to_enter: null as number | null,
+  page: 1,
+  size: 10
+})
+
+const uploadForm = reactive({
+  title: '',
+  note: '',
+  file_url: '',
+  table_type: 'basis' as 'basis' | 'work' | 'job',
+  year: new Date().getFullYear()
+})
+
+// 计算属性
+const proxyPreviewUrl = computed(() => {
+  if (!previewUrl.value) return ''
+  // 使用后端代理接口查看外部网页,附加 token 以通过认证
+  let baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
+  if (baseUrl.includes('localhost') && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
+    baseUrl = baseUrl.replace('localhost', window.location.hostname)
+  }
+  return `${baseUrl}/api/v1/sample/documents/proxy-view?url=${encodeURIComponent(previewUrl.value)}&token=${authStore.token}`
+})
+
+const isHtmlPage = computed(() => {
+  if (!previewUrl.value) return false
+  const url = previewUrl.value.toLowerCase()
+  return url.includes('html') || url.includes('newgbinfo') || !url.split(/[?#]/)[0].includes('.')
+})
+
+// 方法
+const formatSimpleDate = (date: string | null) => {
+  if (!date) return '-'
+  return dayjs(date).format('YYYY-MM-DD')
+}
+
+const formatDate = (date: string | undefined | null, formatStr: string = 'YYYY-MM-DD HH:mm:ss') => {
+  return date ? dayjs(date).format(formatStr) : '-'
+}
+
+
+
+const getFileIcon = (row: DocumentItem) => {
+  const ext = getFileExtension(row).replace('.', '').toLowerCase()
+  if (['pdf'].includes(ext)) return 'pdf'
+  if (['doc', 'docx'].includes(ext)) return 'word'
+  if (['xls', 'xlsx'].includes(ext)) return 'excel'
+  if (['ppt', 'pptx'].includes(ext)) return 'ppt'
+  if (['html', 'htm'].includes(ext)) return 'html'
+  return 'file'
+}
+
+const getFileIconClass = (row: DocumentItem) => {
+  return `icon-${getFileIcon(row)}`
+}
+
+const getKnowledgeBaseName = (sourceType: string | null | undefined) => {
+  const names: Record<string, string> = {
+    basis: '施工标准规范知识库',
+    work: '施工方案知识库',
+    job: '办公制度知识库'
+  }
+  return names[sourceType || ''] || '未知知识库'
+}
+
+const getKnowledgeBaseShortName = (sourceType: string | null | undefined) => {
+  const names: Record<string, string> = {
+    basis: '施工标准规范',
+    work: '施工方案',
+    job: '办公制度'
+  }
+  return names[sourceType || ''] || '未知'
+}
+
+const getKBTagType = (sourceType: string | null | undefined) => {
+  const types: Record<string, string> = {
+    basis: 'primary',
+    work: 'success',
+    job: 'warning'
+  }
+  return types[sourceType || ''] || 'info'
+}
+
+// 为已入库或未转化的行添加特定类名
+const tableRowClassName = ({ row }: { row: DocumentItem }) => {
+  return ''
+}
+
+// 判断是否可以勾选(所有文档均可勾选,用于批量删除等操作)
+const canSelect = (row: DocumentItem) => {
+  return true
+}
+
+const handleSelectionChange = (selection: DocumentItem[]) => {
+  selectedIds.value = selection.map(item => item.id)
+}
+
+const handleBatchEnter = async () => {
+  if (selectedIds.value.length === 0) return
+  
+  const ids = [...selectedIds.value]
+  
+  try {
+    const res = await documentApi.batchEnter(ids)
+    if (res.code === 0) {
+      // 如果有详细详情(换行符标识),使用 MessageBox 显示
+      if (res.message && res.message.includes('\n')) {
+        ElMessageBox.alert(res.message, '入库结果', {
+          confirmButtonText: '确定',
+          customStyle: { 'white-space': 'pre-wrap' },
+          type: res.message.includes('失败') || res.message.includes('跳过') ? 'warning' : 'success'
+        })
+      } else {
+        ElMessage.success(res.message || '入库成功')
+      }
+      fetchDocuments()
+    } else {
+      ElMessageBox.alert(res.message || '入库失败', '操作失败', {
+        confirmButtonText: '确定',
+        type: 'error'
+      })
+    }
+  } catch (error) {
+    console.error('批量入库失败:', error)
+    ElMessage.error('网络连接异常,请稍后重试')
+  }
+}
+
+const handleSingleEnter = async (doc: DocumentItem | null) => {
+  if (!doc) return
+  
+  try {
+    const res = await documentApi.batchEnter([doc.id])
+    if (res.code === 0) {
+      // 无论成功失败,只要有详细消息就弹出提示框
+      if (res.message && (res.message.includes('\n') || res.message.includes('失败') || res.message.includes('跳过'))) {
+        ElMessageBox.alert(res.message, '入库结果', {
+          confirmButtonText: '确定',
+          customStyle: { 'white-space': 'pre-wrap' },
+          type: res.message.includes('失败') || res.message.includes('跳过') ? 'warning' : 'success'
+        })
+      } else {
+        ElMessage.success(res.message || '入库成功')
+      }
+      detailDialogVisible.value = false
+      fetchDocuments()
+    } else {
+      ElMessageBox.alert(res.message || '入库失败', '操作失败', {
+        confirmButtonText: '确定',
+        type: 'error'
+      })
+    }
+  } 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 documentApi.batchDelete([row.id])
+    
+    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 documentApi.batchDelete(selectedIds.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 isEntered = (val: any) => {
+  return val === 1 || val === true
+}
+
+const getConversionStatusTag = (row: DocumentItem) => {
+  switch (row.conversion_status) {
+    case 1: return 'warning'   // 转换中
+    case 2: return 'success'   // 成功
+    case 3: return 'danger'    // 失败
+    default: return 'info'     // 未转换 (0)
+  }
+}
+
+const getConversionStatusText = (row: DocumentItem) => {
+  switch (row.conversion_status) {
+    case 1: return '转换中'
+    case 2: return '转换成功'
+    case 3: return '转换失败'
+    default: return '未转换'
+  }
+}
+
+const fetchDocuments = async () => {
+  loading.value = true
+  try {
+    const res = await documentApi.getList(searchQuery)
+    if (res.code === 0) {
+      documents.value = res.data.items
+      total.value = res.data.total
+      statistics.value.allTotal = res.data.all_total || 0
+      statistics.value.totalEntered = res.data.total_entered || 0
+      
+      // 自动检查是否需要开启轮询
+      const hasConverting = documents.value.some(doc => doc.conversion_status === 1)
+      if (hasConverting) {
+        startPolling()
+      } else {
+        stopPolling()
+      }
+    }
+  } catch (error) {
+    console.error('获取文档列表失败:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+const isRefreshing = ref(false)
+const refreshTimer = ref<any>(null)
+
+const startPolling = () => {
+  if (refreshTimer.value === null) {
+    refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
+  }
+}
+
+const stopPolling = () => {
+  if (refreshTimer.value) {
+    window.clearTimeout(refreshTimer.value)
+    refreshTimer.value = null
+  }
+}
+
+const refreshDocumentsSilently = async () => {
+  if (isRefreshing.value) return
+  isRefreshing.value = true
+  try {
+    const res = await documentApi.getList(searchQuery, true)
+    if (res.code === 0) {
+      documents.value = res.data.items
+      total.value = res.data.total
+      statistics.value.allTotal = res.data.all_total || 0
+      statistics.value.totalEntered = res.data.total_entered || 0
+    }
+  } catch (error) {
+    console.error('静默刷新失败:', error)
+  } finally {
+    isRefreshing.value = false
+    // 如果没有手动停止,且组件未卸载,则安排下一次刷新
+    if (refreshTimer.value !== null) {
+      refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
+    }
+  }
+}
+
+const handleSearch = () => {
+  searchQuery.page = 1
+  fetchDocuments()
+}
+
+const handleSizeChange = (val: number) => {
+  searchQuery.size = val
+  fetchDocuments()
+}
+
+const handleCurrentChange = (val: number) => {
+  searchQuery.page = val
+  fetchDocuments()
+}
+
+const beforeUpload = (file: File) => {
+  const isLt50M = file.size / 1024 / 1024 < 50
+  if (!isLt50M) {
+    ElMessage.error('上传文件大小不能超过 50MB!')
+    return false
+  }
+  return true
+}
+
+const handleExceed = () => {
+  ElMessage.warning('只能上传一个文件,请先移除已上传的文件')
+}
+
+const customUpload = async (options: any) => {
+  const { file, onSuccess, onError } = options
+  try {
+    // 1. 获取预签名 URL
+    const res = await documentApi.getUploadUrl(file.name, file.type || 'application/octet-stream')
+
+    if (res.code !== 0) {
+      throw new Error(res.message || '获取上传链接失败')
+    }
+
+    const { upload_url, file_url } = res.data
+
+    // 2. 直接上传到 MinIO (PUT 请求)
+    await axios.put(upload_url, file, {
+      headers: {
+        'Content-Type': file.type || 'application/octet-stream'
+      }
+    })
+
+    // 3. 上传成功,更新表单
+    uploadForm.file_url = file_url
+    if (!uploadForm.title) {
+      // 如果标题为空,自动填充文件名(去掉后缀)
+      uploadForm.title = file.name.replace(/\.[^/.]+$/, "")
+    }
+    
+    ElMessage.success('文件上传成功')
+    onSuccess(res.data)
+  } catch (error: any) {
+    console.error('文件上传失败:', error)
+    ElMessage.error(error.message || '文件上传失败')
+    onError(error)
+  }
+}
+
+const handleUpload = () => {
+  uploadForm.title = ''
+  uploadForm.note = ''
+  uploadForm.file_url = ''
+  if (uploadRef.value) {
+    uploadRef.value.clearFiles()
+  }
+  uploadDialogVisible.value = true
+}
+
+const submitUpload = async () => {
+  if (!uploadForm.title) {
+    return ElMessage.warning('请输入文档标题')
+  }
+  submitting.value = true
+  try {
+    const res = await documentApi.add(uploadForm)
+    if (res.code === 0) {
+      ElMessage.success('上传成功')
+      uploadDialogVisible.value = false
+      fetchDocuments()
+    }
+  } catch (error) {
+    console.error('上传失败:', error)
+  } finally {
+    submitting.value = false
+  }
+}
+
+const handleEdit = async (row: DocumentItem) => {
+  try {
+    const res = await documentApi.getDetail(row.id)
+    if (res.code === 0 && res.data) {
+      const data = res.data
+      editForm.id = data.id
+      editForm.title = data.title
+      editForm.note = data.note || ''
+      editForm.table_type = data.source_type
+      
+      // 填充扩展字段
+      editForm.standard_no = data.standard_no || ''
+      editForm.issuing_authority = data.issuing_authority || ''
+      editForm.release_date = data.release_date || ''
+      editForm.document_type = data.document_type || ''
+      editForm.professional_field = data.professional_field || ''
+      editForm.validity = data.validity || ''
+      editForm.project_name = data.project_name || ''
+      editForm.project_section = data.project_section || ''
+      editForm.plan_category = data.plan_category || ''
+      editForm.level_1_classification = data.level_1_classification || '施工方案'
+      editForm.level_2_classification = data.level_2_classification || ''
+      editForm.level_3_classification = data.level_3_classification || ''
+      editForm.level_4_classification = data.level_4_classification || ''
+      editForm.plan_summary = data.plan_summary || ''
+      editForm.compilation_basis = data.compilation_basis || ''
+      editForm.file_url = data.file_url || ''
+      
+      editDialogVisible.value = true
+    } else {
+      ElMessage.error(res.message || '获取文档详情失败')
+    }
+  } catch (error) {
+    console.error('获取文档详情失败:', error)
+    ElMessage.error('获取文档详情失败')
+  }
+}
+
+const submitEdit = async () => {
+  if (!editForm.title) {
+    return ElMessage.warning('请输入文档标题')
+  }
+  submitting.value = true
+  try {
+    const res = await documentApi.edit(editForm)
+    if (res.code === 0) {
+      ElMessage.success('更新成功')
+      editDialogVisible.value = false
+      fetchDocuments()
+    }
+  } catch (error) {
+    console.error('编辑失败:', error)
+  } finally {
+    submitting.value = false
+  }
+}
+
+const handleView = (row: DocumentItem) => {
+  if (row.file_url) {
+    handlePreview(row)
+  } else {
+    currentDoc.value = row
+    detailDialogVisible.value = true
+  }
+}
+
+const handleEditFromDetail = () => {
+  if (currentDoc.value) {
+    const doc = currentDoc.value
+    detailDialogVisible.value = false
+    handleEdit(doc)
+  }
+}
+
+const handlePreview = (row: DocumentItem | null) => {
+  if (!row || !row.file_url) return
+  currentDoc.value = row
+  previewTitle.value = row.title
+  
+  const ext = getFileExtension(row).toLowerCase()
+  const officeExtensions = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']
+  isOfficeDoc.value = officeExtensions.includes(ext)
+  
+  // 如果是 Office 文档且有转换后的 MD,优先预览 MD
+  if (isOfficeDoc.value && row.md_url) {
+    previewUrl.value = row.md_url
+    previewDocType.value = 'md'
+    previewTitle.value = `${row.title} (转换预览)`
+  } else {
+    previewUrl.value = row.file_url
+    previewDocType.value = 'original'
+  }
+  
+  previewVisible.value = true
+  // 如果是无法直接预览的 Office 文档,不需要等待 iframe 加载
+  if (isOfficeDoc.value && previewDocType.value === 'original') {
+    previewLoading.value = false
+  } else {
+    previewLoading.value = true
+  }
+}
+
+const handleDownload = (row: DocumentItem) => {
+  if (row.file_url) {
+    const ext = getFileExtension(row)
+    const filename = row.title.endsWith(ext) ? row.title : `${row.title}${ext}`
+    downloadFile(row.file_url, filename)
+  } else {
+    ElMessage.warning('该文档暂无下载链接')
+  }
+}
+
+const handleDownloadConverted = (row: DocumentItem) => {
+  if (row.md_url) {
+    const filename = row.md_display_name || `${row.title}.md`
+    downloadFile(row.md_url, filename)
+  } else {
+    ElMessage.warning('该文档暂无转换后的文件')
+  }
+}
+
+const handleDownloadJson = (row: DocumentItem) => {
+  if (row.json_url) {
+    const filename = row.json_display_name || `${row.title}.json`
+    downloadFile(row.json_url, filename)
+  } else {
+    ElMessage.warning('该文档暂无转换后的 JSON 文件')
+  }
+}
+
+const handleConvert = async (row: DocumentItem) => {
+  // 如果已经转换成功(status 为 2),弹出确认框
+  if (row.conversion_status === 2) {
+    try {
+      await ElMessageBox.confirm(
+        '该文档已经转换完成,再次转换将覆盖原转换后端文件,你继续吗?',
+        '再次转换确认',
+        {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning',
+        }
+      )
+    } catch (error) {
+      // 用户取消
+      return
+    }
+  }
+
+  try {
+    // 乐观更新:设置状态为转换中
+    row.conversion_status = 1
+    row.conversion_error = undefined
+    
+    // 启动转换任务(静默刷新,不触发全局 loading)
+    const res = await documentApi.convert(row.id)
+    if (res.code === 0) {
+      ElMessage.success(res.message || '转换任务已启动')
+      // 触发一次静默刷新以更新状态
+      refreshDocumentsSilently()
+    } else {
+      // 失败了恢复状态并刷新
+      refreshDocumentsSilently()
+    }
+  } catch (error) {
+    console.error('启动转换失败:', error)
+    // 失败了恢复状态并刷新
+    refreshDocumentsSilently()
+  }
+}
+
+const openInNewWindow = () => {
+  if (previewUrl.value) {
+    // 优先尝试在新窗口打开代理后的链接,这有助于控制 Content-Disposition
+    window.open(proxyPreviewUrl.value, '_blank')
+  }
+}
+
+onMounted(() => {
+  fetchDocuments()
+  // 5秒后开始第一次静默刷新,之后递归调用
+  refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
+})
+
+onUnmounted(() => {
+  if (refreshTimer.value) {
+    window.clearTimeout(refreshTimer.value)
+  }
+  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;
+}
+
+.clickable-filename {
+  color: #409eff;
+  cursor: pointer;
+  font-weight: 500;
+  transition: color 0.2s;
+}
+
+.clickable-filename:hover {
+  color: #66b1ff;
+  text-decoration: underline;
+}
+
+.action-btn {
+  padding: 4px 8px;
+  height: auto;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.action-btn-icon {
+  padding: 4px;
+  height: 28px;
+  width: 28px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 4px;
+  transition: all 0.2s;
+}
+
+.action-btn-icon:hover {
+  background-color: #f5f7fa;
+}
+
+.action-btn-icon .el-icon {
+  font-size: 16px;
+}
+
+.action-btn .el-icon {
+  font-size: 14px;
+}
+
+.file-info-cell {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.file-icon-mini {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  border-radius: 4px;
+  font-size: 14px;
+}
+
+.file-icon-mini.pdf { background-color: #fef0f0; color: #f56c6c; }
+.file-icon-mini.word { background-color: #ecf5ff; color: #409eff; }
+.file-icon-mini.excel { background-color: #f0f9eb; color: #67c23a; }
+.file-icon-mini.ppt { background-color: #fff7e6; color: #e6a23c; }
+
+.file-info-content {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  overflow: hidden;
+}
+
+.file-note {
+  font-size: 12px;
+  color: #909399;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  line-height: 1.2;
+}
+
+.file-name-link {
+  color: #409eff;
+  cursor: pointer;
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.file-name-link:hover {
+  text-decoration: underline;
+}
+
+.compact-info {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  line-height: 1.2;
+}
+
+.info-row {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 12px;
+  color: #606266;
+}
+
+.info-row.secondary {
+  color: #909399;
+  font-size: 11px;
+}
+
+.date-cell {
+  display: flex;
+  flex-direction: column;
+  line-height: 1.2;
+}
+
+.time-mini {
+  font-size: 11px;
+  color: #909399;
+}
+
+.conversion-cell {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 4px;
+  padding: 2px 0;
+}
+
+.status-tag {
+  font-weight: 500;
+  margin-bottom: 2px;
+}
+
+.converted-file-links {
+  display: flex;
+  flex-direction: column;
+  gap: 0px;
+  width: 100%;
+}
+
+.converted-file-name {
+  font-size: 12px;
+  line-height: 1.2;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.converted-file-name :deep(.el-link) {
+  font-size: 12px;
+  justify-content: flex-start;
+}
+
+.converted-file-name :deep(.el-link .el-icon) {
+  margin-right: 4px;
+}
+
+.file-name-mini {
+  font-size: 11px;
+  color: #909399;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.status-icon-success { color: #67c23a; font-size: 18px; }
+.status-icon-info { color: #909399; font-size: 18px; }
+
+.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;
+}
+
+/* 文件信息单元格布局 */
+.file-info-cell {
+  display: flex;
+  align-items: center;
+  padding: 8px 0;
+}
+
+.file-icon-wrapper {
+  position: relative;
+  width: 40px;
+  height: 48px;
+  margin-right: 16px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  border-radius: 4px;
+  flex-shrink: 0;
+  transition: transform 0.2s;
+}
+
+.file-icon-wrapper:hover {
+  transform: scale(1.05);
+}
+
+.file-icon-wrapper .el-icon {
+  font-size: 24px;
+  margin-bottom: 2px;
+  color: #fff;
+}
+
+.file-type-label {
+  font-size: 10px;
+  font-weight: bold;
+  color: #fff;
+  text-transform: uppercase;
+}
+
+/* 不同文件类型的背景色 */
+.icon-pdf { background-color: #ff4d4f; }
+.icon-word { background-color: #1890ff; }
+.icon-excel { background-color: #52c41a; }
+.icon-ppt { background-color: #fa8c16; }
+.icon-html { background-color: #13c2c2; }
+.icon-file { background-color: #8c8c8c; }
+
+.file-text-content {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.file-name-title {
+  font-size: 15px;
+  font-weight: 500;
+  color: #303133;
+  margin-bottom: 4px;
+  cursor: pointer;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  transition: color 0.2s;
+}
+
+.file-name-title:hover {
+  color: #409eff;
+}
+
+.file-description-subtitle {
+  font-size: 12px;
+  color: #909399;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* 操作按钮样式 */
+.action-buttons {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 4px;
+}
+
+.dialog-header-custom {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-right: 30px;
+}
+
+.header-actions {
+  display: flex;
+  gap: 10px;
+}
+
+.preview-tip {
+  margin-bottom: 15px;
+}
+
+.preview-content {
+  height: 75vh;
+  display: flex;
+  flex-direction: column;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
+  background-color: #f5f7fa;
+  position: relative;
+}
+
+.unsupported-preview {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #fff;
+}
+
+.unsupported-actions {
+  display: flex;
+  gap: 12px;
+  justify-content: center;
+}
+
+.preview-content iframe {
+  flex: 1;
+  display: block;
+}
+
+:deep(.preview-dialog) {
+  .el-dialog__body {
+    padding: 10px 20px 20px;
+  }
+}
+
+</style>

+ 580 - 0
src/views/images/Index.vue

@@ -0,0 +1,580 @@
+<template>
+  <div class="image-management">
+    <el-container>
+      <!-- 左侧分类树 -->
+      <el-aside width="280px" class="category-aside">
+        <div class="aside-header-buttons">
+          <el-button type="primary" :icon="Plus" @click="handleAddCategory">新增</el-button>
+          <el-button :icon="Edit" @click="handleEditSelectedCategory" :disabled="!currentCategory || currentCategory.id === '0'">修改</el-button>
+          <el-button type="danger" :icon="Delete" @click="handleDeleteSelectedCategory" :disabled="!currentCategory || currentCategory.id === '0'">删除</el-button>
+        </div>
+        <div class="aside-content">
+          <el-tree
+            ref="categoryTree"
+            :data="categories"
+            :props="defaultProps"
+            node-key="id"
+            default-expand-all
+            highlight-current
+            @node-click="handleCategoryClick"
+          >
+            <template #default="{ node, data }">
+              <span class="custom-tree-node">
+                <span class="label">
+                  <el-icon v-if="data.id === '0'"><FolderOpened /></el-icon>
+                  <el-icon v-else><Folder /></el-icon>
+                  {{ node.label }}
+                </span>
+              </span>
+            </template>
+          </el-tree>
+        </div>
+      </el-aside>
+
+      <!-- 右侧图片列表 -->
+      <el-main class="image-main">
+        <div class="main-header">
+          <div class="header-left">
+            <span class="current-category">{{ currentCategoryName }}</span>
+          </div>
+          <div class="header-right">
+            <el-upload
+              ref="uploadRef"
+              class="upload-btn"
+              action="#"
+              :auto-upload="false"
+              :show-file-list="false"
+              multiple
+              accept="image/*"
+              :on-change="handleFileChange"
+            >
+              <el-button type="primary" :icon="Upload">新增图片</el-button>
+            </el-upload>
+          </div>
+        </div>
+
+        <div class="image-list" v-loading="loading">
+          <el-table :data="images" style="width: 100%" height="calc(100vh - 240px)">
+            <el-table-column label="图片预览" width="120">
+              <template #default="scope">
+                <el-image 
+                  class="table-image"
+                  :src="scope.row.image_url" 
+                  :preview-src-list="[scope.row.image_url]"
+                  fit="cover"
+                  preview-teleported
+                >
+                  <template #error>
+                    <div class="image-error">
+                      <el-icon><Picture /></el-icon>
+                    </div>
+                  </template>
+                </el-image>
+              </template>
+            </el-table-column>
+            <el-table-column prop="image_name" label="图片名称" min-width="150" show-overflow-tooltip />
+            <el-table-column prop="category_name" label="图片分类" width="120" />
+            <el-table-column prop="creator_name" label="创建人" width="120" />
+            <el-table-column prop="created_time" label="创建时间" width="170">
+              <template #default="scope">
+                {{ formatDateTime(scope.row.created_time) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" width="120" fixed="right">
+              <template #default="scope">
+                <el-button type="primary" link @click="handlePreview(scope.row)">预览</el-button>
+                <el-button type="danger" link @click="handleDeleteImage(scope.row)">删除</el-button>
+              </template>
+            </el-table-column>
+            <template #empty>
+              <el-empty description="暂无图片" />
+            </template>
+          </el-table>
+
+          <div class="pagination-container">
+            <el-pagination
+              v-model:current-page="queryParams.page"
+              v-model:page-size="queryParams.page_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-main>
+    </el-container>
+
+    <!-- 分类弹窗 -->
+    <el-dialog
+      v-model="categoryDialogVisible"
+      :title="categoryForm.id ? '修改分类' : '新增分类'"
+      width="400px"
+    >
+      <el-form :model="categoryForm" label-width="80px">
+        <el-form-item label="分类名称" required>
+          <el-input v-model="categoryForm.type_name" placeholder="请输入分类名称" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="categoryForm.remark" type="textarea" placeholder="请输入备注" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="categoryDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitCategory">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 图片上传/编辑弹窗 -->
+    <el-dialog
+      v-model="uploadDialogVisible"
+      title="上传图片"
+      width="500px"
+      :close-on-click-modal="false"
+    >
+      <div class="upload-list-container">
+        <div v-for="(item, index) in uploadFileList" :key="index" class="upload-item">
+          <div class="upload-item-preview">
+            <el-image :src="item.url" fit="cover" />
+          </div>
+          <div class="upload-item-info">
+            <el-input v-model="item.name" placeholder="请输入图片名称">
+              <template #append>{{ item.ext }}</template>
+            </el-input>
+          </div>
+          <div class="upload-item-ops">
+            <el-button type="danger" :icon="Delete" circle size="small" @click="removeUploadFile(index)" />
+          </div>
+        </div>
+      </div>
+      <template #footer>
+        <el-button @click="uploadDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="uploading" @click="startBatchUpload">开始上传</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, reactive, computed } from 'vue'
+import { Plus, Edit, Delete, Upload, Picture, Folder, FolderOpened } from '@element-plus/icons-vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { imageApi, type ImageCategory, type ImageItem } from '@/api/image'
+import axios from 'axios'
+
+// --- 数据定义 ---
+
+const loading = ref(false)
+const categories = ref<ImageCategory[]>([])
+const images = ref<ImageItem[]>([])
+const total = ref(0)
+const currentCategory = ref<ImageCategory | null>(null)
+
+const defaultProps = {
+  children: 'children',
+  label: 'type_name'
+}
+
+const queryParams = reactive({
+  category_id: '',
+  page: 1,
+  page_size: 10
+})
+
+const categoryDialogVisible = ref(false)
+const uploadDialogVisible = ref(false)
+const uploading = ref(false)
+const uploadFileList = ref<any[]>([])
+const uploadRef = ref()
+
+const categoryForm = reactive({
+  id: '',
+  type_name: '',
+  parent_id: '0',
+  remark: ''
+})
+
+const currentCategoryName = computed(() => {
+  return currentCategory.value ? currentCategory.value.type_name : '全部分类'
+})
+
+// --- 方法定义 ---
+
+const fetchCategories = async () => {
+  try {
+    const res = await imageApi.getCategories()
+    if (res.code === 0) {
+      categories.value = [
+        { id: '0', type_name: '全部分类', parent_id: '-1', children: res.data, created_time: '', updated_time: '' }
+      ]
+    }
+  } catch (error) {
+    console.error('获取分类失败:', error)
+  }
+}
+
+const fetchImages = async () => {
+  loading.value = true
+  try {
+    const res = await imageApi.getList(queryParams)
+    if (res.code === 0) {
+      images.value = res.data.list
+      total.value = res.data.total
+    }
+  } catch (error) {
+    console.error('获取图片失败:', error)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleCategoryClick = (data: ImageCategory) => {
+  currentCategory.value = data
+  queryParams.category_id = data.id === '0' ? '' : data.id
+  queryParams.page = 1
+  fetchImages()
+}
+
+const handleEditSelectedCategory = () => {
+  if (currentCategory.value && currentCategory.value.id !== '0') {
+    handleEditCategory(currentCategory.value)
+  }
+}
+
+const handleDeleteSelectedCategory = () => {
+  if (currentCategory.value && currentCategory.value.id !== '0') {
+    handleDeleteCategory(currentCategory.value)
+  }
+}
+
+const handleAddCategory = () => {
+  categoryForm.id = ''
+  categoryForm.type_name = ''
+  categoryForm.remark = ''
+  categoryForm.parent_id = currentCategory.value?.id === '0' ? '0' : (currentCategory.value?.id || '0')
+  categoryDialogVisible.value = true
+}
+
+const handleEditCategory = (data: ImageCategory) => {
+  categoryForm.id = data.id
+  categoryForm.type_name = data.type_name
+  categoryForm.remark = data.remark || ''
+  categoryForm.parent_id = data.parent_id
+  categoryDialogVisible.value = true
+}
+
+const handleDeleteCategory = (data: ImageCategory) => {
+  ElMessageBox.confirm(`确定要删除分类 "${data.type_name}" 吗?`, '提示', {
+    type: 'warning'
+  }).then(async () => {
+    try {
+      const res = await imageApi.deleteCategory(data.id)
+      if (res.code === 0) {
+        ElMessage.success('删除成功')
+        fetchCategories()
+      } else {
+        ElMessage.error(res.message)
+      }
+    } catch (error: any) {
+      ElMessage.error(error.message || '删除失败')
+    }
+  })
+}
+
+const submitCategory = async () => {
+  if (!categoryForm.type_name) {
+    return ElMessage.warning('请输入分类名称')
+  }
+  try {
+    let res
+    if (categoryForm.id) {
+      res = await imageApi.updateCategory(categoryForm.id, categoryForm)
+    } else {
+      res = await imageApi.addCategory(categoryForm)
+    }
+    if (res.code === 0) {
+      ElMessage.success(categoryForm.id ? '更新成功' : '新增成功')
+      categoryDialogVisible.value = false
+      fetchCategories()
+    } else {
+      ElMessage.error(res.message)
+    }
+  } catch (error: any) {
+    ElMessage.error(error.message || '操作失败')
+  }
+}
+
+const handleFileChange = (file: any) => {
+  const categoryId = queryParams.category_id || '0'
+  if (categoryId === '0') {
+    ElMessage.warning('请先在左侧选择一个具体的分类再上传图片')
+    return
+  }
+
+  const name = file.name.replace(/\.[^/.]+$/, "")
+  const ext = file.name.split('.').pop()
+  
+  uploadFileList.value.push({
+    file: file.raw,
+    name: name,
+    ext: `.${ext}`,
+    url: URL.createObjectURL(file.raw)
+  })
+  
+  uploadDialogVisible.value = true
+}
+
+const removeUploadFile = (index: number) => {
+  uploadFileList.value.splice(index, 1)
+  if (uploadFileList.value.length === 0) {
+    uploadDialogVisible.value = false
+  }
+}
+
+const startBatchUpload = async () => {
+  if (uploadFileList.value.length === 0) return
+  
+  uploading.value = true
+  let successCount = 0
+  let failCount = 0
+  
+  const categoryId = queryParams.category_id
+
+  for (const item of uploadFileList.value) {
+    try {
+      // 1. 获取预签名 URL
+      const res = await imageApi.getUploadUrl(item.file.name, item.file.type || 'application/octet-stream')
+      if (res.code !== 0) throw new Error(res.message)
+
+      const { upload_url, file_url } = res.data
+
+      // 2. 直接上传到 MinIO
+      await axios.put(upload_url, item.file, {
+        headers: { 'Content-Type': item.file.type || 'application/octet-stream' }
+      })
+
+      // 3. 保存到数据库 (使用修改后的名字)
+      await imageApi.add({
+        image_name: item.name,
+        image_url: file_url,
+        image_type: categoryId,
+        description: ''
+      })
+      
+      successCount++
+    } catch (error) {
+      console.error(`图片 ${item.name} 上传失败:`, error)
+      failCount++
+    }
+  }
+  
+  uploading.value = false
+  uploadDialogVisible.value = false
+  uploadFileList.value = []
+  
+  if (failCount === 0) {
+    ElMessage.success(`成功上传 ${successCount} 张图片`)
+  } else {
+    ElMessage.warning(`上传完成:成功 ${successCount},失败 ${failCount}`)
+  }
+  
+  fetchImages()
+}
+
+const handleDeleteImage = (row: ImageItem) => {
+  ElMessageBox.confirm(`确定要删除图片 "${row.image_name}" 吗?`, '提示', {
+    type: 'warning'
+  }).then(async () => {
+    try {
+      const res = await imageApi.delete(row.id)
+      if (res.code === 0) {
+        ElMessage.success('删除成功')
+        fetchImages()
+      } else {
+        ElMessage.error(res.message)
+      }
+    } catch (error: any) {
+      ElMessage.error(error.message || '删除失败')
+    }
+  })
+}
+
+const handlePreview = (row: ImageItem) => {
+  // el-image 的预览功能已经集成在组件中了
+  // 这里可以手动触发大图预览,如果需要的话
+}
+
+const handleSizeChange = (val: number) => {
+  queryParams.page_size = val
+  fetchImages()
+}
+
+const handleCurrentChange = (val: number) => {
+  queryParams.page = val
+  fetchImages()
+}
+
+const formatDateTime = (dateStr: string) => {
+  if (!dateStr) return '-'
+  const date = new Date(dateStr)
+  return date.toLocaleString()
+}
+
+onMounted(() => {
+  fetchCategories()
+  fetchImages()
+})
+</script>
+
+<style scoped>
+.image-management {
+  height: calc(100vh - 120px);
+  background-color: #f5f7fa;
+  padding: 20px;
+}
+
+.category-aside {
+  background-color: #fff;
+  border-radius: 8px;
+  margin-right: 20px;
+  display: flex;
+  flex-direction: column;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+}
+
+.aside-header-buttons {
+  padding: 20px;
+  border-bottom: 1px solid #f0f2f5;
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.aside-header-buttons .el-button {
+  margin: 0;
+  flex: 1;
+}
+
+.aside-content {
+  flex: 1;
+  padding: 10px;
+  overflow-y: auto;
+}
+
+.custom-tree-node {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 14px;
+  padding-right: 8px;
+}
+
+.custom-tree-node .label {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.custom-tree-node .ops {
+  display: none;
+  gap: 8px;
+  color: #909399;
+}
+
+.el-tree-node__content:hover .ops {
+  display: flex;
+}
+
+.ops .el-icon:hover {
+  color: #409eff;
+}
+
+.image-main {
+  background-color: #fff;
+  border-radius: 8px;
+  padding: 20px;
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
+  display: flex;
+  flex-direction: column;
+}
+
+.main-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.current-category {
+  font-size: 18px;
+  font-weight: bold;
+  color: #303133;
+}
+
+.image-list {
+  flex: 1;
+}
+
+.table-image {
+  width: 80px;
+  height: 60px;
+  border-radius: 4px;
+}
+
+.image-error {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  background: #f5f7fa;
+  color: #909399;
+  font-size: 20px;
+}
+
+.upload-list-container {
+  max-height: 400px;
+  overflow-y: auto;
+  padding: 10px;
+}
+
+.upload-item {
+  display: flex;
+  align-items: center;
+  gap: 15px;
+  padding: 10px;
+  border-bottom: 1px solid #f0f2f5;
+}
+
+.upload-item:last-child {
+  border-bottom: none;
+}
+
+.upload-item-preview {
+  width: 60px;
+  height: 60px;
+  border-radius: 4px;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+
+.upload-item-info {
+  flex: 1;
+}
+
+.upload-item-ops {
+  flex-shrink: 0;
+}
+
+.pagination-container {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.upload-btn {
+  display: inline-block;
+}
+</style>

+ 54 - 0
test_datetime_format.html

@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>DateTime Format Test</title>
+</head>
+<body>
+    <h1>DateTime Format Test</h1>
+    <div id="results"></div>
+
+    <script>
+        // 格式化日期时间函数
+        const formatDateTime = (dateTime) => {
+            if (!dateTime) return '-'
+            const date = new Date(dateTime)
+            
+            // 格式化为 YYYY-MM-DD HH:mm:ss
+            const year = date.getFullYear()
+            const month = String(date.getMonth() + 1).padStart(2, '0')
+            const day = String(date.getDate()).padStart(2, '0')
+            const hours = String(date.getHours()).padStart(2, '0')
+            const minutes = String(date.getMinutes()).padStart(2, '0')
+            const seconds = String(date.getSeconds()).padStart(2, '0')
+            
+            return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
+        }
+
+        // 测试数据
+        const testDates = [
+            '2026-01-06T17:46:31.000Z',
+            '2026-01-26T10:30:15.123Z',
+            '2025-12-25T23:59:59.999Z',
+            null,
+            undefined,
+            ''
+        ]
+
+        const resultsDiv = document.getElementById('results')
+        
+        testDates.forEach((date, index) => {
+            const formatted = formatDateTime(date)
+            const p = document.createElement('p')
+            p.innerHTML = `<strong>Test ${index + 1}:</strong> Input: ${date} → Output: ${formatted}`
+            resultsDiv.appendChild(p)
+        })
+
+        // 期望的输出格式示例
+        const expectedP = document.createElement('p')
+        expectedP.innerHTML = '<strong>Expected format:</strong> 2026-01-06 17:46:31'
+        expectedP.style.color = 'green'
+        expectedP.style.fontWeight = 'bold'
+        resultsDiv.appendChild(expectedP)
+    </script>
+</body>
+</html>