Merge remote-tracking branch 'origin/main'

# Conflicts:
#	frontend/src/i18n/en.json
#	frontend/src/i18n/zh-CN.json
This commit is contained in:
wangjiahao
2025-06-10 17:59:28 +08:00
37 changed files with 610 additions and 139 deletions
@@ -0,0 +1,33 @@
"""010_upgrade_user_language
Revision ID: 8dc3b1bdbfef
Revises: 804b08ac329d
Create Date: 2025-06-10 11:21:35.257604
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '8dc3b1bdbfef'
down_revision = '804b08ac329d'
branch_labels = None
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))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('sys_user', 'language')
# ### end Alembic commands ###
+2 -1
View File
@@ -1,4 +1,5 @@
import json
import orjson
import traceback
from typing import List
@@ -138,7 +139,7 @@ async def stream_sql(session: SessionDep, current_user: CurrentUser, request_que
# execute sql
result = llm_service.execute_sql(sql=sql)
llm_service.save_sql_data(session=session, data_obj=result)
yield json.dumps({'content': json.dumps(result, ensure_ascii=False), 'type': 'sql-data'}) + '\n\n'
yield json.dumps({'content': orjson.dumps(result).decode(), 'type': 'sql-data'}) + '\n\n'
# generate chart
chart_res = llm_service.generate_chart(session=session)
+2 -1
View File
@@ -1,4 +1,5 @@
import json
import orjson
import logging
import warnings
from typing import Any, List, Union, Dict
@@ -218,7 +219,7 @@ class LLMService:
def save_sql_data(self, session: SessionDep, data_obj: Dict[str, Any]):
return save_sql_exec_data(session=session, record_id=self.record.id,
data=json.dumps(data_obj, ensure_ascii=False))
data=orjson.dumps(data_obj).decode())
def finish(self, session: SessionDep):
return finish_record(session=session, record_id=self.record.id)
+1 -1
View File
@@ -18,7 +18,7 @@ router = APIRouter(tags=["datasource"], prefix="/datasource")
path = "/opt/sqlbot/data/excel"
@router.get("/list")
@router.get("/list", operation_id="get_datasource_list")
async def datasource_list(session: SessionDep):
return get_datasource_list(session=session)
+22 -3
View File
@@ -1,5 +1,7 @@
from fastapi import APIRouter, Depends, Request
from apps.system.crud.user import get_user_info
from apps.system.models.user import user_grid
from apps.system.schemas.system_schema import UserLanguage
from common.core.deps import CurrentUser, SessionDep
from common.core.pagination import Paginator
from common.core.schemas import PaginatedResponse, PaginationParams
@@ -8,8 +10,12 @@ router = APIRouter(tags=["user"], prefix="/user")
@router.get("/info")
async def user_info(current_user: CurrentUser):
return current_user.to_dict()
async def user_info(session: SessionDep, current_user: CurrentUser):
db_user = get_user_info(session=session, user_id=current_user.id)
if not db_user:
return {"message": "User not found"}
db_user.password = None
return db_user
@router.get("/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[user_grid])
@@ -24,4 +30,17 @@ async def pager(
return await paginator.get_paginated_response(
model=user_grid,
pagination=pagination,
**filters)
**filters)
@router.put("/language")
async def langChange(session: SessionDep, current_user: CurrentUser, language: UserLanguage):
lang = language.language
if lang not in ["zh-CN", "en"]:
return {"message": "Language not supported"}
db_user = get_user_info(session=session, user_id=current_user.id)
if not db_user:
return {"message": "User not found"}
db_user.language = lang
session.add(db_user)
session.commit()
return {"message": "Language changed successfully", "language": lang}
+6
View File
@@ -10,6 +10,12 @@ def get_user_by_account(*, session: Session, account: str) -> sys_user | None:
result_user = sys_user.model_validate(session_user)
return result_user
def get_user_info(*, session: Session, user_id: int) -> sys_user | None:
db_user = session.get(user_grid, user_id)
if not db_user:
return None
return db_user
def authenticate(*, session: Session, account: str, password: str) -> sys_user | None:
db_user = get_user_by_account(session=session, account=account)
if not db_user:
+1
View File
@@ -21,4 +21,5 @@ class user_grid(sys_user, table=True):
email: str
status: int
create_time: int
language: str = Field(max_length=255, default="zh-CN")
+5 -1
View File
@@ -2,4 +2,8 @@ from pydantic import BaseModel
class model_status(BaseModel):
status: bool
ids: list[int]
ids: list[int]
class UserLanguage(BaseModel):
language: str
+1 -1
View File
@@ -4,7 +4,7 @@ base_template = None
def load():
with open('./template.yaml', 'r') as f:
with open('./template.yaml', 'r', encoding='utf-8') as f:
global base_template
base_template = yaml.load(f, Loader=yaml.SafeLoader)
+3 -1
View File
@@ -20,7 +20,9 @@ wlist = [
"*.woff2",
"*.ttf",
"*.eot",
"*.otf"
"*.otf",
"/mcp",
"/mcp/message"
]
class WhitelistChecker:
+22 -2
View File
@@ -14,15 +14,20 @@ from common.core.config import settings
from common.core.response_middleware import ResponseMiddleware, exception_handler
from alembic.config import Config
from alembic import command
from fastapi_mcp import FastApiMCP
def run_migrations():
alembic_cfg = Config("alembic.ini")
command.upgrade(alembic_cfg, "head")
@asynccontextmanager
async def lifespan(app: FastAPI):
run_migrations()
yield
def custom_generate_unique_id(route: APIRoute) -> str:
tag = route.tags[0] if route.tags and len(route.tags) > 0 else ""
return f"{tag}-{route.name}"
@@ -38,6 +43,17 @@ app = FastAPI(
lifespan=lifespan
)
mcp_app = FastAPI()
mcp = FastApiMCP(
app,
name="SQLBot MCP Server",
description="SQLBot MCP Server",
include_operations=["get_datasource_list"]
)
mcp.mount(mcp_app)
# Set all CORS enabled origins
if settings.all_cors_origins:
app.add_middleware(
@@ -66,9 +82,13 @@ else:
@app.get("/", include_in_schema=False)
async def read_index():
return FileResponse(path=os.path.join(frontend_dist, "index.html"))
app.mount("/", StaticFiles(directory=frontend_dist), name="static")
mcp.setup_server()
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
+1
View File
@@ -34,6 +34,7 @@ dependencies = [
"psycopg2-binary (>=2.9.10,<3.0.0)",
"oracledb (>=3.1.1,<4.0.0)",
"pyyaml (>=6.0.2,<7.0.0)",
"fastapi-mcp (>=0.3.4,<0.4.0)",
]
[[tool.uv.index]]
url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
+12 -5
View File
@@ -7,8 +7,12 @@ template:
- 生成的SQL必须符合{engine}的规范。
- 根据表结构生成SQL语句。
- SQL查询中不能使用星号(*),必须明确指定字段名.
- SQL查询的字段名和别名必须为英文。
- SQL查询的字段名不要自动翻译,别名必须为英文。
- 计算占比,百分比类型字段,保留两位小数,以%结尾。
- 生成SQL时,必须避免关键字冲突:
- 如{engine}是 PostgreSQL、Oracle,则在schema、表名、字段名、别名外层加双引号;
- 如{engine}是 MySQL,则在表名、字段名、别名外层加反引号;
- 如{engine}是 Microsoft SQL Server,则在schema、表名、字段名、别名外层加方括号。
- 生成的SQL使用JSON格式返回:
{{"success":true,"sql":"生成的SQL语句"}}
- 问题与生成SQL无关时,直接回答:
@@ -16,8 +20,8 @@ template:
- 如果根据提供的表结构不能生成符合问题与条件的SQL,回答:
{{"success":false,"message":"无法生成SQL的原因"}}
- 如果问题是图表展示相关且与生成SQL查询无关时,请参考上一次回答的SQL来生成SQL
- 如果问题是图表展示相关,可参考的图表类型为表格(table)、条形图(bar)、折线图(line)或饼图(pie),返回的JSON:
{{"success":true,"sql":"生成的SQL语句","chart-type":"选择的图表类型(table/bar/line/pie)"}}
- 如果问题是图表展示相关,可参考的图表类型为表格(table)、柱状图(column)、条形图(bar)、折线图(line)或饼图(pie),返回的JSON:
{{"success":true,"sql":"生成的SQL语句","chart-type":"选择的图表类型(table/column/bar/line/pie)"}}
### 请遵守以下输出要求:
请逐步推理后给出答案
@@ -44,14 +48,17 @@ template:
- 如果需要表格,则生成的 JSON 格式应为:
{{"type":"table", "title": "标题", "columns": [{{"name":"中文字段名1", "value": "SQL 查询列 1(有别名用别名)"}}, {{"name": "中文字段名 2", "value": "SQL 查询列 2(有别名用别名)"}}]}}
必须从 SQL 查询列中提取“columns”。
- 如果需要柱状图,则生成的 JSON 格式应为:
{{"type":"column", "title": "标题", "axis": {{"x": {{"name":"x轴的中文名称", "value": "SQL 查询 x 轴的列(有别名用别名)"}}, "y": {{"name":"y轴的中文名称","value": "SQL 查询 y 轴的列(有别名用别名)"}}}}
必须从 SQL 查询列中提取“x”和“y”。
- 如果需要条形图,则生成的 JSON 格式应为:
{{"type":"bar", "title": "标题", "axis": {{"x": {{"name":"x轴的中文名称", "value": "SQL 查询 x 轴的列(有别名用别名)"}}, "y": {{"name":"y轴的中文名称","value": "SQL 查询 y 轴的列(有别名用别名)"}}}}
必须从 SQL 查询列中提取“x”和“y”。
- 如果需要折线图,则生成的 JSON 格式应为:
{{"type":"line", "title": "标题", "axis": {{"x": {{"name":"x轴的中文名称","value": "x轴的SQL查询列(有别名用别名)"}}, "y": {{"name":"y轴的中文名称","value": "y轴的SQL查询列(有别名用别名)"}}}}
{{"type":"line", "title": "标题", "axis": {{"x": {{"name":"x轴的中文名称","value": "SQL 查询 x 轴的列(有别名用别名)"}}, "y": {{"name":"y轴的中文名称","value": "SQL 查询 y 轴的列(有别名用别名)"}}}}
其中“x”和“y”必须从SQL查询列中提取。
- 如果需要饼图,则生成的 JSON 格式应为:
{{"type":"pie", "title": "标题", "columns": [{{"name":"中文字段名1","value":"SQL查询列1(有别名用别名)"}},{{"name":"中文字段名2","value":"SQL查询列2(有别名用别名)"}}]}}
{{"type":"pie", "title": "标题", "axis": {{"y": {{"值轴的中文名称","value":"SQL 查询数值的列(有别名用别名)"}}, "series": {{"name":"分类的中文名称","value":"SQL 查询分类的列(有别名用别名)"}}}}
其中“column”必须从SQL查询列中提取。
- 如果答案未知或者与生成JSON无关,则生成的 JSON 格式应为:
{{"type":"error", "reason": "抱歉,我无法回答您的问题。"}}
+2
View File
@@ -10,12 +10,14 @@
},
"dependencies": {
"@antv/g2": "^5.3.3",
"@antv/s2": "^2.4.3",
"@npkg/tinymce-plugins": "^0.0.7",
"@tinymce/tinymce-vue": "^5.1.0",
"dayjs": "^1.11.13",
"element-plus": "^2.10.1",
"element-plus-secondary": "^1.0.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"snowflake-id": "^1.1.0",
"tinymce": "^5.8.2",
"vue": "^3.5.13",
+2 -1
View File
@@ -5,5 +5,6 @@ export const userApi = {
add: (data: any) => request.post('/settings/terminology', data),
edit: (data: any) => request.put('/settings/terminology', data),
delete: (id: number) => request.delete(`/settings/terminology/${id}`),
query: (id: number) => request.get(`/settings/terminology/${id}`)
query: (id: number) => request.get(`/settings/terminology/${id}`),
language: (data: any) => request.put('/user/language', data),
}
+2 -1
View File
@@ -8,5 +8,6 @@ export const AuthApi = {
'Content-Type': 'application/x-www-form-urlencoded'
}
}),
logout: () => request.post('/auth/logout')
logout: () => request.post('/auth/logout'),
info: () => request.get('/user/info')
}
@@ -0,0 +1,63 @@
<template>
<el-dropdown @command="changeLanguage" trigger="hover">
<div class="lang-switch">
<span>{{ selectedLanguage === 'zh-CN' ? '中文' : 'English' }}</span>
<el-icon class="el-icon--right">
<ArrowDown />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="en" :class="{ 'selected-lang': selectedLanguage === 'en' }">
English
</el-dropdown-item>
<el-dropdown-item command="zh-CN" :class="{ 'selected-lang': selectedLanguage === 'zh-CN' }">
中文
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { ArrowDown } from '@element-plus/icons-vue'
import { userApi } from '@/api/auth'
const { locale } = useI18n()
const userStore = useUserStore()
const selectedLanguage = computed(() => {
return userStore.language
})
const changeLanguage = (lang: string) => {
locale.value = lang
userStore.setLanguage(lang)
const param = {
language: lang
}
userApi.language(param)
}
</script>
<style scoped lang="less">
.lang-switch {
display: flex;
align-items: center;
cursor: pointer;
color: var(--el-text-color-primary);
.el-icon--right {
margin-left: 8px;
font-size: 12px;
}
}
.selected-lang {
color: var(--el-color-primary);
}
</style>
+17 -37
View File
@@ -3,7 +3,7 @@
<div class="main-menu" :class="{ 'main-menu-sidebar': !topLayout, 'main-menu-topbar': topLayout }">
<div class="logo">SQLBot</div>
<div v-if="!topLayout || !showSubmenu"
<!-- <div v-if="!topLayout || !showSubmenu"
:class="{ 'workspace-area': !topLayout, 'topbar-workspace-area': topLayout }">
<el-select
v-model="workspace"
@@ -26,7 +26,7 @@
:value="item.value"
/>
</el-select>
</div>
</div> -->
<el-menu
v-if="!topLayout || !showSubmenu"
:default-active="activeMenu"
@@ -37,14 +37,13 @@
<el-icon v-if="item.meta.icon">
<component :is="resolveIcon(item.meta.icon)"/>
</el-icon>
<span>{{ item.meta.title }}</span>
<span>{{ t(`menu.${item.meta.title}`) }}</span>
</el-menu-item>
</el-menu>
<div v-else class="top-bar-title">
<span class="split"/>
<!-- <span>System manage</span> -->
<span>{{ t('common.system_manage') }}</span>
<span>{{ t('common.system_manage') }}</span>
</div>
@@ -59,12 +58,11 @@
</el-button>
</div>
<el-tooltip content="System manage" placement="bottom" v-else>
<el-tooltip :content="t('common.system_manage')" placement="bottom" v-else>
<div class="header-icon-btn" @click="toSystem">
<el-icon>
<iconsystem/>
</el-icon>
<!-- <span>System manage</span> -->
</div>
</el-tooltip>
@@ -77,21 +75,7 @@
<el-dropdown-menu>
<el-dropdown-item @click="switchLayout">Switch Layout</el-dropdown-item>
<el-dropdown-item @click="logout">Logout</el-dropdown-item>
<el-dropdown @command="changeLanguage">
<div class="lang-switch">
Language
<el-icon>
<i class="el-icon-arrow-down"/>
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="en">English</el-dropdown-item>
<el-dropdown-item command="zh-CN">中文</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown-item><language-selector /></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -108,7 +92,7 @@
<el-icon>
<iconsystem/>
</el-icon>
<span>System manage</span>
<span>{{ t('common.system_manage') }}</span>
</div>
</el-tooltip>
@@ -121,6 +105,8 @@
<el-dropdown-menu>
<el-dropdown-item @click="switchLayout">Switch Layout</el-dropdown-item>
<el-dropdown-item @click="logout">Logout</el-dropdown-item>
<el-dropdown-item><language-selector /></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -138,7 +124,7 @@
<el-icon v-if="item.meta.icon">
<component :is="resolveIcon(item.meta.icon)"/>
</el-icon>
<span>{{ item.meta.title }}</span>
<span>{{ t(`menu.${item.meta.title}`) }}</span>
</el-menu-item>
</el-menu>
</div>
@@ -170,8 +156,9 @@ import icon_ai from '@/assets/svg/icon_ai.svg'
import {ArrowLeftBold} from '@element-plus/icons-vue'
import {useCache} from '@/utils/useCache'
import { useI18n } from 'vue-i18n'
import LanguageSelector from '@/components/Language-selector/index.vue'
const { locale, t } = useI18n()
const { t } = useI18n()
const {wsCache} = useCache()
const topLayout = ref(false)
const router = useRouter()
@@ -186,7 +173,8 @@ const routerList = computed(() => {
})
const sysRouterList = computed(() => {
return router.getRoutes().filter(route => route.path.includes('/system'))
const result = router.getRoutes().filter(route => route.path.includes('/system') && !route.redirect)
return result
})
const showSubmenu = computed(() => {
@@ -234,9 +222,6 @@ const switchLayout = () => {
wsCache.set('sqlbot-topbar-layout', topLayout.value)
}
const changeLanguage = (lang: string) => {
locale.value = lang
}
onMounted(() => {
topLayout.value = wsCache.get('sqlbot-topbar-layout') || true
})
@@ -299,6 +284,9 @@ onMounted(() => {
flex: 1;
border-right: none;
border-bottom: none;
&:not(.ed-menu--vertical) {
margin-left: 32px
}
}
}
@@ -558,12 +546,4 @@ onMounted(() => {
}
}
}
.lang-switch {
cursor: pointer;
padding: 0 12px;
&:hover {
color: var(--el-color-primary);
}
}
</style>
+34 -4
View File
@@ -1,9 +1,9 @@
{
"menu": {
"dashboard": "Dashboard",
"chat": "Chat",
"datasource": "Data Source",
"system": "System"
"Data Q&A": "Data Q&A",
"Data Connections": "Data Connections",
"Dashboard": "Dashboard",
"AI Model Configuration": "AI Model Configuration"
},
"common": {
"back": "Back",
@@ -22,5 +22,35 @@
"dashboard": "Dashboard",
"delete_warning": "Are you sure you want to delete?",
"delete_success": "Successfully deleted"
},
"qa": {
"New Conversation": "New Conversation",
"title": "Smart Query",
"placeholder": "Enter your question",
"ask": "Ask",
"loading": "Loading...",
"no_data": "No Data",
"error": "An error occurred, please try again later",
"question_placeholder": "Press Enter to submit, or use Ctrl + Enter for new line",
"greeting": "Hi, I'm SQLBot, nice to serve you",
"description": "I can help query data, generate charts, detect data anomalies, predict data, etc. Please select a data source to start smart querying~",
"select_datasource": "Select Data Source",
"view_more": "View More",
"selected_datasource": "Selected Data Source",
"empty_datasource": "No data source available, please create one first!",
"datasource_not_exist": "Data source does not exist"
},
"ds": {
"title": "Data Source",
"add": "Add Data Source",
"edit": "Edit Data Source",
"delete": "Delete Data Source",
"name": "Data Source Name",
"type": "Data Source Type",
"status": "Status",
"actions": "Actions",
"test_connection": "Test Connection",
"connection_success": "Connection Successful",
"connection_failed": "Connection Failed, Please Check Configuration"
}
}
+6 -5
View File
@@ -1,13 +1,14 @@
import { createI18n } from 'vue-i18n'
import en from './en.json'
import zhCN from './zh-CN.json'
import elementEnLocale from 'element-plus/es/locale/lang/en'
import elementZhLocale from 'element-plus/es/locale/lang/zh-cn'
import elementEnLocale from 'element-plus-secondary/es/locale/lang/en'
import elementZhLocale from 'element-plus-secondary/es/locale/lang/zh-cn'
import { useCache } from '@/utils/useCache'
const { wsCache } = useCache()
const getDefaultLocale = () => {
/* const savedLang = localStorage.getItem('lang')
return savedLang || 'zh-CN' */
return 'zh-CN'
const language = wsCache.get('user.language')
return language || 'zh-CN'
}
const messages = {
+34 -4
View File
@@ -1,9 +1,9 @@
{
"menu": {
"dashboard": "仪表盘",
"chat": "聊天",
"datasource": "数据源",
"system": "系统"
"Data Q&A": "智能问数",
"Data Connections": "数据源",
"Dashboard": "仪表盘",
"AI Model Configuration": "模型配置"
},
"common": {
"back": "返回",
@@ -22,5 +22,35 @@
"dashboard": "仪表板",
"delete_warning": "确定要删除吗?",
"delete_success": "删除成功"
},
"qa": {
"New Conversation": "开启新问数",
"title": "智能问数",
"placeholder": "请输入您的问题",
"ask": "提问",
"loading": "加载中...",
"no_data": "暂无数据",
"error": "发生错误,请稍后再试",
"question_placeholder": "按下 Enter 提交问题, 或使用 Ctrl + Enter 换行",
"greeting": "你好,我是SQLBot,很高兴为你服务",
"description": "我可以帮忙查询数据、生成图表、检测数据异常、预测数据等,请选择一个数据源,开启智能问数吧~",
"select_datasource": "选择数据源",
"view_more": "查看更多",
"selected_datasource": "已选择数据源",
"empty_datasource": "数据源为空,请新建后再开启智能问数!",
"datasource_not_exist": "数据源不存在"
},
"ds": {
"title": "数据源",
"add": "添加数据源",
"edit": "编辑数据源",
"delete": "删除数据源",
"name": "数据源名称",
"type": "数据源类型",
"status": "状态",
"actions": "操作",
"test_connection": "测试连接",
"connection_success": "连接成功",
"connection_failed": "连接失败,请检查配置"
}
}
+3 -3
View File
@@ -91,14 +91,14 @@ const router = createRouter({
{
path: "/system",
component: Layout,
redirect: "/system/user",
redirect: "/system/model",
children: [
{
/* {
path: "user",
name: "user",
component: User,
meta: { title: "User Management", icon: "icon_user" },
},
}, */
{
path: "model",
name: "model",
+13 -7
View File
@@ -1,19 +1,25 @@
import { ElMessage } from 'element-plus-secondary'
import { useCache } from '@/utils/useCache'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const { wsCache } = useCache()
const whiteList = ['/login']
export const watchRouter = (router: any) => {
router.beforeEach(async (to: any, from: any, next: any) => {
const token = wsCache.get('user.token')
if (!token) {
if (whiteList.includes(to.path)) {
next()
} else {
ElMessage.error('Please login first')
next('/login')
}
if (whiteList.includes(to.path)) {
next()
return
}
if (!token) {
ElMessage.error('Please login first')
next('/login')
return
}
if (!wsCache.get('user.uid')) {
await userStore.info()
}
if (to.path === '/') {
next('/chat')
return
+116 -21
View File
@@ -2,29 +2,124 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { AuthApi } from '@/api/login'
import { useCache } from '@/utils/useCache'
import { i18n } from '@/i18n'
import { store } from './index'
const { wsCache } = useCache()
export const useUserStore = defineStore('user', () => {
const token = ref('')
const username = ref('')
const login = async (formData: { username: string; password: string }) => {
username.value = formData.username
const res: any = await AuthApi.login(formData)
token.value = res.access_token
wsCache.set('user.token', token.value)
username.value = formData.username
}
const logout = () => {
token.value = ''
username.value = ''
wsCache.delete('user.token')
}
return {
token,
username,
login,
logout
interface UserState {
token: string
uid: string
name: string
oid: string
language: string
exp: number
time: number
}
export const UserStore = defineStore('user', {
state: (): UserState => {
return {
token: null,
uid: null,
name: null,
oid: null,
language: 'zh-CN',
exp: null,
time: null
}
},
getters: {
getToken(): string {
return this.token
},
getUid(): string {
return this.uid
},
getName(): string {
return this.name
},
getOid(): string {
return this.oid
},
getLanguage(): string {
return this.language
},
getExp(): number {
return this.exp
},
getTime(): number {
return this.time
}
},
actions: {
async login(formData: { username: string; password: string }) {
const res: any = await AuthApi.login(formData)
this.setToken(res.access_token)
},
logout() {
this.clear()
},
async info () {
const res: any = await AuthApi.info()
const res_data = res || {}
const keys: string[] = ['uid', 'name', 'oid', 'language', 'exp', 'time']
keys.forEach(key => {
const dkey = key === 'uid' ? 'id' : key
this[key] = res_data[dkey]
wsCache.set('user.' + key, this[key])
})
this.setLanguage(this.language)
},
setToken(token: string) {
wsCache.set('user.token', token)
this.token = token
},
setExp(exp: number) {
wsCache.set('user.exp', exp)
this.exp = exp
},
setTime(time: number) {
wsCache.set('user.time', time)
this.time = time
},
setUid(uid: string) {
wsCache.set('user.uid', uid)
this.uid = uid
},
setName(name: string) {
wsCache.set('user.name', name)
this.name = name
},
setOid(oid: string) {
wsCache.set('user.oid', oid)
this.oid = oid
},
setLanguage(language: string) {
if (!language || language === 'zh_CN') {
language = 'zh-CN'
}
wsCache.set('user.language', language)
this.language = language
i18n.global.locale.value = language
/* const { locale } = useI18n()
locale.value = language */
// locale.setLang(language)
},
clear() {
const keys: string[] = ['token', 'uid', 'name', 'oid', 'language', 'exp', 'time']
keys.forEach(key => wsCache.delete('user.' + key))
}
}
})
})
export const useUserStore = () => {
return UserStore(store)
}
+33 -14
View File
@@ -5,6 +5,7 @@ import {computed, nextTick, ref} from "vue";
import type {TabsPaneContext} from 'element-plus-secondary'
import {Loading} from "@element-plus/icons-vue";
import Component from "./component/Component.vue"
import type {ChartTypes} from "@/views/chat/component/BaseChart.ts";
const props = defineProps<{
message?: ChatMessage
@@ -43,9 +44,13 @@ const dataObject = computed<{
})
const chartObject = computed<{
type: "table" | "bar" | "line" | "pie"
type: ChartTypes
title: string
axis: { x: { name: string, value: string }, y: { name: string, value: string } }
axis: {
x: { name: string, value: string },
y: { name: string, value: string },
series: { name: string, value: string }
}
columns: Array<{ name: string, value: string }>
}>(() => {
if (props.message?.record?.chart) {
@@ -66,10 +71,16 @@ const yAxis = computed(() => {
}
return []
})
const series = computed(() => {
if (chartObject.value?.axis?.series) {
return [chartObject.value.axis.series]
}
return []
})
const currentChartType = ref<"table" | "bar" | 'column' | "line" | "pie" | undefined>(undefined)
const currentChartType = ref<ChartTypes | undefined>(undefined)
const chartType = computed<"table" | "bar" | 'column' | "line" | "pie">({
const chartType = computed<ChartTypes>({
get() {
if (currentChartType.value) {
return currentChartType.value
@@ -148,16 +159,19 @@ function onTypeChange(type: string) {
</template>
<template v-else-if="settings.type === 'chart'">
<div>
<div v-if="message.record.chart">
<Component
ref="chartRef"
v-if="message.record.id"
:id="message.record.id"
:type="chartType"
:columns="chartObject?.columns"
:x="xAxis"
:y="yAxis"
:data="dataObject.data"/>
<div v-if="message.record.chart" class="chart-base-container">
<div>
<Component
ref="chartRef"
v-if="message.record.id"
:id="message.record.id"
:type="chartType"
:columns="chartObject?.columns"
:x="xAxis"
:y="yAxis"
:series="series"
:data="dataObject.data"/>
</div>
</div>
</div>
<div v-if="message.record.error" style="color: red">
@@ -184,4 +198,9 @@ function onTypeChange(type: string) {
.type-tabs {
margin-right: 24px;
}
.chart-base-container {
padding: 20px;
background: rgba(224, 224, 226, 0.29);
}
</style>
+4
View File
@@ -165,6 +165,10 @@ function handleCommand(command: string | number | object, chat: Chat) {
.title {
flex: 1;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-more {
+8 -7
View File
@@ -4,7 +4,8 @@ import {ChatInfo} from "@/api/chat.ts";
import {computed, onMounted, ref} from "vue";
import {datasourceApi} from "@/api/datasource.ts";
import DatasourceItemCard from '../ds/DatasourceItemCard.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(defineProps<{
modelValue?: number
currentChat: ChatInfo
@@ -64,13 +65,13 @@ onMounted(() => {
<template>
<ChatBlock>
<div class="welcome-content">
<div>你好,我是SQLBot,很高兴为你服务</div>
<div class="sub">我可以帮忙查询数据、生成图表、检测数据异常、预测数据等,请选择一个数据源,开启智能问数吧~</div>
<div>{{ t('qa.greeting') }}</div>
<div class="sub">{{ t('qa.description') }}</div>
<template v-if="editable">
<template v-if="dsList.length>0">
<div class="ds-select-row">
<div>选择数据源</div>
<el-button @click="showDs" link type="primary">查看更多</el-button>
<div>{{ t('qa.select_datasource') }}</div>
<el-button @click="showDs" link type="primary">{{ t('qa.view_more') }}</el-button>
</div>
<div class="ds-row-container">
<template v-for="(item, _index) in dsList" :key="_index">
@@ -80,12 +81,12 @@ onMounted(() => {
</div>
</template>
<div v-else>
数据源为空,请新建后再开启智能问数!
{{ t('qa.empty_datasource') }}
</div>
</template>
<template v-else>
<div class="ds-select-row">
<div>已选择数据源</div>
<div>{{ t('qa.selected_datasource') }}</div>
</div>
<div class="ds-row-container">
<DatasourceItemCard :ds="ds"/>
+15 -3
View File
@@ -1,15 +1,27 @@
export interface ChartAxis {
name: string,
value: string,
type?: 'x' | 'y' | 'series'
}
export interface ChartData {
[key: string]: any
}
export type ChartTypes = "table" | "bar" | 'column' | "line" | "pie"
export abstract class BaseChart {
id: string;
_name: string = 'base-chart';
axis: Array<{ name: string, value: string, type?: 'x' | 'y' }> = [];
data: Array<{ [key: string]: any }> = [];
axis: Array<ChartAxis> = [];
data: Array<ChartData> = [];
constructor(id: string, name: string) {
this.id = id;
this._name = name;
}
init(axis: Array<{ name: string, value: string, type?: 'x' | 'y' }>, data: Array<{ [key: string]: any }>): void {
init(axis: Array<ChartAxis>, data: Array<ChartData>): void {
this.axis = axis;
this.data = data;
}
@@ -11,6 +11,12 @@ export abstract class BaseG2Chart extends BaseChart {
container: id,
autoFit: true,
})
this.chart.theme({
view: {
viewFill: '#FFFFFF',
},
})
}
render() {
@@ -1,20 +1,22 @@
<script setup lang="ts">
import {computed, onMounted, onUnmounted} from "vue";
import {getChartInstance} from "@/views/chat/component/index.ts";
import type {BaseChart} from "@/views/chat/component/BaseChart.ts";
import type {BaseChart, ChartAxis, ChartData} from "@/views/chat/component/BaseChart.ts";
const params = withDefaults(defineProps<{
id: string | number
type: string
data?: Array<{ [key: string]: any }>
columns?: Array<{ name: string, value: string }>
x?: Array<{ name: string, value: string }>
y?: Array<{ name: string, value: string }>
data?: Array<ChartData>
columns?: Array<ChartAxis>
x?: Array<ChartAxis>
y?: Array<ChartAxis>
series?: Array<ChartAxis>
}>(), {
data: () => [],
columns: () => [],
x: () => [],
y: () => [],
series: () => [],
})
const chartId = computed(() => {
@@ -22,7 +24,7 @@ const chartId = computed(() => {
})
const axis = computed(() => {
const _list: Array<{ name: string, value: string, type?: 'x' | 'y' }> = []
const _list: Array<ChartAxis> = []
params.columns.forEach(column => {
_list.push({name: column.name, value: column.value})
})
@@ -32,6 +34,9 @@ const axis = computed(() => {
params.y.forEach(column => {
_list.push({name: column.name, value: column.value, type: 'y'})
})
params.series.forEach(column => {
_list.push({name: column.name, value: column.value, type: 'series'})
})
return _list
})
@@ -76,7 +81,7 @@ onUnmounted(() => {
.chart-container {
height: 100%;
width: 100%;
min-height: 200px;
min-width: 200px;
min-height: 360px;
min-width: 360px;
}
</style>
@@ -1,4 +1,5 @@
import {BaseG2Chart} from "@/views/chat/component/BaseG2Chart.ts";
import type {ChartAxis, ChartData} from "@/views/chat/component/BaseChart.ts";
export class Bar extends BaseG2Chart {
@@ -6,7 +7,7 @@ export class Bar extends BaseG2Chart {
super(id, "bar");
}
init(axis: Array<{ name: string; value: string; type: "x" | "y" }>, data: Array<{ name: string; value: string }>) {
init(axis: Array<ChartAxis>, data: Array<ChartData>) {
super.init(axis, data);
const x = this.axis.filter(item => item.type === "x");
const y = this.axis.filter(item => item.type === "y");
@@ -26,7 +27,7 @@ export class Bar extends BaseG2Chart {
.scale('y', {
nice: true,
})
.interaction('elementHighlight', { background: true });
.interaction('elementHighlight', {background: true});
}
@@ -1,4 +1,5 @@
import {BaseG2Chart} from "@/views/chat/component/BaseG2Chart.ts";
import type {ChartAxis, ChartData} from "@/views/chat/component/BaseChart.ts";
export class Column extends BaseG2Chart {
@@ -6,7 +7,7 @@ export class Column extends BaseG2Chart {
super(id, "column");
}
init(axis: Array<{ name: string; value: string; type: "x" | "y" }>, data: Array<{ name: string; value: string }>) {
init(axis: Array<ChartAxis>, data: Array<ChartData>) {
super.init(axis, data);
const x = this.axis.filter(item => item.type === "x");
@@ -26,7 +27,7 @@ export class Column extends BaseG2Chart {
.scale('y', {
nice: true,
})
.interaction('elementHighlight', { background: true });
.interaction('elementHighlight', {background: true});
}
@@ -1,4 +1,5 @@
import {BaseG2Chart} from "@/views/chat/component/BaseG2Chart.ts";
import type {ChartAxis, ChartData} from "@/views/chat/component/BaseChart.ts";
export class Line extends BaseG2Chart {
@@ -6,7 +7,7 @@ export class Line extends BaseG2Chart {
super(id, "line");
}
init(axis: Array<{ name: string; value: string; type: "x" | "y" }>, data: Array<{ name: string; value: string }>) {
init(axis: Array<ChartAxis>, data: Array<ChartData>) {
super.init(axis, data);
const x = this.axis.filter(item => item.type === "x");
@@ -0,0 +1,32 @@
import {BaseG2Chart} from "@/views/chat/component/BaseG2Chart.ts";
import type {ChartAxis, ChartData} from "@/views/chat/component/BaseChart.ts";
export class Pie extends BaseG2Chart {
constructor(id: string) {
super(id, "pie");
}
init(axis: Array<ChartAxis>, data: Array<ChartData>) {
super.init(axis, data);
const y = this.axis.filter(item => item.type === "y");
const series = this.axis.filter(item => item.type === "series");
if (series.length == 0 || y.length == 0) {
return;
}
this.chart.coordinate({type: 'theta', outerRadius: 0.8});
this.chart?.interval()
.transform({type: 'stackY'})
.data(data)
.encode('y', y[0].value)
.encode('color', series[0].value)
.legend('color', {position: 'bottom', layout: {justifyContent: 'center'}})
;
}
}
@@ -0,0 +1,79 @@
import {BaseChart, type ChartAxis, type ChartData} from "@/views/chat/component/BaseChart.ts";
import {TableSheet, type S2Options, type S2DataConfig, type S2MountContainer} from "@antv/s2";
import {debounce} from "lodash-es";
export class Table extends BaseChart {
table?: TableSheet = undefined
container: S2MountContainer | null = null
debounceRender: any
resizeObserver: ResizeObserver
constructor(id: string) {
super(id, "table");
this.container = document.getElementById(id);
this.debounceRender = debounce(async (width?: number, height?: number) => {
if (this.table) {
this.table.changeSheetSize(width, height);
await this.table.render(false);
}
}, 200);
this.resizeObserver = new ResizeObserver(([entry] = []) => {
const [size] = entry.borderBoxSize || [];
this.debounceRender(size.inlineSize, size.blockSize);
});
if (this.container?.parentElement) {
this.resizeObserver.observe(this.container.parentElement);
}
}
init(axis: Array<ChartAxis>, data: Array<ChartData>) {
super.init(axis, data);
const s2DataConfig: S2DataConfig = {
fields: {
columns: this.axis?.map(a => a.value) ?? [],
},
meta: this.axis?.map(a => {
return {
field: a.value,
name: a.name
}
}) ?? [],
data: this.data,
};
const s2Options: S2Options = {
width: 600,
height: 360,
placeholder: {
cell: '-',
empty: {
icon: 'Empty',
description: 'No Data',
},
},
};
if (this.container) {
this.table = new TableSheet(this.container, s2DataConfig, s2Options);
}
}
render() {
this.table?.render()
}
destroy() {
this.table?.destroy()
this.resizeObserver?.disconnect()
}
}
@@ -2,11 +2,15 @@ import {BaseChart} from "@/views/chat/component/BaseChart.ts";
import {Bar} from "@/views/chat/component/charts/Bar.ts";
import {Column} from "@/views/chat/component/charts/Column.ts";
import {Line} from "@/views/chat/component/charts/Line.ts";
import {Table} from "@/views/chat/component/charts/Table.ts";
import {Pie} from "@/views/chat/component/charts/Pie.ts";
const CHART_TYPE_MAP: { [key: string]: any } = {
"table": Table,
"column": Column,
"bar": Bar,
"line": Line,
"pie": Pie
}
const isParent = (type: any, parentType: any) => {
+5 -3
View File
@@ -7,7 +7,7 @@
<el-icon>
<Plus/>
</el-icon>
New Conversation
{{ t('qa.New Conversation') }}
</el-button>
</el-header>
<el-main class="chat-list">
@@ -46,7 +46,7 @@
<template
v-if="currentChat.datasource && currentChat.datasource_name"
>
Datasource:{{ currentChat.datasource_name }}
{{ t('ds.title') }}:{{ currentChat.datasource_name }}
</template>
</div>
<div class="input-wrapper">
@@ -57,7 +57,7 @@
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="Press Enter to send, Ctrl + Enter for new line"
:placeholder="t('qa.question_placeholder')"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="handleCtrlEnter"
/>
@@ -85,6 +85,8 @@ import {Chat, chatApi, ChatInfo, type ChatMessage, ChatRecord, questionApi,} fro
import ChatList from "./ChatList.vue";
import ChatRow from "./ChatRow.vue";
import ChatAnswer from "./ChatAnswer.vue";
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const inputMessage = ref("");