From dda71e90a5ae28cf3b057ded19e65ecff2a5a41b Mon Sep 17 00:00:00 2001 From: ulleo Date: Thu, 13 Nov 2025 19:36:01 +0800 Subject: [PATCH] feat: add export terminologies --- backend/apps/terminology/api/terminology.py | 49 +++- backend/apps/terminology/curd/terminology.py | 260 ++++++++---------- backend/locales/en.json | 57 ++-- backend/locales/ko-KR.json | 65 +++-- backend/locales/zh-CN.json | 7 +- frontend/src/api/professional.ts | 6 + frontend/src/components/layout/LayoutDsl.vue | 26 +- frontend/src/i18n/en.json | 2 +- frontend/src/i18n/ko-KR.json | 2 +- frontend/src/i18n/zh-CN.json | 2 +- .../src/views/system/professional/index.vue | 113 ++++---- 11 files changed, 324 insertions(+), 265 deletions(-) diff --git a/backend/apps/terminology/api/terminology.py b/backend/apps/terminology/api/terminology.py index 239e4c25..42bd5389 100644 --- a/backend/apps/terminology/api/terminology.py +++ b/backend/apps/terminology/api/terminology.py @@ -1,9 +1,15 @@ +import asyncio +import io from typing import Optional +import pandas as pd from fastapi import APIRouter, Query +from fastapi.responses import StreamingResponse +from apps.chat.models.chat_model import AxisObj +from apps.chat.task.llm import LLMService from apps.terminology.curd.terminology import page_terminology, create_terminology, update_terminology, \ - delete_terminology, enable_terminology + delete_terminology, enable_terminology, get_all_terminology from apps.terminology.models.terminology_model import TerminologyInfo from common.core.deps import SessionDep, CurrentUser, Trans @@ -42,3 +48,44 @@ async def delete(session: SessionDep, id_list: list[int]): @router.get("/{id}/enable/{enabled}") async def enable(session: SessionDep, id: int, enabled: bool, trans: Trans): enable_terminology(session, id, enabled, trans) + + +@router.get("/export") +async def export_excel(session: SessionDep, trans: Trans, current_user: CurrentUser, + word: Optional[str] = Query(None, description="搜索术语(可选)")): + def inner(): + _list = get_all_terminology(session, word, oid=current_user.oid) + + data_list = [] + for obj in _list: + _data = { + "word": obj.word, + "other_words": ', '.join(obj.other_words) if obj.other_words else '', + "description": obj.description, + "all_data_sources": 'Y' if obj.specific_ds else 'N', + "datasource": ', '.join(obj.datasource_names) if obj.datasource_names else '', + } + data_list.append(_data) + + fields = [] + fields.append(AxisObj(name=trans('i18n_terminology.term_name'), value='word')) + fields.append(AxisObj(name=trans('i18n_terminology.synonyms'), value='other_words')) + fields.append(AxisObj(name=trans('i18n_terminology.term_description'), value='description')) + fields.append(AxisObj(name=trans('i18n_terminology.effective_data_sources'), value='datasource')) + fields.append(AxisObj(name=trans('i18n_terminology.all_data_sources'), value='all_data_sources')) + + md_data, _fields_list = LLMService.convert_object_array_for_pandas(fields, data_list) + + df = pd.DataFrame(md_data, columns=_fields_list) + + buffer = io.BytesIO() + + with pd.ExcelWriter(buffer, engine='xlsxwriter', + engine_kwargs={'options': {'strings_to_numbers': False}}) as writer: + df.to_excel(writer, sheet_name='Sheet1', index=False) + + buffer.seek(0) + return io.BytesIO(buffer.getvalue()) + + result = await asyncio.to_thread(inner) + return StreamingResponse(result, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") diff --git a/backend/apps/terminology/curd/terminology.py b/backend/apps/terminology/curd/terminology.py index 8720bff7..85946fa7 100644 --- a/backend/apps/terminology/curd/terminology.py +++ b/backend/apps/terminology/curd/terminology.py @@ -17,24 +17,18 @@ from common.utils.embedding_threads import run_save_terminology_embeddings -def page_terminology(session: SessionDep, current_page: int = 1, page_size: int = 10, name: Optional[str] = None, - oid: Optional[int] = 1): - _list: List[TerminologyInfo] = [] - +def get_terminology_base_query(oid: int, name: Optional[str] = None): + """ + 获取术语查询的基础查询结构 + """ child = aliased(Terminology) - current_page = max(1, current_page) - page_size = max(10, page_size) - - total_count = 0 - total_pages = 0 - if name and name.strip() != "": keyword_pattern = f"%{name.strip()}%" # 步骤1:先找到所有匹配的节点ID(无论是父节点还是子节点) matched_ids_subquery = ( select(Terminology.id) - .where(and_(Terminology.word.ilike(keyword_pattern), Terminology.oid == oid)) # LIKE查询条件 + .where(and_(Terminology.word.ilike(keyword_pattern), Terminology.oid == oid)) .subquery() ) @@ -51,15 +45,32 @@ def page_terminology(session: SessionDep, current_page: int = 1, page_size: int ) .where(Terminology.pid.is_(None)) # 只取父节点 ) + else: + parent_ids_subquery = ( + select(Terminology.id) + .where(and_(Terminology.pid.is_(None), Terminology.oid == oid)) + ) + + return parent_ids_subquery, child - count_stmt = select(func.count()).select_from(parent_ids_subquery.subquery()) - total_count = session.execute(count_stmt).scalar() - total_pages = (total_count + page_size - 1) // page_size - if current_page > total_pages: - current_page = 1 +def build_terminology_query(session: SessionDep, oid: int, name: Optional[str] = None, + paginate: bool = True, current_page: int = 1, page_size: int = 10): + """ + 构建术语查询的通用方法 + """ + parent_ids_subquery, child = get_terminology_base_query(oid, name) + + # 计算总数 + count_stmt = select(func.count()).select_from(parent_ids_subquery.subquery()) + total_count = session.execute(count_stmt).scalar() + + if paginate: + # 分页处理 + page_size = max(10, page_size) + total_pages = (total_count + page_size - 1) // page_size + current_page = max(1, min(current_page, total_pages)) if total_pages > 0 else 1 - # 步骤3:获取分页后的父节点ID paginated_parent_ids = ( parent_ids_subquery .order_by(Terminology.create_time.desc()) @@ -67,145 +78,85 @@ def page_terminology(session: SessionDep, current_page: int = 1, page_size: int .limit(page_size) .subquery() ) - - # 步骤4:获取这些父节点的childrenNames - children_subquery = ( - select( - child.pid, - func.jsonb_agg(child.word).filter(child.word.isnot(None)).label('other_words') - ) - .where(child.pid.isnot(None)) - .group_by(child.pid) - .subquery() - ) - - # 创建子查询来获取数据源名称,添加类型转换 - datasource_names_subquery = ( - select( - func.jsonb_array_elements(Terminology.datasource_ids).cast(BigInteger).label('ds_id'), - Terminology.id.label('term_id') - ) - .where(Terminology.id.in_(paginated_parent_ids)) - .subquery() - ) - - # 主查询 - stmt = ( - select( - Terminology.id, - Terminology.word, - Terminology.create_time, - Terminology.description, - Terminology.specific_ds, - Terminology.datasource_ids, - children_subquery.c.other_words, - func.jsonb_agg(CoreDatasource.name).filter(CoreDatasource.id.isnot(None)).label('datasource_names'), - Terminology.enabled - ) - .outerjoin( - children_subquery, - Terminology.id == children_subquery.c.pid - ) - # 关联数据源名称子查询和 CoreDatasource 表 - .outerjoin( - datasource_names_subquery, - datasource_names_subquery.c.term_id == Terminology.id - ) - .outerjoin( - CoreDatasource, - CoreDatasource.id == datasource_names_subquery.c.ds_id - ) - .where(and_(Terminology.id.in_(paginated_parent_ids), Terminology.oid == oid)) - .group_by( - Terminology.id, - Terminology.word, - Terminology.create_time, - Terminology.description, - Terminology.specific_ds, - Terminology.datasource_ids, - children_subquery.c.other_words, - Terminology.enabled - ) - .order_by(Terminology.create_time.desc()) - ) else: - parent_ids_subquery = ( - select(Terminology.id) - .where(and_(Terminology.pid.is_(None), Terminology.oid == oid)) # 只取父节点 - ) - count_stmt = select(func.count()).select_from(parent_ids_subquery.subquery()) - total_count = session.execute(count_stmt).scalar() - total_pages = (total_count + page_size - 1) // page_size - - if current_page > total_pages: - current_page = 1 + # 不分页,获取所有数据 + total_pages = 1 + current_page = 1 + page_size = total_count if total_count > 0 else 1 paginated_parent_ids = ( parent_ids_subquery .order_by(Terminology.create_time.desc()) - .offset((current_page - 1) * page_size) - .limit(page_size) .subquery() ) - children_subquery = ( - select( - child.pid, - func.jsonb_agg(child.word).filter(child.word.isnot(None)).label('other_words') - ) - .where(child.pid.isnot(None)) - .group_by(child.pid) - .subquery() + # 构建公共查询部分 + children_subquery = ( + select( + child.pid, + func.jsonb_agg(child.word).filter(child.word.isnot(None)).label('other_words') ) + .where(child.pid.isnot(None)) + .group_by(child.pid) + .subquery() + ) - # 创建子查询来获取数据源名称 - datasource_names_subquery = ( - select( - func.jsonb_array_elements(Terminology.datasource_ids).cast(BigInteger).label('ds_id'), - Terminology.id.label('term_id') - ) - .where(Terminology.id.in_(paginated_parent_ids)) - .subquery() + # 创建子查询来获取数据源名称 + datasource_names_subquery = ( + select( + func.jsonb_array_elements(Terminology.datasource_ids).cast(BigInteger).label('ds_id'), + Terminology.id.label('term_id') ) + .where(Terminology.id.in_(paginated_parent_ids)) + .subquery() + ) - stmt = ( - select( - Terminology.id, - Terminology.word, - Terminology.create_time, - Terminology.description, - Terminology.specific_ds, - Terminology.datasource_ids, - children_subquery.c.other_words, - func.jsonb_agg(CoreDatasource.name).filter(CoreDatasource.id.isnot(None)).label('datasource_names'), - Terminology.enabled - ) - .outerjoin( - children_subquery, - Terminology.id == children_subquery.c.pid - ) - # 关联数据源名称子查询和 CoreDatasource 表 - .outerjoin( - datasource_names_subquery, - datasource_names_subquery.c.term_id == Terminology.id - ) - .outerjoin( - CoreDatasource, - CoreDatasource.id == datasource_names_subquery.c.ds_id - ) - .where(and_(Terminology.id.in_(paginated_parent_ids), Terminology.oid == oid)) - .group_by(Terminology.id, - Terminology.word, - Terminology.create_time, - Terminology.description, - Terminology.specific_ds, - Terminology.datasource_ids, - children_subquery.c.other_words, - Terminology.enabled - ) - .order_by(Terminology.create_time.desc()) + stmt = ( + select( + Terminology.id, + Terminology.word, + Terminology.create_time, + Terminology.description, + Terminology.specific_ds, + Terminology.datasource_ids, + children_subquery.c.other_words, + func.jsonb_agg(CoreDatasource.name).filter(CoreDatasource.id.isnot(None)).label('datasource_names'), + Terminology.enabled ) + .outerjoin( + children_subquery, + Terminology.id == children_subquery.c.pid + ) + .outerjoin( + datasource_names_subquery, + datasource_names_subquery.c.term_id == Terminology.id + ) + .outerjoin( + CoreDatasource, + CoreDatasource.id == datasource_names_subquery.c.ds_id + ) + .where(and_(Terminology.id.in_(paginated_parent_ids), Terminology.oid == oid)) + .group_by( + Terminology.id, + Terminology.word, + Terminology.create_time, + Terminology.description, + Terminology.specific_ds, + Terminology.datasource_ids, + children_subquery.c.other_words, + Terminology.enabled + ) + .order_by(Terminology.create_time.desc()) + ) + + return stmt, total_count, total_pages, current_page, page_size + +def execute_terminology_query(session: SessionDep, stmt) -> List[TerminologyInfo]: + """ + 执行查询并返回术语信息列表 + """ + _list = [] result = session.execute(stmt) for row in result: @@ -221,9 +172,34 @@ def page_terminology(session: SessionDep, current_page: int = 1, page_size: int enabled=row.enabled if row.enabled is not None else False, )) + return _list + + +def page_terminology(session: SessionDep, current_page: int = 1, page_size: int = 10, + name: Optional[str] = None, oid: Optional[int] = 1): + """ + 分页查询术语(原方法保持不变) + """ + stmt, total_count, total_pages, current_page, page_size = build_terminology_query( + session, oid, name, True, current_page, page_size + ) + _list = execute_terminology_query(session, stmt) + return current_page, page_size, total_count, total_pages, _list +def get_all_terminology(session: SessionDep, name: Optional[str] = None, oid: Optional[int] = 1): + """ + 获取所有术语(不分页) + """ + stmt, total_count, total_pages, current_page, page_size = build_terminology_query( + session, oid, name, False + ) + _list = execute_terminology_query(session, stmt) + + return _list + + def create_terminology(session: SessionDep, info: TerminologyInfo, oid: int, trans: Trans): create_time = datetime.datetime.now() diff --git a/backend/locales/en.json b/backend/locales/en.json index 6d922a4c..131a5ec7 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1,60 +1,65 @@ { - "i18n_default_workspace": "Default workspace", + "i18n_default_workspace": "Default Workspace", "i18n_ds_name_exist": "Name already exists", - "i18n_concat_admin": "Please contact administrator!", + "i18n_concat_admin": "Please contact the administrator!", "i18n_exist": "{msg} already exists!", "i18n_name": "Name", - "i18n_not_exist": "{msg} not exists", + "i18n_not_exist": "{msg} does not exist!", "i18n_error": "{key} error!", "i18n_miss_args": "Missing {key} parameter!", "i18n_format_invalid": "{key} format is incorrect!", "i18n_login": { - "account_pwd_error": "Account or password error!", + "account_pwd_error": "Incorrect account or password!", "no_associated_ws": "No associated workspace, {msg}", - "user_disable": "Account disabled, {msg}" + "user_disable": "Account is disabled, {msg}" }, "i18n_user": { "account": "Account", "email": "Email", "password": "Password", - "language_not_support": "System does not support [{key}] language!", - "ws_miss": "The current user is not in the workspace [{ws}]!" + "language_not_support": "The system does not support [{key}] language!", + "ws_miss": "Current user is not in the workspace [{ws}]!" }, "i18n_ws": { "title": "Workspace" }, "i18n_permission": { - "only_admin": "Only administrators can call this!", - "no_permission": "No permission to access {url}{msg}", - "authenticate_invalid": "Authenticate invalid [{msg}]", - "token_expired": "Token has expired" + "only_admin": "Only administrators are allowed to call!", + "no_permission": "No permission to call {url}{msg}", + "authenticate_invalid": "Authentication invalid【{msg}】", + "token_expired": "Token expired" }, "i18n_llm": { "validate_error": "Validation failed [{msg}]", - "delete_default_error": "Cannot delete default model [{key}]!", - "miss_default": "The default large language model has not been configured" + "delete_default_error": "Cannot delete the default model [{key}]!", + "miss_default": "Default large language model has not been configured" }, - "i18n_ds_invalid": "Datasource Invalid", + "i18n_ds_invalid": "Datasource connection is invalid", "i18n_embedded": { - "invalid_origin": "Domain verification failed [{origin}]" + "invalid_origin": "Domain name validation failed【{origin}】" }, "i18n_terminology": { - "terminology_not_exists": "Terminology does not exists", - "datasource_cannot_be_none": "Datasource cannot be none or empty", - "cannot_be_repeated": "Term name, synonyms cannot be repeated", - "exists_in_db": "Term name, synonyms exists" + "terminology_not_exists": "This terminology does not exist", + "datasource_cannot_be_none": "Datasource cannot be empty", + "cannot_be_repeated": "Terminology name and synonyms cannot be repeated", + "exists_in_db": "Terminology name and synonyms already exist", + "term_name": "Terminology Name", + "term_description": "Terminology Description", + "effective_data_sources": "Effective Data Sources", + "all_data_sources": "All Data Sources", + "synonyms": "Synonyms" }, "i18n_data_training": { - "datasource_cannot_be_none": "Datasource cannot be none or empty", - "datasource_assistant_cannot_be_none": "Datasource or Advanced application cannot be all none", - "data_training_not_exists": "Example does not exists", - "exists_in_db": "Question exists" + "datasource_cannot_be_none": "Datasource cannot be empty", + "datasource_assistant_cannot_be_none": "Datasource or advanced application cannot both be empty", + "data_training_not_exists": "This example does not exist", + "exists_in_db": "This question already exists" }, "i18n_custom_prompt": { - "exists_in_db": "Prompt name exists", - "not_exists": "Prompt does not exists" + "exists_in_db": "Template name already exists", + "not_exists": "This template does not exist" }, "i18n_excel_export": { - "data_is_empty": "The form data is empty, cannot export data" + "data_is_empty": "Form data is empty, unable to export data" } } \ No newline at end of file diff --git a/backend/locales/ko-KR.json b/backend/locales/ko-KR.json index 03528a31..e7a06ba3 100644 --- a/backend/locales/ko-KR.json +++ b/backend/locales/ko-KR.json @@ -1,56 +1,65 @@ { - "i18n_default_workspace": "기본 워크스페이스", - "i18n_ds_name_exist": "이미 존재하는 이름입니다", - "i18n_concat_admin": "관리자에게 문의해 주세요!", + "i18n_default_workspace": "기본 작업 공간", + "i18n_ds_name_exist": "이름이 이미 존재합니다", + "i18n_concat_admin": "관리자에게 문의하세요!", "i18n_exist": "{msg}이(가) 이미 존재합니다!", "i18n_name": "이름", - "i18n_not_exist": "{msg}을(를) 찾을 수 없습니다", + "i18n_not_exist": "{msg}이(가) 존재하지 않습니다!", "i18n_error": "{key} 오류!", - "i18n_miss_args": "{key} 매개변수가 없습니다!", + "i18n_miss_args": "{key} 매개변수가 누락되었습니다!", "i18n_format_invalid": "{key} 형식이 올바르지 않습니다!", "i18n_login": { - "account_pwd_error": "계정 또는 비밀번호가 올바르지 않습니다!", - "no_associated_ws": "연결된 워크스페이스가 없습니다. {msg}", - "user_disable": "계정이 비활성화되었습니다. {msg}" + "account_pwd_error": "계정 또는 비밀번호가 잘못되었습니다!", + "no_associated_ws": "연결된 작업 공간이 없습니다, {msg}", + "user_disable": "계정이 비활성화되었습니다, {msg}" }, "i18n_user": { "account": "계정", "email": "이메일", "password": "비밀번호", - "language_not_support": "[{key}] 언어는 지원하지 않습니다!", - "ws_miss": "현재 사용자는 워크스페이스 [{ws}]에 속해 있지 않습니다!" + "language_not_support": "시스템이 [{key}] 언어를 지원하지 않습니다!", + "ws_miss": "현재 사용자가 [{ws}] 작업 공간에 속해 있지 않습니다!" }, "i18n_ws": { - "title": "워크스페이스" + "title": "작업 공간" }, "i18n_permission": { "only_admin": "관리자만 호출할 수 있습니다!", - "no_permission": "{url}{msg}에 접근 권한이 없습니다", - "authenticate_invalid": "인증이 실패했습니다 [{msg}]", - "token_expired": "토큰이 만료되었습니다" + "no_permission": "{url}{msg} 호출 권한이 없습니다", + "authenticate_invalid": "인증 무효 【{msg}】", + "token_expired": "토큰이 만료됨" }, "i18n_llm": { - "validate_error": "검증에 실패했습니다 [{msg}]", - "delete_default_error": "기본 모델 [{key}]은 삭제할 수 없습니다!", - "miss_default": "기본 LLM이 아직 설정되지 않았습니다" + "validate_error": "검증 실패 [{msg}]", + "delete_default_error": "기본 모델 [{key}]을(를) 삭제할 수 없습니다!", + "miss_default": "기본 대형 언어 모델이 구성되지 않았습니다" }, - "i18n_ds_invalid": "데이터 소스가 유효하지 않습니다", + "i18n_ds_invalid": "데이터 소스 연결이 무효합니다", "i18n_embedded": { - "invalid_origin": "도메인 검증에 실패했습니다 [{origin}]" + "invalid_origin": "도메인 이름 검증 실패 【{origin}】" }, "i18n_terminology": { - "terminology_not_exists": "용어를 찾을 수 없습니다", - "datasource_cannot_be_none": "데이터 소스를 선택해 주세요", - "cannot_be_repeated": "용어 이름과 동의어는 중복될 수 없습니다", - "exists_in_db": "용어 이름 또는 동의어가 이미 존재합니다" + "terminology_not_exists": "이 용어가 존재하지 않습니다", + "datasource_cannot_be_none": "데이터 소스는 비울 수 없습니다", + "cannot_be_repeated": "용어 이름과 동의어는 반복될 수 없습니다", + "exists_in_db": "용어 이름과 동의어가 이미 존재합니다", + "term_name": "용어 이름", + "term_description": "용어 설명", + "effective_data_sources": "유효 데이터 소스", + "all_data_sources": "모든 데이터 소스", + "synonyms": "동의어" }, "i18n_data_training": { - "datasource_cannot_be_none": "데이터 소스를 선택해 주세요", - "datasource_assistant_cannot_be_none": "데이터 소스와 고급 애플리케이션이 모두 비어 있을 수는 없습니다.", - "data_training_not_exists": "예제가 존재하지 않습니다", - "exists_in_db": "질문이 이미 존재합니다" + "datasource_cannot_be_none": "데이터 소스는 비울 수 없습니다", + "datasource_assistant_cannot_be_none": "데이터 소스와 고급 애플리케이션을 모두 비울 수 없습니다", + "data_training_not_exists": "이 예시가 존재하지 않습니다", + "exists_in_db": "이 질문이 이미 존재합니다" + }, + "i18n_custom_prompt": { + "exists_in_db": "템플릿 이름이 이미 존재합니다", + "not_exists": "이 템플릿이 존재하지 않습니다" }, "i18n_excel_export": { - "data_is_empty": "양식 데이터가 없어 내보낼 수 없습니다" + "data_is_empty": "폼 데이터가 비어 있어 데이터를 내보낼 수 없습니다" } } \ No newline at end of file diff --git a/backend/locales/zh-CN.json b/backend/locales/zh-CN.json index 7a593a09..c2dbdcaf 100644 --- a/backend/locales/zh-CN.json +++ b/backend/locales/zh-CN.json @@ -42,7 +42,12 @@ "terminology_not_exists": "该术语不存在", "datasource_cannot_be_none": "数据源不能为空", "cannot_be_repeated": "术语名称,同义词不能重复", - "exists_in_db": "术语名称,同义词已存在" + "exists_in_db": "术语名称,同义词已存在", + "term_name": "术语名称", + "term_description": "术语描述", + "effective_data_sources": "生效数据源", + "all_data_sources": "所有数据源", + "synonyms": "同义词" }, "i18n_data_training": { "datasource_cannot_be_none": "数据源不能为空", diff --git a/frontend/src/api/professional.ts b/frontend/src/api/professional.ts index 9cc62fe4..64408d3a 100644 --- a/frontend/src/api/professional.ts +++ b/frontend/src/api/professional.ts @@ -10,4 +10,10 @@ export const professionalApi = { deleteEmbedded: (params: any) => request.delete('/system/terminology', { data: params }), getOne: (id: any) => request.get(`/system/terminology/${id}`), enable: (id: any, enabled: any) => request.get(`/system/terminology/${id}/enable/${enabled}`), + export2Excel: (params: any) => + request.get(`/system/terminology/export`, { + params, + responseType: 'blob', + requestOptions: { customError: true }, + }), } diff --git a/frontend/src/components/layout/LayoutDsl.vue b/frontend/src/components/layout/LayoutDsl.vue index d20b3af4..cbfacd67 100644 --- a/frontend/src/components/layout/LayoutDsl.vue +++ b/frontend/src/components/layout/LayoutDsl.vue @@ -59,16 +59,16 @@ const showSysmenu = computed(() => {