Compare commits

...

6 Commits

22 changed files with 567 additions and 881 deletions

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 273 icons */
/* Auto-generated MDI subset 270 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -324,10 +324,6 @@
content: "\F0CFD";
}
.mdi-cursor-move::before {
content: "\F01BE";
}
.mdi-database::before {
content: "\F01BC";
}
@@ -404,10 +400,6 @@
content: "\F0209";
}
.mdi-eye-outline::before {
content: "\F06D0";
}
.mdi-eyedropper::before {
content: "\F020A";
}
@@ -980,10 +972,6 @@
content: "\F0666";
}
.mdi-store::before {
content: "\F04DC";
}
.mdi-subdirectory-arrow-right::before {
content: "\F060D";
}

View File

@@ -2,7 +2,11 @@
<div
v-if="props.active"
class="chat-ui"
:class="{ 'is-dark': isDark, 'sidebar-collapsed': isSidebarCollapsed }"
:class="{
'is-dark': isDark,
'sidebar-collapsed': isSidebarCollapsed,
'dashboard-layout': !props.chatboxMode,
}"
>
<v-navigation-drawer
v-model="chatSidebarDrawer"
@@ -169,160 +173,6 @@
</section>
</div>
<div class="sidebar-footer">
<StyledMenu
location="top start"
offset="10"
:close-on-content-click="false"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="settings-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
>
<Settings
:size="20"
:class="['sidebar-action-icon', { 'mr-2': !isSidebarCollapsed }]"
/>
<span v-if="!isSidebarCollapsed">{{
t("core.common.settings")
}}</span>
</v-btn>
</template>
<div class="settings-menu-content">
<v-menu
location="end"
offset="8"
open-on-hover
:close-on-content-click="true"
>
<template #activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item settings-menu-item"
rounded="md"
>
<template #prepend>
<Cable :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
tm("transport.title")
}}</v-list-item-title>
<template #append>
<span class="settings-menu-value">{{
currentTransportLabel
}}</span>
<ChevronRight :size="18" class="styled-menu-lucide-icon" />
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="item in transportOptions"
:key="item.value"
class="styled-menu-item"
:class="{
'styled-menu-item-active': transportMode === item.value,
}"
rounded="md"
@click="transportMode = item.value"
>
<v-list-item-title>{{
tm(item.labelKey)
}}</v-list-item-title>
<template #append>
<Check
v-if="transportMode === item.value"
:size="18"
class="styled-menu-lucide-icon"
/>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-menu
location="end"
offset="8"
open-on-hover
:close-on-content-click="true"
>
<template #activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item settings-menu-item"
rounded="md"
>
<template #prepend>
<Languages :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
t("core.common.language")
}}</v-list-item-title>
<template #append>
<span class="settings-menu-value">{{
currentLanguage?.label || locale
}}</span>
<ChevronRight :size="18" class="styled-menu-lucide-icon" />
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languageOptions"
:key="lang.value"
class="styled-menu-item"
:class="{
'styled-menu-item-active': locale === lang.value,
}"
rounded="md"
@click="switchLanguage(lang.value as Locale)"
>
<template #prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.label }}</v-list-item-title>
<template #append>
<Check
v-if="locale === lang.value"
:size="18"
class="styled-menu-lucide-icon"
/>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-list-item
class="styled-menu-item settings-menu-item"
rounded="md"
@click="toggleTheme"
>
<template #prepend>
<Sun
v-if="isDark"
:size="18"
class="styled-menu-lucide-icon"
/>
<Moon v-else :size="18" class="styled-menu-lucide-icon" />
</template>
<v-list-item-title>{{
isDark ? tm("modes.lightMode") : tm("modes.darkMode")
}}</v-list-item-title>
</v-list-item>
</div>
</StyledMenu>
</div>
</v-navigation-drawer>
<main
@@ -551,20 +401,12 @@ import { useDisplay } from "vuetify";
import { isAxiosError } from "axios";
import {
Box,
Cable,
Check,
ChevronRight,
Languages,
Moon,
PanelLeft,
Pencil,
Settings,
SquarePen,
Sun,
Trash2,
} from "@lucide/vue";
import { chatApi, providerApi } from "@/api/v1";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ProjectDialog, {
type ProjectFormData,
} from "@/components/chat/ProjectDialog.vue";
@@ -592,12 +434,7 @@ import { useProjects } from "@/composables/useProjects";
import { useChatHeaderStore } from "@/stores/chatHeader";
import { useCustomizerStore } from "@/stores/customizer";
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
import {
useI18n,
useLanguageSwitcher,
useModuleI18n,
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import {
contextLimit,
@@ -621,8 +458,6 @@ const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const toast = useToast();
const { languageOptions, currentLanguage, switchLanguage, locale } =
useLanguageSwitcher();
const {
sessions,
currSessionId,
@@ -789,20 +624,6 @@ const transportMode = ref<TransportMode>(
? "websocket"
: "sse",
);
const transportOptions: Array<{ value: TransportMode; labelKey: string }> = [
{ value: "sse", labelKey: "transport.sse" },
{ value: "websocket", labelKey: "transport.websocket" },
];
const currentTransportLabel = computed(() =>
tm(
transportOptions.find((item) => item.value === transportMode.value)
?.labelKey || "transport.sse",
),
);
watch(transportMode, (mode) => {
localStorage.setItem("chat.transportMode", mode);
});
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
const canSend = computed(
@@ -1657,9 +1478,6 @@ async function stopCurrentSession() {
}
}
function toggleTheme() {
customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark");
}
</script>
<style scoped>
@@ -1701,6 +1519,10 @@ function toggleTheme() {
--chat-section-label: rgba(255, 255, 255, 0.5);
}
.chat-ui.dashboard-layout .chat-main {
padding-top: 0;
}
.chat-sidebar {
top: 0 !important;
height: 100vh !important;
@@ -1853,8 +1675,7 @@ function toggleTheme() {
flex: 0 0 auto;
}
.new-chat-btn,
.settings-btn {
.new-chat-btn {
color: rgb(var(--v-theme-on-surface));
border-radius: 8px;
}
@@ -1869,8 +1690,7 @@ function toggleTheme() {
margin-right: 12px !important;
}
.new-chat-btn,
.settings-btn {
.new-chat-btn {
width: 100%;
min-height: 36px;
height: 36px;
@@ -1886,13 +1706,11 @@ function toggleTheme() {
margin-bottom: 2px;
}
.new-chat-btn:not(.icon-only),
.settings-btn:not(.icon-only) {
.new-chat-btn:not(.icon-only) {
padding-inline: 10px;
}
.new-chat-btn.icon-only,
.settings-btn.icon-only {
.new-chat-btn.icon-only {
width: 36px !important;
height: 36px !important;
min-width: 36px !important;
@@ -1901,27 +1719,19 @@ function toggleTheme() {
justify-content: center;
}
.chat-sidebar.collapsed .new-chat-btn.icon-only :deep(.v-btn__content),
.chat-sidebar.collapsed .settings-btn.icon-only :deep(.v-btn__content) {
.chat-sidebar.collapsed .new-chat-btn.icon-only :deep(.v-btn__content) {
display: flex;
align-items: center;
justify-content: center;
}
.new-chat-btn :deep(.v-btn__content),
.settings-btn :deep(.v-btn__content) {
.new-chat-btn :deep(.v-btn__content) {
min-width: 0;
font-size: 14px;
line-height: 20px;
}
.chat-sidebar.collapsed .sidebar-footer {
display: flex;
justify-content: center;
}
.new-chat-btn:hover,
.settings-btn:hover {
.new-chat-btn:hover {
background: var(--chat-session-active-bg);
}
@@ -2038,72 +1848,6 @@ function toggleTheme() {
color: rgb(var(--v-theme-on-surface));
}
.sidebar-footer {
margin-top: auto;
padding: 10px 16px 14px;
}
.chat-sidebar.collapsed .sidebar-footer {
width: 56px;
box-sizing: border-box;
padding-inline: 10px;
}
.settings-menu-content {
min-width: 270px;
padding: 6px;
}
.settings-menu-item {
min-height: 42px;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__prepend) {
width: 28px;
margin-inline-end: 12px;
align-self: center;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__content) {
min-width: 0;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item-title) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-menu-content :deep(.settings-menu-item .v-list-item__append) {
margin-inline-start: auto;
padding-inline-start: 18px;
gap: 8px;
align-self: center;
}
.styled-menu-lucide-icon {
flex: 0 0 auto;
color: currentcolor;
stroke-width: 2;
}
.settings-menu-value {
color: var(--chat-muted);
font-size: 12px;
margin-right: 4px;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.language-flag {
display: inline-block;
width: 20px;
margin-right: 8px;
}
.chat-main {
flex: 1;
min-width: 0;
@@ -2307,4 +2051,9 @@ kbd {
padding: 0;
}
}
.chat-ui.dashboard-layout .chat-sidebar {
top: 0 !important;
height: 100% !important;
}
</style>

View File

@@ -180,7 +180,7 @@ const { success: toastSuccess, error: toastError } = useToast();
const variant = computed(() => props.variant);
const menuLocation = computed(() =>
props.variant === "header" ? "bottom start" : "top",
props.variant === "header" ? "bottom end" : "top",
);
const selectedProvider = computed(() =>

View File

@@ -45,7 +45,7 @@ localStorage.setItem('uiTheme', uiTheme);
const config: ConfigProps = {
Sidebar_drawer: true,
Customizer_drawer: false,
mini_sidebar: false,
mini_sidebar: true,
fontTheme: 'Roboto',
uiTheme,
themeMode,

View File

@@ -20,6 +20,23 @@
},
"openapi": {
"title": "OpenAPI"
},
"resources": {
"title": "About"
}
},
"resources": {
"changelog": {
"subtitle": "Review changes in the current and previous versions."
},
"documentation": {
"subtitle": "Open the official AstrBot documentation."
},
"faq": {
"subtitle": "Read common questions and troubleshooting notes."
},
"github": {
"subtitle": "Visit the AstrBot GitHub repository."
}
},
"systemConfig": {
@@ -58,6 +75,12 @@
},
"network": {
"title": "Network",
"transport": {
"title": "Chat Transport Mode",
"subtitle": "Choose the connection method Chat uses for streaming messages. Stored locally in this browser.",
"sse": "SSE",
"websocket": "WebSocket"
},
"githubProxy": {
"title": "GitHub Proxy Address",
"subtitle": "Set the GitHub proxy address used when downloading plugins or updating AstrBot. This is effective in mainland China's network environment. Can be customized, input takes effect in real time. All addresses do not guarantee stability. If errors occur when updating plugins/projects, please first check if the proxy address is working properly.",

View File

@@ -20,6 +20,23 @@
},
"openapi": {
"title": "OpenAPI"
},
"resources": {
"title": "О программе"
}
},
"resources": {
"changelog": {
"subtitle": "Просмотреть изменения текущей и предыдущих версий."
},
"documentation": {
"subtitle": "Открыть официальную документацию AstrBot."
},
"faq": {
"subtitle": "Посмотреть частые вопросы и советы по устранению проблем."
},
"github": {
"subtitle": "Открыть репозиторий AstrBot на GitHub."
}
},
"systemConfig": {
@@ -58,6 +75,12 @@
},
"network": {
"title": "Сеть",
"transport": {
"title": "Транспорт чата",
"subtitle": "Выберите способ подключения, который чат использует для потоковой передачи сообщений. Настройка хранится локально в браузере.",
"sse": "SSE",
"websocket": "WebSocket"
},
"githubProxy": {
"title": "Зеркало GitHub",
"subtitle": "Адрес для ускорения загрузки плагинов и обновлений AstrBot. Особенно актуально для пользователей из Китая. Все адреса предоставляются как есть, если обновление не удается — проверьте доступность выбранного зеркала.",

View File

@@ -20,6 +20,23 @@
},
"openapi": {
"title": "OpenAPI"
},
"resources": {
"title": "关于"
}
},
"resources": {
"changelog": {
"subtitle": "查看当前版本和历史版本的更新内容。"
},
"documentation": {
"subtitle": "打开 AstrBot 官方文档。"
},
"faq": {
"subtitle": "查看常见问题与排障说明。"
},
"github": {
"subtitle": "访问 AstrBot GitHub 仓库。"
}
},
"systemConfig": {
@@ -58,6 +75,12 @@
},
"network": {
"title": "网络",
"transport": {
"title": "通信传输模式",
"subtitle": "选择聊天消息流式传输时使用的连接方式,设置保存在浏览器本地。",
"sse": "SSE",
"websocket": "WebSocket"
},
"githubProxy": {
"title": "GitHub 加速地址",
"subtitle": "设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效。所有地址均不保证稳定性,如果在更新插件/项目时出现报错,请首先检查加速地址是否能正常使用。",

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { RouterView, useRoute } from "vue-router";
import { ref, onMounted, computed, watch } from "vue";
import { ref, onMounted, computed } from "vue";
import VerticalSidebarVue from "./vertical-sidebar/VerticalSidebar.vue";
import VerticalHeaderVue from "./vertical-header/VerticalHeader.vue";
import ReadmeDialog from "@/components/shared/ReadmeDialog.vue";
import Chat from "@/components/chat/Chat.vue";
import { useCustomizerStore } from "@/stores/customizer";
import { useRouterLoadingStore } from "@/stores/routerLoading";
import { useCommonStore } from "@/stores/common";
@@ -18,27 +17,13 @@ const commonStore = useCommonStore();
const { locale } = useI18n();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
const isCurrentChatRoute = computed(
() => route.path === "/chat" || route.path.startsWith("/chat/"),
);
const isPluginPageRoute = computed(
() => route.path.startsWith("/plugin-page/"),
);
const isFullScreenRoute = computed(
() => isCurrentChatRoute.value || isPluginPageRoute.value,
);
const shouldMountChat = ref(isCurrentChatRoute.value);
const showSidebar = computed(() => !isCurrentChatRoute.value);
const isFullScreenRoute = computed(() => isPluginPageRoute.value);
const showFirstNoticeDialog = ref(false);
watch(isCurrentChatRoute, (isChatRoute) => {
if (isChatRoute) {
shouldMountChat.value = true;
}
});
const maybeShowFirstNotice = async () => {
if (localStorage.getItem(FIRST_NOTICE_SEEN_KEY) === "1") {
return;
@@ -107,18 +92,11 @@ onMounted(() => {
style="z-index: 9999; position: absolute; opacity: 0.3"
/>
<VerticalHeaderVue />
<VerticalSidebarVue v-if="showSidebar" />
<v-main
:class="{ 'chat-main': isCurrentChatRoute }"
:style="{
height: isCurrentChatRoute ? '100vh' : undefined,
overflow: isCurrentChatRoute ? 'hidden' : undefined,
}"
>
<VerticalSidebarVue />
<v-main>
<v-container
fluid
class="page-wrapper"
:class="{ 'chat-mode-container': isCurrentChatRoute }"
:style="{
height: isFullScreenRoute ? '100%' : 'calc(100% - 8px)',
padding: isFullScreenRoute ? '0' : undefined,
@@ -129,18 +107,10 @@ onMounted(() => {
:style="{
height: '100%',
width: '100%',
overflow: isCurrentChatRoute ? 'hidden' : undefined,
position: isPluginPageRoute ? 'relative' : undefined,
}"
>
<div
v-if="shouldMountChat"
v-show="isCurrentChatRoute"
style="height: 100%; width: 100%; overflow: hidden"
>
<Chat :active="isCurrentChatRoute" />
</div>
<RouterView v-if="!isCurrentChatRoute" />
<RouterView />
</div>
</v-container>
</v-main>
@@ -154,14 +124,3 @@ onMounted(() => {
</v-locale-provider>
</template>
<style scoped>
.chat-mode-container {
min-height: unset !important;
height: 100% !important;
overflow: hidden !important;
}
.chat-main {
padding-top: 0 !important;
}
</style>

View File

@@ -13,7 +13,7 @@ import "highlight.js/styles/github.css";
import { useI18n } from "@/i18n/composables";
import { router } from "@/router";
import { useRoute } from "vue-router";
import { useDisplay, useTheme } from "vuetify";
import { useTheme } from "vuetify";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import { useLanguageSwitcher } from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
@@ -29,11 +29,8 @@ const customizer = useCustomizerStore();
const commonStore = useCommonStore();
const chatHeader = useChatHeaderStore();
const theme = useTheme();
const { lgAndUp } = useDisplay();
const { t } = useI18n();
const route = useRoute();
const LAST_BOT_ROUTE_KEY = "astrbot:last_bot_route";
const LAST_CHAT_ROUTE_KEY = "astrbot:last_chat_route";
const SHOW_PRE_RELEASES_KEY = "astrbot:updateDialog:showPreReleases";
let dialog = ref(false);
let accountWarning = ref(false);
@@ -124,21 +121,6 @@ const desktopUpdateStatus = ref("");
const isChatPath = computed(
() => route.path === "/chat" || route.path.startsWith("/chat/"),
);
const isDarkTheme = computed(
() => theme.global.current.value.dark || customizer.uiTheme.includes("Dark"),
);
const chatHeaderStyle = computed(() => {
if (!isChatPath.value) return undefined;
const sidebarWidth = lgAndUp.value
? customizer.chatSidebarCollapsed
? 56
: 280
: 0;
return {
left: `${sidebarWidth}px`,
width: `calc(100% - ${sidebarWidth}px)`,
};
});
const chatHeaderSubtitleText = computed(() => {
const title = chatHeader.title.trim();
const subtitle = chatHeader.subtitle.trim();
@@ -150,6 +132,11 @@ function toggleChatSidebarFromHeader() {
customizer.TOGGLE_CHAT_SIDEBAR();
}
function toggleDesktopSidebarFromHeader() {
customizer.Sidebar_drawer = true;
customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar);
}
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
if (typeof window === "undefined") {
return null;
@@ -891,11 +878,7 @@ function openReleaseNotesDialog(body: string, tag: string) {
}
function handleLogoClick() {
if (isChatPath.value) {
aboutDialog.value = true;
} else {
router.push("/about");
}
router.push("/about");
}
getVersion();
@@ -911,107 +894,12 @@ onUnmounted(() => {
stopRestartReloadTimer();
});
// 视图模式切换
onMounted(() => {
// 初次加載時保存當前路由
if (typeof window !== "undefined") {
if (isChatPath.value) {
// 保存 chat ID
const parts = route.fullPath.split("/");
const sessionId = parts[2];
if (sessionId) {
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
console.log("Initial save chat ID:", sessionId);
}
} else {
// 保存 bot 路由(非 chat 頁面)
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, route.fullPath);
console.log("Initial save bot route:", route.fullPath);
}
}
});
// 监听 viewMode 变化,切换到 bot 模式时跳转到首页
// 保存 bot 模式的最後路由
// 監聽 route 變化,保存最後一次 bot 路由
watch(showPreReleases, (value) => {
if (typeof window === "undefined") return;
localStorage.setItem(SHOW_PRE_RELEASES_KEY, value ? "true" : "false");
});
watch(
() => route.fullPath,
(newPath) => {
if (typeof window === "undefined") return;
console.log("Route changed:", {
newPath,
isChat: isChatPath.value,
currentChatId: route.params.id,
});
try {
// 使用現有的 isChatPath 計算屬性來避免名稱衝突
const isChat = isChatPath.value; // 這裡使用已經計算好的 isChatPath
// ✅ bot只存「非 chat 頁」
if (!isChat) {
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
}
// ✅ chat只存 sessionId
if (isChat) {
const parts = newPath.split("/");
const sessionId = parts[2];
if (sessionId) {
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
}
}
} catch (e) {
console.error("Failed to save route:", e);
}
},
);
const currentMode = computed({
get: () => (isChatPath.value ? "chat" : "bot"),
set: (val: "chat" | "bot") => {
try {
// 檢查 window 和 sessionStorage 是否存在
if (
typeof window === "undefined" ||
typeof sessionStorage === "undefined"
) {
// 如果在非瀏覽器環境中,不做任何 sessionStorage 操作
console.warn("sessionStorage is not available in this environment");
return;
}
if (val === "chat") {
const lastSessionId = sessionStorage.getItem(LAST_CHAT_ROUTE_KEY);
router.push(lastSessionId ? `/chat/${lastSessionId}` : "/chat");
} else {
let lastBotRoute = sessionStorage.getItem(LAST_BOT_ROUTE_KEY) || "/";
if (lastBotRoute.startsWith("/chat")) {
lastBotRoute = "/";
}
router.push(lastBotRoute);
}
} catch (e) {
// 在受限隱私模式等環境中sessionStorage 操作可能會拋出 SecurityError
console.warn("Failed to access sessionStorage in currentMode setter:", e);
}
},
});
const mainMenuOpen = ref(false);
const nextMode = computed<"chat" | "bot">(() =>
isChatPath.value ? "bot" : "chat",
);
function switchMode() {
currentMode.value = nextMode.value;
mainMenuOpen.value = false;
}
// Merry Christmas! 🎄
const isChristmas = computed(() => {
@@ -1051,40 +939,33 @@ onMounted(async () => {
elevation="0"
height="50"
class="top-header"
:class="{
'chat-mode-header': isChatPath,
'chat-mode-header--dark': isChatPath && isDarkTheme,
}"
:absolute="isChatPath"
:style="chatHeaderStyle"
>
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
<!-- 桌面端 menu 按钮 -->
<v-btn
v-if="!isChatPath"
style="margin-left: 16px"
class="hidden-md-and-down"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)"
aria-label="Toggle sidebar"
@click.stop="toggleDesktopSidebarFromHeader"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 menu 按钮 -->
<v-btn
v-if="!isChatPath"
class="hidden-lg-and-up ms-3"
icon
rounded="sm"
variant="flat"
aria-label="Toggle sidebar"
@click.stop="customizer.SET_SIDEBAR_DRAWER"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<div
v-if="!isChatPath"
class="logo-container"
:class="{
'mobile-logo': $vuetify.display.xs,
@@ -1118,6 +999,9 @@ onMounted(async () => {
</v-icon>
</v-btn>
<v-spacer />
<!-- 版本提示信息 - 在手机上隐藏 -->
<div
v-if="isChatPath"
class="chat-header-context"
@@ -1128,10 +1012,7 @@ onMounted(async () => {
</div>
</div>
<v-spacer />
<!-- 版本提示信息 - 在手机上隐藏 -->
<div v-if="!isChatPath" class="mr-4 hidden-xs">
<div v-else class="mr-4 hidden-xs">
<small v-if="hasNewVersion">
{{ t("core.header.version.hasNewVersion") }}
</small>
@@ -1140,33 +1021,16 @@ onMounted(async () => {
</small>
</div>
<div class="header-actions" :class="{ 'chat-header-actions': isChatPath }">
<!-- Bot/Chat mode switch - single button, hidden in chat mobile menu -->
<v-btn
v-if="!isChatPath || !$vuetify.display.smAndDown"
class="mode-switch-btn"
:class="{ 'mr-4 hidden-xs': !isChatPath }"
variant="text"
size="small"
rounded="sm"
@click="switchMode"
>
<v-icon start>{{ nextMode === "bot" ? "mdi-robot" : "mdi-chat" }}</v-icon>
{{ nextMode === "bot" ? "Bot" : "Chat" }}
</v-btn>
<div class="header-actions">
<!-- 功能菜单 -->
<StyledMenu v-model="mainMenuOpen" offset="12" location="bottom end">
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
size="small"
:class="[
'action-btn',
isChatPath ? 'chat-action-btn' : 'mr-4',
]"
:color="isChatPath ? undefined : 'var(--v-theme-surface)'"
:variant="isChatPath ? 'text' : 'flat'"
class="action-btn mr-4"
color="var(--v-theme-surface)"
variant="flat"
rounded="sm"
icon
>
@@ -1174,29 +1038,6 @@ onMounted(async () => {
</v-btn>
</template>
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
<template
v-if="
(isChatPath && $vuetify.display.smAndDown) ||
(!isChatPath && $vuetify.display.xs)
"
>
<div class="mobile-mode-switch-wrapper">
<v-btn
class="mobile-mode-switch-btn"
variant="text"
block
@click="switchMode"
>
<v-icon start>{{
nextMode === "bot" ? "mdi-robot" : "mdi-chat"
}}</v-icon>
{{ nextMode === "bot" ? "Bot" : "Chat" }}
</v-btn>
</div>
<v-divider class="my-1" />
</template>
<!-- 语言切换分组 -->
<v-menu
open-on-click
@@ -2033,21 +1874,6 @@ onMounted(async () => {
flex: 0 1 auto;
}
.top-header.chat-mode-header {
background: #fdfcfc !important;
border-bottom: 0;
box-shadow: none !important;
}
.top-header.chat-mode-header.chat-mode-header--dark {
background: rgb(var(--v-theme-background)) !important;
}
.top-header.chat-mode-header .v-toolbar__content {
padding: 0 16px 0 20px;
background: transparent !important;
}
.chat-mobile-sidebar-toggle {
width: 32px !important;
height: 32px !important;
@@ -2071,13 +1897,15 @@ onMounted(async () => {
.chat-header-context {
min-width: 0;
max-width: clamp(180px, calc(100vw - 600px), 460px);
max-width: min(360px, 38vw);
align-self: stretch;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
padding: 5px 18px 5px 0;
padding: 5px 10px 5px 0;
overflow: hidden;
text-align: right;
}
.chat-header-context .provider-trigger--header {
@@ -2090,7 +1918,7 @@ onMounted(async () => {
.chat-header-context .provider-trigger-title {
min-width: 0;
max-width: min(300px, 52vw);
max-width: min(240px, 30vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2117,41 +1945,6 @@ onMounted(async () => {
align-items: center;
}
.chat-header-actions {
gap: 4px;
margin-right: 0;
}
.mode-switch-btn {
margin: 0;
border: 0;
color: rgb(var(--v-theme-on-surface));
font-size: 14px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
box-shadow: none;
}
.mode-switch-btn {
min-width: 62px;
background: transparent !important;
padding: 0 6px;
}
.mode-switch-btn .v-btn__overlay {
opacity: 0 !important;
}
.mode-switch-btn .v-icon {
font-size: 19px;
}
.chat-action-btn {
margin-right: 0;
color: rgb(var(--v-theme-on-surface));
}
/* 响应式布局样式 */
.logo-container {
margin-left: 10px;
@@ -2240,30 +2033,6 @@ onMounted(async () => {
opacity: 0.85;
}
.mobile-mode-switch-wrapper {
display: flex;
justify-content: center;
padding: 8px 12px 4px;
}
.mobile-mode-switch-btn {
width: 100%;
justify-content: flex-start;
color: rgb(var(--v-theme-on-surface));
font-size: 14px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.mobile-mode-switch-btn .v-icon {
font-size: 19px;
}
.mobile-mode-switch-btn .v-btn__overlay {
opacity: 0 !important;
}
/* 移动端对话框标题样式 */
.mobile-card-title {
display: flex;
@@ -2472,17 +2241,8 @@ onMounted(async () => {
font-size: 16px;
}
.chat-header-actions .chat-action-btn,
.chat-header-actions .mode-switch-btn {
margin-right: 0;
}
.top-header.chat-mode-header .v-toolbar__content {
padding: 0 12px 0 14px;
}
.chat-header-context {
max-width: calc(100vw - 92px);
max-width: calc(100vw - 210px);
padding-right: 8px;
}

View File

@@ -1,17 +1,15 @@
<script setup>
import { useI18n } from '@/i18n/composables';
import { useCustomizerStore } from '@/stores/customizer';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const props = defineProps({ item: Object, level: Number });
const props = defineProps({ item: Object, level: Number, rail: Boolean });
const { t } = useI18n();
const customizer = useCustomizerStore();
const route = useRoute();
const itemStyle = computed(() => {
const lvl = props.level ?? 0;
const indent = customizer.mini_sidebar ? '0px' : `${lvl * 24}px`;
const indent = props.rail ? '0px' : `${lvl * 24}px`;
return { '--indent-padding': indent };
});
@@ -22,7 +20,11 @@ const isItemActive = computed(() => {
const [path, hash] = props.item.to.split('#');
return route.path === path && route.hash === `#${hash}`;
}
return route.path === props.item.to;
const targetPath = props.item.to.replace(/\/$/, '') || '/';
if (targetPath === '/') {
return route.path === targetPath;
}
return route.path === targetPath || route.path.startsWith(`${targetPath}/`);
});
const itemTitle = computed(() => {
@@ -33,9 +35,19 @@ const itemTitle = computed(() => {
</script>
<template>
<v-list-group v-if="item.children" :value="item.title" :class="{ 'group-bordered': customizer.mini_sidebar }">
<template v-slot:activator="{ props }">
<v-list-item v-bind="props" rounded class="mb-1" color="secondary" :prepend-icon="item.icon"
<v-list-group v-if="item.children" :value="item.title" :class="{ 'rail-group': rail }">
<template v-slot:activator="{ props: groupProps }">
<v-tooltip v-if="rail" location="right" :text="itemTitle" open-delay="180">
<template v-slot:activator="{ props: tooltipProps }">
<v-list-item v-bind="{ ...groupProps, ...tooltipProps }" rounded class="mb-1" color="secondary"
:prepend-icon="item.icon" :style="{ '--indent-padding': '0px' }" :aria-label="itemTitle">
<v-list-item-title style="font-size: 14px; font-weight: 500; line-height: 1.2; word-break: break-word;">
{{ itemTitle }}
</v-list-item-title>
</v-list-item>
</template>
</v-tooltip>
<v-list-item v-else v-bind="groupProps" rounded class="mb-1" color="secondary" :prepend-icon="item.icon"
:style="{ '--indent-padding': '0px' }">
<v-list-item-title style="font-size: 14px; font-weight: 500; line-height: 1.2; word-break: break-word;">
{{ itemTitle }}
@@ -45,10 +57,33 @@ const itemTitle = computed(() => {
<!-- children -->
<template v-for="(child, index) in item.children" :key="child.title || child.to || `child-${index}`">
<NavItem :item="child" :level="(level || 0) + 1" />
<NavItem :item="child" :level="(level || 0) + 1" :rail="rail" />
</template>
</v-list-group>
<v-tooltip v-else-if="rail" location="right" :text="itemTitle" open-delay="180">
<template v-slot:activator="{ props: tooltipProps }">
<v-list-item v-bind="tooltipProps" :to="item.type === 'external' ? '' : item.to"
:href="item.type === 'external' ? item.to : ''" :active="isItemActive" rounded class="mb-1"
color="secondary" :disabled="item.disabled" :target="item.type === 'external' ? '_blank' : ''"
:style="itemStyle" :aria-label="itemTitle">
<template v-slot:prepend>
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
</template>
<v-list-item-title style="font-size: 14px;">{{ itemTitle }}</v-list-item-title>
<v-list-item-subtitle v-if="item.subCaption" class="text-caption mt-n1 hide-menu">
{{ item.subCaption }}
</v-list-item-subtitle>
<template v-slot:append v-if="item.chip">
<v-chip :color="item.chipColor" class="sidebarchip hide-menu" :size="item.chipIcon ? 'small' : 'default'"
:variant="item.chipVariant" :prepend-icon="item.chipIcon">
{{ item.chip }}
</v-chip>
</template>
</v-list-item>
</template>
</v-tooltip>
<v-list-item v-else :to="item.type === 'external' ? '' : item.to" :href="item.type === 'external' ? item.to : ''"
:active="isItemActive" rounded class="mb-1" color="secondary" :disabled="item.disabled"
:target="item.type === 'external' ? '_blank' : ''" :style="itemStyle">
@@ -69,10 +104,16 @@ const itemTitle = computed(() => {
</template>
<style>
/* 在折叠(mini)状态下,分组展开时给整个分组(母项+子项)加边框以便区分 */
.group-bordered.v-list-group--open {
border: 2px solid rgba(var(--v-theme-borderLight), 0.35);
border-radius: 8px;
background: rgba(var(--v-theme-borderLight), 0.04);
.rail-group {
border-radius: 12px;
transition: background-color 0.18s ease;
}
.rail-group.v-list-group--open {
background: rgba(var(--v-theme-primary), 0.06);
}
.rail-group.v-list-group--open > .v-list-group__items {
padding-bottom: 2px;
}
</style>

View File

@@ -1,18 +1,15 @@
<script setup>
import { ref, shallowRef, computed, onMounted, onUnmounted, watch } from 'vue';
import { useTheme } from 'vuetify';
import { useCustomizerStore } from '../../../stores/customizer';
import { useI18n } from '@/i18n/composables';
import sidebarItems, { MORE_GROUP_KEY } from './sidebarItem';
import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
import { usePluginSidebarItems } from '@/composables/usePluginSidebarItems';
const { t, locale } = useI18n();
const { t } = useI18n();
const customizer = useCustomizerStore();
const theme = useTheme();
const { pluginItems } = usePluginSidebarItems();
function buildSidebarMenu() {
@@ -105,159 +102,24 @@ onUnmounted(() => {
window.removeEventListener('sidebar-customization-changed', handleCustomEvent);
});
const showIframe = ref(false);
const starCount = ref(null);
// 更新日志对话框
const changelogDialog = ref(false);
const sidebarWidth = ref(235);
const minSidebarWidth = 200;
const maxSidebarWidth = 300;
const isResizing = ref(false);
const isDark = computed(() => customizer.uiTheme === 'PurpleThemeDark');
const themeColors = computed(() => theme.current.value.colors);
const iframeBackground = computed(() => isDark.value ? themeColors.value.surface || 'white' : 'white');
const dragHeaderBackground = computed(() => isDark.value ? themeColors.value.mcpCardBg || themeColors.value.surface || 'white' : '#f0f0f0');
const frameBorder = computed(() => `1px solid ${isDark.value ? (themeColors.value.borderLight || '#ccc') : '#ccc'}`);
const isMobile = window.innerWidth < 768;
const isRailSidebar = computed(() => !isMobile && customizer.mini_sidebar);
if (isMobile) {
customizer.Sidebar_drawer = false;
} else {
customizer.Sidebar_drawer = true;
customizer.SET_MINI_SIDEBAR(true);
}
const dragPos = ref({ left: '', top: '' });
const iframeStyle = computed(() => {
const base = isMobile
? { position: 'fixed', top: '10%', left: '0%', width: '100%', height: '80%', zIndex: '1002' }
: { position: 'fixed', bottom: '16px', right: '16px', width: '490px', height: '640px', zIndex: '10000000' };
const pos = dragPos.value.left ? { left: dragPos.value.left, top: dragPos.value.top, bottom: 'auto', right: 'auto' } : {};
return {
...base,
...pos,
minWidth: '300px',
minHeight: '200px',
background: iframeBackground.value,
resize: 'both',
overflow: 'auto',
borderRadius: '12px',
boxShadow: isDark.value ? '0px 4px 16px rgba(0, 0, 0, 0.5)' : '0px 4px 12px rgba(0, 0, 0, 0.1)',
};
});
const iframeInnerStyle = computed(() => ({
width: '100%',
height: 'calc(100% - 66px)',
border: 'none',
borderBottomLeftRadius: '12px',
borderBottomRightRadius: '12px',
filter: isDark.value ? 'invert(0.88) hue-rotate(180deg)' : 'none',
}));
const dragHeaderStyle = computed(() => ({
width: '100%',
padding: '8px',
background: dragHeaderBackground.value,
borderBottom: frameBorder.value,
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'move'
}));
function toggleIframe() {
showIframe.value = !showIframe.value;
}
function openIframeLink(url) {
if (typeof window !== 'undefined') {
let url_ = url || "https://docs.astrbot.app";
window.open(url_, "_blank");
}
}
function openFaqLink() {
const faqUrl = locale.value === 'en-US'
? 'https://docs.astrbot.app/en/faq.html'
: 'https://docs.astrbot.app/faq.html';
openIframeLink(faqUrl);
}
let offsetX = 0;
let offsetY = 0;
let isDragging = false;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function startDrag(clientX, clientY) {
isDragging = true;
const dm = document.getElementById('draggable-iframe');
const rect = dm.getBoundingClientRect();
offsetX = clientX - rect.left;
offsetY = clientY - rect.top;
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}
function onMouseDown(event) {
startDrag(event.clientX, event.clientY);
}
function onMouseMove(event) {
if (isDragging) {
moveAt(event.clientX, event.clientY);
}
}
function onMouseUp() {
endDrag();
}
function onTouchStart(event) {
if (event.touches.length === 1) {
const touch = event.touches[0];
startDrag(touch.clientX, touch.clientY);
}
}
function onTouchMove(event) {
if (isDragging && event.touches.length === 1) {
event.preventDefault();
const touch = event.touches[0];
moveAt(touch.clientX, touch.clientY);
}
}
function onTouchEnd() {
endDrag();
}
function moveAt(clientX, clientY) {
const dm = document.getElementById('draggable-iframe');
const newLeft = clamp(clientX - offsetX, 0, window.innerWidth - dm.offsetWidth);
const newTop = clamp(clientY - offsetY, 0, window.innerHeight - dm.offsetHeight);
// Sync dragged position to reactive variable
dragPos.value = { left: newLeft + 'px', top: newTop + 'px' };
}
function endDrag() {
isDragging = false;
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
}
function startSidebarResize(event) {
isResizing.value = true;
document.body.style.userSelect = 'none';
@@ -291,30 +153,6 @@ function startSidebarResize(event) {
document.addEventListener('mouseup', onMouseUpResize);
}
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
async function fetchStarCount() {
try {
const response = await fetch('https://cloud.astrbot.app/api/v1/github/repo-info');
const data = await response.json();
if (data.data && data.data.stargazers_count) {
starCount.value = data.data.stargazers_count;
console.debug('Fetched star count:', starCount.value);
}
} catch (error) {
console.debug('Failed to fetch star count:', error);
}
}
fetchStarCount();
// 打开更新日志对话框
function openChangelogDialog() {
changelogDialog.value = true;
}
</script>
<template>
@@ -326,46 +164,39 @@ function openChangelogDialog() {
app
class="leftSidebar"
:width="sidebarWidth"
:rail="customizer.mini_sidebar"
:rail="isRailSidebar"
>
<div class="sidebar-container">
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': customizer.mini_sidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
<div v-if="!isRailSidebar" class="sidebar-brand">
<img
class="sidebar-brand-logo"
src="@/assets/images/plugin_icon.png"
alt="AstrBot logo"
>
<div class="sidebar-brand-copy">
<span class="sidebar-brand-title">AstrBot</span>
<span class="sidebar-brand-subtitle">WebUI</span>
</div>
</div>
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': isRailSidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
<NavItem :item="item" class="leftPadding" />
<NavItem :item="item" class="leftPadding" :rail="isRailSidebar" />
</template>
</v-list>
<div class="sidebar-footer" v-if="!customizer.mini_sidebar">
<v-btn class="sidebar-footer-btn" size="small" variant="tonal" color="primary" to="/settings" prepend-icon="mdi-cog">
{{ t('core.navigation.settings') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-note-text-outline"
@click="openChangelogDialog">
{{ t('core.navigation.changelog') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-book-open-variant"
@click="toggleIframe">
{{ t('core.navigation.documentation') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-frequently-asked-questions"
@click="openFaqLink">
{{ t('core.navigation.faq') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-github"
@click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
{{ t('core.navigation.github') }}
<v-chip
v-if="starCount"
size="x-small"
variant="outlined"
class="ml-2"
style="font-weight: normal;"
>{{ formatNumber(starCount) }}</v-chip>
<div class="sidebar-footer">
<v-btn class="sidebar-footer-btn" :class="{ 'sidebar-footer-icon-btn': isRailSidebar }" :size="isRailSidebar ? 'default' : 'small'"
variant="text" to="/settings"
:prepend-icon="isRailSidebar ? undefined : 'mdi-cog'" :aria-label="t('core.navigation.settings')">
<v-icon v-if="isRailSidebar" icon="mdi-cog" />
<template v-else>{{ t('core.navigation.settings') }}</template>
<v-tooltip v-if="isRailSidebar" activator="parent" location="right" :text="t('core.navigation.settings')" open-delay="180" />
</v-btn>
</div>
</div>
<div
v-if="!customizer.mini_sidebar && customizer.Sidebar_drawer"
v-if="!isRailSidebar && !isMobile && customizer.Sidebar_drawer"
class="sidebar-resize-handle"
@mousedown="startSidebarResize"
:class="{ 'resizing': isResizing }"
@@ -373,44 +204,6 @@ function openChangelogDialog() {
</div>
</v-navigation-drawer>
<div
v-if="showIframe"
id="draggable-iframe"
:style="iframeStyle"
>
<div :style="dragHeaderStyle" @mousedown="onMouseDown" @touchstart="onTouchStart">
<div style="display: flex; align-items: center;">
<v-icon icon="mdi-cursor-move" />
<span style="margin-left: 8px;">{{ t('core.navigation.drag') }}</span>
</div>
<div style="display: flex; gap: 8px;">
<v-btn
icon
@click.stop="openIframeLink('https://docs.astrbot.app')"
@mousedown.stop
:style="{ borderRadius: '8px', border: frameBorder }"
>
<v-icon icon="mdi-open-in-new" />
</v-btn>
<v-btn
icon
@click.stop="toggleIframe"
@mousedown.stop
:style="{ borderRadius: '8px', border: frameBorder }"
>
<v-icon icon="mdi-close" />
</v-btn>
</div>
</div>
<iframe
src="https://docs.astrbot.app"
:style="iframeInnerStyle"
></iframe>
</div>
<!-- 更新日志对话框 -->
<ChangelogDialog v-model="changelogDialog" />
</template>
<style scoped>
@@ -455,4 +248,75 @@ function openChangelogDialog() {
.leftSidebar .v-navigation-drawer__content {
position: relative;
}
.leftSidebar:not(.v-navigation-drawer--rail) .sidebar-footer {
align-items: stretch;
padding: 10px 16px 16px !important;
border-top: 1px solid rgba(var(--v-theme-borderLight), 0.35);
}
.leftSidebar:not(.v-navigation-drawer--rail) .sidebar-footer-btn {
width: 100% !important;
max-width: none !important;
min-height: 40px;
justify-content: flex-start !important;
border-radius: 12px !important;
color: rgba(var(--v-theme-on-surface), 0.76);
font-weight: 500;
letter-spacing: 0;
padding-inline: 12px !important;
transition:
background-color 0.18s ease,
color 0.18s ease;
}
.leftSidebar:not(.v-navigation-drawer--rail) .sidebar-footer-btn:hover,
.leftSidebar:not(.v-navigation-drawer--rail) .sidebar-footer-btn.v-btn--active {
color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.09);
}
.leftSidebar:not(.v-navigation-drawer--rail) .sidebar-footer-btn :deep(.v-btn__content) {
justify-content: flex-start;
gap: 8px;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 10px;
min-height: 64px;
padding: 14px 18px 10px;
border-bottom: 1px solid rgba(var(--v-theme-borderLight), 0.35);
flex-shrink: 0;
}
.sidebar-brand-logo {
width: 36px;
height: 36px;
object-fit: contain;
flex: 0 0 auto;
}
.sidebar-brand-copy {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.1;
}
.sidebar-brand-title {
color: rgb(var(--v-theme-on-surface));
font-size: 18px;
font-weight: 800;
white-space: nowrap;
}
.sidebar-brand-subtitle {
margin-top: 3px;
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
</style>

View File

@@ -26,6 +26,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-hand-wave-outline',
to: '/welcome',
},
{
title: 'core.navigation.chat',
icon: 'mdi-star-outline',
to: '/chat',
},
{
title: 'core.navigation.platforms',
icon: 'mdi-robot',
@@ -44,17 +49,12 @@ const sidebarItem: menu[] = [
{
title: 'core.navigation.extension',
icon: 'mdi-puzzle',
to: '/extension#installed',
to: '/extension',
children: [
{
title: 'core.navigation.extensionTabs.installed',
icon: 'mdi-puzzle',
to: '/extension#installed'
},
{
title: 'core.navigation.extensionTabs.market',
icon: 'mdi-store',
to: '/extension#market'
to: '/extension'
},
{
title: 'core.navigation.extensionTabs.mcp',

View File

@@ -4,16 +4,14 @@ const ChatBoxRoutes = {
children: [
{
name: 'ChatBox',
path: '/chatbox',
path: '',
component: () => import('@/views/ChatBoxPage.vue')
},
{
path: ':conversationId',
name: 'ChatBoxDetail',
component: () => import('@/views/ChatBoxPage.vue'),
children: [
{
path: ':conversationId',
name: 'ChatBoxDetail',
component: () => import('@/views/ChatBoxPage.vue'),
props: true
}
]
props: true
}
]
};

View File

@@ -159,16 +159,9 @@ const MainRoutes = {
// },
{
name: 'Chat',
path: '/chat',
path: '/chat/:conversationId?',
component: () => import('@/views/ChatPage.vue'),
children: [
{
path: ':conversationId',
name: 'ChatDetail',
component: () => import('@/views/ChatPage.vue'),
props: true
}
]
props: true
},
{
name: 'Settings',

View File

@@ -65,6 +65,101 @@
.leftPadding {
margin-left: 0px;
}
.listitem {
padding: 10px !important;
.v-list-group__items .v-list-item,
.v-list-item {
width: 56px;
min-width: 56px;
max-width: 56px;
min-height: 50px;
margin-bottom: 8px !important;
margin-inline: auto;
padding-inline: 0 !important;
grid-template-areas: "prepend" !important;
grid-template-columns: 1fr !important;
justify-content: center;
place-items: center;
}
.rail-group {
width: 56px;
margin-bottom: 8px;
margin-inline: auto;
padding: 4px 0 2px;
.v-list-item {
width: 56px;
min-width: 56px;
max-width: 56px;
margin-bottom: 6px !important;
}
}
.v-list-item__prepend {
grid-area: prepend;
width: 100%;
min-width: 0;
padding: 0 !important;
justify-content: center;
margin-inline-end: 0;
.v-icon {
margin: 0 !important;
}
}
.v-list-item__spacer,
.v-list-item__append,
.v-list-item__content {
display: none;
}
}
.sidebar-container {
align-items: center;
}
.sidebar-footer {
width: 100%;
padding: 12px 10px 18px !important;
gap: 8px;
.sidebar-footer-icon-btn {
width: 56px !important;
min-width: 56px !important;
max-width: 56px !important;
height: 52px !important;
border-radius: 14px !important;
color: rgba(var(--v-theme-on-surface), 0.82);
margin-bottom: 0 !important;
padding: 0 !important;
justify-content: center !important;
text-align: center;
transition:
background-color 0.18s ease,
color 0.18s ease;
.v-btn__content {
width: 100%;
justify-content: center;
}
.v-icon {
font-size: 22px;
margin: 0 !important;
opacity: 0.98;
}
&:hover,
&.v-btn--active {
color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.09);
}
}
}
}
@media only screen and (min-width: 1170px) {
.mini-sidebar {
@@ -122,3 +217,16 @@
}
}
}
.v-navigation-drawer--rail.leftSidebar {
.sidebar-container .sidebar-footer {
.sidebar-footer-btn.sidebar-footer-icon-btn {
justify-content: center !important;
text-align: center;
.v-btn__content {
justify-content: center;
}
}
}
}

View File

@@ -10,6 +10,8 @@ import Chat from '@/components/chat/Chat.vue'
<style scoped>
.chat-container {
height: calc(100vh - 60px)
height: 100%;
min-height: calc(100vh - 66px);
overflow: hidden;
}
</style>
</style>

View File

@@ -390,7 +390,11 @@ const updateDialogPluginLogo = computed(() => {
</v-card>
</v-col>
<v-col v-if="activeTab === 'market'" cols="12" md="12">
<v-col
v-if="activeTab === 'installed' || activeTab === 'market'"
cols="12"
md="12"
>
<div class="d-flex align-center justify-center mt-4 mb-4 gap-4">
<v-btn
variant="text"

View File

@@ -11,7 +11,10 @@
:key="item.id"
type="button"
class="settings-nav__item"
:class="{ 'settings-nav__item--active': activeSettingsSection === item.id }"
:class="{
'settings-nav__item--active': activeSettingsSection === item.id,
'settings-nav__item--divider': item.dividerBefore
}"
:aria-pressed="activeSettingsSection === item.id"
@click="activeSettingsSection = item.id"
>
@@ -147,6 +150,33 @@
<div class="settings-section__title">{{ tm('sections.network.title') }}</div>
</div>
<div class="settings-section__content">
<div class="settings-list-card">
<div class="settings-item">
<div class="settings-item__label">
<div class="settings-item__title">{{ tm('network.transport.title') }}</div>
<div class="settings-item__subtitle">{{ tm('network.transport.subtitle') }}</div>
</div>
<div class="settings-item__control settings-item__control--transport">
<v-btn-toggle
v-model="chatTransportMode"
mandatory
divided
density="compact"
color="primary"
class="transport-mode-toggle"
>
<v-btn
v-for="option in chatTransportOptions"
:key="option.value"
:value="option.value"
>
{{ option.title }}
</v-btn>
</v-btn-toggle>
</div>
</div>
</div>
<template v-if="!systemConfigLoading">
<div
v-for="group in networkSystemConfigGroups"
@@ -395,12 +425,39 @@
</div>
</div>
</section>
<section id="settings-resources" class="settings-section" v-show="activeSettingsSection === 'resources'">
<div class="settings-section__heading">
<div class="settings-section__title">{{ tm('sections.resources.title') }}</div>
</div>
<div class="settings-section__content">
<div class="settings-list-card">
<div
v-for="item in resourceItems"
:key="item.key"
class="settings-item"
>
<div class="settings-item__label">
<div class="settings-item__title">{{ item.title }}</div>
<div class="settings-item__subtitle">{{ item.subtitle }}</div>
</div>
<div class="settings-item__control">
<v-btn variant="tonal" @click="item.action()">
<v-icon class="mr-2">{{ item.icon }}</v-icon>
{{ item.title }}
</v-btn>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
<WaitingForRestart ref="wfr" />
<BackupDialog ref="backupDialog" />
<ChangelogDialog v-model="changelogDialog" />
<DashboardTwoFactorDialog
v-model="configSave2faDialogVisible"
:error-message="configSave2faError"
@@ -421,9 +478,10 @@ import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import BackupDialog from '@/components/shared/BackupDialog.vue';
import StorageCleanupPanel from '@/components/shared/StorageCleanupPanel.vue';
import DashboardTwoFactorDialog from '@/components/shared/DashboardTwoFactorDialog.vue';
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import { copyToClipboard } from '@/utils/clipboard';
import { useModuleI18n } from '@/i18n/composables';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { PurpleTheme } from '@/theme/LightTheme';
import { useToastStore } from '@/stores/toast';
@@ -431,6 +489,7 @@ import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
const { tm } = useModuleI18n('features/settings');
const { tm: tmMeta } = useModuleI18n('features/config-metadata');
const { t, locale } = useI18n();
const toastStore = useToastStore();
const confirmDialog = useConfirmDialog();
const theme = useTheme();
@@ -442,6 +501,11 @@ const getStoredColor = (key, fallback) => {
const primaryColor = ref(getStoredColor('themePrimary', PurpleTheme.colors.primary));
const secondaryColor = ref(getStoredColor('themeSecondary', PurpleTheme.colors.secondary));
const chatTransportMode = ref(
typeof window !== 'undefined' && localStorage.getItem('chat.transportMode') === 'websocket'
? 'websocket'
: 'sse'
);
const resolveThemes = () => {
if (theme?.themes?.value) return theme.themes.value;
@@ -476,6 +540,11 @@ watch(secondaryColor, (value) => {
applyThemeColors(primaryColor.value, value);
});
watch(chatTransportMode, (value) => {
if (typeof window === 'undefined') return;
localStorage.setItem('chat.transportMode', value === 'websocket' ? 'websocket' : 'sse');
});
const wfr = ref(null);
const backupDialog = ref(null);
const apiKeys = ref([]);
@@ -497,6 +566,7 @@ const configSave2faRotationHint = ref('');
const configSavePendingData = ref(null);
const systemConfigAutoSaveTimer = ref(null);
const activeSettingsSection = ref('general');
const changelogDialog = ref(false);
const apiKeyExpiryOptions = computed(() => [
{ title: tm('apiKey.expiryOptions.day1'), value: 1 },
@@ -520,13 +590,61 @@ const availableScopes = [
{ value: 'skill', label: 'skill' }
];
const chatTransportOptions = computed(() => [
{ title: tm('network.transport.sse'), value: 'sse' },
{ title: tm('network.transport.websocket'), value: 'websocket' }
]);
const settingsNavItems = computed(() => [
{ id: 'general', label: tm('sections.general.title'), icon: 'mdi mdi-tune-variant' },
{ id: 'appearance', label: tm('sections.appearance.title'), icon: 'mdi mdi-palette-outline' },
{ id: 'network', label: tm('sections.network.title'), icon: 'mdi mdi-lan-connect' },
{ id: 'security', label: tm('sections.security.title'), icon: 'mdi mdi-shield-lock-outline' },
{ id: 'maintenance', label: tm('sections.maintenance.title'), icon: 'mdi mdi-tools' },
{ id: 'openapi', label: tm('sections.openapi.title'), icon: 'mdi mdi-api' }
{ id: 'openapi', label: tm('sections.openapi.title'), icon: 'mdi mdi-api' },
{ id: 'resources', label: tm('sections.resources.title'), icon: 'mdi mdi-information-outline', dividerBefore: true }
]);
const openExternalLink = (url) => {
if (typeof window === 'undefined') return;
window.open(url, '_blank', 'noopener,noreferrer');
};
const openFaqLink = () => {
openExternalLink(locale.value === 'en-US'
? 'https://docs.astrbot.app/en/faq.html'
: 'https://docs.astrbot.app/faq.html');
};
const resourceItems = computed(() => [
{
key: 'changelog',
title: t('core.navigation.changelog'),
subtitle: tm('resources.changelog.subtitle'),
icon: 'mdi-note-text-outline',
action: () => { changelogDialog.value = true; }
},
{
key: 'documentation',
title: t('core.navigation.documentation'),
subtitle: tm('resources.documentation.subtitle'),
icon: 'mdi-book-open-variant',
action: () => openExternalLink('https://docs.astrbot.app')
},
{
key: 'faq',
title: t('core.navigation.faq'),
subtitle: tm('resources.faq.subtitle'),
icon: 'mdi-frequently-asked-questions',
action: openFaqLink
},
{
key: 'github',
title: t('core.navigation.github'),
subtitle: tm('resources.github.subtitle'),
icon: 'mdi-github',
action: () => openExternalLink('https://github.com/AstrBotDevs/AstrBot')
}
]);
const configIncludedScopes = ['bot', 'provider'];
@@ -916,6 +1034,8 @@ onMounted(async () => {
activeSettingsSection.value = 'maintenance';
} else if (hash.includes('settings-openapi')) {
activeSettingsSection.value = 'openapi';
} else if (hash.includes('settings-resources')) {
activeSettingsSection.value = 'resources';
}
});
@@ -995,6 +1115,20 @@ onUnmounted(() => {
color: rgb(var(--v-theme-on-surface));
}
.settings-nav__item--divider {
margin-top: 13px;
}
.settings-nav__item--divider::after {
position: absolute;
top: -9px;
right: 0;
left: 0;
height: 1px;
background: var(--settings-divider);
content: "";
}
.settings-nav__item--active {
background: rgba(var(--v-theme-on-surface), 0.07);
color: rgb(var(--v-theme-on-surface));
@@ -1132,6 +1266,23 @@ onUnmounted(() => {
max-width: none;
}
.settings-item__control--transport {
max-width: 260px;
}
.transport-mode-toggle {
width: 100%;
overflow: hidden;
border: 1px solid var(--settings-border);
border-radius: 10px;
}
.transport-mode-toggle :deep(.v-btn) {
flex: 1 1 0;
min-width: 0;
text-transform: none;
}
.settings-item__control :deep(.v-btn) {
min-height: 36px;
border-radius: 10px;

View File

@@ -221,7 +221,7 @@ const togglePinnedExtension = (extension) => {
</script>
<template>
<v-tab-item v-show="activeTab === 'installed'">
<v-tab-item v-show="activeTab === 'installed' || activeTab === 'market'">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<h2 class="text-h2 mb-0">{{ tm("titles.installedAstrBotPlugins") }}</h2>

View File

@@ -184,7 +184,7 @@ const openMarketPluginDetail = (plugin) => {
</script>
<template>
<v-tab-item v-show="activeTab === 'market'">
<v-tab-item v-show="activeTab === 'installed' || activeTab === 'market'">
<div class="mb-6 pt-4 pb-4">
<div class="d-flex align-center" style="gap: 12px">
<div class="d-flex align-center" style="gap: 12px; min-width: 0">