feat: Chat UI

This commit is contained in:
ulleo
2025-05-26 16:48:26 +08:00
parent 4f830d98db
commit 9fceb1fa52
7 changed files with 477 additions and 212 deletions
+10 -2
View File
@@ -16,6 +16,14 @@ export const questionApi = {
query: (id: number) => request.get(`/chat/question/${id}`)
}
export interface ChatMessage {
role: 'user' | 'assistant'
create_time?: Date | string
content?: string | number
isTyping?: boolean
isWelcome?: boolean
}
export class ChatRecord {
id?: number
chat_id?: number
@@ -147,11 +155,11 @@ function startChat(data: any): Promise<ChatInfo> {
return request.post('/chat/start', data)
}
function renameChat(chat_id: number, brief: string): Promise<string> {
function renameChat(chat_id: number | undefined, brief: string): Promise<string> {
return request.post('/chat/rename', {id: chat_id, brief: brief})
}
function deleteChat(id: number): Promise<string> {
function deleteChat(id: number | undefined): Promise<string> {
return request.get(`/chat/delete/${id}`)
}
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type {ChatMessage} from "@/api/chat.ts";
defineProps<{
msg?: ChatMessage
}>()
</script>
<template>
<div class="chat-block">
<slot>
{{ msg?.content }}
</slot>
</div>
</template>
<style scoped lang="less">
.chat-block {
border-radius: 2px;
background-color: white;
padding: 12px;
word-wrap: break-word;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import ChatBlock from './ChatBlock.vue'
import WelcomeBlock from './WelcomeBlock.vue'
import {ChatInfo, type ChatMessage} from "@/api/chat.ts";
import {computed} from "vue";
const props = withDefaults(defineProps<{
msg: ChatMessage
currentChat: ChatInfo
datasource?: number
}>(),
{}
)
const emits = defineEmits(["update:datasource"])
const _datasource = computed({
get() {
return props.datasource
},
set(v) {
emits("update:datasource", v)
}
})
</script>
<template>
<div class="chat-row" :class="{'right-to-left': msg.role === 'user'}">
<el-avatar shape="square" v-if="msg.role === 'assistant'">SQBot</el-avatar>
<el-avatar shape="square" v-if="msg.role === 'user'"/>
<ChatBlock v-if="!msg.isWelcome" :msg="msg" :class="{'row-full': msg.role === 'assistant'}">
<slot></slot>
</ChatBlock>
<WelcomeBlock v-else v-model="_datasource" :current-chat="currentChat" class="row-full"/>
</div>
</template>
<style scoped lang="less">
.chat-row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
padding: 20px 20px 0;
&.right-to-left {
flex-direction: row-reverse;
}
.row-full {
flex: 1;
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import ChatBlock from './ChatBlock.vue'
import {ChatInfo} from "@/api/chat.ts";
import {computed, onMounted, ref} from "vue";
import {datasourceApi} from "@/api/datasource.ts";
import DatasourceItemCard from '../ds/DatasourceItemCard.vue'
const props = withDefaults(defineProps<{
modelValue?: number
currentChat: ChatInfo
}>(),
{}
)
const dsList = ref<Array<any>>([])
const editable = computed(() => {
return props.currentChat?.id === undefined
})
const ds = computed(() => {
for (let i = 0; i < dsList.value.length; i++) {
const item = dsList.value[i]
if (props.modelValue === item.id) {
return item
}
}
if (props.currentChat && props.currentChat.datasource !== undefined) {
return {
id: props.currentChat.datasource,
name: props.currentChat.datasource_name ?? 'Datasource does not exist',
type: props.currentChat.engine_type
}
}
return undefined
})
const emits = defineEmits(["update:modelValue"])
function selectDs(ds: any) {
if (editable.value) {
emits("update:modelValue", ds.id)
}
}
function listDs() {
datasourceApi.list().then((res) => {
dsList.value = res
})
}
function showDs() {
listDs()
}
onMounted(() => {
listDs()
})
</script>
<template>
<ChatBlock>
<div>你好,我是SQLBot,很高兴为你服务</div>
<div class="sub">我可以帮忙查询数据、生成图表、检测数据异常、预测数据等,请选择一个数据源,开启智能问数吧~</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>
<div class="ds-row-container">
<template v-for="(item, _index) in dsList" :key="_index">
<DatasourceItemCard :ds="item" @click="selectDs(item)" v-if="_index<3 || item?.id===modelValue"
class="ds-card" :class="[item?.id===modelValue? 'ds-card-selected': '']"/>
</template>
</div>
</template>
<div v-else>
数据源为空,请新建后再开启智能问数!
</div>
</template>
<template v-else>
<div class="ds-select-row">
<div>已选择数据源</div>
</div>
<div class="ds-row-container">
<DatasourceItemCard :ds="ds"/>
</div>
</template>
</ChatBlock>
</template>
<style scoped lang="less">
.sub {
color: grey;
font-size: 0.8em;
}
.ds-select-row {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
}
.ds-row-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
}
.ds-card {
cursor: pointer;
}
.ds-card-selected {
border-color: var(--ed-color-primary-light-5);
}
</style>
+60 -50
View File
@@ -19,39 +19,33 @@
<el-container :loading="loading">
<el-main class="chat-record-list">
<el-scrollbar>
<div v-for="message in computedMessages">
{{ message.role }}: {{ message.content }}
<div v-if="message.isWelcome">
<el-select v-model="currentChat.datasource" :disabled="currentChat.id!==undefined">
<el-option v-for="item in dsList"
:value="item.id"
:key="item.id"
:label="item.name"/>
</el-select>
</div>
</div>
<template v-for="(message, _index) in computedMessages" :key="_index">
<ChatRow :current-chat="currentChat" v-model:datasource="currentChat.datasource" :msg="message"/>
</template>
</el-scrollbar>
</el-main>
<el-footer>
<div class="chat-input">
<div class="input-wrapper">
<el-input
v-model="inputMessage"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="Press Enter to send, Ctrl + Enter for new line"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="handleCtrlEnter"
/>
<div class="input-actions">
<el-button circle type="primary" class="send-btn" @click="sendMessage">
<el-icon>
<Position/>
</el-icon>
</el-button>
</div>
</div>
<el-footer class="chat-footer">
<div style="height: 24px;">
<template v-if="currentChat.datasource">
使用数据源:{{ currentChat.datasource_name }}
</template>
</div>
<div class="input-wrapper">
<el-input
class="input-area"
v-model="inputMessage"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 8 }"
placeholder="Press Enter to send, Ctrl + Enter for new line"
@keydown.enter.exact.prevent="sendMessage"
@keydown.ctrl.enter.exact.prevent="handleCtrlEnter"
/>
<el-button link type="primary" class="input-icon" @click="sendMessage">
<el-icon size="20">
<Position/>
</el-icon>
</el-button>
</div>
</el-footer>
</el-container>
@@ -62,23 +56,14 @@
<script setup lang="ts">
import {ref, computed, nextTick, watch, onMounted} from 'vue'
import {Plus, Position} from '@element-plus/icons-vue'
import {Chat, chatApi, ChatInfo, ChatRecord, questionApi} from '@/api/chat'
import {datasourceApi} from "@/api/datasource.ts"
import {Chat, chatApi, ChatInfo, type ChatMessage, ChatRecord, questionApi} from '@/api/chat'
import ChatList from './ChatList.vue'
interface ChatMessage {
role: 'user' | 'assistant'
create_time?: Date | string
content?: string | number
isTyping?: boolean
isWelcome?: boolean
}
import ChatRow from './ChatRow.vue'
const inputMessage = ref('')
const loading = ref<boolean>(false);
const chatList = ref<Array<ChatInfo>>([])
const dsList = ref<any>([])
const currentChatId = ref<number | undefined>()
const currentChat = ref<ChatInfo>(new ChatInfo())
@@ -131,7 +116,6 @@ const createNewChat = () => {
currentChat.value = new ChatInfo()
currentChatId.value = undefined
inputMessage.value = ''
listDs()
}
function getChatList() {
@@ -183,15 +167,9 @@ function onChatRenamed(chat: Chat) {
}
}
function listDs() {
datasourceApi.list().then((res) => {
dsList.value = res
})
}
onMounted(() => {
getChatList()
listDs()
})
@@ -347,7 +325,39 @@ const handleCtrlEnter = (e: KeyboardEvent) => {
}
.chat-record-list {
padding: 20px 0 0 0;
padding: 0 0 20px 0;
}
.chat-footer {
--ed-footer-height: 120px;
display: flex;
flex-direction: column;
.input-wrapper {
flex: 1;
position: relative;
.input-area {
height: 100%;
padding-bottom: 8px;
:deep(.ed-textarea__inner) {
height: 100% !important;
}
}
.input-icon {
min-width: unset;
position: absolute;
bottom: 14px;
right: 8px;
}
}
}
@@ -0,0 +1,148 @@
<script setup lang="ts">
import {datetimeFormat} from "@/utils/utils.ts";
import SQLServerDs from "@/assets/svg/ds/sqlServer-ds.svg";
import ExcelDs from "@/assets/svg/ds/Excel-ds.svg";
import PgDs from "@/assets/svg/ds/pg-ds.svg";
import MysqlDs from "@/assets/svg/ds/mysql-ds.svg";
import OracleDs from "@/assets/svg/ds/oracle-ds.svg";
defineProps<{
ds: any
}>()
const getStatus = (status: string) => {
if (status === 'Success') {
return 'connected'
}
if (status === 'Fail') {
return 'failed'
}
if (status === 'Checking') {
return 'needs-verification'
}
}
</script>
<template>
<div
class="connection-card"
>
<div class="connection-icon">
<Icon>
<MysqlDs v-if="ds.type === 'mysql'"/>
<SQLServerDs v-else-if="ds.type === 'sqlServer'"/>
<PgDs v-else-if="ds.type === 'pg'"/>
<ExcelDs v-else-if="ds.type === 'excel'"/>
<OracleDs v-else-if="ds.type === 'oracle'"/>
</Icon>
</div>
<div class="connection-details">
<div class="connection-name">{{ ds.name }}</div>
<div class="connection-type">{{ ds.type_name }}</div>
<div class="connection-host">{{ ds.description }}</div>
<div class="connection-last">{{ datetimeFormat(ds.create_time) }}</div>
</div>
<div class="connection-status" :class="`${getStatus(ds.status)}`">{{ ds.status }}</div>
<slot></slot>
</div>
</template>
<style scoped lang="less">
.connection-card {
background-color: white;
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
display: flex;
position: relative;
border: 1px solid #dadce0;
transition: all 0.2s ease;
align-items: center;
}
.connection-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background-color: #e8f0fe;
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
flex-shrink: 0;
}
.connection-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.connection-name {
font-weight: 600;
font-size: 18px;
color: #202124;
margin-bottom: 8px;
line-height: 1.3;
display: flex;
}
.connection-type {
color: #5f6368;
margin-bottom: 8px;
font-size: 14px;
display: flex;
}
.connection-host {
color: #5f6368;
margin-bottom: 8px;
font-size: 14px;
display: flex;
align-items: center;
}
.connection-last {
color: #5f6368;
font-size: 14px;
margin-bottom: 0;
display: flex;
align-items: center;
}
}
.connection-status {
position: absolute;
right: 20px;
top: 18px;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
max-width: 50px;
text-align: center;
justify-content: center;
opacity: 0.9;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.connection-status.connected {
background-color: #e6f4ea;
color: #34a853;
}
.connection-status.failed {
background-color: #fce8e6;
color: #ea4335;
}
.connection-status.needs-verification {
background-color: #fef7e0;
color: #fbbc05;
}
</style>
+50 -160
View File
@@ -3,15 +3,17 @@
<div class="header">
<div class="mt-4">
<el-input
v-model="searchValue"
style="max-width: 300px"
placeholder="Search Datasource..."
class="input-with-select"
clearable
@change="searchHandle"
v-model="searchValue"
style="max-width: 300px"
placeholder="Search Datasource..."
class="input-with-select"
clearable
@change="searchHandle"
>
<template #prepend>
<el-icon><Search /></el-icon>
<el-icon>
<Search/>
</el-icon>
</template>
</el-input>
</div>
@@ -20,33 +22,17 @@
</div>
<div class="connections-container">
<div
class="connection-card"
v-for="ds in dsList"
>
<div class="connection-icon">
<Icon>
<MysqlDs v-if="ds.type === 'mysql'"/>
<SQLServerDs v-else-if="ds.type === 'sqlServer'"/>
<PgDs v-else-if="ds.type === 'pg'"/>
<ExcelDs v-else-if="ds.type === 'excel'"/>
<OracleDs v-else-if="ds.type === 'oracle'"/>
</Icon>
</div>
<div class="connection-details">
<div class="connection-name">{{ ds.name }}</div>
<div class="connection-type">{{ ds.type_name }}</div>
<div class="connection-host">{{ ds.description }}</div>
<div class="connection-last">{{ datetimeFormat(ds.create_time) }}</div>
</div>
<div class="connection-status" :class="`${getStatus(ds.status)}`">{{ ds.status }}</div>
<div class="connection-actions">
<el-button class="action-btn" circle @click="getTables(ds.id, ds.name)" :icon="List" />
<el-button class="action-btn" circle @click="editTables(ds)" :icon="CreditCard" />
<el-button v-if="ds.type !== 'excel'" type="primary" class="action-btn" circle @click="editDs(ds)" :icon="IconOpeEdit"/>
<el-button type="danger" class="action-btn" circle @click="deleteDs(ds)" :icon="IconOpeDelete"/>
</div>
</div>
<template v-for="ds in dsList">
<DatasourceItemCard :ds="ds">
<div class="connection-actions">
<el-button class="action-btn" circle @click="getTables(ds.id, ds.name)" :icon="List"/>
<el-button class="action-btn" circle @click="editTables(ds)" :icon="CreditCard"/>
<el-button v-if="ds.type !== 'excel'" type="primary" class="action-btn" circle @click="editDs(ds)"
:icon="IconOpeEdit"/>
<el-button type="danger" class="action-btn" circle @click="deleteDs(ds)" :icon="IconOpeDelete"/>
</div>
</DatasourceItemCard>
</template>
</div>
</div>
<DsForm ref="dsForm" @refresh="refresh"/>
@@ -55,18 +41,13 @@
import IconOpeAdd from '@/assets/svg/operate/ope-add.svg'
import IconOpeEdit from '@/assets/svg/operate/ope-edit.svg'
import IconOpeDelete from '@/assets/svg/operate/ope-delete.svg'
import MysqlDs from '@/assets/svg/ds/mysql-ds.svg'
import SQLServerDs from '@/assets/svg/ds/sqlServer-ds.svg'
import PgDs from '@/assets/svg/ds/pg-ds.svg'
import ExcelDs from '@/assets/svg/ds/Excel-ds.svg'
import OracleDs from '@/assets/svg/ds/oracle-ds.svg'
import { Search, List, CreditCard } from '@element-plus/icons-vue'
import { ref, onMounted } from 'vue'
import {Search, List, CreditCard} from '@element-plus/icons-vue'
import {ref, onMounted} from 'vue'
import DsForm from './form.vue'
import { datasourceApi } from '@/api/datasource'
import { datetimeFormat } from '@/utils/utils'
import { ElMessageBox } from 'element-plus-secondary'
import { useRouter } from 'vue-router'
import {datasourceApi} from '@/api/datasource'
import {ElMessageBox} from 'element-plus-secondary'
import {useRouter} from 'vue-router'
import DatasourceItemCard from "@/views/ds/DatasourceItemCard.vue";
const searchValue = ref<string>('')
const dsForm = ref()
@@ -75,21 +56,12 @@ const allDsList = ref<any>([])// all ds list
const router = useRouter()
const loading = ref(false)
const getStatus = (status: string) => {
if (status === 'Success') {
return 'connected'
}
if (status === 'Fail') {
return 'failed'
}
if (status === 'Checking') {
return 'needs-verification'
}
}
function searchHandle() {
if(searchValue.value) {
dsList.value = JSON.parse(JSON.stringify(allDsList.value)).filter((item: any) => {return item.name.toLowerCase().includes(searchValue.value.toLowerCase())})
if (searchValue.value) {
dsList.value = JSON.parse(JSON.stringify(allDsList.value)).filter((item: any) => {
return item.name.toLowerCase().includes(searchValue.value.toLowerCase())
})
} else {
dsList.value = JSON.parse(JSON.stringify(allDsList.value))
}
@@ -118,22 +90,22 @@ const editTables = (item: any) => {
const deleteDs = (item: any) => {
ElMessageBox.confirm(
'Delete this datasource?',
'Delete',
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'warning',
}
'Delete this datasource?',
'Delete',
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'warning',
}
)
.then(() => {
datasourceApi.delete(item.id).then(() => {
console.log('success')
list()
})
})
.catch(() => {
})
.then(() => {
datasourceApi.delete(item.id).then(() => {
console.log('success')
list()
})
})
.catch(() => {
})
}
const getTables = (id: number, name: string) => {
@@ -157,7 +129,7 @@ onMounted(() => {
</script>
<style lang="less" scoped>
.header{
.header {
background-color: white;
padding: 16px 20px;
border-radius: 8px;
@@ -171,102 +143,20 @@ onMounted(() => {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 24px;
.connection-card {
background-color: white;
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
display: flex;
position: relative;
border: 1px solid #dadce0;
transition: all 0.2s ease;
align-items: center;
}
.connection-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background-color: #e8f0fe;
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
flex-shrink: 0;
}
.connection-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
.connection-name {
font-weight: 600;
font-size: 18px;
color: #202124;
margin-bottom: 8px;
line-height: 1.3;
display: flex;
}
.connection-type {
color: #5f6368;
margin-bottom: 8px;
font-size: 14px;
display: flex;
}
.connection-host {
color: #5f6368;
margin-bottom: 8px;
font-size: 14px;
display: flex;
align-items: center;
}
.connection-last {
color: #5f6368;
font-size: 14px;
margin-bottom: 0;
display: flex;
align-items: center;
}
}
.connection-status {
position: absolute;
right: 20px;
top: 18px;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
max-width: 50px;
text-align: center;
justify-content: center;
opacity: 0.9;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.connection-status.connected {
background-color: #e6f4ea;
color: #34a853;
}
.connection-status.failed {
background-color: #fce8e6;
color: #ea4335;
}
.connection-status.needs-verification {
background-color: #fef7e0;
color: #fbbc05;
}
.connection-actions {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
.action-btn {
display: none;
min-width: 0;
}
}
:hover .action-btn{
:hover .action-btn {
display: flex;
}
}