feat: enhance chat interface and mobile responsiveness (#5635)

This commit is contained in:
Soulter
2026-03-02 12:26:55 +08:00
committed by GitHub
parent 66d0f0afd4
commit ac44d1fdef
8 changed files with 125 additions and 45 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="keywords" content="AstrBot Soulter" />
<meta name="description" content="AstrBot Dashboard" />
<meta name="robots" content="noindex, nofollow" />
+12 -8
View File
@@ -37,14 +37,7 @@
<!-- 正常聊天界面 -->
<template v-else>
<div class="conversation-header fade-in" v-if="isMobile">
<!-- 手机端菜单按钮 -->
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" variant="text">
<v-icon>mdi-menu</v-icon>
</v-btn>
</div>
<!-- 面包屑导航 -->
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
<div class="breadcrumb-content">
<span class="breadcrumb-emoji">{{ currentSessionProject.emoji || '📁' }}</span>
@@ -241,6 +234,7 @@ const route = useRoute();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const theme = useTheme();
const customizer = useCustomizerStore();
// UI 状态
const isMobile = ref(false);
@@ -342,19 +336,28 @@ function checkMobile() {
isMobile.value = window.innerWidth <= 768;
if (!isMobile.value) {
mobileMenuOpen.value = false;
customizer.SET_CHAT_SIDEBAR(false);
}
}
function toggleMobileSidebar() {
mobileMenuOpen.value = !mobileMenuOpen.value;
customizer.SET_CHAT_SIDEBAR(mobileMenuOpen.value);
}
function closeMobileSidebar() {
mobileMenuOpen.value = false;
customizer.SET_CHAT_SIDEBAR(false);
}
// 同步 nav header 中的 sidebar toggle
watch(() => customizer.chatSidebarOpen, (val) => {
if (isMobile.value) {
mobileMenuOpen.value = val;
}
});
function toggleTheme() {
const customizer = useCustomizerStore();
const newTheme = customizer.uiTheme === 'PurpleTheme' ? 'PurpleThemeDark' : 'PurpleTheme';
customizer.SET_UI_THEME(newTheme);
theme.global.name.value = newTheme;
@@ -722,6 +725,7 @@ onBeforeUnmount(() => {
height: 100%;
max-height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
.chat-page-container {
+40 -31
View File
@@ -32,11 +32,12 @@
</div>
</transition>
<textarea ref="inputField" v-model="localPrompt" @keydown="handleKeyDown" :disabled="disabled"
placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
placeholder="Ask AstrBot..." class="chat-textarea"
autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="false"
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 16px 20px; min-height: 40px; max-height: 200px; overflow-y: auto; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
<div
style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden;">
<!-- Settings Menu -->
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
<template v-slot:activator="{ props: activatorProps }">
@@ -72,9 +73,9 @@
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center; flex-shrink: 0;">
<input type="file" ref="imageInputRef" @change="handleFileSelect" style="display: none" multiple />
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
<v-progress-circular v-if="disabled && !mobile" indeterminate size="16" class="mr-1" width="1.5" />
<!-- <v-btn @click="$emit('openLiveMode')"
icon
variant="text"
@@ -87,36 +88,21 @@
</v-tooltip>
</v-btn> -->
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
class="record-btn" size="small">
class="record-btn">
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip>
</v-btn>
<v-btn
icon
v-if="isRunning"
@click="$emit('stop')"
variant="text"
class="send-btn"
size="small"
>
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('input.stopGenerating') }}
</v-tooltip>
</v-btn>
<v-btn
v-else
@click="$emit('send')"
icon="mdi-send"
variant="text"
color="deep-purple"
:disabled="!canSend"
class="send-btn"
size="small"
/>
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
:disabled="!canSend" class="send-btn" />
</div>
</div>
</div>
@@ -152,7 +138,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { useDisplay } from 'vuetify';
import { useModuleI18n } from '@/i18n/composables';
import { useCustomizerStore } from '@/stores/customizer';
import ConfigSelector from './ConfigSelector.vue';
@@ -251,21 +238,34 @@ function handleReplyAfterLeave() {
isReplyClosing.value = false;
}
function handleKeyDown(e: KeyboardEvent) {
// Enter 发送消息或触发命令
if (e.keyCode === 13 && !e.shiftKey) {
e.preventDefault();
const { mobile } = useDisplay();
// 检查是否是 /astr_live_dev 命令
// Auto-resize textarea
function autoResize() {
const el = inputField.value;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
watch(localPrompt, () => {
nextTick(autoResize);
});
function handleKeyDown(e: KeyboardEvent) {
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (localPrompt.value.trim() === '/astr_live_dev') {
emit('openLiveMode');
localPrompt.value = '';
return;
}
if (canSend.value) {
emit('send');
}
return;
}
// Ctrl+B 录音
@@ -588,11 +588,20 @@ defineExpose({
@media (max-width: 768px) {
.input-area {
padding: 0 !important;
padding-bottom: 10px !important;
}
.input-container {
width: 100% !important;
max-width: 100% !important;
}
.input-area textarea,
.chat-textarea {
min-height: 32px !important;
max-height: 160px !important;
font-size: 16px !important;
padding: 16px 16px 12px 16px !important;
}
}
</style>
@@ -37,7 +37,7 @@
@deleteProject="$emit('deleteProject', $event)"
/>
<div style="overflow-y: auto; flex-grow: 1;"
<div style="overflow-y: auto; flex-grow: 1; overscroll-behavior-y: contain;"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
@@ -326,6 +326,13 @@ function handleTransportModeChange(mode: string | null) {
transition: all 0.2s ease;
}
@media (max-width: 768px) {
.conversation-actions {
opacity: 1 !important;
visibility: visible !important;
}
}
.edit-title-btn,
.delete-conversation-btn {
opacity: 0.7;
@@ -965,6 +965,7 @@ export default {
height: 100%;
max-height: 100%;
overflow-y: auto;
overscroll-behavior-y: contain;
padding: 16px;
display: flex;
flex-direction: column;
@@ -468,6 +468,12 @@ onMounted(async () => {
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 chat sidebar 展开按钮 - 仅在 chat 模式下的小屏幕显示 -->
<v-btn v-if="customizer.viewMode === 'chat'" class="hidden-lg-and-up ms-1" icon rounded="sm" variant="flat"
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()">
<v-icon>mdi-menu</v-icon>
</v-btn>
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
<span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
@@ -488,13 +494,13 @@ onMounted(async () => {
</small>
</div>
<!-- Bot/Chat 模式切换按钮 -->
<!-- Bot/Chat 模式切换按钮 - 手机端隐藏移入 ... 菜单 -->
<v-btn-toggle
v-model="viewMode"
mandatory
variant="outlined"
density="compact"
class="mr-4"
class="mr-4 hidden-xs"
color="primary"
>
<v-btn value="bot" size="small">
@@ -524,6 +530,30 @@ onMounted(async () => {
</v-btn>
</template>
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
<template v-if="$vuetify.display.xs">
<div class="mobile-mode-toggle-wrapper">
<v-btn-toggle
v-model="viewMode"
mandatory
variant="outlined"
density="compact"
color="primary"
class="mobile-mode-toggle"
>
<v-btn value="bot" size="small">
<v-icon start>mdi-robot</v-icon>
Bot
</v-btn>
<v-btn value="chat" size="small">
<v-icon start>mdi-chat</v-icon>
Chat
</v-btn>
</v-btn-toggle>
</div>
<v-divider class="my-1" />
</template>
<!-- 语言切换 -->
<v-list-item
v-for="lang in languages"
@@ -888,6 +918,10 @@ onMounted(async () => {
margin-left: 22px;
}
.mobile-logo.chat-mode-logo {
margin-left: 4px;
}
.logo-text {
font-size: 24px;
font-weight: 1000;
@@ -926,6 +960,20 @@ onMounted(async () => {
margin-right: 8px;
}
.mobile-mode-toggle-wrapper {
display: flex;
justify-content: center;
padding: 8px 12px 4px;
}
.mobile-mode-toggle {
width: 100%;
}
.mobile-mode-toggle .v-btn {
flex: 1;
}
/* 移动端对话框标题样式 */
.mobile-card-title {
display: flex;
+4
View File
@@ -15,3 +15,7 @@
@import './components/VScrollbar';
@import './pages/dashboards';
html, body {
overscroll-behavior-y: none;
}
+9 -2
View File
@@ -10,7 +10,8 @@ export const useCustomizerStore = defineStore({
fontTheme: "Poppins",
uiTheme: config.uiTheme,
inputBg: config.inputBg,
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot' // 'bot' 或 'chat'
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot', // 'bot' 或 'chat'
chatSidebarOpen: false // chat mode mobile sidebar state
}),
getters: {},
@@ -30,7 +31,13 @@ export const useCustomizerStore = defineStore({
},
SET_VIEW_MODE(payload: 'bot' | 'chat') {
this.viewMode = payload;
localStorage.setItem("viewMode", payload);
localStorage.setItem('viewMode', payload);
},
TOGGLE_CHAT_SIDEBAR() {
this.chatSidebarOpen = !this.chatSidebarOpen;
},
SET_CHAT_SIDEBAR(payload: boolean) {
this.chatSidebarOpen = payload;
},
}
});