feat: Add a back to bottom button to the chat page(#2957)

v3.2
wangdan-fit2cloud 2025-09-12 16:52:55 +08:00
parent 1fb280aa3d
commit 0c53e31b2a
9 changed files with 145 additions and 66 deletions

View File

@ -3,8 +3,10 @@
<div class="text-center mb-8" v-if="loading"> <div class="text-center mb-8" v-if="loading">
<el-button class="border-primary video-stop-button" @click="stopChat"> <el-button class="border-primary video-stop-button" @click="stopChat">
<app-icon iconName="app-video-stop" class="mr-8"></app-icon> <app-icon iconName="app-video-stop" class="mr-8"></app-icon>
{{ $t('chat.operation.stopChat') }}</el-button> {{ $t('chat.operation.stopChat') }}</el-button
>
</div> </div>
<div class="operate-textarea"> <div class="operate-textarea">
<el-scrollbar max-height="136"> <el-scrollbar max-height="136">
<div <div
@ -332,7 +334,7 @@ const props = withDefaults(
available: true, available: true,
}, },
) )
const emit = defineEmits(['update:chatId', 'update:loading', 'update:showUserInput']) const emit = defineEmits(['update:chatId', 'update:loading', 'update:showUserInput', 'backBottom'])
const chartOpenId = ref<string>() const chartOpenId = ref<string>()
const chatId_context = computed({ const chatId_context = computed({
get: () => { get: () => {

View File

@ -7,6 +7,12 @@
position: relative; position: relative;
color: var(--app-text-color); color: var(--app-text-color);
box-sizing: border-box; box-sizing: border-box;
.back-bottom-button {
position: absolute;
right: 14px;
top: -30px;
}
&__content { &__content {
padding-top: 0; padding-top: 0;
box-sizing: border-box; box-sizing: border-box;
@ -16,7 +22,6 @@
} }
.content { .content {
:deep(ol) { :deep(ol) {
margin-left: 16px !important; margin-left: 16px !important;
} }

View File

@ -66,35 +66,39 @@
</TransitionContent> </TransitionContent>
</div> </div>
</el-scrollbar> </el-scrollbar>
<div style="position: relative">
<ChatInputOperate <!-- 置底按钮 -->
:app-id="appId" <el-button v-if="isBottom" circle class="back-bottom-button" @click="setScrollBottom">
:application-details="applicationDetails" <el-icon><ArrowDownBold /></el-icon>
:is-mobile="isMobile" </el-button>
:type="type" <ChatInputOperate
:send-message="sendMessage" :app-id="appId"
:open-chat-id="openChatId" :application-details="applicationDetails"
:validate="validate" :is-mobile="isMobile"
:chat-management="ChatManagement" :type="type"
v-model:chat-id="chartOpenId" :send-message="sendMessage"
v-model:loading="loading" :open-chat-id="openChatId"
v-model:show-user-input="showUserInput" :validate="validate"
v-if="type !== 'log'" :chat-management="ChatManagement"
> v-model:chat-id="chartOpenId"
<template #userInput> v-model:loading="loading"
<el-button v-model:show-user-input="showUserInput"
v-if="isUserInput || isAPIInput" v-if="type !== 'log'"
class="user-input-button mb-8" >
@click="toggleUserInput" <template #userInput>
> <el-button
<AppIcon iconName="app-edit" :size="16" class="mr-4"></AppIcon> v-if="isUserInput || isAPIInput"
<span class="ellipsis"> class="user-input-button mb-8"
{{ userInputTitle || $t('chat.userInput') }} @click="toggleUserInput"
</span> >
</el-button> <AppIcon iconName="app-edit" :size="16" class="mr-4"></AppIcon>
</template> <span class="ellipsis">
</ChatInputOperate> {{ userInputTitle || $t('chat.userInput') }}
</span>
</el-button>
</template>
</ChatInputOperate>
</div>
<Control></Control> <Control></Control>
</template> </template>
</div> </div>
@ -603,10 +607,12 @@ function chatMessage(chat?: any, problem?: string, re_chat?: boolean, other_para
const scrollTop = ref(0) const scrollTop = ref(0)
const scorll = ref(true) const scorll = ref(true)
const isBottom = ref(false)
const getMaxHeight = () => { const getMaxHeight = () => {
return dialogScrollbar.value!.scrollHeight return dialogScrollbar.value!.scrollHeight
} }
/** /**
* 滚动滚动条到最上面 * 滚动滚动条到最上面
* @param $event * @param $event
@ -621,6 +627,8 @@ const handleScrollTop = ($event: any) => {
} else { } else {
scorll.value = false scorll.value = false
} }
isBottom.value =
scrollTop.value + scrollDiv.value.wrapRef.offsetHeight < dialogScrollbar.value!.scrollHeight
emit('scroll', { ...$event, dialogScrollbar: dialogScrollbar.value, scrollDiv: scrollDiv.value }) emit('scroll', { ...$event, dialogScrollbar: dialogScrollbar.value, scrollDiv: scrollDiv.value })
} }
/** /**

View File

@ -36,7 +36,7 @@
<el-pagination <el-pagination
v-model:current-page="paginationConfig.current_page" v-model:current-page="paginationConfig.current_page"
v-model:page-size="paginationConfig.page_size" v-model:page-size="paginationConfig.page_size"
:page-sizes="pageSizes" :page-sizes="paginationConfig.page_sizes|| pageSizes"
:total="paginationConfig.total" :total="paginationConfig.total"
layout="total, prev, pager, next, sizes" layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange" @size-change="handleSizeChange"

View File

@ -290,6 +290,11 @@
height: 20px; height: 20px;
font-weight: 400; font-weight: 400;
} }
.el-tag--plain.el-tag--info {
color: var(--app-text-color-secondary);
font-weight: 500;
border-color: var(--app-text-color-light-1);
}
// el-select // el-select
.el-select__selected-item { .el-select__selected-item {

View File

@ -207,6 +207,7 @@ const paginationConfig = reactive({
current_page: 1, current_page: 1,
page_size: 10, page_size: 10,
total: 0, total: 0,
page_sizes: [10, 20, 50, 100, 1000],
}) })
const filterText = ref('') const filterText = ref('')

View File

@ -179,7 +179,7 @@
<template #title> <template #title>
<div> <div>
{{ item.name }} {{ item.name }}
<el-tag v-if="item.version" class="ml-4"> <el-tag v-if="item.version" class="ml-4" type="info" effect="plain">
{{ item.version }} {{ item.version }}
</el-tag> </el-tag>
</div> </div>

View File

@ -1,10 +1,29 @@
<template> <template>
<CardBox :title="props.tool.name" :description="props.tool.desc" class="cursor"> <CardBox :title="props.tool.name" :description="props.tool.desc" class="cursor">
<template #icon> <template #icon>
<el-avatar v-if="isAppIcon(props.tool?.icon)" shape="square" :size="32" style="background: none"> <el-avatar
v-if="isAppIcon(props.tool?.icon)"
shape="square"
:size="32"
style="background: none"
>
<img :src="resetUrl(props.tool?.icon)" alt="" /> <img :src="resetUrl(props.tool?.icon)" alt="" />
</el-avatar> </el-avatar>
<el-avatar v-else-if="props.tool?.name" :name="props.tool?.name" pinyinColor shape="square" :size="32" /> <el-avatar
v-else-if="props.tool?.name"
:name="props.tool?.name"
pinyinColor
shape="square"
:size="32"
/>
</template>
<template #title>
<div>
{{ props.tool?.name }}
<el-tag v-if="props.tool?.version" class="ml-4" type="info" effect="plain">
{{ props.tool?.version }}
</el-tag>
</div>
</template> </template>
<template #subTitle> <template #subTitle>
<el-text class="color-secondary" size="small"> <el-text class="color-secondary" size="small">
@ -29,24 +48,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import {isAppIcon, resetUrl} from '@/utils/common' import { isAppIcon, resetUrl } from '@/utils/common'
const props = defineProps<{ const props = defineProps<{
tool: any, tool: any
getSubTitle: (v: any) => string getSubTitle: (v: any) => string
addLoading: boolean addLoading: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'handleAdd'): void; (e: 'handleAdd'): void
(e: 'handleDetail'): void; (e: 'handleDetail'): void
}>(); }>()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.el-card { .el-card {
:deep(.card-footer) { :deep(.card-footer) {
&>div:first-of-type { & > div:first-of-type {
flex: 1; flex: 1;
} }

View File

@ -1,19 +1,34 @@
<template> <template>
<el-dialog v-model="dialogVisible" width="1000" append-to-body class="tool-store-dialog" align-center <el-dialog
:close-on-click-modal="false" :close-on-press-escape="false"> v-model="dialogVisible"
width="1000"
append-to-body
class="tool-store-dialog"
align-center
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<template #header="{ titleId }"> <template #header="{ titleId }">
<div class="dialog-header flex-between mb-8"> <div class="dialog-header flex-between mb-8">
<h4 :id="titleId" class="medium"> <h4 :id="titleId" class="medium">
{{ $t('views.tool.toolStore.title') }} {{ $t('views.tool.toolStore.title') }}
</h4> </h4>
<el-radio-group v-model="toolType" @change="radioChange" class="app-radio-button-group"> <el-radio-group v-model="toolType" @change="radioChange" class="app-radio-button-group">
<el-radio-button value="INTERNAL">{{ $t('views.tool.toolStore.internal') }}</el-radio-button> <el-radio-button value="INTERNAL">{{
$t('views.tool.toolStore.internal')
}}</el-radio-button>
<el-radio-button value="APPSTORE">{{ $t('views.tool.toolStore.title') }}</el-radio-button> <el-radio-button value="APPSTORE">{{ $t('views.tool.toolStore.title') }}</el-radio-button>
</el-radio-group> </el-radio-group>
<div class="flex align-center" style="margin-right: 28px;"> <div class="flex align-center" style="margin-right: 28px">
<el-input v-model="searchValue" :placeholder="$t('common.search')" prefix-icon="Search" class="w-240 mr-8" <el-input
clearable @change="getList" /> v-model="searchValue"
:placeholder="$t('common.search')"
prefix-icon="Search"
class="w-240 mr-8"
clearable
@change="getList"
/>
<el-divider direction="vertical" /> <el-divider direction="vertical" />
</div> </div>
</div> </div>
@ -21,23 +36,40 @@
<LayoutContainer v-loading="loading"> <LayoutContainer v-loading="loading">
<template #left> <template #left>
<el-anchor direction="vertical" :offset="130" type="default" container=".category-scrollbar" <el-anchor
@click="handleClick"> direction="vertical"
<el-anchor-link v-for="category in categories" :key="category.id" :href="`#category-${category.id}`" :offset="130"
:title="category.title" /> type="default"
container=".category-scrollbar"
@click="handleClick"
>
<el-anchor-link
v-for="category in categories"
:key="category.id"
:href="`#category-${category.id}`"
:title="category.title"
/>
</el-anchor> </el-anchor>
</template> </template>
<el-scrollbar class="layout-bg" wrap-class="p-16-24 category-scrollbar"> <el-scrollbar class="layout-bg" wrap-class="p-16-24 category-scrollbar">
<template v-if="filterList === null"> <template v-if="filterList === null">
<div v-for="category in categories" :key="category.id"> <div v-for="category in categories" :key="category.id">
<h4 class="title-decoration-1 mb-16 mt-8 color-text-primary" :id="`category-${category.id}`"> <h4
class="title-decoration-1 mb-16 mt-8 color-text-primary"
:id="`category-${category.id}`"
>
{{ category.title }} {{ category.title }}
</h4> </h4>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col v-for="tool in category.tools" :key="tool.id" :span="12" class="mb-16"> <el-col v-for="tool in category.tools" :key="tool.id" :span="12" class="mb-16">
<ToolCard :tool="tool" :addLoading="addLoading" :get-sub-title="getSubTitle" <ToolCard
@handleAdd="handleOpenAdd(tool)" @handleDetail="handleDetail(tool)" /> :tool="tool"
:addLoading="addLoading"
:get-sub-title="getSubTitle"
@handleAdd="handleOpenAdd(tool)"
@handleDetail="handleDetail(tool)"
/>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
@ -49,8 +81,13 @@
</h4> </h4>
<el-row :gutter="16" v-if="filterList.length"> <el-row :gutter="16" v-if="filterList.length">
<el-col v-for="tool in filterList" :key="tool.id" :span="12" class="mb-16"> <el-col v-for="tool in filterList" :key="tool.id" :span="12" class="mb-16">
<ToolCard :tool="tool" :addLoading="addLoading" :get-sub-title="getSubTitle" <ToolCard
@handleAdd="handleOpenAdd(tool)" @handleDetail="handleDetail(tool)" /> :tool="tool"
:addLoading="addLoading"
:get-sub-title="getSubTitle"
@handleAdd="handleOpenAdd(tool)"
@handleDetail="handleDetail(tool)"
/>
</el-col> </el-col>
</el-row> </el-row>
<el-empty v-else :description="$t('common.noData')" /> <el-empty v-else :description="$t('common.noData')" />
@ -70,7 +107,7 @@ import ToolCard from './ToolCard.vue'
import { MsgSuccess } from '@/utils/message' import { MsgSuccess } from '@/utils/message'
import InternalDescDrawer from './InternalDescDrawer.vue' import InternalDescDrawer from './InternalDescDrawer.vue'
import AddInternalToolDialog from './AddInternalToolDialog.vue' import AddInternalToolDialog from './AddInternalToolDialog.vue'
import { loadSharedApi } from "@/utils/dynamics-api/shared-api.ts"; import { loadSharedApi } from '@/utils/dynamics-api/shared-api.ts'
import useStore from '@/stores' import useStore from '@/stores'
const { user } = useStore() const { user } = useStore()
interface ToolCategory { interface ToolCategory {
@ -81,7 +118,7 @@ interface ToolCategory {
const props = defineProps({ const props = defineProps({
apiType: { apiType: {
type: String as () => 'workspace' | 'systemShare' | 'systemManage', type: String as () => 'workspace' | 'systemShare' | 'systemManage',
default: 'workspace' default: 'workspace',
}, },
}) })
const emit = defineEmits(['refresh']) const emit = defineEmits(['refresh'])
@ -102,12 +139,12 @@ const categories = ref<ToolCategory[]>([
{ {
id: 'web_search', id: 'web_search',
title: t('views.tool.toolStore.webSearch'), title: t('views.tool.toolStore.webSearch'),
tools: [] tools: [],
}, },
{ {
id: 'database_search', id: 'database_search',
title: t('views.tool.toolStore.databaseQuery'), title: t('views.tool.toolStore.databaseQuery'),
tools: [] tools: [],
}, },
// { // {
// id: 'image', // id: 'image',
@ -128,7 +165,7 @@ const categories = ref<ToolCategory[]>([
const filterList = ref<any>(null) const filterList = ref<any>(null)
function getSubTitle(tool: any) { function getSubTitle(tool: any) {
return categories.value.find(i => i.id === tool.label)?.title ?? '' return categories.value.find((i) => i.id === tool.label)?.title ?? ''
} }
function open(id: string) { function open(id: string) {
@ -156,7 +193,7 @@ async function getInternalToolList() {
filterList.value = res.data filterList.value = res.data
} else { } else {
filterList.value = null filterList.value = null
categories.value.forEach(category => { categories.value.forEach((category) => {
// if (category.id === 'recommend') { // if (category.id === 'recommend') {
// category.tools = res.data // category.tools = res.data
// } else { // } else {
@ -183,7 +220,7 @@ async function getStoreToolList() {
categories.value = tags.map((tag: any) => ({ categories.value = tags.map((tag: any) => ({
id: tag.key, id: tag.key,
title: tag.name, // title: tag.name, //
tools: storeTools.filter((tool: any) => tool.label === tag.key) tools: storeTools.filter((tool: any) => tool.label === tag.key),
})) }))
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -244,7 +281,7 @@ async function handleStoreAdd(tool: any) {
download_callback_url: tool.downloadCallbackUrl, download_callback_url: tool.downloadCallbackUrl,
icon: tool.icon, icon: tool.icon,
versions: tool.versions, versions: tool.versions,
label: tool.label label: tool.label,
} }
await loadSharedApi({ type: 'tool', systemType: props.apiType }) await loadSharedApi({ type: 'tool', systemType: props.apiType })
.addStoreTool(tool.id, obj, addLoading) .addStoreTool(tool.id, obj, addLoading)
@ -268,6 +305,9 @@ defineExpose({ open })
<style lang="scss"> <style lang="scss">
.tool-store-dialog { .tool-store-dialog {
padding: 0; padding: 0;
.el-dialog__headerbtn {
top: 7px;
}
.el-dialog__header { .el-dialog__header {
padding: 12px 20px 4px 24px; padding: 12px 20px 4px 24px;
@ -314,7 +354,6 @@ defineExpose({ open })
} }
} }
} }
} }
.category-scrollbar { .category-scrollbar {