chenkun преди 1 месец
родител
ревизия
ca8a7b1293
променени са 9 файла, в които са добавени 999 реда и са изтрити 144 реда
  1. 6 2
      src/api/document.ts
  2. 96 0
      src/api/image.ts
  3. 18 11
      src/api/request.ts
  4. 9 0
      src/layouts/MainLayout.vue
  5. 6 0
      src/router/index.ts
  6. 2 2
      src/views/apps/Create.vue
  7. 160 49
      src/views/basic-info/Index.vue
  8. 117 80
      src/views/documents/Index.vue
  9. 585 0
      src/views/images/Index.vue

+ 6 - 2
src/api/document.ts

@@ -69,8 +69,12 @@ const prefix = '/api/v1/sample'
 
 export const documentApi = {
   // 获取文档列表
-  getList(params: DocumentQueryParams): Promise<ApiResponse<PageResult<DocumentItem>>> {
-    return request.get(`${prefix}/documents/list`, { params })
+  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)
   },
 
   // 获取文档详情

+ 96 - 0
src/api/image.ts

@@ -0,0 +1,96 @@
+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
+  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 })
+  }
+}

+ 18 - 11
src/api/request.ts

@@ -1,5 +1,5 @@
 import axios from 'axios'
-import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
+import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
 import { ElMessage } from 'element-plus'
 import { useAuthStore } from '@/stores/auth'
 import { getToken, getRefreshToken } from '@/utils/auth'
@@ -8,7 +8,7 @@ import router from '@/router'
 // 创建axios实例
 const request: AxiosInstance = axios.create({
   baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
-  timeout: 10000,
+  timeout: 30000, // 调整为 30 秒,避免长时间挂起
   headers: {
     'Content-Type': 'application/json'
   }
@@ -16,7 +16,7 @@ const request: AxiosInstance = axios.create({
 
 // 请求拦截器
 request.interceptors.request.use(
-  (config: AxiosRequestConfig) => {
+  (config: InternalAxiosRequestConfig) => {
     const token = getToken()
     if (token && config.headers) {
       config.headers.Authorization = `Bearer ${token}`
@@ -39,11 +39,14 @@ request.interceptors.response.use(
     }
     
     // 业务错误
-    ElMessage.error(message || '请求失败')
+    if (!response.config.headers?.['Skip-Error-Message']) {
+      ElMessage.error(message || '请求失败')
+    }
     return Promise.reject(new Error(message || '请求失败'))
   },
   async (error) => {
     const { response } = error
+    const skipErrorMessage = error.config?.headers?.['Skip-Error-Message']
     
     if (response) {
       const { status, data } = response
@@ -75,32 +78,36 @@ request.interceptors.response.use(
           // 刷新失败或没有refresh token,跳转到登录页
           await authStore.logout()
           router.push('/login')
-          ElMessage.error('登录已过期,请重新登录')
+          if (!skipErrorMessage) {
+            ElMessage.error('登录已过期,请重新登录')
+          }
           break
           
         case 403:
-          ElMessage.error('权限不足')
+          if (!skipErrorMessage) ElMessage.error('权限不足')
           router.push('/unauthorized')
           break
           
         case 404:
-          ElMessage.error('请求的资源不存在')
+          if (!skipErrorMessage) ElMessage.error('请求的资源不存在')
           break
           
         case 429:
-          ElMessage.error('请求过于频繁,请稍后再试')
+          if (!skipErrorMessage) ElMessage.error('请求过于频繁,请稍后再试')
           break
           
         case 500:
-          ElMessage.error('服务器内部错误')
+          if (!skipErrorMessage) ElMessage.error('服务器内部错误')
           break
           
         default:
-          ElMessage.error(data?.message || '请求失败')
+          if (!skipErrorMessage) ElMessage.error(data?.message || '请求失败')
       }
     } else {
       // 网络错误
-      ElMessage.error('网络连接失败,请检查网络设置')
+      if (!skipErrorMessage) {
+        ElMessage.error('网络连接失败,请检查网络设置')
+      }
     }
     
     return Promise.reject(error)

+ 9 - 0
src/layouts/MainLayout.vue

@@ -249,6 +249,15 @@ const getDefaultMenus = (): MenuItem[] => {
       ]
     })
 
+    defaultMenus.push({
+      id: 'images',
+      name: 'images',
+      title: '图片管理中心',
+      path: '/admin/images',
+      icon: 'Picture',
+      children: []
+    })
+
     defaultMenus.push({
       id: 'admin',
       name: 'admin',

+ 6 - 0
src/router/index.ts

@@ -104,6 +104,12 @@ const routes: RouteRecordRaw[] = [
         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',

+ 2 - 2
src/views/apps/Create.vue

@@ -424,9 +424,9 @@ const createApp = async () => {
       throw new Error(result.message || '创建应用失败')
     }
     
-  } catch (error) {
+  } catch (error: any) {
     console.error('创建应用失败:', error)
-    ElMessage.error(`创建应用失败: ${error.message}`)
+    ElMessage.error(`创建应用失败: ${error.message || '未知错误'}`)
     
     // 如果是认证错误,跳转到登录页
     if (error.message.includes('401') || error.message.includes('Unauthorized')) {

+ 160 - 49
src/views/basic-info/Index.vue

@@ -158,61 +158,149 @@
     </el-card>
 
     <!-- 新增/编辑对话框 -->
-    <el-dialog v-model="formDialogVisible" :title="formTitle" width="600px">
+    <el-dialog v-model="formDialogVisible" :title="formTitle" width="800px">
       <el-form :model="editForm" label-width="110px">
-        <el-form-item :label="titleLabel" required>
-          <el-input v-model="editForm.title" :placeholder="'请输入' + titleLabel" />
-        </el-form-item>
-        
-        <template v-if="infoType === 'basis'">
-          <el-form-item label="标准编号">
-            <el-input v-model="editForm.standard_no" placeholder="请输入标准编号" />
-          </el-form-item>
-        </template>
+        <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-form-item :label="authorityLabel">
-          <el-input v-model="editForm.issuing_authority" :placeholder="'请输入' + authorityLabel" />
-        </el-form-item>
+          <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-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 :span="12">
+            <el-form-item :label="authorityLabel">
+              <el-input v-model="editForm.issuing_authority" :placeholder="'请输入' + authorityLabel" />
+            </el-form-item>
+          </el-col>
 
-        <template 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>
-        </template>
+          <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>
 
-        <template 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-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>
-        </template>
+          <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>
 
-        <template v-if="infoType === 'work'">
-          <el-form-item label="项目名称">
-            <el-input v-model="editForm.project_name" placeholder="请输入项目名称" />
-          </el-form-item>
-          <el-form-item label="项目标段">
-            <el-input v-model="editForm.project_section" placeholder="请输入项目标段" />
-          </el-form-item>
-        </template>
+          <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-form-item label="文件链接">
-          <el-input v-model="editForm.file_url" placeholder="请输入文件预览链接 (URL)" />
-        </el-form-item>
+          <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="12" v-if="infoType === 'basis'">
+            <el-form-item label="参编单位">
+              <el-input v-model="editForm.participating_units" placeholder="请输入参编单位" />
+            </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="12" v-if="infoType === 'basis'">
+            <el-form-item label="引用依据">
+              <el-input v-model="editForm.reference_basis" placeholder="请输入引用依据" />
+            </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="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="12" v-for="i in 9" :key="i">
+              <el-form-item :label="'编制依据 ' + i">
+                <el-input v-model="editForm['compilation_basis_' + i]" :placeholder="'请输入编制依据 ' + i" />
+              </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.file_url" placeholder="请输入文件预览链接 (URL)" />
+            </el-form-item>
+          </el-col>
+        </el-row>
       </el-form>
       <template #footer>
         <el-button @click="formDialogVisible = false">取消</el-button>
@@ -316,7 +404,30 @@ const editForm = reactive<any>({
   validity: '现行',
   project_name: '',
   project_section: '',
-  file_url: ''
+  file_url: '',
+  // 新增字段 - Basis
+  english_name: '',
+  implementation_date: '',
+  drafting_unit: '',
+  approving_department: '',
+  participating_units: '',
+  engineering_phase: '',
+  reference_basis: '',
+  source_url: '',
+  // 新增字段 - Work
+  plan_summary: '',
+  compilation_basis_1: '',
+  compilation_basis_2: '',
+  compilation_basis_3: '',
+  compilation_basis_4: '',
+  compilation_basis_5: '',
+  compilation_basis_6: '',
+  compilation_basis_7: '',
+  compilation_basis_8: '',
+  compilation_basis_9: '',
+  // 新增字段 - Job
+  effective_start_date: '',
+  effective_end_date: ''
 })
 
 const formTitle = computed(() => editForm.id ? `编辑${moduleName.value}` : `新增${moduleName.value}`)

+ 117 - 80
src/views/documents/Index.vue

@@ -99,28 +99,6 @@
           </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="created_by" 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 label="修改时间" min-width="150" prop="updated_time">
-          <template #default="scope">
-            {{ formatDate(scope.row.updated_time) }}
-          </template>
-        </el-table-column>
-
         <el-table-column label="转换状态" min-width="180">
           <template #default="scope">
             <div class="conversion-cell">
@@ -149,6 +127,28 @@
           </template>
         </el-table-column>
 
+        <el-table-column prop="created_by" 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 label="修改时间" min-width="150" prop="updated_time">
+          <template #default="scope">
+            {{ formatDate(scope.row.updated_time) }}
+          </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">
@@ -176,17 +176,6 @@
                 </el-button>
               </el-tooltip>
               
-              <el-tooltip content="详情" placement="top">
-                <el-button 
-                  link 
-                  type="primary" 
-                  @click="handleView(scope.row)"
-                  class="action-btn-icon"
-                >
-                  <el-icon><View /></el-icon>
-                </el-button>
-              </el-tooltip>
-              
               <el-tooltip content="下载" placement="top" v-if="scope.row.file_url">
                 <el-button 
                   link 
@@ -288,7 +277,7 @@
         <el-form-item label="文档标题" required>
           <el-input v-model="uploadForm.title" placeholder="请输入文档标题" />
         </el-form-item>
-        <el-form-item label="文档链接">
+        <el-form-item label="文档链接" v-if="false">
           <el-input v-model="uploadForm.file_url" placeholder="请输入文档链接 (URL) 或通过下方上传文件" />
         </el-form-item>
         <el-form-item label="文件上传">
@@ -427,12 +416,27 @@
         </el-descriptions-item>
       </el-descriptions>
       <template #footer>
-        <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 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>
@@ -498,7 +502,7 @@ const searchQuery = reactive({
   table_type: null as string | null,
   whether_to_enter: null as number | null,
   page: 1,
-  size: 20
+  size: 10
 })
 
 const uploadForm = reactive({
@@ -602,18 +606,12 @@ const getKBTagType = (sourceType: string | null | undefined) => {
 
 // 为已入库或未转化的行添加特定类名
 const tableRowClassName = ({ row }: { row: DocumentItem }) => {
-  if (isEntered(row.whether_to_enter)) {
-    return 'row-entered'
-  }
-  if (row.conversion_status !== 2) {
-    return 'row-not-ready'
-  }
   return ''
 }
 
-// 判断是否可以勾选(已入库的数据不可勾选,未转化成功的数据也不可勾选
+// 判断是否可以勾选(所有文档均可勾选,用于批量删除等操作)
 const canSelect = (row: DocumentItem) => {
-  return !isEntered(row.whether_to_enter) && row.conversion_status === 2
+  return true
 }
 
 const handleSelectionChange = (selection: DocumentItem[]) => {
@@ -623,13 +621,29 @@ const handleSelectionChange = (selection: DocumentItem[]) => {
 const handleBatchEnter = async () => {
   if (selectedIds.value.length === 0) return
   
-  // 过滤掉未转化的 ID (理论上 UI 已经拦截,但双重保险)
-  const readyIds = documents.value
-    .filter(doc => selectedIds.value.includes(doc.id) && doc.conversion_status === 2)
-    .map(doc => doc.id)
+  // 找出选中项中符合入库条件的:未入库 且 转换成功
+  const selectedDocs = documents.value.filter(doc => selectedIds.value.includes(doc.id))
+  const readyDocs = selectedDocs.filter(doc => !isEntered(doc.whether_to_enter) && doc.conversion_status === 2)
+  const readyIds = readyDocs.map(doc => doc.id)
     
   if (readyIds.length === 0) {
-    return ElMessage.warning('选中的文档中没有已完成转化的,无法入库')
+    return ElMessage.warning('选中的文档中没有符合入库条件的(需转换成功且未入库)')
+  }
+
+  if (readyIds.length < selectedIds.value.length) {
+    try {
+      await ElMessageBox.confirm(
+        `选中的 ${selectedIds.value.length} 个文档中,仅有 ${readyIds.length} 个符合入库条件(已转换成功)。是否继续对这 ${readyIds.length} 个文档执行入库?`,
+        '入库提示',
+        {
+          confirmButtonText: '继续入库',
+          cancelButtonText: '取消',
+          type: 'info'
+        }
+      )
+    } catch {
+      return
+    }
   }
 
   try {
@@ -774,39 +788,41 @@ const fetchDocuments = async () => {
   }
 }
 
-// 轮询转换状态
-let pollingTimer: any = null
+const isRefreshing = ref(false)
+const refreshTimer = ref<any>(null)
+
 const startPolling = () => {
-  if (pollingTimer) return
-  pollingTimer = setInterval(() => {
-    // 轮询时不显示全局 loading
-    refreshDocumentsSilently()
-  }, 3000)
+  if (refreshTimer.value === null) {
+    refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
+  }
 }
 
 const stopPolling = () => {
-  if (pollingTimer) {
-    clearInterval(pollingTimer)
-    pollingTimer = null
+  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)
+    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
-      
-      const hasConverting = documents.value.some(doc => doc.conversion_status === 1)
-      if (!hasConverting) {
-        stopPolling()
-      }
     }
   } catch (error) {
     console.error('静默刷新失败:', error)
+  } finally {
+    isRefreshing.value = false
+    // 如果没有手动停止,且组件未卸载,则安排下一次刷新
+    if (refreshTimer.value !== null) {
+      refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
+    }
   }
 }
 
@@ -1002,22 +1018,43 @@ const handleDownloadJson = (row: DocumentItem) => {
 }
 
 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 = null
+    row.conversion_error = undefined
     
+    // 启动转换任务(静默刷新,不触发全局 loading)
     const res = await documentApi.convert(row.id)
     if (res.code === 0) {
       ElMessage.success(res.message || '转换任务已启动')
-      fetchDocuments()
+      // 触发一次静默刷新以更新状态
+      refreshDocumentsSilently()
     } else {
-      // 失败了恢复状态
-      fetchDocuments()
+      // 失败了恢复状态并刷新
+      refreshDocumentsSilently()
     }
   } catch (error) {
     console.error('启动转换失败:', error)
-    fetchDocuments()
+    // 失败了恢复状态并刷新
+    refreshDocumentsSilently()
   }
 }
 
@@ -1029,9 +1066,14 @@ const openInNewWindow = () => {
 
 onMounted(() => {
   fetchDocuments()
+  // 5秒后开始第一次静默刷新,之后递归调用
+  refreshTimer.value = window.setTimeout(refreshDocumentsSilently, 5000)
 })
 
 onUnmounted(() => {
+  if (refreshTimer.value) {
+    window.clearTimeout(refreshTimer.value)
+  }
   stopPolling()
 })
 </script>
@@ -1434,9 +1476,4 @@ onUnmounted(() => {
   }
 }
 
-/* 隐藏已入库或未转化完成行的复选框 */
-:deep(.row-entered .el-table-column--selection .el-checkbox),
-:deep(.row-not-ready .el-table-column--selection .el-checkbox) {
-  display: none;
-}
 </style>

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

@@ -0,0 +1,585 @@
+<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 prop="updated_time" label="修改时间" width="170">
+              <template #default="scope">
+                {{ formatDateTime(scope.row.updated_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>