From 9a34d322be538989e3e051ec15bfdc8c5c4b5608 Mon Sep 17 00:00:00 2001 From: huhuhuhr <757033301@qq.com> Date: Wed, 11 Mar 2026 13:44:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E6=BA=90/AI=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B/=E6=88=90=E5=91=98/=E6=9D=83=E9=99=90=20=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E5=AF=BC=E5=87=BA=E4=B8=8E=E6=89=B9=E9=87=8F=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据源:全部导出(含表/字段/关系)、批量导入合并(同名更新) - AI 模型:全部导出、批量导入合并(按 name) - 成员管理:后端 uws export/import 接口,前端全部导出/批量导入 - 权限配置:前端全部导出/批量导入,导入时规范化 payload 修复 permission_list 报错 Made-with: Cursor --- Dockerfile | 7 +- backend/apps/datasource/api/datasource.py | 43 ++++- backend/apps/datasource/crud/datasource.py | 179 +++++++++++++++++- backend/apps/datasource/models/datasource.py | 40 ++++ backend/apps/system/api/aimodel.py | 123 +++++++++++- backend/apps/system/api/workspace.py | 107 ++++++++++- .../apps/system/schemas/ai_model_schema.py | 13 +- frontend/src/api/datasource.ts | 6 + frontend/src/api/system.ts | 6 + frontend/src/api/workspace.ts | 11 ++ frontend/src/i18n/en.json | 24 +++ frontend/src/i18n/zh-CN.json | 24 +++ frontend/src/views/ds/Datasource.vue | 76 ++++++++ frontend/src/views/ds/index.vue | 99 +++++++++- frontend/src/views/system/member/index.vue | 71 +++++++ frontend/src/views/system/model/Model.vue | 76 ++++++++ .../src/views/system/permission/index.vue | 112 +++++++++++ 17 files changed, 988 insertions(+), 29 deletions(-) diff --git a/Dockerfile b/Dockerfile index 204a92d0f..71a7b562a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN mkdir -p ${APP_HOME} ${UI_HOME} COPY frontend /tmp/frontend -RUN cd /tmp/frontend && npm install && npm run build && mv dist ${UI_HOME}/dist +RUN cd /tmp/frontend && npm config set registry https://registry.npmmirror.com && npm install && npm run build && mv dist ${UI_HOME}/dist FROM registry.cn-qingdao.aliyuncs.com/dataease/sqlbot-base:latest AS sqlbot-builder @@ -57,10 +57,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libpixman-1-dev libfreetype6-dev \ && rm -rf /var/lib/apt/lists/* -# configure npm +# configure npm (use China mirror for faster downloads) RUN npm config set fund false \ && npm config set audit false \ - && npm config set progress false + && npm config set progress false \ + && npm config set registry https://registry.npmmirror.com COPY g2-ssr/app.js g2-ssr/package.json /app/ COPY g2-ssr/charts/* /app/charts/ diff --git a/backend/apps/datasource/api/datasource.py b/backend/apps/datasource/api/datasource.py index c86fcf034..1ac469ff1 100644 --- a/backend/apps/datasource/api/datasource.py +++ b/backend/apps/datasource/api/datasource.py @@ -27,13 +27,19 @@ from common.core.deps import SessionDep, CurrentUser, Trans from common.utils.utils import SQLBotLogUtil from apps.ai_model.model_factory import create_llm -from ..crud.datasource import get_datasource_list, check_status, create_ds, update_ds, delete_ds, getTables, getFields, \ - execSql, update_table_and_fields, getTablesByDs, chooseTables, preview, updateTable, updateField, get_ds, fieldEnum, \ - check_status_by_id, sync_single_fields, copy_ds +from ..crud.datasource import ( + get_datasource_list, check_status, create_ds, update_ds, delete_ds, getTables, getFields, + execSql, update_table_and_fields, getTablesByDs, chooseTables, preview, updateTable, updateField, + get_ds, fieldEnum, check_status_by_id, sync_single_fields, copy_ds, + export_datasources, import_datasources, +) from ..crud.field import get_fields_by_table_id from ..crud.table import get_tables_by_ds_id -from ..models.datasource import CoreDatasource, CreateDatasource, TableObj, CoreTable, CoreField, FieldObj, \ - TableSchemaResponse, ColumnSchemaResponse, PreviewResponse +from ..models.datasource import ( + CoreDatasource, CreateDatasource, TableObj, CoreTable, CoreField, FieldObj, + TableSchemaResponse, ColumnSchemaResponse, PreviewResponse, + DatasourceExportPayload, DatasourceImportPayload, +) from common.audit.models.log_model import OperationType, OperationModules from common.audit.schemas.logger_decorator import LogConfig, system_log @@ -131,6 +137,33 @@ class CopyDatasourceRequest(BaseModel): name: Optional[str] = None +class DatasourceExportRequest(BaseModel): + """批量导出数据源:请求体为要导出的数据源 id 列表""" + ids: List[int] = [] + + +@router.post("/export", response_model=DatasourceExportPayload, summary="批量导出数据源", + description="导出数据源基础信息、选中表、表映射关系、标准元数据(表/字段备注)。") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def export_datasources_api( + session: SessionDep, user: CurrentUser, body: DatasourceExportRequest = Body(default=None), +): + ids = (body.ids if body and body.ids else []) or [] + if not ids: + return DatasourceExportPayload(datasources=[]) + return export_datasources(session, user, ids) + + +@router.post("/import", response_model=List[CoreDatasource], summary="批量导入数据源", + description="从导出 JSON 批量创建数据源,含表、字段元数据及表映射关系。") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +@system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.DATASOURCE)) +async def import_datasources_api( + session: SessionDep, trans: Trans, user: CurrentUser, body: DatasourceImportPayload, +): + return await import_datasources(session, trans, user, body) + + @router.post("/copy/{id}", response_model=CoreDatasource, summary=f"{PLACEHOLDER_PREFIX}ds_copy") @require_permissions(permission=SqlbotPermission(role=['ws_admin'], keyExpression="id", type='ds')) @system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.DATASOURCE, result_id_expr="id")) diff --git a/backend/apps/datasource/crud/datasource.py b/backend/apps/datasource/crud/datasource.py index 15e77062d..b57b8ee05 100644 --- a/backend/apps/datasource/crud/datasource.py +++ b/backend/apps/datasource/crud/datasource.py @@ -23,8 +23,11 @@ from .table import get_tables_by_ds_id from ..crud.field import delete_field_by_ds_id, update_field from ..crud.table import delete_table_by_ds_id, update_table -from ..models.datasource import CoreDatasource, CreateDatasource, CoreTable, CoreField, ColumnSchema, TableObj, \ - DatasourceConf, TableAndFields +from ..models.datasource import ( + CoreDatasource, CreateDatasource, CoreTable, CoreField, ColumnSchema, TableObj, + DatasourceConf, TableAndFields, + ExportDatasourceItem, ExportTableItem, ExportFieldItem, DatasourceExportPayload, DatasourceImportPayload, +) from ...openapi.models.openapiModels import DatasourceResponse @@ -690,6 +693,178 @@ def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDat return schema_str + +def export_datasources( + session: SessionDep, user: CurrentUser, ids: List[int] +) -> DatasourceExportPayload: + """批量导出数据源:基础信息、选中表、表映射关系、标准元数据(表/字段备注等)。""" + current_oid = user.oid if user.oid is not None else 1 + if user.isAdmin: + pass # admin 可导出任意 oid 的数据源,由调用方传 ids + items = [] + for ds_id in ids: + ds = session.exec(select(CoreDatasource).where(CoreDatasource.id == ds_id)).first() + if not ds or (ds.oid != current_oid and not user.isAdmin): + continue + tables = session.query(CoreTable).filter(CoreTable.ds_id == ds_id).order_by(CoreTable.table_name).all() + table_items = [] + for t in tables: + fields = session.query(CoreField).filter(CoreField.table_id == t.id).order_by(CoreField.field_index).all() + field_items = [ + ExportFieldItem( + id=f.id, + field_name=f.field_name or "", + field_type=f.field_type or "", + field_comment=f.field_comment or "", + custom_comment=f.custom_comment or "", + checked=f.checked, + field_index=f.field_index, + ) + for f in fields + ] + table_items.append( + ExportTableItem( + id=t.id, + table_name=t.table_name or "", + table_comment=t.table_comment or "", + custom_comment=t.custom_comment or "", + checked=t.checked, + fields=field_items, + ) + ) + ds_dict = { + "name": ds.name, + "description": ds.description or "", + "type": ds.type, + "type_name": ds.type_name or "", + "configuration": ds.configuration, + "status": ds.status or "Success", + "num": ds.num or "0/0", + "recommended_config": ds.recommended_config or 1, + } + items.append( + ExportDatasourceItem( + version=1, + datasource=ds_dict, + tables=table_items, + table_relation=copy.deepcopy(ds.table_relation) if ds.table_relation else [], + ) + ) + return DatasourceExportPayload(datasources=items) + + +def _apply_import_to_ds( + session: SessionDep, + new_ds: CoreDatasource, + item: ExportDatasourceItem, +) -> None: + """对已存在或新创建的数据源应用导入的表/字段/表关系。""" + new_tables = session.query(CoreTable).filter(CoreTable.ds_id == new_ds.id).order_by(CoreTable.table_name).all() + old_to_new_table_id = {} + old_to_new_field_id = {} + for exp_t in item.tables: + new_t = next((x for x in new_tables if x.table_name == exp_t.table_name), None) + if not new_t: + continue + old_to_new_table_id[exp_t.id] = new_t.id + new_fields = session.query(CoreField).filter(CoreField.table_id == new_t.id).order_by(CoreField.field_index).all() + for exp_f in exp_t.fields: + new_f = next((x for x in new_fields if x.field_name == exp_f.field_name), None) + if new_f: + old_to_new_field_id[exp_f.id] = new_f.id + if exp_f.custom_comment: + new_f.custom_comment = exp_f.custom_comment + session.add(new_f) + session.commit() + + if item.table_relation: + new_relation = copy.deepcopy(item.table_relation) + for rel in new_relation: + if rel.get("shape") != "edge": + continue + for key, mapping in [("source", old_to_new_table_id), ("target", old_to_new_table_id)]: + cell = (rel.get(key) or {}).get("cell") + if cell is not None and cell in mapping: + rel.setdefault(key, {})["cell"] = mapping[cell] + for key, mapping in [("source", old_to_new_field_id), ("target", old_to_new_field_id)]: + port = (rel.get(key) or {}).get("port") + if port is not None and port in mapping: + rel.setdefault(key, {})["port"] = mapping[port] + new_ds.table_relation = new_relation + session.add(new_ds) + session.commit() + updateNum(session, new_ds) + run_save_ds_embeddings([new_ds.id]) + + +@clear_cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.DS_ID_LIST, keyExpression="user.oid") +async def import_datasources( + session: SessionDep, trans: Trans, user: CurrentUser, payload: DatasourceImportPayload +) -> List[CoreDatasource]: + """批量导入数据源:合并模式——同名称则更新,否则新建;不因重复报错。""" + current_oid = user.oid if user.oid is not None else 1 + result = [] + for item in payload.datasources: + if not item.datasource or not item.datasource.get("name"): + continue + ds_dict = item.datasource + name = ds_dict["name"] + tables_payload = [ + CoreTable( + table_name=t.table_name, + table_comment=t.table_comment or "", + custom_comment=t.custom_comment or "", + ) + for t in item.tables + ] + + existing = session.exec( + select(CoreDatasource).where( + and_(CoreDatasource.name == name, CoreDatasource.oid == current_oid) + ) + ).first() + + if existing: + try: + existing.description = ds_dict.get("description") or "" + existing.type = ds_dict.get("type") or "mysql" + existing.type_name = DB.get_db(existing.type).db_name + existing.configuration = ds_dict.get("configuration") or "" + existing.status = ds_dict.get("status") or "Success" + existing.recommended_config = ds_dict.get("recommended_config") or 1 + session.add(existing) + session.commit() + clear_ds_engine_cache(existing.id) + sync_table(session, existing, tables_payload) + session.refresh(existing) + _apply_import_to_ds(session, existing, item) + session.refresh(existing) + result.append(existing) + except Exception as e: + SQLBotLogUtil.warning(f"import_datasources merge ds {name}: {e}") + continue + else: + try: + create_ds_obj = CreateDatasource( + name=name, + description=ds_dict.get("description") or "", + type=ds_dict.get("type") or "mysql", + configuration=ds_dict.get("configuration") or "", + status=ds_dict.get("status") or "Success", + num=ds_dict.get("num") or "0/0", + recommended_config=ds_dict.get("recommended_config") or 1, + tables=tables_payload, + ) + new_ds = await create_ds(session, trans, user, create_ds_obj) + _apply_import_to_ds(session, new_ds, item) + session.refresh(new_ds) + result.append(new_ds) + except Exception as e: + SQLBotLogUtil.warning(f"import_datasources create ds {name}: {e}") + continue + return result + + @cache(namespace=CacheNamespace.AUTH_INFO, cacheName=CacheName.DS_ID_LIST, keyExpression="oid") async def get_ws_ds(session, oid) -> list: stmt = select(CoreDatasource.id).distinct().where(CoreDatasource.oid == oid) diff --git a/backend/apps/datasource/models/datasource.py b/backend/apps/datasource/models/datasource.py index 2f5f4a452..41460cf48 100644 --- a/backend/apps/datasource/models/datasource.py +++ b/backend/apps/datasource/models/datasource.py @@ -190,3 +190,43 @@ class PreviewResponse(BaseModel): fields: List | None = [] data: List | None = [] sql: str | None = '' + + +# ---------- 数据源批量导入导出 ---------- +class ExportFieldItem(BaseModel): + """导出用字段项,保留 id 用于导入时表关系重映射""" + id: int = 0 + field_name: str = '' + field_type: str = '' + field_comment: str = '' + custom_comment: str = '' + checked: bool = True + field_index: int = 0 + + +class ExportTableItem(BaseModel): + """导出用表项,保留 id 用于导入时表关系重映射""" + id: int = 0 + table_name: str = '' + table_comment: str = '' + custom_comment: str = '' + checked: bool = True + fields: List[ExportFieldItem] = [] + + +class ExportDatasourceItem(BaseModel): + """单条数据源导出结构:基础信息 + 选中表 + 表映射关系 + 标准元数据""" + version: int = 1 + datasource: dict = {} # name, description, type, type_name, configuration, status, num, recommended_config + tables: List[ExportTableItem] = [] + table_relation: List = [] # 与 CoreDatasource.table_relation 一致,含 source/target cell(表id) port(字段id) + + +class DatasourceExportPayload(BaseModel): + """批量导出请求/响应""" + datasources: List[ExportDatasourceItem] = [] + + +class DatasourceImportPayload(BaseModel): + """批量导入请求体""" + datasources: List[ExportDatasourceItem] = [] diff --git a/backend/apps/system/api/aimodel.py b/backend/apps/system/api/aimodel.py index ce37229e2..af50764ec 100644 --- a/backend/apps/system/api/aimodel.py +++ b/backend/apps/system/api/aimodel.py @@ -1,11 +1,15 @@ import json -from typing import List, Union +from typing import List, Union, Optional from fastapi.responses import StreamingResponse +from fastapi import APIRouter, Path, Query, Body +from pydantic import BaseModel from apps.ai_model.model_factory import LLMConfig, LLMFactory from apps.swagger.i18n import PLACEHOLDER_PREFIX -from apps.system.schemas.ai_model_schema import AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem -from fastapi import APIRouter, Path, Query +from apps.system.schemas.ai_model_schema import ( + AiModelConfigItem, AiModelCreator, AiModelEditor, AiModelGridItem, + AiModelExportItem, AiModelExportPayload, +) from sqlmodel import func, select, update from apps.system.models.system_model import AiModelDetail @@ -95,6 +99,114 @@ async def query( items = session.exec(statement).all() return items + +class AiModelExportRequest(BaseModel): + """批量导出 AI 模型:请求体为要导出的模型 id 列表,空则导出全部""" + ids: Optional[List[int]] = None + + +@router.post("/export", response_model=AiModelExportPayload, summary="批量导出 AI 模型配置", + description="导出 AI 模型配置(含 api_domain/api_key 解密后导出,便于迁移或备份)。") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +async def export_aimodels( + session: SessionDep, + body: AiModelExportRequest = Body(default=None), +): + ids = body.ids if body and body.ids else None + if ids is not None and len(ids) == 0: + items = [] + else: + stmt = select(AiModelDetail).order_by(AiModelDetail.default_model.desc(), AiModelDetail.name) + if ids: + stmt = stmt.where(AiModelDetail.id.in_(ids)) + rows = session.exec(stmt).all() + items = [] + for db_model in rows: + config_list: List[AiModelConfigItem] = [] + if db_model.config: + try: + raw = json.loads(db_model.config) + config_list = [AiModelConfigItem(**item) for item in raw] + except Exception: + pass + try: + api_key = await sqlbot_decrypt(db_model.api_key) if db_model.api_key else "" + api_domain = await sqlbot_decrypt(db_model.api_domain) if db_model.api_domain else "" + except Exception: + api_key = db_model.api_key or "" + api_domain = db_model.api_domain or "" + items.append( + AiModelExportItem( + name=db_model.name, + model_type=db_model.model_type, + base_model=db_model.base_model, + supplier=db_model.supplier, + protocol=db_model.protocol, + default_model=db_model.default_model, + api_domain=api_domain, + api_key=api_key or "", + config_list=config_list, + ) + ) + return AiModelExportPayload(version=1, models=items) + + +@router.post("/import", summary="批量导入 AI 模型配置", + description="从导出的 JSON 批量导入,合并模式:同名称则更新,否则新建,不报错。") +@require_permissions(permission=SqlbotPermission(role=['admin'])) +@system_log(LogConfig(operation_type=OperationType.CREATE, module=OperationModules.AI_MODEL)) +async def import_aimodels( + session: SessionDep, + body: AiModelExportPayload = Body(...), +): + result = [] + for item in body.models or []: + if not item.name: + continue + try: + existing = session.exec( + select(AiModelDetail).where(AiModelDetail.name == item.name) + ).first() + config_json = json.dumps([c.model_dump(exclude_unset=True) for c in (item.config_list or [])]) + if existing: + existing.model_type = item.model_type + existing.base_model = item.base_model + existing.supplier = item.supplier + existing.protocol = item.protocol + existing.api_domain = item.api_domain or "" + existing.api_key = item.api_key or "" + existing.config = config_json + session.add(existing) + session.commit() + session.refresh(existing) + result.append(existing) + else: + data = { + "name": item.name, + "model_type": item.model_type, + "base_model": item.base_model, + "supplier": item.supplier, + "protocol": item.protocol, + "default_model": False, + "api_domain": item.api_domain or "", + "api_key": item.api_key or "", + "config": config_json, + } + detail = AiModelDetail.model_validate(data) + detail.create_time = get_timestamp() + count = session.exec(select(func.count(AiModelDetail.id))).one() + if count == 0: + detail.default_model = True + session.add(detail) + session.commit() + session.refresh(detail) + result.append(detail) + except Exception as e: + SQLBotLogUtil.warning(f"import_aimodels skip {item.name}: {e}") + continue + return result + + @router.get("/{id}", response_model=AiModelEditor, summary=f"{PLACEHOLDER_PREFIX}system_model_query", description=f"{PLACEHOLDER_PREFIX}system_model_query") @require_permissions(permission=SqlbotPermission(role=['admin'])) async def get_model_by_id( @@ -170,9 +282,6 @@ async def delete_model( ): item = session.get(AiModelDetail, id) if item.default_model: - raise Exception(trans('i18n_llm.delete_default_error', key = item.name)) + raise Exception(trans('i18n_llm.delete_default_error', key=item.name)) session.delete(item) session.commit() - - - \ No newline at end of file diff --git a/backend/apps/system/api/workspace.py b/backend/apps/system/api/workspace.py index 0c909bb1c..040027731 100644 --- a/backend/apps/system/api/workspace.py +++ b/backend/apps/system/api/workspace.py @@ -1,6 +1,7 @@ -from typing import Optional -from fastapi import APIRouter, HTTPException, Path, Query -from sqlmodel import exists, or_, select, delete as sqlmodel_delete, update as sqlmodel_update +from typing import Optional, List +from fastapi import APIRouter, HTTPException, Path, Query, Body +from pydantic import BaseModel +from sqlmodel import exists, or_, select, delete as sqlmodel_delete, update as sqlmodel_update from apps.swagger.i18n import PLACEHOLDER_PREFIX from apps.system.crud.user import clean_user_cache from apps.system.crud.workspace import reset_single_user_oid, reset_user_oid @@ -17,6 +18,106 @@ router = APIRouter(tags=["system_ws"], prefix="/system/workspace") + +class MemberExportItem(BaseModel): + uid: int + account: str + name: str + weight: int + + +class MemberExportPayload(BaseModel): + version: int = 1 + oid: int = 0 + members: List[MemberExportItem] = [] + + +class MemberImportItem(BaseModel): + account: str + weight: int = 0 + + +class MemberImportPayload(BaseModel): + members: List[MemberImportItem] = [] + + +@router.post("/uws/export", response_model=MemberExportPayload, summary="全部导出成员", + description="导出当前工作空间全部成员(账号、姓名、权重)。") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +async def uws_export_all( + session: SessionDep, + current_user: CurrentUser, + trans: Trans, + oid: Optional[int] = Query(None, description="空间ID(仅admin生效)"), +): + if current_user.isAdmin and oid is not None: + workspace_id = oid + else: + workspace_id = current_user.oid or 1 + stmt = ( + select(UserModel.id, UserModel.account, UserModel.name, UserWsModel.weight) + .join(UserWsModel, UserModel.id == UserWsModel.uid) + .where(UserWsModel.oid == workspace_id, UserModel.id != 1) + .order_by(UserModel.account) + ) + rows = session.exec(stmt).all() + members = [ + MemberExportItem(uid=r[0], account=r[1] or "", name=r[2] or "", weight=r[3] or 0) + for r in rows + ] + return MemberExportPayload(version=1, oid=workspace_id, members=members) + + +@router.post("/uws/import", response_model=List[MemberExportItem], summary="批量导入成员", + description="合并导入:按账号匹配,存在则更新权重,不存在则加入工作空间。") +@require_permissions(permission=SqlbotPermission(role=['ws_admin'])) +@system_log(LogConfig(operation_type=OperationType.ADD, module=OperationModules.MEMBER)) +async def uws_import( + session: SessionDep, + current_user: CurrentUser, + trans: Trans, + body: MemberImportPayload = Body(...), + oid: Optional[int] = Query(None, description="空间ID(仅admin生效)"), +): + if current_user.isAdmin and oid is not None: + workspace_id = oid + else: + workspace_id = current_user.oid or 1 + result = [] + for item in body.members or []: + if not item.account or not item.account.strip(): + continue + account = item.account.strip() + user = session.exec(select(UserModel).where(UserModel.account == account, UserModel.id != 1)).first() + if not user: + continue + uid = user.id + existing = session.exec( + select(UserWsModel).where(UserWsModel.uid == uid, UserWsModel.oid == workspace_id) + ).first() + try: + if existing: + existing.weight = item.weight + session.add(existing) + session.commit() + await clean_user_cache(uid) + else: + session.add( + UserWsModel(oid=workspace_id, uid=uid, weight=item.weight) + ) + session.commit() + await reset_single_user_oid(session, uid, workspace_id) + await clean_user_cache(uid) + u = session.get(UserModel, uid) + result.append( + MemberExportItem(uid=uid, account=u.account or "", name=u.name or "", weight=item.weight) + ) + except Exception: + session.rollback() + continue + return result + + @router.get("/uws/option/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[UserWsOption], summary=f"{PLACEHOLDER_PREFIX}ws_user_grid_api", description=f"{PLACEHOLDER_PREFIX}ws_user_grid_api") @require_permissions(permission=SqlbotPermission(role=['ws_admin'])) async def option_pager( diff --git a/backend/apps/system/schemas/ai_model_schema.py b/backend/apps/system/schemas/ai_model_schema.py index 019aa358b..a47e2427d 100644 --- a/backend/apps/system/schemas/ai_model_schema.py +++ b/backend/apps/system/schemas/ai_model_schema.py @@ -27,4 +27,15 @@ class AiModelCreator(AiModelItem): config_list: List[AiModelConfigItem] = Field(description=f"{PLACEHOLDER_PREFIX}config_list") class AiModelEditor(AiModelCreator, BaseCreatorDTO): - pass \ No newline at end of file + pass + + +class AiModelExportItem(AiModelCreator): + """单条 AI 模型导出结构,用于批量导入导出(不含 id)。""" + pass + + +class AiModelExportPayload(BaseModel): + """AI 模型批量导出响应 / 导入请求""" + version: int = 1 + models: List[AiModelExportItem] = [] \ No newline at end of file diff --git a/frontend/src/api/datasource.ts b/frontend/src/api/datasource.ts index 89e205d53..ca8b9ad51 100644 --- a/frontend/src/api/datasource.ts +++ b/frontend/src/api/datasource.ts @@ -41,4 +41,10 @@ export const datasourceApi = { responseType: 'blob', requestOptions: { customError: true }, }), + /** 批量导出数据源(基础信息、选中表、表映射、元数据) */ + exportBatch: (ids: number[]) => + request.post<{ datasources: any[] }>('/datasource/export', { ids }), + /** 批量导入数据源 */ + importBatch: (payload: { datasources: any[] }) => + request.post('/datasource/import', payload), } diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index f9ed3ec10..27040f6cc 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -29,4 +29,10 @@ export const modelApi = { check: (data: any) => request.fetchStream('/system/aimodel/status', data), platform: (id: number) => request.get(`/system/platform/org/${id}`), userSync: (data: any) => request.post(`/system/platform/user/sync`, data), + /** 批量导出 AI 模型配置(ids 为空则导出全部) */ + exportBatch: (ids?: number[]) => + request.post<{ version: number; models: any[] }>('/system/aimodel/export', { ids: ids ?? null }), + /** 批量导入 AI 模型配置 */ + importBatch: (payload: { version?: number; models: any[] }) => + request.post('/system/aimodel/import', payload), } diff --git a/frontend/src/api/workspace.ts b/frontend/src/api/workspace.ts index 5298045b5..91e4ec1ee 100644 --- a/frontend/src/api/workspace.ts +++ b/frontend/src/api/workspace.ts @@ -15,3 +15,14 @@ export const workspaceDelete = (id: any) => request.delete(`/system/workspace/${ export const workspaceList = () => request.get('/system/workspace') export const workspaceDetail = (id: any) => request.get(`/system/workspace/${id}`) export const uwsOption = (params: any) => request.get('system/workspace/uws/option', { params }) + +/** 全部导出当前工作空间成员 */ +export const workspaceUwsExport = (params?: { oid?: number }) => + request.post<{ version: number; oid: number; members: { uid: number; account: string; name: string; weight: number }[] }>( + '/system/workspace/uws/export', + {}, + { params } + ) +/** 批量导入成员(合并:按账号更新或新增) */ +export const workspaceUwsImport = (payload: { members: { account: string; weight?: number }[] }, params?: { oid?: number }) => + request.post('/system/workspace/uws/import', payload, { params }) diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a9b2b920d..2fe8fb3f1 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -362,6 +362,13 @@ "upload_format_hint": "Supports xlsx, xls, csv", "add": "Add Data Source", "delete": "Delete Data Source", + "batch_export": "Export All", + "batch_import": "Batch Import", + "batch_export_empty": "No datasources to export", + "batch_export_success": "Export successful", + "batch_import_success": "Imported {count} datasource(s) successfully", + "batch_import_invalid": "Invalid import file, please use exported JSON", + "batch_import_failed": "Import failed", "name": "Data Source Name", "type": "Data Source Type", "status": "Status", @@ -528,6 +535,13 @@ "system_default_model_de": "System default model", "relevant_results_found": "No relevant results found", "add_model": "Add model", + "batch_export": "Export All", + "batch_import": "Batch Import", + "batch_export_empty": "No models to export", + "batch_export_success": "Export successful", + "batch_import_success": "Imported {count} model(s) successfully", + "batch_import_invalid": "Invalid import file, please use exported JSON", + "batch_import_failed": "Import failed", "select_supplier": "Select supplier", "the_basic_model": "Please set a name for the basic model", "the_basic_model_de": "Please select the basic model", @@ -646,6 +660,11 @@ "id_account_to_add": "Search name/account", "find_user": "Find user", "add_successfully": "Add successfully", + "export_all": "Export All", + "batch_import": "Batch Import", + "batch_import_success": "Imported {count} member(s) successfully", + "batch_import_invalid": "Invalid import file, please use exported JSON", + "batch_import_failed": "Import failed", "member_management": "Member management", "permission_configuration": "Permission Config", "set": "Settings", @@ -653,6 +672,11 @@ }, "permission": { "search_rule_group": "Search rule group", + "export_all": "Export All", + "batch_import": "Batch Import", + "batch_import_success": "Imported {count} permission rule(s) successfully", + "batch_import_invalid": "Invalid import file, please use exported JSON", + "batch_import_failed": "Import failed", "add_rule_group": "Add rule group", "permission_rule": "Permission rules", "restricted_user": "Restricted user", diff --git a/frontend/src/i18n/zh-CN.json b/frontend/src/i18n/zh-CN.json index c702eed3d..7f226b27d 100644 --- a/frontend/src/i18n/zh-CN.json +++ b/frontend/src/i18n/zh-CN.json @@ -363,6 +363,13 @@ "local_excelcsv": "本地 Excel/CSV", "add": "添加数据源", "delete": "删除数据源", + "batch_export": "全部导出", + "batch_import": "批量导入", + "batch_export_empty": "当前没有可导出的数据源", + "batch_export_success": "导出成功", + "batch_import_success": "成功导入 {count} 个数据源", + "batch_import_invalid": "导入文件格式无效,请使用导出的 JSON 文件", + "batch_import_failed": "导入失败", "name": "数据源名称", "type": "数据源类型", "status": "状态", @@ -528,6 +535,13 @@ "system_default_model_de": "系统默认模型", "relevant_results_found": "没有找到相关结果", "add_model": "添加模型", + "batch_export": "全部导出", + "batch_import": "批量导入", + "batch_export_empty": "当前没有可导出的模型", + "batch_export_success": "导出成功", + "batch_import_success": "成功导入 {count} 个模型配置", + "batch_import_invalid": "导入文件格式无效,请使用导出的 JSON 文件", + "batch_import_failed": "导入失败", "select_supplier": "选择供应商", "the_basic_model": "请给基础模型设置一个名称", "the_basic_model_de": "请选择基础模型", @@ -647,12 +661,22 @@ "find_user": "查找用户", "add_successfully": "添加成功", "member_management": "成员管理", + "export_all": "全部导出", + "batch_import": "批量导入", + "batch_import_success": "成功导入 {count} 个成员", + "batch_import_invalid": "导入文件格式无效,请使用导出的 JSON 文件", + "batch_import_failed": "导入失败", "permission_configuration": "权限配置", "set": "设置", "operate_with_caution": "删除后,该工作空间下的用户将被移除,所有资源也将被删除,请谨慎操作。" }, "permission": { "search_rule_group": "搜索规则组", + "export_all": "全部导出", + "batch_import": "批量导入", + "batch_import_success": "成功导入 {count} 条权限规则", + "batch_import_invalid": "导入文件格式无效,请使用导出的 JSON 文件", + "batch_import_failed": "导入失败", "add_rule_group": "添加规则组", "permission_rule": "权限规则", "restricted_user": "受限用户", diff --git a/frontend/src/views/ds/Datasource.vue b/frontend/src/views/ds/Datasource.vue index 8b0521f38..b736bec88 100644 --- a/frontend/src/views/ds/Datasource.vue +++ b/frontend/src/views/ds/Datasource.vue @@ -199,6 +199,8 @@ const dataTableDetail = (ele: any) => { } const selectedIds = ref([]) +const exportLoading = ref(false) +const importFileRef = ref(null) const hasSelection = computed(() => selectedIds.value.length > 0) @@ -277,6 +279,67 @@ const back = () => { currentDataTable.value = null } +const exportBatch = () => { + const ids = (datasourceList.value || []).map((d: any) => d.id).filter(Boolean) + if (!ids.length) { + ElMessage.warning(t('ds.batch_export_empty')) + return + } + exportLoading.value = true + datasourceApi + .exportBatch(ids) + .then((res) => { + const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `datasources_export_${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + ElMessage.success(t('ds.batch_export_success')) + }) + .finally(() => { + exportLoading.value = false + }) +} + +const triggerImportFile = () => { + importFileRef.value?.click() +} + +const onImportFile = (e: Event) => { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = () => { + try { + const payload = JSON.parse(reader.result as string) + if (!payload.datasources || !Array.isArray(payload.datasources)) { + ElMessage.error(t('ds.batch_import_invalid')) + return + } + searchLoading.value = true + datasourceApi + .importBatch(payload) + .then((created) => { + ElMessage.success(t('ds.batch_import_success', { count: created?.length ?? 0 })) + search() + }) + .catch((err) => { + ElMessage.error(err?.message || t('ds.batch_import_failed')) + }) + .finally(() => { + searchLoading.value = false + }) + } catch { + ElMessage.error(t('ds.batch_import_invalid')) + } + input.value = '' + } + reader.readAsText(file) +} + useEmitt({ name: 'ds-index-click', callback: back, @@ -343,6 +406,19 @@ useEmitt({ + + {{ $t('ds.batch_export') }} + + + {{ $t('ds.batch_import') }} + + + + {{ $t('workspace.export_all') }} + + + {{ $t('workspace.batch_import') }} + +