diff --git a/backend/alembic/versions/010_upgrade_user_language.py b/backend/alembic/versions/010_upgrade_user_language.py index cca99de1..d05e94bb 100644 --- a/backend/alembic/versions/010_upgrade_user_language.py +++ b/backend/alembic/versions/010_upgrade_user_language.py @@ -20,7 +20,7 @@ depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column('sys_user', sa.Column('language', sa.VARCHAR(length=255), server_default=sa.text("'zh-CN'::character varying"), nullable=False)) + op.add_column('sys_user', sa.Column('language', sa.VARCHAR(length=255), server_default=sa.text("'zh-CN'"), nullable=False)) # ### end Alembic commands ### diff --git a/backend/alembic/versions/018_modify_chat.py b/backend/alembic/versions/018_modify_chat.py index 07a3e8ff..24b6f983 100644 --- a/backend/alembic/versions/018_modify_chat.py +++ b/backend/alembic/versions/018_modify_chat.py @@ -23,8 +23,8 @@ def upgrade(): existing_type=sa.INTEGER(), type_=sa.BigInteger(), existing_nullable=False, - autoincrement=True, - existing_server_default=sa.Identity(always=True, start=1, increment=1, minvalue=1, maxvalue=2147483647, cycle=False, cache=1)) + autoincrement=True + ) op.alter_column('chat', 'datasource', existing_type=sa.INTEGER(), type_=sa.BigInteger(), @@ -33,8 +33,7 @@ def upgrade(): existing_type=sa.INTEGER(), type_=sa.BigInteger(), existing_nullable=False, - autoincrement=True, - existing_server_default=sa.Identity(always=True, start=1, increment=1, minvalue=1, maxvalue=2147483647, cycle=False, cache=1)) + autoincrement=True) op.alter_column('chat_record', 'chat_id', existing_type=sa.INTEGER(), type_=sa.BigInteger(), @@ -68,8 +67,7 @@ def downgrade(): existing_type=sa.BigInteger(), type_=sa.INTEGER(), existing_nullable=False, - autoincrement=True, - existing_server_default=sa.Identity(always=True, start=1, increment=1, minvalue=1, maxvalue=2147483647, cycle=False, cache=1)) + autoincrement=True) op.alter_column('chat', 'datasource', existing_type=sa.BigInteger(), type_=sa.INTEGER(), @@ -78,6 +76,5 @@ def downgrade(): existing_type=sa.BigInteger(), type_=sa.INTEGER(), existing_nullable=False, - autoincrement=True, - existing_server_default=sa.Identity(always=True, start=1, increment=1, minvalue=1, maxvalue=2147483647, cycle=False, cache=1)) + autoincrement=True) # ### end Alembic commands ### diff --git a/backend/alembic/versions/030_permission_oid.py b/backend/alembic/versions/030_permission_oid.py index f1610b7f..d7e6831c 100644 --- a/backend/alembic/versions/030_permission_oid.py +++ b/backend/alembic/versions/030_permission_oid.py @@ -24,9 +24,7 @@ def upgrade(): existing_type=sa.INTEGER(), type_=sa.BigInteger(), existing_nullable=False, - autoincrement=True, - existing_server_default=sa.Identity(always=True, start=1, increment=1, minvalue=1, - maxvalue=2147483647, cycle=False, cache=1)) + autoincrement=True) # ### end Alembic commands ### @@ -36,8 +34,6 @@ def downgrade(): existing_type=sa.BigInteger(), type_=sa.INTEGER(), existing_nullable=False, - autoincrement=True, - existing_server_default=sa.Identity(always=True, start=1, increment=1, minvalue=1, - maxvalue=2147483647, cycle=False, cache=1)) + autoincrement=True) op.drop_column('ds_rules', 'oid') # ### end Alembic commands ### 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/apps/system/api/user.py b/backend/apps/system/api/user.py index 825679cf..be616134 100644 --- a/backend/apps/system/api/user.py +++ b/backend/apps/system/api/user.py @@ -1,6 +1,7 @@ +from collections import defaultdict from typing import Optional from fastapi import APIRouter, HTTPException, Query -from sqlmodel import func, or_, select, delete as sqlmodel_delete +from sqlmodel import SQLModel, func, or_, select, delete as sqlmodel_delete from apps.system.crud.user import check_account_exists, check_email_exists, check_email_format, check_pwd_format, get_db_user, single_delete, user_ws_options from apps.system.models.system_model import UserWsModel, WorkspaceModel from apps.system.models.user import UserModel @@ -11,6 +12,7 @@ from common.core.pagination import Paginator from common.core.schemas import PaginatedResponse, PaginationParams from common.core.security import default_md5_pwd, md5pwd, verify_md5pwd from common.core.sqlbot_cache import clear_cache + router = APIRouter(tags=["user"], prefix="/user") @router.get("/info") @@ -30,43 +32,15 @@ async def pager( pagination = PaginationParams(page=pageNum, size=pageSize) paginator = Paginator(session) filters = {} - - stmt = ( - select( - UserModel, - func.coalesce( - func.array_remove( - func.array_agg(UserWsModel.oid), - None - ), - [] - ).label("oid_list") - #func.coalesce(func.string_agg(WorkspaceModel.name, ','), '').label("space_name") - ) - .join(UserWsModel, UserModel.id == UserWsModel.uid, isouter=True) - #.join(WorkspaceModel, UserWsModel.oid == WorkspaceModel.id, isouter=True) - .where(UserModel.id != 1) - .group_by(UserModel.id) - .order_by(UserModel.create_time) - ) - if status is not None: - stmt = stmt.where(UserModel.status == status) + origin_stmt = select(UserModel.id).join(UserWsModel, UserModel.id == UserWsModel.uid).where(UserModel.id != 1).distinct() if oidlist: - user_filter = ( - select(UserModel.id) - .join(UserWsModel, UserModel.id == UserWsModel.uid) - .where(UserWsModel.oid.in_(oidlist)) - .distinct() - ) - stmt = stmt.where(UserModel.id.in_(user_filter)) - - """ if origins is not None: - stmt = stmt.where(UserModel.origin == origins) """ - + origin_stmt = origin_stmt.where(UserWsModel.oid.in_(oidlist)) + if status is not None: + origin_stmt = origin_stmt.where(UserModel.status == status) if keyword: keyword_pattern = f"%{keyword}%" - stmt = stmt.where( + origin_stmt = origin_stmt.where( or_( UserModel.account.ilike(keyword_pattern), UserModel.name.ilike(keyword_pattern), @@ -75,21 +49,47 @@ async def pager( ) user_page = await paginator.get_paginated_response( - stmt=stmt, + stmt=origin_stmt, pagination=pagination, **filters) - - """ for item in user_page.items: - space_name: str = item['space_name'] - if space_name and 'i18n_default_workspace' in space_name: - parts = list(map( - lambda x: trans(x) if x == "i18n_default_workspace" else x, - space_name.split(',') - )) - output_str = ','.join(parts) - item['space_name'] = output_str """ + uid_list = [item.get('id') for item in user_page.items] + if not uid_list: + return user_page + stmt = ( + select(UserModel, UserWsModel.oid.label('ws_oid')) + .join(UserWsModel, UserModel.id == UserWsModel.uid, isouter=True) + .where(UserModel.id.in_(uid_list)) + .order_by(UserModel.create_time) + ) + user_workspaces = session.exec(stmt).all() + merged = defaultdict(list) + extra_attrs = {} + + for (user, ws_oid) in user_workspaces: + item = {} + item.update(user.model_dump()) + user_id = item['id'] + merged[user_id].append(ws_oid) + if user_id not in extra_attrs: + extra_attrs[user_id] = {k: v for k, v in item.items() if k != "ws_oid"} + + # 组合结果 + result = [ + {**extra_attrs[user_id], "oid_list": oid_list} + for user_id, oid_list in merged.items() + ] + user_page.items = result return user_page +def format_user_dict(row) -> dict: + result_dict = {} + for item, key in zip(row, row._fields): + if isinstance(item, SQLModel): + result_dict.update(item.model_dump()) + else: + result_dict[key] = item + + return result_dict @router.get("/ws") async def ws_options(session: SessionDep, current_user: CurrentUser, trans: Trans) -> list[UserWs]: return await user_ws_options(session, current_user.id, trans) diff --git a/backend/common/core/config.py b/backend/common/core/config.py index fa488d1a..6161aacb 100644 --- a/backend/common/core/config.py +++ b/backend/common/core/config.py @@ -44,11 +44,13 @@ class Settings(BaseSettings): self.FRONTEND_HOST ] - POSTGRES_SERVER: str + POSTGRES_SERVER: str = 'localhost' POSTGRES_PORT: int = 5432 - POSTGRES_USER: str - POSTGRES_PASSWORD: str = "" - POSTGRES_DB: str = "" + POSTGRES_USER: str = 'root' + POSTGRES_PASSWORD: str = "123456" + POSTGRES_DB: str = "sqlbot" + SQLBOT_DB_URL: str = '' + #SQLBOT_DB_URL: str = 'mysql+pymysql://root:Password123%40mysql@127.0.0.1:3306/sqlbot' TOKEN_KEY: str = "X-SQLBOT-TOKEN" DEFAULT_PWD: str = "SQLBot@123456" @@ -64,7 +66,9 @@ class Settings(BaseSettings): @computed_field # type: ignore[prop-decorator] @property - def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn | str: + if self.SQLBOT_DB_URL: + return self.SQLBOT_DB_URL return MultiHostUrl.build( scheme="postgresql+psycopg", username=self.POSTGRES_USER, @@ -75,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/common/core/pagination.py b/backend/common/core/pagination.py index 5423c7ec..ab791147 100644 --- a/backend/common/core/pagination.py +++ b/backend/common/core/pagination.py @@ -12,6 +12,8 @@ class Paginator: self.session = session def _process_result_row(self, row: Row) -> Dict[str, Any]: result_dict = {} + if isinstance(row, int): + return {'id': row} for item, key in zip(row, row._fields): if isinstance(item, SQLModel): result_dict.update(item.model_dump()) 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/components/about/index.vue b/frontend/src/components/about/index.vue index ee72bee4..2c258d60 100644 --- a/frontend/src/components/about/index.vue +++ b/frontend/src/components/about/index.vue @@ -6,9 +6,10 @@ import type { F2CLicense } from './index.ts' import { licenseApi } from '@/api/license' import { ElMessage } from 'element-plus-secondary' import { useI18n } from 'vue-i18n' +import { useUserStore } from '@/stores/user.ts' const dialogVisible = ref(false) const { t } = useI18n() -//const userStore = useUserStore() +const userStore = useUserStore() const license: F2CLicense = reactive({ status: '', corporation: '', @@ -22,12 +23,12 @@ const license: F2CLicense = reactive({ }) const tipsSuffix = ref('') const build = ref('') -const isAdmin = ref(true) +const isAdmin = ref(false) const fileList = reactive([]) const dynamicCardClass = ref('') const loading = ref(false) onMounted(() => { - // isAdmin.value = userStore.getUid === '1' + isAdmin.value = userStore.getUid === '1' initVersion() getLicenseInfo() }) diff --git a/frontend/src/entity/supplier.ts b/frontend/src/entity/supplier.ts index 5201a371..ad1cb5b2 100644 --- a/frontend/src/entity/supplier.ts +++ b/frontend/src/entity/supplier.ts @@ -52,7 +52,7 @@ export const supplierList: Array<{ }, { id: 4, - name: '腾讯云', + name: '腾讯混元', icon: icon_txy_colorful, model_config: { 0: { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 0bd732f8..7e2b3e3f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -168,5 +168,5 @@ const router = createRouter({ }, ], }) -await watchRouter(router) +watchRouter(router) export default router diff --git a/frontend/src/router/watch.ts b/frontend/src/router/watch.ts index 15dc3f9b..cc2fedef 100644 --- a/frontend/src/router/watch.ts +++ b/frontend/src/router/watch.ts @@ -6,10 +6,10 @@ const userStore = useUserStore() const { wsCache } = useCache() const whiteList = ['/login'] const assistantWhiteList = ['/assistant'] -export const watchRouter = async (router: any) => { - await loadXpackStatic() - LicenseGenerator.generateRouters(router) +export const watchRouter = (router: any) => { router.beforeEach(async (to: any, from: any, next: any) => { + await loadXpackStatic() + LicenseGenerator.generateRouters(router) if (assistantWhiteList.includes(to.path)) { next() return 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() +} @@ -270,17 +307,48 @@ function copy() { - - - - - - - - - + + + + + + + + + + + + + + + + {{ t('chat.export_to') }} + + + + + {{ t('chat.excel') }} + + + + + + {{ t('chat.picture') }} + + + + - + @@ -313,7 +381,7 @@ function copy() {