diff --git a/backend/apps/chat/api/chat.py b/backend/apps/chat/api/chat.py index 4eeb6245..1d745f92 100644 --- a/backend/apps/chat/api/chat.py +++ b/backend/apps/chat/api/chat.py @@ -1,12 +1,16 @@ +import datetime import traceback +import numpy as np +import pandas as pd from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from apps.chat.curd.chat import list_chats, get_chat_with_records, create_chat, rename_chat, \ delete_chat, get_chat_chart_data, get_chat_predict_data -from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, ChatQuestion +from apps.chat.models.chat_model import CreateChat, ChatRecord, RenameChat, ChatQuestion, ExcelData from apps.chat.task.llm import LLMService +from common.core.config import settings from common.core.deps import CurrentAssistant, SessionDep, CurrentUser router = APIRouter(tags=["Data Q&A"], prefix="/chat") @@ -183,3 +187,27 @@ async def analysis_or_predict(session: SessionDep, current_user: CurrentUser, ch ) return StreamingResponse(llm_service.await_result(), media_type="text/event-stream") + + +@router.post("/excel/export") +async def export_excel(excel_data: ExcelData): + _fields_list = [] + data = [] + for _data in excel_data.data: + _row = [] + for field in excel_data.axis: + _row.append(_data.get(field.value)) + data.append(_row) + for field in excel_data.axis: + _fields_list.append(field.name) + df = pd.DataFrame(np.array(data), columns=_fields_list) + + file_name = f"{excel_data.name}-{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.xlsx" + + file_path = f'{(settings.EXCEL_PATH if settings.EXCEL_PATH[-1] == "/" else (settings.EXCEL_PATH + "/"))}{file_name}' + + file_download_path = f'{(settings.SERVER_EXCEL_HOST if settings.SERVER_EXCEL_HOST[-1] == "/" else (settings.SERVER_EXCEL_HOST + "/"))}{file_name}' + + df.to_excel(file_path, index=False) + + return file_download_path diff --git a/backend/apps/chat/models/chat_model.py b/backend/apps/chat/models/chat_model.py index c909d6a4..07dae5e2 100644 --- a/backend/apps/chat/models/chat_model.py +++ b/backend/apps/chat/models/chat_model.py @@ -180,3 +180,15 @@ class McpQuestion(BaseModel): question: str = Body(description='用户提问') chat_id: int = Body(description='会话ID') token: str = Body(description='token') + + +class AxisObj(BaseModel): + name: str = '' + value: str = '' + type: str | None = None + + +class ExcelData(BaseModel): + axis: list[AxisObj] = [] + data: list[dict] = [] + name: str = 'Excel' diff --git a/backend/apps/datasource/crud/datasource.py b/backend/apps/datasource/crud/datasource.py index e4309ffd..8ab2687b 100644 --- a/backend/apps/datasource/crud/datasource.py +++ b/backend/apps/datasource/crud/datasource.py @@ -4,10 +4,9 @@ from typing import List, Optional from fastapi import HTTPException from sqlalchemy import and_, text, func - from sqlmodel import select - +from apps.datasource.crud.permission import get_column_permission_fields, get_row_permission_filters, is_normal_user from apps.datasource.utils.utils import aes_decrypt from apps.db.constant import DB from apps.db.db import get_engine, get_tables, get_fields, exec_sql @@ -20,7 +19,7 @@ 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 apps.datasource.crud.permission import get_column_permission_fields, get_row_permission_filters, is_normal_user + def get_datasource_list(session: SessionDep, user: CurrentUser, oid: Optional[int] = None) -> List[CoreDatasource]: current_oid = user.oid if user.oid is not None else 1 @@ -249,11 +248,13 @@ def preview(session: SessionDep, current_user: CurrentUser, id: int, data: Table f_list = [f for f in data.fields if f.checked] if is_normal_user(current_user): # column is checked, and, column permission for data.fields - f_list = get_column_permission_fields(session=session, current_user=current_user, table=data.table, fields=f_list) or f_list + f_list = get_column_permission_fields(session=session, current_user=current_user, table=data.table, + fields=f_list) or f_list # row permission tree where_str = '' - filter_mapping = get_row_permission_filters(session=session, current_user=current_user, ds=ds, tables=None, single_table=data.table) + filter_mapping = get_row_permission_filters(session=session, current_user=current_user, ds=ds, tables=None, + single_table=data.table) if filter_mapping: mapping_dict = filter_mapping[0] where_str = mapping_dict.get('filter') @@ -325,13 +326,12 @@ def get_table_obj_by_ds(session: SessionDep, current_user: CurrentUser, ds: Core fields = session.query(CoreField).filter(and_(CoreField.table_id == table.id, CoreField.checked == True)).all() # do column permissions, filter fields - fields = get_column_permission_fields(session=session, current_user=current_user, table=table, fields=fields) or fields + fields = get_column_permission_fields(session=session, current_user=current_user, table=table, + fields=fields) or fields _list.append(TableAndFields(schema=schema, table=table, fields=fields)) return _list - - def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDatasource) -> str: schema_str = "" table_objs = get_table_obj_by_ds(session=session, current_user=current_user, ds=ds) @@ -360,4 +360,5 @@ def get_table_schema(session: SessionDep, current_user: CurrentUser, ds: CoreDat field_list.append(f"({field.field_name}:{field.field_type}, {field_comment})") schema_str += ",\n".join(field_list) schema_str += '\n]\n' + # todo 外键 return schema_str diff --git a/backend/common/core/config.py b/backend/common/core/config.py index 1777fb9c..6161aacb 100644 --- a/backend/common/core/config.py +++ b/backend/common/core/config.py @@ -79,8 +79,10 @@ class Settings(BaseSettings): ) MCP_IMAGE_PATH: str = '/opt/sqlbot/images' + EXCEL_PATH: str MCP_IMAGE_HOST: str = 'http://localhost:3000' SERVER_IMAGE_HOST: str + SERVER_EXCEL_HOST: str settings = Settings() # type: ignore print(settings) diff --git a/backend/main.py b/backend/main.py index e72c915d..085062db 100644 --- a/backend/main.py +++ b/backend/main.py @@ -44,6 +44,9 @@ app = FastAPI( lifespan=lifespan ) +app.mount("/excel", StaticFiles(directory=settings.EXCEL_PATH), name="excel") + + mcp_app = FastAPI() # mcp server, images path mcp_app.mount("/images", StaticFiles(directory=settings.MCP_IMAGE_PATH), name="images") diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 90df3be0..62ddde24 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -329,4 +329,5 @@ export const chatApi = { return request.fetchStream(`/chat/recommend_questions/${record_id}`, {}, controller) }, checkLLMModel: () => request.get('/system/aimodel/default', { requestOptions: { silent: true } }), + export2Excel: (data: any) => request.post('/chat/excel/export', data), } diff --git a/frontend/src/assets/svg/icon_file-excel_colorful.svg b/frontend/src/assets/svg/icon_file-excel_colorful.svg new file mode 100644 index 00000000..67fde90a --- /dev/null +++ b/frontend/src/assets/svg/icon_file-excel_colorful.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/svg/icon_file-image_colorful.svg b/frontend/src/assets/svg/icon_file-image_colorful.svg new file mode 100644 index 00000000..96b8c691 --- /dev/null +++ b/frontend/src/assets/svg/icon_file-image_colorful.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/views/chat/chat-block/ChartBlock.vue b/frontend/src/views/chat/chat-block/ChartBlock.vue index fa44fe15..e6701211 100644 --- a/frontend/src/views/chat/chat-block/ChartBlock.vue +++ b/frontend/src/views/chat/chat-block/ChartBlock.vue @@ -12,7 +12,9 @@ import ICON_PIE from '@/assets/svg/chart/icon_pie_outlined.svg' import ICON_TABLE from '@/assets/svg/chart/icon_form_outlined.svg' import icon_sql_outlined from '@/assets/svg/icon_sql_outlined.svg' import icon_export_outlined from '@/assets/svg/icon_export_outlined.svg' -// import icon_into_item_outlined from '@/assets/svg/icon_into-item_outlined.svg' +import icon_file_image_colorful from '@/assets/svg/icon_file-image_colorful.svg' +import icon_file_excel_colorful from '@/assets/svg/icon_file-excel_colorful.svg' +import icon_into_item_outlined from '@/assets/svg/icon_into-item_outlined.svg' import icon_window_max_outlined from '@/assets/svg/icon_window-max_outlined.svg' import icon_window_mini_outlined from '@/assets/svg/icon_window-mini_outlined.svg' import icon_copy_outlined from '@/assets/svg/icon_copy_outlined.svg' @@ -20,6 +22,8 @@ import { useI18n } from 'vue-i18n' import SQLComponent from '@/views/chat/component/SQLComponent.vue' import { useAssistantStore } from '@/stores/assistant' import AddViewDashboard from '@/views/dashboard/common/AddViewDashboard.vue' +import html2canvas from 'html2canvas' +import { chatApi } from '@/api/chat' const props = withDefaults( defineProps<{ @@ -55,6 +59,8 @@ const dataObject = computed<{ const assistantStore = useAssistantStore() const isAssistant = computed(() => assistantStore.getAssistant) +const chartId = computed(() => props.message?.record?.id + (props.enlarge ? '-fullscreen' : '')) + const data = computed(() => { if (props.isPredict) { let _list = [] @@ -191,27 +197,27 @@ function showSql() { sqlShow.value = true } -// function addToDashboard() { -// const recordeInfo = { -// id: '1-1', -// data: { -// data: data.value, -// }, -// chart: {}, -// } -// // @ts-expect-error eslint-disable-next-line @typescript-eslint/ban-ts-comment -// const chartBaseInfo = JSON.parse(props.message?.record?.chart) -// recordeInfo['chart'] = { -// type: chartBaseInfo.type, -// title: chartBaseInfo.title, -// columns: chartBaseInfo.columns, -// xAxis: chartBaseInfo.axis?.x ? [chartBaseInfo.axis.x] : [], -// yAxis: chartBaseInfo.axis?.y ? [chartBaseInfo.axis.y] : [], -// series: chartBaseInfo.axis?.series ? [chartBaseInfo.axis.series] : [], -// } -// // @ts-expect-error eslint-disable-next-line @typescript-eslint/ban-ts-comment -// addViewRef.value?.optInit(recordeInfo) -// } +function addToDashboard() { + const recordeInfo = { + id: '1-1', + data: { + data: data.value, + }, + chart: {}, + } + // @ts-expect-error eslint-disable-next-line @typescript-eslint/ban-ts-comment + const chartBaseInfo = JSON.parse(props.message?.record?.chart) + recordeInfo['chart'] = { + type: chartBaseInfo.type, + title: chartBaseInfo.title, + columns: chartBaseInfo.columns, + xAxis: chartBaseInfo.axis?.x ? [chartBaseInfo.axis.x] : [], + yAxis: chartBaseInfo.axis?.y ? [chartBaseInfo.axis.y] : [], + series: chartBaseInfo.axis?.series ? [chartBaseInfo.axis.series] : [], + } + // @ts-expect-error eslint-disable-next-line @typescript-eslint/ban-ts-comment + addViewRef.value?.optInit(recordeInfo) +} function copy() { if (props.message?.record?.sql) { @@ -219,6 +225,37 @@ function copy() { ElMessage.info(t('qa.copied')) } } + +const exportRef = ref() + +function exportToExcel() { + chatApi + .export2Excel({ ...chartRef.value?.getExcelData(), name: chartObject.value.title }) + .then((res) => { + console.log('download_path: ' + res) + //todo download + }) + exportRef.value?.hide() +} +function exportToImage() { + const obj = document.getElementById('chart-component-' + chartId.value) + if (obj) { + html2canvas(obj).then((canvas) => { + canvas.toBlob(function (blob) { + if (blob) { + const link = document.createElement('a') + link.download = (chartObject.value.title ?? 'chart') + '.png' // Specify filename + link.href = URL.createObjectURL(blob) + document.body.appendChild(link) // Append to body to make it clickable + link.click() // Programmatically click the link + document.body.removeChild(link) // Clean up + URL.revokeObjectURL(link.href) // Release the object URL + } + }, 'image/png') + }) + } + exportRef.value?.hide() +}