完善前端/允许退出登录

This commit is contained in:
LIghtJUNction
2026-02-27 23:41:43 +08:00
parent e5d85e402b
commit b8ffecf500
8 changed files with 884 additions and 512 deletions
+1 -1
View File
@@ -64,7 +64,7 @@
"sass": "1.66.1",
"sass-loader": "13.3.2",
"typescript": "5.1.6",
"vite": "4.4.9",
"vite": "^7.3.1",
"vue-cli-plugin-vuetify": "2.5.8",
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
+249 -231
View File
@@ -1,165 +1,177 @@
<template>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || 'default' }}
</p>
</div>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@openLiveMode=""
ref="chatInputRef"
/>
</div>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList
v-if="messages && messages.length > 0"
:messages="messages"
:isDark="isDark"
:isStreaming="isStreaming || isConvRunning"
@openImagePreview="openImagePreview"
ref="messageList"
/>
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot </span>
</div>
</v-card-text>
</v-card>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || "default" }}
</p>
</div>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@openLiveMode=""
ref="chatInputRef"
/>
</div>
</div>
</v-card-text>
</v-card>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t("core.common.imagePreview") }}</span>
<v-btn
icon="mdi-close"
variant="text"
@click="imagePreviewDialog = false"
/>
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import MessageList from '@/components/chat/MessageList.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from "vue";
import axios from "axios";
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { useTheme } from "vuetify";
import MessageList from "@/components/chat/MessageList.vue";
import ChatInput from "@/components/chat/ChatInput.vue";
import { useMessages } from "@/composables/useMessages";
import { useMediaHandling } from "@/composables/useMediaHandling";
import { useRecording } from "@/composables/useRecording";
import { useToast } from "@/utils/toast";
import { buildWebchatUmoDetails } from "@/utils/chatConfigBinding";
interface Props {
configId?: string | null;
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
configId: null
configId: null,
});
const { t } = useI18n();
const { error: showError } = useToast();
// UI 状态
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
const previewImageUrl = ref("");
// 会话管理(不使用 useSessions 避免路由跳转)
const currSessionId = ref('');
const currSessionId = ref("");
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
async function bindConfigToSession(sessionId: string) {
const confId = (props.configId || '').trim();
if (!confId || confId === 'default') {
return;
}
const confId = (props.configId || "").trim();
if (!confId || confId === "default") {
return;
}
const umoDetails = buildWebchatUmoDetails(sessionId, false);
const umoDetails = buildWebchatUmoDetails(sessionId, false);
await axios.post('/api/config/umo_abconf_route/update', {
umo: umoDetails.umo,
conf_id: confId
});
await axios.post("/api/config/umo_abconf_route/update", {
umo: umoDetails.umo,
conf_id: confId,
});
}
async function newSession() {
try {
const response = await axios.get("/api/chat/new_session");
const sessionId = response.data.data.session_id;
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
try {
await bindConfigToSession(sessionId);
} catch (err) {
console.error('Failed to bind config to session', err);
}
currSessionId.value = sessionId;
return sessionId;
await bindConfigToSession(sessionId);
} catch (err) {
console.error(err);
throw err;
console.error("Failed to bind config to session", err);
}
currSessionId.value = sessionId;
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
function updateSessionTitle(sessionId: string, title: string) {
// 独立模式不需要更新会话标题
// 独立模式不需要更新会话标题
}
function getSessions() {
// 独立模式不需要加载会话列表
// 独立模式不需要加载会话列表
}
const {
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache,
} = useMediaHandling();
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
isRecording,
startRecording: startRec,
stopRecording: stopRec,
} = useRecording();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
stopMessage: stopMsg,
toggleStreaming
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
stopMessage: stopMsg,
toggleStreaming,
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// 组件引用
@@ -167,190 +179,196 @@ const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
// 输入状态
const prompt = ref('');
const prompt = ref("");
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const isDark = computed(
() => useCustomizerStore().uiTheme === "PurpleThemeDark",
);
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
}
async function handleStartRecording() {
await startRec();
await startRec();
}
async function handleStopRecording() {
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
}
async function handleFileSelect(files: FileList) {
for (const file of files) {
await processAndUploadImage(file);
}
for (const file of Array.from(files)) {
await processAndUploadImage(file);
}
}
async function handleSendMessage() {
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return;
if (
!prompt.value.trim() &&
stagedFiles.value.length === 0 &&
!stagedAudioUrl.value
) {
return;
}
try {
if (!currSessionId.value) {
await newSession();
}
try {
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map((f) => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type,
}));
const promptToSend = prompt.value.trim();
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map(f => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type
}));
// 清空输入和附件
prompt.value = "";
clearStaged();
// 清空输入和附件
prompt.value = '';
clearStaged();
// 获取选择的提供商和模型
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || "";
const selectedModelName = selection?.modelName || "";
// 获取选择的提供商和模型
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
await sendMsg(
promptToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName,
);
await sendMsg(
promptToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName
);
// 滚动到底部
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error('Failed to send message:', err);
showError(t('features.chat.errors.sendMessageFailed'));
// 恢复输入内容,让用户可以重试
// 注意:附件已经上传到服务器,所以不恢复附件
}
// 滚动到底部
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error("Failed to send message:", err);
showError(t("features.chat.errors.sendMessageFailed"));
// 恢复输入内容,让用户可以重试
// 注意:附件已经上传到服务器,所以不恢复附件
}
}
async function handleStopMessage() {
await stopMsg();
await stopMsg();
}
onMounted(async () => {
// 独立模式在挂载时创建新会话
try {
await newSession();
} catch (err) {
console.error('Failed to create initial session:', err);
showError(t('features.chat.errors.createSessionFailed'));
}
// 独立模式在挂载时创建新会话
try {
await newSession();
} catch (err) {
console.error("Failed to create initial session:", err);
showError(t("features.chat.errors.createSessionFailed"));
}
});
onBeforeUnmount(() => {
cleanupMediaCache();
cleanupMediaCache();
});
</script>
<style scoped>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.standalone-chat-card {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
}
.standalone-chat-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.conversation-header-info h4 {
margin: 0;
font-weight: 500;
margin: 0;
font-weight: 500;
}
.conversation-header-actions {
display: flex;
gap: 8px;
align-items: center;
display: flex;
gap: 8px;
align-items: center;
}
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.welcome-title {
font-size: 28px;
margin-bottom: 8px;
font-size: 28px;
margin-bottom: 8px;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
animation: fadeIn 0.3s ease-in-out;
}
.preview-image-large {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
</style>
@@ -10,7 +10,8 @@
"theme": {
"light": "Light Mode",
"dark": "Dark Mode"
}
},
"logout": "Log Out"
},
"updateDialog": {
"title": "Update AstrBot",
@@ -65,6 +65,11 @@
"subtitle": "Restart AstrBot",
"button": "Restart"
},
"logout": {
"title": "Log Out",
"subtitle": "Log out of the current account",
"button": "Log Out"
},
"migration": {
"title": "Data Migration to v4.0.0",
"subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant",
@@ -10,7 +10,8 @@
"theme": {
"light": "浅色模式",
"dark": "深色模式"
}
},
"logout": "退出登录"
},
"updateDialog": {
"title": "更新 AstrBot",
@@ -65,6 +65,11 @@
"subtitle": "重启 AstrBot",
"button": "重启"
},
"logout": {
"title": "退出登录",
"subtitle": "退出当前账号,回到登录界面",
"button": "退出登录"
},
"migration": {
"title": "数据迁移到 v4.0.0 格式",
"subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手",
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -22,7 +22,7 @@
</div> -->
<v-row class="mt-2">
<v-col cols="12">
<div class="d-flex align-center">
<div>
<!-- <v-text-field
v-model="apiBaseUrl"
:label="tm('network.server.label')"
@@ -425,6 +425,22 @@
</v-btn>
</div>
</v-list-item>
<v-list-item
:subtitle="tm('system.logout.subtitle')"
:title="tm('system.logout.title')"
>
<div class="d-flex align-center mt-2">
<v-btn
color="error"
variant="tonal"
prepend-icon="mdi-logout"
@click="logout"
>
{{ tm("system.logout.button") }}
</v-btn>
</div>
</v-list-item>
</v-list>
</div>
@@ -439,6 +455,7 @@ import { useTheme } from "vuetify";
import { useCustomizerStore } from "@/stores/customizer";
import { useCommonStore } from "@/stores/common";
import { useApiStore } from "@/stores/api";
import { useAuthStore } from "@/stores/auth";
import ProxySelector from "@/components/shared/ProxySelector.vue";
import SidebarCustomizer from "@/components/shared/SidebarCustomizer.vue";
import WaitingForRestart from "@/components/shared/WaitingForRestart.vue";
@@ -474,6 +491,7 @@ const toastStore = useToast();
const theme = useTheme();
const apiStore = useApiStore();
const authStore = useAuthStore();
const apiBaseUrl = ref(apiStore.apiBaseUrl);
@@ -725,6 +743,12 @@ const restartAstrBot = async () => {
wfr.value.dialog = true;
};
const logout = () => {
if (confirm(t("core.common.dialog.confirmMessage"))) {
authStore.logout();
}
};
const startMigration = async () => {
if (migrationDialog.value) {
const result = await migrationDialog.value.open();