diff --git a/dashboard/src/main.ts b/dashboard/src/main.ts index e1c736aa2..7c0d0b175 100644 --- a/dashboard/src/main.ts +++ b/dashboard/src/main.ts @@ -1,36 +1,42 @@ -import { createApp } from 'vue'; -import { createPinia } from 'pinia'; -import App from './App.vue'; -import { router } from './router'; -import vuetify from './plugins/vuetify'; -import confirmPlugin from './plugins/confirmPlugin'; -import { setupI18n } from './i18n/composables'; -import '@/scss/style.scss'; -import VueApexCharts from 'vue3-apexcharts'; +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import App from "./App.vue"; +import { router } from "./router"; +import vuetify from "./plugins/vuetify"; +import confirmPlugin from "./plugins/confirmPlugin"; +import { setupI18n } from "./i18n/composables"; +import "@/scss/style.scss"; +import VueApexCharts from "vue3-apexcharts"; -import print from 'vue3-print-nb'; -import { loader } from '@guolao/vue-monaco-editor' -import * as monaco from 'monaco-editor'; -import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; -import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; -import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; -import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; -import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; -import axios from 'axios'; -import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs'; +import print from "vue3-print-nb"; +import { loader } from "@guolao/vue-monaco-editor"; +import * as monaco from "monaco-editor"; +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; +import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; +import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"; +import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; +import axios from "axios"; +import { waitForRouterReadyInBackground } from "./utils/routerReadiness.mjs"; +import { + getApiBaseUrl, + resolveApiUrl, + resolvePublicUrl, + setApiBaseUrl, +} from "@/utils/request"; (self as any).MonacoEnvironment = { getWorker(_: string, label: string) { - if (label === 'json') { + if (label === "json") { return new jsonWorker(); } - if (label === 'css' || label === 'scss' || label === 'less') { + if (label === "css" || label === "scss" || label === "less") { return new cssWorker(); } - if (label === 'html' || label === 'handlebars' || label === 'razor') { + if (label === "html" || label === "handlebars" || label === "razor") { return new htmlWorker(); } - if (label === 'typescript' || label === 'javascript') { + if (label === "typescript" || label === "javascript") { return new tsWorker(); } return new editorWorker(); @@ -38,102 +44,150 @@ import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs'; }; // 初始化新的i18n系统,等待完成后再挂载应用 -setupI18n().then(async () => { - console.log('🌍 新i18n系统初始化完成'); - - const app = createApp(App); - const pinia = createPinia(); - app.use(pinia); - app.use(router); - app.use(print); - app.use(VueApexCharts); - app.use(vuetify); - app.use(confirmPlugin); - await router.isReady(); - app.mount('#app'); - - // 挂载后同步 Vuetify 主题 - import('./stores/customizer').then(({ useCustomizerStore }) => { - const customizer = useCustomizerStore(pinia); - vuetify.theme.global.name.value = customizer.uiTheme; - const storedPrimary = localStorage.getItem('themePrimary'); - const storedSecondary = localStorage.getItem('themeSecondary'); - if (storedPrimary || storedSecondary) { - const themes = vuetify.theme.themes.value; - ['PurpleTheme', 'PurpleThemeDark'].forEach((name) => { - const theme = themes[name]; - if (!theme?.colors) return; - if (storedPrimary) theme.colors.primary = storedPrimary; - if (storedSecondary) theme.colors.secondary = storedSecondary; - if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary; - if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary; - }); - } - }); -}).catch(error => { - console.error('❌ 新i18n系统初始化失败:', error); - - // 即使i18n初始化失败,也要挂载应用(使用回退机制) - const app = createApp(App); - const pinia = createPinia(); - app.use(pinia); - app.use(router); - app.use(print); - app.use(VueApexCharts); - app.use(vuetify); - app.use(confirmPlugin); - app.mount('#app'); - waitForRouterReadyInBackground(router); - - // 挂载后同步 Vuetify 主题 - import('./stores/customizer').then(({ useCustomizerStore }) => { - const customizer = useCustomizerStore(pinia); - vuetify.theme.global.name.value = customizer.uiTheme; - const storedPrimary = localStorage.getItem('themePrimary'); - const storedSecondary = localStorage.getItem('themeSecondary'); - if (storedPrimary || storedSecondary) { - const themes = vuetify.theme.themes.value; - ['PurpleTheme', 'PurpleThemeDark'].forEach((name) => { - const theme = themes[name]; - if (!theme?.colors) return; - if (storedPrimary) theme.colors.primary = storedPrimary; - if (storedSecondary) theme.colors.secondary = storedSecondary; - if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary; - if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary; - }); - } - }); -}); +setupI18n() + .then(async () => { + console.log("🌍 新i18n系统初始化完成"); + const app = createApp(App); + const pinia = createPinia(); + app.use(pinia); + app.use(router); + app.use(print); + app.use(VueApexCharts); + app.use(vuetify); + app.use(confirmPlugin); + await router.isReady(); + app.mount("#app"); + + // 挂载后同步 Vuetify 主题 + import("./stores/customizer").then(({ useCustomizerStore }) => { + const customizer = useCustomizerStore(pinia); + vuetify.theme.global.name.value = customizer.uiTheme; + const storedPrimary = localStorage.getItem("themePrimary"); + const storedSecondary = localStorage.getItem("themeSecondary"); + if (storedPrimary || storedSecondary) { + const themes = vuetify.theme.themes.value; + ["PurpleTheme", "PurpleThemeDark"].forEach((name) => { + const theme = themes[name]; + if (!theme?.colors) return; + if (storedPrimary) theme.colors.primary = storedPrimary; + if (storedSecondary) theme.colors.secondary = storedSecondary; + if (storedPrimary && theme.colors.darkprimary) + theme.colors.darkprimary = storedPrimary; + if (storedSecondary && theme.colors.darksecondary) + theme.colors.darksecondary = storedSecondary; + }); + } + }); + }) + .catch((error) => { + console.error("❌ 新i18n系统初始化失败:", error); + + // 即使i18n初始化失败,也要挂载应用(使用回退机制) + const app = createApp(App); + const pinia = createPinia(); + app.use(pinia); + app.use(router); + app.use(print); + app.use(VueApexCharts); + app.use(vuetify); + app.use(confirmPlugin); + app.mount("#app"); + waitForRouterReadyInBackground(router); + + // 挂载后同步 Vuetify 主题 + import("./stores/customizer").then(({ useCustomizerStore }) => { + const customizer = useCustomizerStore(pinia); + vuetify.theme.global.name.value = customizer.uiTheme; + const storedPrimary = localStorage.getItem("themePrimary"); + const storedSecondary = localStorage.getItem("themeSecondary"); + if (storedPrimary || storedSecondary) { + const themes = vuetify.theme.themes.value; + ["PurpleTheme", "PurpleThemeDark"].forEach((name) => { + const theme = themes[name]; + if (!theme?.colors) return; + if (storedPrimary) theme.colors.primary = storedPrimary; + if (storedSecondary) theme.colors.secondary = storedSecondary; + if (storedPrimary && theme.colors.darkprimary) + theme.colors.darkprimary = storedPrimary; + if (storedSecondary && theme.colors.darksecondary) + theme.colors.darksecondary = storedSecondary; + }); + } + }); + }); axios.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); + const token = localStorage.getItem("token"); if (token) { - config.headers['Authorization'] = `Bearer ${token}`; + config.headers["Authorization"] = `Bearer ${token}`; } - const locale = localStorage.getItem('astrbot-locale'); + const locale = localStorage.getItem("astrbot-locale"); if (locale) { - config.headers['Accept-Language'] = locale; + config.headers["Accept-Language"] = locale; } return config; }); -// Keep fetch() calls consistent with axios by automatically attaching the JWT. -// Some parts of the UI use fetch directly; without this, those requests will 401. -const _origFetch = window.fetch.bind(window); -window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { - const token = localStorage.getItem('token'); - if (!token) return _origFetch(input, init); - - const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined)); - if (!headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${token}`); +// 1. 定义加载配置的函数 +async function loadAppConfig() { + try { + const configUrl = new URL(resolvePublicUrl("config.json")); + configUrl.searchParams.set("t", `${Date.now()}`); + const response = await fetch(configUrl.toString()); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); + } catch (error) { + console.warn("Failed to load config.json, falling back to default.", error); + return {}; } - const locale = localStorage.getItem('astrbot-locale'); - if (locale && !headers.has('Accept-Language')) { - headers.set('Accept-Language', locale); - } - return _origFetch(input, { ...init, headers }); -}; +} -loader.config({ monaco }) +async function initApp() { + const config = await loadAppConfig(); + const configApiUrl = config.apiBaseUrl || ""; + const envApiUrl = import.meta.env.VITE_API_BASE || ""; + + const localApiUrl = localStorage.getItem("apiBaseUrl"); + const apiBaseUrl = + localApiUrl !== null ? localApiUrl : configApiUrl || envApiUrl; + + if (apiBaseUrl) { + console.log(`API Base URL set to: ${apiBaseUrl}`); + } + + setApiBaseUrl(apiBaseUrl); + + // Keep fetch() calls consistent with axios by automatically attaching the JWT. + // Some parts of the UI use fetch directly; without this, those requests will 401. + const _origFetch = window.fetch.bind(window); + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + let url = input; + if (typeof input === "string" && input.startsWith("/api")) { + url = resolveApiUrl(input, getApiBaseUrl()); + } + + const token = localStorage.getItem("token"); + const headers = new Headers( + init?.headers || + (typeof input !== "string" && "headers" in input + ? (input as Request).headers + : undefined), + ); + + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + const locale = localStorage.getItem("astrbot-locale"); + if (locale && !headers.has("Accept-Language")) { + headers.set("Accept-Language", locale); + } + return _origFetch(url, { ...init, headers }); + }; +} + +initApp(); + +loader.config({ monaco }); diff --git a/dashboard/src/utils/request.ts b/dashboard/src/utils/request.ts new file mode 100644 index 000000000..1cb2b64f1 --- /dev/null +++ b/dashboard/src/utils/request.ts @@ -0,0 +1,182 @@ +import axios, { type InternalAxiosRequestConfig } from "axios"; + +const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//; + +function isAbsoluteUrl(value: string): boolean { + return ABSOLUTE_URL_PATTERN.test(value); +} + +function stripTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ""); +} + +function ensureLeadingSlash(value: string): string { + if (!value) { + return "/"; + } + return value.startsWith("/") ? value : `/${value}`; +} + +function stripLeadingApiPrefix(path: string): string { + const normalizedPath = ensureLeadingSlash(path); + const strippedPath = normalizedPath.replace(/^\/api(?=\/|$)/, ""); + return strippedPath || "/"; +} + +function baseEndsWithApi(baseUrl: string): boolean { + if (!baseUrl) { + return false; + } + + if (isAbsoluteUrl(baseUrl)) { + try { + return new URL(baseUrl).pathname.replace(/\/+$/, "").endsWith("/api"); + } catch { + return baseUrl.replace(/\/+$/, "").endsWith("/api"); + } + } + + return stripTrailingSlashes(baseUrl).endsWith("/api"); +} + +function normalizePathForBase(path: string, baseUrl = ""): string { + if (!path) { + return "/"; + } + + if (isAbsoluteUrl(path)) { + return path; + } + + const normalizedPath = ensureLeadingSlash(path); + if (baseEndsWithApi(baseUrl)) { + return stripLeadingApiPrefix(normalizedPath); + } + return normalizedPath; +} + +function joinBaseAndPath(baseUrl: string, path: string): string { + const cleanBase = stripTrailingSlashes(baseUrl); + const cleanPath = path.replace(/^\/+/, ""); + return `${cleanBase}/${cleanPath}`; +} + +function normalizeBaseUrl(baseUrl: string | null | undefined): string { + return stripTrailingSlashes(baseUrl?.trim() || ""); +} + +export function normalizeConfiguredApiBaseUrl( + baseUrl: string | null | undefined, +): string { + return normalizeBaseUrl(baseUrl); +} + +export function getApiBaseUrlValidationError( + baseUrl: string | null | undefined, +): string { + const normalizedBaseUrl = normalizeConfiguredApiBaseUrl(baseUrl); + + if (!normalizedBaseUrl) { + return ""; + } + + try { + const parsedUrl = new URL(normalizedBaseUrl); + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return "API Base URL must use http:// or https://"; + } + } catch { + return "API Base URL must be a valid absolute URL"; + } + + return ""; +} + +export function getApiBaseUrl(): string { + return normalizeBaseUrl(service.defaults.baseURL); +} + +export function setApiBaseUrl(baseUrl: string | null | undefined): string { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + service.defaults.baseURL = normalizedBaseUrl; + return normalizedBaseUrl; +} + +export function resolveApiUrl( + path: string, + baseUrl: string | null | undefined = getApiBaseUrl(), +): string { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const normalizedPath = normalizePathForBase(path, normalizedBaseUrl); + + if (isAbsoluteUrl(normalizedPath)) { + return normalizedPath; + } + + if (!normalizedBaseUrl) { + return normalizedPath; + } + + return joinBaseAndPath(normalizedBaseUrl, normalizedPath); +} + +export function resolvePublicUrl(path: string): string { + const base = import.meta.env.BASE_URL || "/"; + const cleanBase = base.endsWith("/") ? base : `${base}/`; + return new URL( + path.replace(/^\/+/, ""), + window.location.origin + cleanBase, + ).toString(); +} + +export function resolveWebSocketUrl( + path: string, + queryParams?: Record, +): string { + const resolvedApiUrl = resolveApiUrl(path); + const url = new URL(resolvedApiUrl, window.location.href); + + if (url.protocol === "https:") { + url.protocol = "wss:"; + } else if (url.protocol === "http:") { + url.protocol = "ws:"; + } + + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + } + + return url.toString(); +} + +const service = axios.create({ + baseURL: normalizeBaseUrl(import.meta.env.VITE_API_BASE), + timeout: 10000, +}); + +service.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const normalizedBaseUrl = normalizeBaseUrl( + config.baseURL ?? service.defaults.baseURL, + ); + + if (typeof config.url === "string") { + config.url = normalizePathForBase(config.url, normalizedBaseUrl); + } + + const token = localStorage.getItem("token"); + if (token) { + config.headers.set("Authorization", `Bearer ${token}`); + } + + const locale = localStorage.getItem("astrbot-locale"); + if (locale) { + config.headers.set("Accept-Language", locale); + } + + return config; +}); + +export default service; +export * from "axios";