Эх сурвалжийг харах

feat: 添加专业知识库 - 样本中心对接

支持从样本中心导入专业知识库(编制依据、施工方案等),新增
样本中心API客户端、服务端视图及前端创建对话框。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mengboxin137-blip 6 өдөр өмнө
parent
commit
a1addb31e5

+ 1 - 0
apps/knowledge/services/__init__.py

@@ -0,0 +1 @@
+# coding=utf-8

+ 110 - 0
apps/knowledge/services/sample_center_client.py

@@ -0,0 +1,110 @@
+# coding=utf-8
+"""
+样本中心 API 客户端
+用于对接外部样本中心系统的知识库查询和批量入库接口
+"""
+import time
+from typing import Dict, Optional
+
+import requests
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+from common.exception.app_exception import AppApiException
+from common.utils.logger import maxkb_logger
+
+
+class SampleCenterClient:
+    """样本中心 API 客户端"""
+
+    def __init__(self, base_url: str = None, app_id: str = None, app_secret: str = None):
+        self.base_url = (base_url or getattr(settings, 'SAMPLE_CENTER_BASE_URL', '')).rstrip('/')
+        self.app_id = app_id or getattr(settings, 'SAMPLE_CENTER_APP_ID', '')
+        self.app_secret = app_secret or getattr(settings, 'SAMPLE_CENTER_APP_SECRET', '')
+        self._token = None
+        self._token_expires_at = 0
+
+    def _get_token(self) -> str:
+        """获取访问令牌(自动缓存和刷新)"""
+        if self._token and time.time() < self._token_expires_at - 60:
+            return self._token
+
+        url = f"{self.base_url}/api/v1/auth/token"
+        payload = {
+            "app_id": self.app_id,
+            "app_secret": self.app_secret,
+        }
+
+        try:
+            response = requests.post(url, json=payload, timeout=10)
+            response.raise_for_status()
+            result = response.json()
+
+            if result.get('code') != '000000':
+                raise AppApiException(500, _('Sample center auth failed: {msg}').format(
+                    msg=result.get('message', 'unknown error')))
+
+            data = result['data']
+            self._token = data['access_token']
+            self._token_expires_at = time.time() + data.get('expires_in', 7200)
+            return self._token
+        except requests.exceptions.RequestException as e:
+            maxkb_logger.error(f'Sample center auth request failed: {e}')
+            raise AppApiException(502, _('Failed to connect to sample center: {error}').format(error=str(e)))
+
+    def _request(self, method: str, path: str, **kwargs) -> Dict:
+        """统一请求方法"""
+        token = self._get_token()
+        url = f"{self.base_url}{path}"
+        headers = {
+            'Authorization': f'Bearer {token}',
+            'X-App-Id': self.app_id,
+            'Content-Type': 'application/json',
+        }
+        headers.update(kwargs.pop('headers', {}))
+
+        try:
+            response = requests.request(method, url, headers=headers, timeout=30, **kwargs)
+            response.raise_for_status()
+            result = response.json()
+
+            if result.get('code') != '000000':
+                raise AppApiException(500, _('Sample center API error: {msg}').format(
+                    msg=result.get('message', 'unknown error')))
+
+            return result.get('data', {})
+        except requests.exceptions.RequestException as e:
+            maxkb_logger.error(f'Sample center API request failed: {e}')
+            raise AppApiException(502, _('Failed to connect to sample center: {error}').format(error=str(e)))
+
+    def list_knowledge_bases(self, page: int = 1, page_size: int = 20) -> Dict:
+        """查询知识库列表"""
+        params = {'page': page, 'page_size': page_size}
+        return self._request('GET', '/api/v1/knowledge-bases', params=params)
+
+    def get_knowledge_base(self, kb_id: str) -> Dict:
+        """查询知识库详情"""
+        return self._request('GET', f'/api/v1/knowledge-bases/{kb_id}')
+
+    def batch_import(self, kb_id: str, task_no: str, parents: list,
+                     children: list = None, callback_url: str = None) -> Dict:
+        """提交批量入库任务"""
+        payload = {
+            'task_no': task_no,
+            'parents': parents,
+        }
+        if children:
+            payload['children'] = children
+        if callback_url:
+            payload['callback_url'] = callback_url
+
+        return self._request('POST', f'/api/v1/knowledge-bases/{kb_id}/batch-import', json=payload)
+
+    def get_import_task(self, task_id: str) -> Dict:
+        """查询批量入库任务状态"""
+        return self._request('GET', f'/api/v1/knowledge-bases/batch-import/{task_id}')
+
+
+def get_sample_center_client(**kwargs) -> SampleCenterClient:
+    """获取样本中心客户端实例"""
+    return SampleCenterClient(**kwargs)

+ 115 - 0
apps/knowledge/views/sample_center.py

@@ -0,0 +1,115 @@
+# coding=utf-8
+"""
+样本中心知识库对接视图
+提供样本中心知识库查询、批量入库等功能
+"""
+import uuid_utils.compat as uuid
+from django.utils.translation import gettext as _
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from common.auth import TokenAuth
+from common.exception.app_exception import AppApiException
+from common.result import result
+from knowledge.services.sample_center_client import get_sample_center_client
+
+
+class SampleCenterView(APIView):
+    """样本中心知识库对接"""
+    authentication_classes = [TokenAuth]
+
+    class ListKnowledgeBases(APIView):
+        """查询样本中心知识库列表"""
+        authentication_classes = [TokenAuth]
+
+        def get(self, request: Request):
+            page = int(request.query_params.get('page', 1))
+            page_size = int(request.query_params.get('page_size', 20))
+            base_url = request.query_params.get('base_url', '')
+            app_id = request.query_params.get('app_id', '')
+            app_secret = request.query_params.get('app_secret', '')
+
+            if not all([base_url, app_id, app_secret]):
+                raise AppApiException(400, _('base_url, app_id, app_secret are required'))
+
+            client = get_sample_center_client(
+                base_url=base_url,
+                app_id=app_id,
+                app_secret=app_secret,
+            )
+            data = client.list_knowledge_bases(page=page, page_size=page_size)
+            return result.success(data)
+
+    class GetKnowledgeBase(APIView):
+        """查询样本中心知识库详情"""
+        authentication_classes = [TokenAuth]
+
+        def get(self, request: Request):
+            kb_id = request.query_params.get('kb_id', '')
+            base_url = request.query_params.get('base_url', '')
+            app_id = request.query_params.get('app_id', '')
+            app_secret = request.query_params.get('app_secret', '')
+
+            if not all([kb_id, base_url, app_id, app_secret]):
+                raise AppApiException(400, _('kb_id, base_url, app_id, app_secret are required'))
+
+            client = get_sample_center_client(
+                base_url=base_url,
+                app_id=app_id,
+                app_secret=app_secret,
+            )
+            data = client.get_knowledge_base(kb_id)
+            return result.success(data)
+
+    class BatchImport(APIView):
+        """提交批量入库任务"""
+        authentication_classes = [TokenAuth]
+
+        def post(self, request: Request):
+            kb_id = request.data.get('kb_id', '')
+            base_url = request.data.get('base_url', '')
+            app_id = request.data.get('app_id', '')
+            app_secret = request.data.get('app_secret', '')
+            parents = request.data.get('parents', [])
+            children = request.data.get('children', [])
+            callback_url = request.data.get('callback_url', '')
+
+            if not all([kb_id, base_url, app_id, app_secret, parents]):
+                raise AppApiException(400, _('kb_id, base_url, app_id, app_secret, parents are required'))
+
+            task_no = f"IMP{uuid.uuid4().hex[:16].upper()}"
+
+            client = get_sample_center_client(
+                base_url=base_url,
+                app_id=app_id,
+                app_secret=app_secret,
+            )
+            data = client.batch_import(
+                kb_id=kb_id,
+                task_no=task_no,
+                parents=parents,
+                children=children,
+                callback_url=callback_url,
+            )
+            return result.success(data)
+
+    class GetImportTask(APIView):
+        """查询批量入库任务状态"""
+        authentication_classes = [TokenAuth]
+
+        def get(self, request: Request):
+            task_id = request.query_params.get('task_id', '')
+            base_url = request.query_params.get('base_url', '')
+            app_id = request.query_params.get('app_id', '')
+            app_secret = request.query_params.get('app_secret', '')
+
+            if not all([task_id, base_url, app_id, app_secret]):
+                raise AppApiException(400, _('task_id, base_url, app_id, app_secret are required'))
+
+            client = get_sample_center_client(
+                base_url=base_url,
+                app_id=app_id,
+                app_secret=app_secret,
+            )
+            data = client.get_import_task(task_id)
+            return result.success(data)

+ 109 - 0
ui/src/api/knowledge/sample-center.ts

@@ -0,0 +1,109 @@
+import { get, post } from '@/request/index'
+import { type Ref } from 'vue'
+import type { Result } from '@/request/Result'
+
+export interface SampleCenterKB {
+  id: string
+  name: string
+  parent_table: string
+  child_table: string
+  document_count: number
+  status: number
+  created_at: string
+  created_by: string
+  metadata_schema: Array<{
+    field_name_cn: string
+    field_name_en: string
+    field_type: string
+    description: string
+  }>
+}
+
+export interface SampleCenterKBDetail extends SampleCenterKB {
+  description: string
+  updated_at: string
+}
+
+export interface ImportTask {
+  task_id: string
+  task_no: string
+  status: 'pending' | 'processing' | 'completed' | 'failed'
+  progress?: {
+    total: number
+    processed: number
+    succeeded: number
+    failed: number
+  }
+  created_at?: string
+  completed_at?: string
+}
+
+export interface ParentItem {
+  index: number
+  parent_id: string
+  hierarchy?: string
+  text: string
+  metadata?: Record<string, any>
+  doc_id?: string
+  tag_list?: string[]
+  permission?: {
+    visible_roles?: string[]
+    visible_users?: string[]
+  }
+}
+
+export interface ChildItem extends ParentItem {
+  parent_id: string
+}
+
+export function getSampleCenterKBList(
+  params: {
+    base_url: string
+    app_id: string
+    app_secret: string
+    page?: number
+    page_size?: number
+  },
+  loading?: Ref<boolean>,
+): Promise<Result<{ total: number; items: SampleCenterKB[] }>> {
+  return get('workspace/sample-center/knowledge-bases', params, loading)
+}
+
+export function getSampleCenterKBDetail(
+  params: {
+    kb_id: string
+    base_url: string
+    app_id: string
+    app_secret: string
+  },
+  loading?: Ref<boolean>,
+): Promise<Result<SampleCenterKBDetail>> {
+  return get('workspace/sample-center/knowledge-bases/detail', params, loading)
+}
+
+export function submitBatchImport(
+  data: {
+    kb_id: string
+    base_url: string
+    app_id: string
+    app_secret: string
+    parents: ParentItem[]
+    children?: ChildItem[]
+    callback_url?: string
+  },
+  loading?: Ref<boolean>,
+): Promise<Result<ImportTask>> {
+  return post('workspace/sample-center/knowledge-bases/batch-import', data, loading)
+}
+
+export function getImportTaskStatus(
+  params: {
+    task_id: string
+    base_url: string
+    app_id: string
+    app_secret: string
+  },
+  loading?: Ref<boolean>,
+): Promise<Result<ImportTask>> {
+  return get('workspace/sample-center/batch-import/task', params, loading)
+}

+ 132 - 0
ui/src/views/knowledge/create-component/CreateSampleCenterKnowledgeDialog.vue

@@ -0,0 +1,132 @@
+<template>
+  <el-dialog
+    title="样本中心知识库"
+    v-model="dialogVisible"
+    width="720"
+    append-to-body
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+  >
+    <!-- 连接配置 -->
+    <el-form v-if="!connected" :model="configForm" label-width="100px" label-position="left" class="sample-center-form">
+      <el-form-item label="API 地址" required>
+        <el-input v-model="configForm.base_url" placeholder="https://sample-center.example.com" />
+      </el-form-item>
+      <el-form-item label="App ID" required>
+        <el-input v-model="configForm.app_id" placeholder="应用标识" />
+      </el-form-item>
+      <el-form-item label="App Secret" required>
+        <el-input v-model="configForm.app_secret" type="password" placeholder="应用密钥" show-password />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="connect" :loading="connecting">连接</el-button>
+      </el-form-item>
+    </el-form>
+
+    <!-- 知识库列表 -->
+    <div v-else>
+      <el-table :data="kbList" v-loading="loadingKB" style="width: 100%">
+        <el-table-column prop="name" label="知识库名称" min-width="200" />
+        <el-table-column prop="document_count" label="文档数量" width="100" />
+        <el-table-column prop="status" label="状态" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
+              {{ row.status === 1 ? '启用' : '禁用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100">
+          <template #default="{ row }">
+            <el-button type="primary" link @click="selectKB(row)">选择</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="dialogVisible = false" :loading="loading">取消</el-button>
+        <el-button v-if="connected" @click="connected = false">返回</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { getSampleCenterKBList } from '@/api/knowledge/sample-center'
+import type { SampleCenterKB } from '@/api/knowledge/sample-center'
+
+const emit = defineEmits(['select'])
+
+const dialogVisible = ref(false)
+const connected = ref(false)
+const connecting = ref(false)
+const loading = ref(false)
+const loadingKB = ref(false)
+const kbList = ref<SampleCenterKB[]>([])
+
+const configForm = ref({
+  base_url: '',
+  app_id: '',
+  app_secret: '',
+})
+
+const open = () => {
+  dialogVisible.value = true
+  connected.value = false
+  kbList.value = []
+}
+
+const connect = async () => {
+  if (!configForm.value.base_url || !configForm.value.app_id || !configForm.value.app_secret) {
+    ElMessage.warning('请填写完整的连接配置')
+    return
+  }
+  connecting.value = true
+  try {
+    const res = await getSampleCenterKBList({
+      ...configForm.value,
+      page: 1,
+      page_size: 100,
+    })
+    kbList.value = res.data?.items || []
+    connected.value = true
+  } catch (e: any) {
+    ElMessage.error(e?.message || '连接失败,请检查配置')
+  } finally {
+    connecting.value = false
+  }
+}
+
+const selectKB = (row: SampleCenterKB) => {
+  emit('select', {
+    ...row,
+    config: configForm.value,
+  })
+  dialogVisible.value = false
+}
+
+defineExpose({ open })
+</script>
+
+<style lang="scss">
+.sample-center-form .el-form-item {
+  display: flex !important;
+  align-items: center !important;
+}
+.sample-center-form .el-form-item__label {
+  display: inline-block !important;
+  width: 100px !important;
+  flex-shrink: 0;
+}
+.sample-center-form .el-form-item__content {
+  flex: 1 !important;
+  min-width: 0 !important;
+}
+.sample-center-form .el-input,
+.sample-center-form .el-input__wrapper {
+  width: 100% !important;
+}
+</style>