Skip to content

feat(dashboard): Merge theme management from 'dev' into 'main'#7527

Open
kyangconn wants to merge 1 commit intoAstrBotDevs:masterfrom
kyangconn:feat/auto-switch-button
Open

feat(dashboard): Merge theme management from 'dev' into 'main'#7527
kyangconn wants to merge 1 commit intoAstrBotDevs:masterfrom
kyangconn:feat/auto-switch-button

Conversation

@kyangconn
Copy link
Copy Markdown
Contributor

@kyangconn kyangconn commented Apr 13, 2026

feat(dashboard): add theme auto-switching and add theme management in customizer
feat(dashboard): enhance types in Settings

在Settings.vue中添加了一组按钮和一组预设颜色,按钮用于显式切换深浅色模式+选择根据系统自动切换。预设颜色用于更改主题色。
统一了各处的获取主题的方式,现在可以通过useCustomizer().isDarkTheme获取是否为深色主题,通过useCustomizer().currentTheme获取主题值。

Modifications / 改动点

dashboard/src/views/Settings.vue: 把dev中的Settings页面merge进来,以便更好地实现主题管理,深浅色管理。
dashboard/src/stores/customizer.ts: 往里面加入了用于切换深浅色的函数,以及更好地获取当前主题的getter。
i18n/*/chat.json i18n/*/settings.json i18n/*/auth.json: 统一深浅色相关的字符串键值。
其他有用isDark变量的页面:采用customizer中的新的主题管理。
dashboard/src/types/api.ts: 用于Settings.vue中的api相关类型增强。
dashboard/src/theme/constant.ts: 用于统一默认深浅色的变量值。
dashboard/src/utils/request.ts: 用于类型增强和辅助函数(暂时没用到)。

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

图片

Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Add centralized theme management with light/dark/auto modes, theme presets, and improved API key handling in the Settings page while wiring all consumers to the new customizer store and typed API utilities.

New Features:

  • Introduce theme presets, color pickers, and light/dark/auto switching in the dashboard Settings view backed by the customizer store.
  • Add typed API key management flows using a shared axios wrapper and explicit API response types.

Bug Fixes:

  • Fix inconsistent dark-mode detection by unifying all components on the customizer store’s theme state instead of Vuetify’s global theme or hard-coded theme names.

Enhancements:

  • Refactor theme handling to use named light/dark theme constants, expose current theme state via the customizer store, and propagate isDarkTheme usage across views and components.
  • Improve the Settings layout with card-based sections and richer UI for network, system, API key, and migration controls.
  • Extend theme type definitions to cover additional Material Design color tokens and make primary/secondary colors required.
  • Add reusable URL and WebSocket resolution helpers and base URL validation around a configured axios instance used by the dashboard.

feat(dashboard): add theme auto-switching and add theme management in customizer
feat(dashboard): enhance types in Settings
@auto-assign auto-assign bot requested review from Fridemn and advent259141 April 13, 2026 19:41
@dosubot dosubot bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Apr 13, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In StatsPage.vue the chart color palette now uses the static PurpleTheme/PurpleThemeDark definitions instead of Vuetify’s current theme, so user-customized primary/secondary colors and presets in the new Settings theme controls will not be reflected in the charts; consider sourcing colors from the active theme/customizer instead to keep visuals consistent.
  • The theme preset implementation in Settings.vue stores and matches presets by their name string (which currently mixes Chinese and English), so any future renaming or localization of these labels will break persistence; it would be more robust to store and match by the stable id field and derive display labels via i18n.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `StatsPage.vue` the chart color palette now uses the static `PurpleTheme`/`PurpleThemeDark` definitions instead of Vuetify’s current theme, so user-customized primary/secondary colors and presets in the new Settings theme controls will not be reflected in the charts; consider sourcing colors from the active theme/customizer instead to keep visuals consistent.
- The theme preset implementation in `Settings.vue` stores and matches presets by their `name` string (which currently mixes Chinese and English), so any future renaming or localization of these labels will break persistence; it would be more robust to store and match by the stable `id` field and derive display labels via i18n.

## Individual Comments

### Comment 1
<location path="dashboard/src/utils/request.ts" line_range="68-73" />
<code_context>
+  return stripTrailingSlashes(baseUrl?.trim() || "");
+}
+
+export function normalizeConfiguredApiBaseUrl(
+  baseUrl: string | null | undefined,
+): string {
+  const cleaned = normalizeBaseUrl(baseUrl);
+  // Prepend https:// if it doesn't already have a protocol
+  if (cleaned && !/^https?:\/\//i.test(cleaned)) {
+    return `https://${cleaned}`;
+  }
</code_context>
<issue_to_address>
**issue (bug_risk):** Forcing `https://` for base URLs without protocol can break valid `http` setups.

In `normalizeConfiguredApiBaseUrl`, any `baseUrl` without a protocol is forced to `https://`, which will break valid HTTP-only configs (e.g. `localhost:8080` or intranet hosts) by silently switching them to HTTPS. Consider either deriving the protocol from `window.location.protocol` when available, or keeping the value as-is and surfacing invalid URLs via validation instead of implicitly upgrading to HTTPS.
</issue_to_address>

### Comment 2
<location path="dashboard/src/views/Settings.vue" line_range="420-392" />
<code_context>
+);
+
+// Theme presets based on MD3 color system
+const themePresets = [
+  {
+    id: "blue-business",
+    name: "活力商务蓝",
+    nameEn: "Business Blue",
+    primary: "#005FB0",
+    secondary: "#565E71",
+    tertiary: "#006B5B",
+  },
+  {
+    id: "purple-default",
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Storing the selected preset by localized name makes the preset lookup brittle.

In `applyThemePreset` and `selectedThemePreset`, the preset is looked up by `name`, which is a localized label and may change with translations or copy edits, breaking the mapping. Use a stable `id` (e.g. `blue-business`) for storage/comparison, and derive the display label from i18n or from `name`/`nameEn` only when rendering.

Suggested implementation:

```
 // Theme presets based on MD3 color system
// Presets are referenced by a stable `id`; localized `name`/`nameEn` are used only for display.
const themePresets = [

```

I can't see the rest of the file, but to fully implement your suggestion you should:

1. **Store the preset by `id` instead of localized name**
   - Find where the selected preset is stored, e.g. something like:
     ```ts
     const selectedThemePreset = ref(getStoredColor("themePreset", "优雅紫"));
     ```
   - Change the stored value to the preset `id`:
     ```ts
     const selectedThemePreset = ref(getStoredColor("themePreset", "purple-default"));
     ```
   - Ensure any calls to `setItem`/`storeColor` for the preset write the `id`, not `name`.

2. **Look up presets by `id` (with backward compatibility)**
   - Add a helper after the `themePresets` array:
     ```ts
     const getThemePresetByKey = (key: string | null | undefined) => {
       if (!key) return themePresets[0];
       // preferred: lookup by stable id
       let preset = themePresets.find(p => p.id === key);
       if (!preset) {
         // backward compatibility: previous versions may have stored localized name
         preset = themePresets.find(p => p.name === key || p.nameEn === key);
       }
       return preset ?? themePresets[0];
     };
     ```

3. **Update `applyThemePreset` to use `id`**
   - If it currently looks like:
     ```ts
     const applyThemePreset = (presetName: string) => {
       const preset = themePresets.find(p => p.name === presetName);
       // ...
       selectedThemePreset.value = presetName;
       storeColor("themePreset", presetName);
     };
     ```
   - Change it to:
     ```ts
     const applyThemePreset = (presetId: string) => {
       const preset = getThemePresetByKey(presetId);
       // ...
       selectedThemePreset.value = preset.id;
       storeColor("themePreset", preset.id);
     };
     ```

4. **Update `selectedThemePreset` computed/use-sites**
   - Wherever the selected preset object is derived, use `getThemePresetByKey(selectedThemePreset.value)` so it works with both the new `id` storage and any legacy stored names.
   - When rendering in the template (dropdowns, labels, etc.), use:
     ```vue
     {{ preset.name }} <!-- or i18n key derived from preset.id -->
     ```
     instead of comparing or binding against `preset.name` for identification; use `preset.id` for v-model / value bindings.

These adjustments ensure the selected preset is always keyed by a stable `id` and not by a localized label, while remaining compatible with previously stored values that used the translated name.
</issue_to_address>

### Comment 3
<location path="dashboard/src/views/Settings.vue" line_range="764-773" />
<code_context>
+  }
 };

 const restartAstrBot = async () => {
-    try {
-        await restartAstrBotRuntime(wfr.value);
-    } catch (error) {
-        console.error(error);
+  try {
+    await restartAstrBotRuntime(wfr.value);
+  } catch (error: unknown) {
+    if (error instanceof AxiosError) {
+      showToast(
+        error?.response?.data?.message || tm("apiKey.messages.restartFailed"),
+        "error",
+      );
+    } else {
+      console.error(
+        "An unexpected error occurred while restarting AstrBot:",
+        error,
+      );
+      showToast(tm("apiKey.messages.restartFailed"), "error");
     }
-}
</code_context>
<issue_to_address>
**suggestion:** Reusing `apiKey` i18n messages for restart errors couples unrelated domains.

Using `tm("apiKey.messages.restartFailed")` here ties restart error handling to the API key i18n namespace. This creates an unnecessary dependency and risks breakage if the API key messages are renamed or moved. Prefer defining a restart-specific error key under a more appropriate namespace (e.g. `system` or `settings`) and referencing that instead.

Suggested implementation:

```
  try {
    await restartAstrBotRuntime(wfr.value);
  } catch (error: unknown) {
    if (error instanceof AxiosError) {
      showToast(
        error?.response?.data?.message || tm("settings.messages.restartFailed"),
        "error",
      );
    } else {
      console.error(
        "An unexpected error occurred while restarting AstrBot:",
        error,
      );
      showToast(tm("settings.messages.restartFailed"), "error");
    }
  }

```

1. Define a new i18n key `settings.messages.restartFailed` (and translations) in your i18n resources, under the appropriate namespace/file for settings/system-level messages.
2. Optionally, if there are any other restart-related toasts in the codebase, update them to use the same `settings.messages.restartFailed` key for consistency.
</issue_to_address>

### Comment 4
<location path="dashboard/src/utils/request.ts" line_range="26" />
<code_context>
+  return strippedPath || "/";
+}
+
+function baseEndsWithApi(baseUrl: string): boolean {
+  if (!baseUrl) {
+    return false;
</code_context>
<issue_to_address>
**issue (complexity):** Consider introducing a single canonical URL builder that encapsulates `/api` handling and base URL normalization so the various helpers and interceptor logic become simpler and more predictable.

You can keep all existing behavior but reduce the mental overhead by collapsing the URL/path helpers into a single canonical resolver and pushing the `/api` rule into a parameter.

Right now, these interact in nonobvious ways:

- `baseEndsWithApi``normalizePathForBase``stripLeadingApiPrefix`
- `normalizeBaseUrl``normalizeConfiguredApiBaseUrl``getApiBaseUrl` / `setApiBaseUrl` / `service.defaults.baseURL`
- `resolveApiUrl` vs interceptor path handling vs `resolveWebSocketUrl`

A small restructuring keeps behavior but simplifies the surface area.

### 1. Canonical URL resolver

Introduce one helper that encodes the `/api` behavior andabsolute URLhandling in one place:

```ts
function buildUrl(
  baseUrl: string | null | undefined,
  path: string,
  options: { stripApiIfBaseEndsWithApi?: boolean } = {},
): string {
  const base = normalizeBaseUrl(baseUrl);
  if (!path) return "/";

  if (isAbsoluteUrl(path)) return path;

  let normalizedPath = ensureLeadingSlash(path);

  if (options.stripApiIfBaseEndsWithApi && base && base.replace(/\/+$/, "").endsWith("/api")) {
    normalizedPath = stripLeadingApiPrefix(normalizedPath);
  }

  if (!base) return normalizedPath;

  return `${stripTrailingSlashes(base)}${normalizedPath}`;
}
```

Then:

```ts
export function resolveApiUrl(
  path: string,
  baseUrl: string | null | undefined = getApiBaseUrl(),
): string {
  return buildUrl(baseUrl, path, { stripApiIfBaseEndsWithApi: true });
}
```

And in the interceptor:

```ts
service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const effectiveBase = config.baseURL ?? service.defaults.baseURL;

  if (typeof config.url === "string") {
    config.url = buildUrl(effectiveBase, config.url, { stripApiIfBaseEndsWithApi: true });
  }

  // headers setup unchanged...
  return config;
});
```

This allows you to:

- Remove `baseEndsWithApi`, `normalizePathForBase`, and `joinBaseAndPath`.
- Keep `/api` stripping documented and localized to a single helper.

### 2. Make base URL normalization single-source

`normalizeConfiguredApiBaseUrl` currently both normalizes and conditionally prepends `https://`, while `service.defaults.baseURL` is initialized with `normalizeBaseUrl(import.meta.env.VITE_API_BASE)`.

To avoid thehas this already been through normalizeConfiguredApiBaseUrl?concern, initialize `service.defaults.baseURL` through the same function you expose:

```ts
const initialBaseUrl = normalizeConfiguredApiBaseUrl(import.meta.env.VITE_API_BASE);

const service = axios.create({
  baseURL: initialBaseUrl,
  timeout: 10000,
});
```

`getApiBaseUrl` and `setApiBaseUrl` then become straightforward and always operate on the same normalization:

```ts
export function getApiBaseUrl(): string {
  return normalizeBaseUrl(service.defaults.baseURL);
}

export function setApiBaseUrl(baseUrl: string | null | undefined): string {
  const normalizedBaseUrl = normalizeConfiguredApiBaseUrl(baseUrl);
  service.defaults.baseURL = normalizedBaseUrl;
  return normalizedBaseUrl;
}
```

This reduces branching about “raw” vs “configured” base URLs and makes the flow from env → axios → helpers easier to follow while preserving all behaviors.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +68 to +73
export function normalizeConfiguredApiBaseUrl(
baseUrl: string | null | undefined,
): string {
const cleaned = normalizeBaseUrl(baseUrl);
// Prepend https:// if it doesn't already have a protocol
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Forcing https:// for base URLs without protocol can break valid http setups.

In normalizeConfiguredApiBaseUrl, any baseUrl without a protocol is forced to https://, which will break valid HTTP-only configs (e.g. localhost:8080 or intranet hosts) by silently switching them to HTTPS. Consider either deriving the protocol from window.location.protocol when available, or keeping the value as-is and surfacing invalid URLs via validation instead of implicitly upgrading to HTTPS.

get() {
if (customizer.autoSwitchTheme) return "auto";
return customizer.isDarkTheme ? "dark" : "light";
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Storing the selected preset by localized name makes the preset lookup brittle.

In applyThemePreset and selectedThemePreset, the preset is looked up by name, which is a localized label and may change with translations or copy edits, breaking the mapping. Use a stable id (e.g. blue-business) for storage/comparison, and derive the display label from i18n or from name/nameEn only when rendering.

Suggested implementation:

 // Theme presets based on MD3 color system
// Presets are referenced by a stable `id`; localized `name`/`nameEn` are used only for display.
const themePresets = [

I can't see the rest of the file, but to fully implement your suggestion you should:

  1. Store the preset by id instead of localized name

    • Find where the selected preset is stored, e.g. something like:
      const selectedThemePreset = ref(getStoredColor("themePreset", "优雅紫"));
    • Change the stored value to the preset id:
      const selectedThemePreset = ref(getStoredColor("themePreset", "purple-default"));
    • Ensure any calls to setItem/storeColor for the preset write the id, not name.
  2. Look up presets by id (with backward compatibility)

    • Add a helper after the themePresets array:
      const getThemePresetByKey = (key: string | null | undefined) => {
        if (!key) return themePresets[0];
        // preferred: lookup by stable id
        let preset = themePresets.find(p => p.id === key);
        if (!preset) {
          // backward compatibility: previous versions may have stored localized name
          preset = themePresets.find(p => p.name === key || p.nameEn === key);
        }
        return preset ?? themePresets[0];
      };
  3. Update applyThemePreset to use id

    • If it currently looks like:
      const applyThemePreset = (presetName: string) => {
        const preset = themePresets.find(p => p.name === presetName);
        // ...
        selectedThemePreset.value = presetName;
        storeColor("themePreset", presetName);
      };
    • Change it to:
      const applyThemePreset = (presetId: string) => {
        const preset = getThemePresetByKey(presetId);
        // ...
        selectedThemePreset.value = preset.id;
        storeColor("themePreset", preset.id);
      };
  4. Update selectedThemePreset computed/use-sites

    • Wherever the selected preset object is derived, use getThemePresetByKey(selectedThemePreset.value) so it works with both the new id storage and any legacy stored names.
    • When rendering in the template (dropdowns, labels, etc.), use:
      {{ preset.name }} <!-- or i18n key derived from preset.id -->
      instead of comparing or binding against preset.name for identification; use preset.id for v-model / value bindings.

These adjustments ensure the selected preset is always keyed by a stable id and not by a localized label, while remaining compatible with previously stored values that used the translated name.

Comment on lines 764 to +773
const restartAstrBot = async () => {
try {
await restartAstrBotRuntime(wfr.value);
} catch (error) {
console.error(error);
try {
await restartAstrBotRuntime(wfr.value);
} catch (error: unknown) {
if (error instanceof AxiosError) {
showToast(
error?.response?.data?.message || tm("apiKey.messages.restartFailed"),
"error",
);
} else {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Reusing apiKey i18n messages for restart errors couples unrelated domains.

Using tm("apiKey.messages.restartFailed") here ties restart error handling to the API key i18n namespace. This creates an unnecessary dependency and risks breakage if the API key messages are renamed or moved. Prefer defining a restart-specific error key under a more appropriate namespace (e.g. system or settings) and referencing that instead.

Suggested implementation:

  try {
    await restartAstrBotRuntime(wfr.value);
  } catch (error: unknown) {
    if (error instanceof AxiosError) {
      showToast(
        error?.response?.data?.message || tm("settings.messages.restartFailed"),
        "error",
      );
    } else {
      console.error(
        "An unexpected error occurred while restarting AstrBot:",
        error,
      );
      showToast(tm("settings.messages.restartFailed"), "error");
    }
  }

  1. Define a new i18n key settings.messages.restartFailed (and translations) in your i18n resources, under the appropriate namespace/file for settings/system-level messages.
  2. Optionally, if there are any other restart-related toasts in the codebase, update them to use the same settings.messages.restartFailed key for consistency.

return strippedPath || "/";
}

function baseEndsWithApi(baseUrl: string): boolean {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider introducing a single canonical URL builder that encapsulates /api handling and base URL normalization so the various helpers and interceptor logic become simpler and more predictable.

You can keep all existing behavior but reduce the mental overhead by collapsing the URL/path helpers into a single canonical resolver and pushing the /api rule into a parameter.

Right now, these interact in non‑obvious ways:

  • baseEndsWithApinormalizePathForBasestripLeadingApiPrefix
  • normalizeBaseUrlnormalizeConfiguredApiBaseUrlgetApiBaseUrl / setApiBaseUrl / service.defaults.baseURL
  • resolveApiUrl vs interceptor path handling vs resolveWebSocketUrl

A small restructuring keeps behavior but simplifies the surface area.

1. Canonical URL resolver

Introduce one helper that encodes the /api behavior and “absolute URL” handling in one place:

function buildUrl(
  baseUrl: string | null | undefined,
  path: string,
  options: { stripApiIfBaseEndsWithApi?: boolean } = {},
): string {
  const base = normalizeBaseUrl(baseUrl);
  if (!path) return "/";

  if (isAbsoluteUrl(path)) return path;

  let normalizedPath = ensureLeadingSlash(path);

  if (options.stripApiIfBaseEndsWithApi && base && base.replace(/\/+$/, "").endsWith("/api")) {
    normalizedPath = stripLeadingApiPrefix(normalizedPath);
  }

  if (!base) return normalizedPath;

  return `${stripTrailingSlashes(base)}${normalizedPath}`;
}

Then:

export function resolveApiUrl(
  path: string,
  baseUrl: string | null | undefined = getApiBaseUrl(),
): string {
  return buildUrl(baseUrl, path, { stripApiIfBaseEndsWithApi: true });
}

And in the interceptor:

service.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const effectiveBase = config.baseURL ?? service.defaults.baseURL;

  if (typeof config.url === "string") {
    config.url = buildUrl(effectiveBase, config.url, { stripApiIfBaseEndsWithApi: true });
  }

  // headers setup unchanged...
  return config;
});

This allows you to:

  • Remove baseEndsWithApi, normalizePathForBase, and joinBaseAndPath.
  • Keep /api stripping documented and localized to a single helper.

2. Make base URL normalization single-source

normalizeConfiguredApiBaseUrl currently both normalizes and conditionally prepends https://, while service.defaults.baseURL is initialized with normalizeBaseUrl(import.meta.env.VITE_API_BASE).

To avoid the “has this already been through normalizeConfiguredApiBaseUrl?” concern, initialize service.defaults.baseURL through the same function you expose:

const initialBaseUrl = normalizeConfiguredApiBaseUrl(import.meta.env.VITE_API_BASE);

const service = axios.create({
  baseURL: initialBaseUrl,
  timeout: 10000,
});

getApiBaseUrl and setApiBaseUrl then become straightforward and always operate on the same normalization:

export function getApiBaseUrl(): string {
  return normalizeBaseUrl(service.defaults.baseURL);
}

export function setApiBaseUrl(baseUrl: string | null | undefined): string {
  const normalizedBaseUrl = normalizeConfiguredApiBaseUrl(baseUrl);
  service.defaults.baseURL = normalizedBaseUrl;
  return normalizedBaseUrl;
}

This reduces branching about “raw” vs “configured” base URLs and makes the flow from env → axios → helpers easier to follow while preserving all behaviors.

@dosubot dosubot bot added the area:webui The bug / feature is about webui(dashboard) of astrbot. label Apr 13, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the theme management system by centralizing logic in the Pinia store, adding support for system theme synchronization and color presets. It also introduces a new centralized API request utility and significantly updates the settings page with a card-based layout and API key management. Review feedback highlights a potential issue with URL normalization for local development, suggests refactoring the large settings component for better maintainability, and identifies opportunities to improve performance and consistency by reusing store instances in Vue components.

Comment on lines +68 to +77
export function normalizeConfiguredApiBaseUrl(
baseUrl: string | null | undefined,
): string {
const cleaned = normalizeBaseUrl(baseUrl);
// Prepend https:// if it doesn't already have a protocol
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
return `https://${cleaned}`;
}
return cleaned;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

此函数强制将没有协议的 URL 转换为 https://。这可能会在本地开发环境中导致问题,因为本地服务器(如 localhost:8080)通常使用 http://。建议修改此逻辑以更好地支持本地开发。


const theme = useTheme();
const isDark = computed(() => theme.global.current.value.dark);
const isDark = computed(() => (useCustomizerStore()).isDarkTheme);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了提高代码的可读性和性能,建议在 setup 作用域内只获取一次 customizer store 实例,而不是在 computed 属性中重复调用 useCustomizerStore()

建议修改为:

const customizer = useCustomizerStore();
const isDark = computed(() => customizer.isDarkTheme);

const scrollContainer = ref(null);
const renderedHtml = ref("");
const isDark = computed(() => theme.global.current.value.dark);
const isDark = computed(() => (useCustomizerStore()).isDarkTheme);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了提高代码的可读性和性能,建议在 setup 作用域内只获取一次 customizer store 实例,而不是在 computed 属性中重复调用 useCustomizerStore()

建议修改为:

const customizer = useCustomizerStore();
const isDark = computed(() => customizer.isDarkTheme);

Comment on lines +675 to 686
@click="useCustomizerStore().TOGGLE_DARK_MODE()"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<v-icon>
{{ useCustomizerStore().uiTheme === 'PurpleThemeDark' ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}
{{ useCustomizerStore().isDarkTheme ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}
</v-icon>
</template>
<v-list-item-title>
{{ useCustomizerStore().uiTheme === 'PurpleThemeDark' ? t('core.header.buttons.theme.light') : t('core.header.buttons.theme.dark') }}
{{ useCustomizerStore().isDarkTheme ? t('core.header.buttons.theme.light') : t('core.header.buttons.theme.dark') }}
</v-list-item-title>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在模板中多次调用 useCustomizerStore() 会导致不必要的性能开销。由于 customizer store 实例已在 setup 脚本中定义,建议直接在模板中使用 customizer 变量来访问其属性和方法。

        @click="customizer.TOGGLE_DARK_MODE()"
        class="styled-menu-item"
        rounded="md"
      >
        <template v-slot:prepend>
          <v-icon>
            {{ customizer.isDarkTheme ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}
          </v-icon>
        </template>
        <v-list-item-title>
          {{ customizer.isDarkTheme ? t('core.header.buttons.theme.light') : t('core.header.buttons.theme.dark') }}
        </v-list-item-title>

Comment on lines 1 to 805
<template>

<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 24px;">

<v-list lines="two">
<v-list-subheader>{{ tm('network.title') }}</v-list-subheader>

<v-list-item>
<ProxySelector></ProxySelector>
</v-list-item>

<v-list-subheader>{{ tm('sidebar.title') }}</v-list-subheader>

<v-list-item :subtitle="tm('sidebar.customize.subtitle')" :title="tm('sidebar.customize.title')">
<SidebarCustomizer></SidebarCustomizer>
</v-list-item>

<v-list-subheader>{{ tm('theme.title') }}</v-list-subheader>

<v-list-item :subtitle="tm('theme.subtitle')" :title="tm('theme.customize.title')">
<v-row class="mt-2" dense>
<v-col cols="4" sm="2">
<v-text-field
v-model="primaryColor"
type="color"
:label="tm('theme.customize.primary')"
hide-details
variant="outlined"
density="compact"
style="max-width: 220px;"
/>
</v-col>
<v-col cols="4" sm="2 ">
<v-text-field
v-model="secondaryColor"
type="color"
:label="tm('theme.customize.secondary')"
hide-details
variant="outlined"
density="compact"
style="max-width: 220px;"
/>
</v-col>
<v-col cols="12">
<v-btn size="small" variant="tonal" color="primary" @click="resetThemeColors">
<v-icon class="mr-2">mdi-restore</v-icon>
{{ tm('theme.customize.reset') }}
</v-btn>
</v-col>
</v-row>
</v-list-item>

<v-list-subheader>{{ tm('system.title') }}</v-list-subheader>

<v-list-item :subtitle="tm('system.backup.subtitle')" :title="tm('system.backup.title')">
<v-btn style="margin-top: 16px;" color="primary" @click="openBackupDialog">
<v-icon class="mr-2">mdi-backup-restore</v-icon>
{{ tm('system.backup.button') }}
<div class="settings-page">
<!-- Network Card -->
<v-card class="mb-4" variant="flat">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" size="20">mdi-earth</v-icon>
{{ tm("network.title") }}
</v-card-title>
<v-card-text>
<ProxySelector />
</v-card-text>
</v-card>

<!-- Sidebar Card -->
<v-card class="mb-4" variant="flat">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" size="20">mdi-menu</v-icon>
{{ tm("sidebar.title") }}
</v-card-title>
<v-card-subtitle>{{ tm("sidebar.customize.subtitle") }}</v-card-subtitle>
<v-card-text>
<SidebarCustomizer />
</v-card-text>
</v-card>

<!-- Theme Card -->
<v-card class="mb-4" variant="flat">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" size="20">mdi-palette</v-icon>
{{ tm("theme.customize.title") }}
</v-card-title>
<v-card-subtitle>{{ tm("theme.subtitle") }}</v-card-subtitle>
<v-card-text>
<!-- Row 1: Preset selector + theme mode -->
<div class="d-flex flex-wrap align-center ga-4 mb-4 ml-3">
<v-select
v-model="selectedThemePreset"
:items="presetOptions"
:label="tm('theme.customize.preset')"
hide-details
variant="outlined"
density="compact"
style="min-width: 200px; max-width: 280px"
@update:model-value="applyThemePreset"
/>
<v-btn-toggle
v-model="themeMode"
mandatory
density="compact"
color="primary"
>
<v-btn value="light" size="small">
<v-icon class="mr-1" size="18">mdi-white-balance-sunny</v-icon>
{{ tm("theme.customize.light") }}
</v-btn>
<v-btn value="dark" size="small">
<v-icon class="mr-1" size="18">mdi-moon-waning-crescent</v-icon>
{{ tm("theme.customize.dark") }}
</v-btn>
<v-btn value="auto" size="small">
<v-icon class="mr-1" size="18">mdi-sync</v-icon>
{{ tm("theme.customize.auto") }}
</v-btn>
</v-btn-toggle>
<v-tooltip location="top">
<template #activator="{ props }">
<v-icon v-bind="props" size="16" color="primary" class="ml-1"
>mdi-help-circle-outline</v-icon
>
</template>
<span>{{ tm("theme.customize.autoSwitchDesc") }}</span>
</v-tooltip>
</div>

<!-- Row 2: Color pickers + reset -->
<v-card variant="outlined" class="pa-3">
<div class="text-body-2 text-medium-emphasis mb-3">
{{ tm("theme.customize.colors") }}
</div>
<div class="d-flex flex-wrap align-center ga-4">
<div class="d-flex align-center ga-3">
<v-text-field
v-model="primaryColor"
type="color"
:label="tm('theme.customize.primary')"
hide-details
variant="outlined"
density="compact"
style="width: 140px"
/>
<div
class="color-preview"
:style="{ backgroundColor: primaryColor }"
/>
</div>
<div class="d-flex align-center ga-3">
<v-text-field
v-model="secondaryColor"
type="color"
:label="tm('theme.customize.secondary')"
hide-details
variant="outlined"
density="compact"
style="width: 140px"
/>
<div
class="color-preview"
:style="{ backgroundColor: secondaryColor }"
/>
</div>
<v-btn
size="small"
variant="tonal"
color="primary"
@click="resetThemeColors"
>
<v-icon class="mr-1" size="16">mdi-restore</v-icon>
{{ tm("theme.customize.reset") }}
</v-btn>
</div>
</v-card>
</v-card-text>
</v-card>

<!-- System Card -->
<v-card class="mb-4" variant="flat">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" size="20">mdi-cog</v-icon>
{{ tm("system.title") }}
</v-card-title>
<v-card-text>
<div class="d-flex flex-wrap ga-3 mb-4">
<div>
<div class="text-body-2 mb-1">{{ tm("system.backup.title") }}</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ tm("system.backup.subtitle") }}
</div>
<v-btn color="primary" size="small" @click="openBackupDialog">
<v-icon class="mr-1" size="16">mdi-backup-restore</v-icon>
{{ tm("system.backup.button") }}
</v-btn>
</div>
<v-divider vertical class="mx-2 mx-md-4" />
<div>
<div class="text-body-2 mb-1">{{ tm("system.restart.title") }}</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ tm("system.restart.subtitle") }}
</div>
<v-btn color="error" size="small" @click="restartAstrBot">
<v-icon class="mr-1" size="16">mdi-restart</v-icon>
{{ tm("system.restart.button") }}
</v-btn>
</div>
<v-divider vertical class="mx-2 mx-md-4" />
<div>
<div class="text-body-2 mb-1">
{{ tm("system.migration.title") }}
</div>
<div class="text-caption text-medium-emphasis mb-2">
{{ tm("system.migration.subtitle") }}
</div>
<v-btn
color="primary"
size="small"
variant="outlined"
@click="startMigration"
>
<v-icon class="mr-1" size="16">mdi-database-import</v-icon>
{{ tm("system.migration.button") }}
</v-btn>
</div>
</div>
<StorageCleanupPanel />
</v-card-text>
</v-card>

<!-- API Key Card -->
<v-card class="mb-4" variant="flat">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" size="20">mdi-key</v-icon>
{{ tm("apiKey.manageTitle") }}
<v-tooltip location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="x-small"
variant="text"
class="ml-2"
:aria-label="tm('apiKey.docsLink')"
href="https://docs.astrbot.app/dev/openapi.html"
target="_blank"
rel="noopener noreferrer"
>
<v-icon size="18">mdi-help-circle-outline</v-icon>
</v-btn>
</template>
<span>{{ tm("apiKey.docsLink") }}</span>
</v-tooltip>
</v-card-title>
<v-card-subtitle>{{ tm("apiKey.subtitle") }}</v-card-subtitle>
<v-card-text>
<v-row density="compact">
<v-col cols="12" md="4">
<v-text-field
v-model="newApiKeyName"
:label="tm('apiKey.name')"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="newApiKeyExpiresInDays"
:items="apiKeyExpiryOptions"
:label="tm('apiKey.expiresInDays')"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" md="5" class="d-flex align-center">
<v-btn
color="primary"
:loading="apiKeyCreating"
@click="createApiKey"
>
<v-icon class="mr-2">mdi-key-plus</v-icon>
{{ tm("apiKey.create") }}
</v-btn>
</v-col>
<v-col v-if="newApiKeyExpiresInDays === 'permanent'" cols="12">
<v-alert type="warning" variant="tonal" density="comfortable">
{{ tm("apiKey.permanentWarning") }}
</v-alert>
</v-col>
<v-col cols="12">
<div class="text-caption text-medium-emphasis mb-2">
{{ tm("apiKey.scopes") }}
</div>
<v-chip-group v-model="newApiKeyScopes" multiple>
<v-chip
v-for="scope in availableScopes"
:key="scope.value"
:value="scope.value"
:color="
newApiKeyScopes.includes(scope.value) ? 'primary' : undefined
"
:variant="
newApiKeyScopes.includes(scope.value) ? 'flat' : 'tonal'
"
>
{{ scope.label }}
</v-chip>
</v-chip-group>
</v-col>
<v-col v-if="createdApiKeyPlaintext" cols="12">
<v-alert type="warning" variant="tonal">
<div class="d-flex align-center justify-space-between flex-wrap">
<span>{{ tm("apiKey.plaintextHint") }}</span>
<v-btn
size="small"
variant="text"
color="primary"
@click="copyCreatedApiKey"
>
<v-icon class="mr-1">mdi-content-copy</v-icon>
{{ tm("apiKey.copy") }}
</v-btn>
</v-list-item>

<v-list-item :subtitle="tm('system.restart.subtitle')" :title="tm('system.restart.title')">
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">{{ tm('system.restart.button') }}</v-btn>
</v-list-item>

<v-list-item class="py-2">
<StorageCleanupPanel />
</v-list-item>

<v-list-subheader>{{ tm('apiKey.title') }}</v-list-subheader>

<v-list-item :subtitle="tm('apiKey.subtitle')">
<template #title>
<div class="d-flex align-center">
<span>{{ tm('apiKey.manageTitle') }}</span>
<v-tooltip location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
size="x-small"
variant="text"
class="ml-2"
:aria-label="tm('apiKey.docsLink')"
href="https://docs.astrbot.app/dev/openapi.html"
target="_blank"
rel="noopener noreferrer"
>
<v-icon size="18">mdi-help-circle-outline</v-icon>
</v-btn>
</template>
<span>{{ tm('apiKey.docsLink') }}</span>
</v-tooltip>
</div>
</template>
<v-row class="mt-2" dense>
<v-col cols="12" md="4">
<v-text-field
v-model="newApiKeyName"
:label="tm('apiKey.name')"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="newApiKeyExpiresInDays"
:items="apiKeyExpiryOptions"
:label="tm('apiKey.expiresInDays')"
variant="outlined"
density="compact"
hide-details
/>
</v-col>
<v-col v-if="newApiKeyExpiresInDays === 'permanent'" cols="12">
<v-alert type="warning" variant="tonal" density="comfortable">
{{ tm('apiKey.permanentWarning') }}
</v-alert>
</v-col>
<v-col cols="12" md="5" class="d-flex align-center">
<v-btn color="primary" :loading="apiKeyCreating" @click="createApiKey">
<v-icon class="mr-2">mdi-key-plus</v-icon>
{{ tm('apiKey.create') }}
</v-btn>
</v-col>

<v-col cols="12">
<div class="text-caption text-medium-emphasis mb-1">{{ tm('apiKey.scopes') }}</div>
<v-chip-group v-model="newApiKeyScopes" multiple>
<v-chip
v-for="scope in availableScopes"
:key="scope.value"
:value="scope.value"
:color="newApiKeyScopes.includes(scope.value) ? 'primary' : undefined"
:variant="newApiKeyScopes.includes(scope.value) ? 'flat' : 'tonal'"
>
{{ scope.label }}
</v-chip>
</v-chip-group>
</v-col>

<v-col v-if="createdApiKeyPlaintext" cols="12">
<v-alert type="warning" variant="tonal">
<div class="d-flex align-center justify-space-between flex-wrap">
<span>{{ tm('apiKey.plaintextHint') }}</span>
<v-btn size="small" variant="text" color="primary" @click="copyCreatedApiKey">
<v-icon class="mr-1">mdi-content-copy</v-icon>{{ tm('apiKey.copy') }}
</v-btn>
</div>
<code style="word-break: break-all;">{{ createdApiKeyPlaintext }}</code>
</v-alert>
</v-col>

<v-col cols="12">
<v-table density="compact">
<thead>
<tr>
<th>{{ tm('apiKey.table.name') }}</th>
<th>{{ tm('apiKey.table.prefix') }}</th>
<th>{{ tm('apiKey.table.scopes') }}</th>
<th>{{ tm('apiKey.table.status') }}</th>
<th>{{ tm('apiKey.table.lastUsed') }}</th>
<th>{{ tm('apiKey.table.createdAt') }}</th>
<th>{{ tm('apiKey.table.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in apiKeys" :key="item.key_id">
<td>{{ item.name }}</td>
<td><code>{{ item.key_prefix }}</code></td>
<td>{{ (item.scopes || []).join(', ') }}</td>
<td>
<v-chip
size="small"
:color="item.is_revoked || item.is_expired ? 'error' : 'success'"
variant="tonal"
>
{{ item.is_revoked || item.is_expired ? tm('apiKey.status.inactive') : tm('apiKey.status.active') }}
</v-chip>
</td>
<td>{{ formatDate(item.last_used_at) }}</td>
<td>{{ formatDate(item.created_at) }}</td>
<td>
<v-btn
v-if="!item.is_revoked"
size="x-small"
color="warning"
variant="tonal"
class="mr-2"
@click="revokeApiKey(item.key_id)"
>
{{ tm('apiKey.revoke') }}
</v-btn>
<v-btn
size="x-small"
color="error"
variant="tonal"
@click="deleteApiKey(item.key_id)"
>
{{ tm('apiKey.delete') }}
</v-btn>
</td>
</tr>
<tr v-if="apiKeys.length === 0">
<td colspan="7" class="text-center text-medium-emphasis">
{{ tm('apiKey.empty') }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-list-item>
</v-list>

<v-list-item :subtitle="tm('system.migration.subtitle')" :title="tm('system.migration.title')">
<v-btn style="margin-top: 16px;" color="primary" @click="startMigration">{{ tm('system.migration.button') }}</v-btn>
</v-list-item>

</div>

<WaitingForRestart ref="wfr"></WaitingForRestart>
<MigrationDialog ref="migrationDialog"></MigrationDialog>
<BackupDialog ref="backupDialog"></BackupDialog>

</div>
<code style="word-break: break-all">{{
createdApiKeyPlaintext
}}</code>
</v-alert>
</v-col>
<v-col cols="12">
<v-table density="compact">
<thead>
<tr>
<th>{{ tm("apiKey.table.name") }}</th>
<th>{{ tm("apiKey.table.prefix") }}</th>
<th>{{ tm("apiKey.table.scopes") }}</th>
<th>{{ tm("apiKey.table.status") }}</th>
<th>{{ tm("apiKey.table.lastUsed") }}</th>
<th>{{ tm("apiKey.table.createdAt") }}</th>
<th>{{ tm("apiKey.table.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in apiKeys" :key="item.key_id">
<td>{{ item.name }}</td>
<td>
<code>{{ item.key_prefix }}</code>
</td>
<td>{{ (item.scopes || []).join(", ") }}</td>
<td>
<v-chip
size="small"
:color="
item.is_revoked || item.is_expired ? 'error' : 'success'
"
variant="tonal"
>
{{
item.is_revoked || item.is_expired
? tm("apiKey.status.inactive")
: tm("apiKey.status.active")
}}
</v-chip>
</td>
<td>{{ formatDate(item.last_used_at) }}</td>
<td>{{ formatDate(item.created_at) }}</td>
<td>
<v-btn
v-if="!item.is_revoked"
size="x-small"
color="warning"
variant="tonal"
class="mr-2"
@click="revokeApiKey(item.key_id)"
>
{{ tm("apiKey.revoke") }}
</v-btn>
<v-btn
size="x-small"
color="error"
variant="tonal"
@click="deleteApiKey(item.key_id)"
>
{{ tm("apiKey.delete") }}
</v-btn>
</td>
</tr>
<tr v-if="apiKeys.length === 0">
<td colspan="7" class="text-center text-medium-emphasis">
{{ tm("apiKey.empty") }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>

<WaitingForRestart ref="wfr" />
<MigrationDialog ref="migrationDialog" />
<BackupDialog ref="backupDialog" />
</template>

<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import BackupDialog from '@/components/shared/BackupDialog.vue';
import StorageCleanupPanel from '@/components/shared/StorageCleanupPanel.vue';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { PurpleTheme } from '@/theme/LightTheme';
import { useToastStore } from '@/stores/toast';

const { tm } = useModuleI18n('features/settings');
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import axios, { AxiosError } from "@/utils/request";
import WaitingForRestart from "@/components/shared/WaitingForRestart.vue";
import ProxySelector from "@/components/shared/ProxySelector.vue";
import MigrationDialog from "@/components/shared/MigrationDialog.vue";
import SidebarCustomizer from "@/components/shared/SidebarCustomizer.vue";
import BackupDialog from "@/components/shared/BackupDialog.vue";
import StorageCleanupPanel from "@/components/shared/StorageCleanupPanel.vue";
import { restartAstrBot as restartAstrBotRuntime } from "@/utils/restartAstrBot";
import { useModuleI18n } from "@/i18n/composables";
import { useTheme } from "vuetify";
import { PurpleTheme } from "@/theme/LightTheme";
import {
LIGHT_THEME_NAME,
DARK_THEME_NAME,
type ThemeMode,
} from "@/theme/constants";
import { useToastStore } from "@/stores/toast";
import { useCustomizerStore } from "@/stores/customizer";
import type {
ApiKey,
ApiKeyActionResponse,
ApiKeyCreatePayload,
ApiKeyCreateResponse,
ApiKeyExpiresDays,
ApiKeyListResponse,
} from "@/types/api";

const { tm } = useModuleI18n("features/settings");
const toastStore = useToastStore();
const theme = useTheme();
const customizer = useCustomizerStore();

// Theme mode toggle (light/dark/auto)
const themeMode = computed({
get() {
if (customizer.autoSwitchTheme) return "auto";
return customizer.isDarkTheme ? "dark" : "light";
},
set(mode: ThemeMode) {
if (mode === "auto") {
customizer.SET_AUTO_SYNC(true);
customizer.APPLY_SYSTEM_THEME();
return;
}

const getStoredColor = (key, fallback) => {
const stored = typeof window !== 'undefined' ? localStorage.getItem(key) : null;
return stored || fallback;
customizer.SET_AUTO_SYNC(false);
const newTheme = mode === "dark" ? DARK_THEME_NAME : LIGHT_THEME_NAME;
customizer.SET_UI_THEME(newTheme);
},
});

const getStoredColor = (key: string, fallback: string) => {
const stored =
typeof window !== "undefined" ? localStorage.getItem(key) : null;
return stored || fallback;
};

const primaryColor = ref(getStoredColor('themePrimary', PurpleTheme.colors.primary));
const secondaryColor = ref(getStoredColor('themeSecondary', PurpleTheme.colors.secondary));
const primaryColor = ref(
getStoredColor("themePrimary", PurpleTheme.colors.primary),
);
const secondaryColor = ref(
getStoredColor("themeSecondary", PurpleTheme.colors.secondary),
);

// Theme presets based on MD3 color system
const themePresets = [
{
id: "blue-business",
name: "活力商务蓝",
nameEn: "Business Blue",
primary: "#005FB0",
secondary: "#565E71",
tertiary: "#006B5B",
},
{
id: "purple-default",
name: "优雅紫",
nameEn: "Elegant Purple",
primary: "#6750A4",
secondary: "#625B71",
tertiary: "#7D5260",
},
{
id: "teal-fresh",
name: "自然清新绿",
nameEn: "Nature Green",
primary: "#386A20",
secondary: "#55624C",
tertiary: "#19686A",
},
{
id: "orange-warm",
name: "温暖橙棕",
nameEn: "Warm Orange",
primary: "#9C4323",
secondary: "#77574E",
tertiary: "#6C5D2F",
},
{
id: "ocean-breeze",
name: "海洋清风",
nameEn: "Ocean Breeze",
primary: "#0077B6",
secondary: "#4A5568",
tertiary: "#00B4D8",
},
{
id: "rose-romantic",
name: "浪漫玫瑰",
nameEn: "Romantic Rose",
primary: "#BE185D",
secondary: "#9F1239",
tertiary: "#DB2777",
},
];

// Get stored preset or default to blue-business name
const selectedThemePreset = ref(
localStorage.getItem("themePreset") || themePresets[0].name,
);

// Simple array for dropdown display
const presetOptions = themePresets.map((p) => p.name);

const applyThemePreset = (presetName: string) => {
const preset = themePresets.find((p) => p.name === presetName);
if (!preset) return;

// Store the preset selection (store by name for display consistency)
localStorage.setItem("themePreset", presetName);
selectedThemePreset.value = presetName;

// Update primary and secondary colors
primaryColor.value = preset.primary;
secondaryColor.value = preset.secondary;
localStorage.setItem("themePrimary", preset.primary);
localStorage.setItem("themeSecondary", preset.secondary);

// Apply to themes
applyThemeColors(preset.primary, preset.secondary);

const resolveThemes = () => {
if (theme?.themes?.value) return theme.themes.value;
if (theme?.global?.themes?.value) return theme.global.themes.value;
return null;
toastStore.add({
message: tm("theme.customize.presetApplied") || "主题已应用",
color: "success",
});
};

const applyThemeColors = (primary, secondary) => {
const themes = resolveThemes();
if (!themes) return;
['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
const themeDef = themes[name];
if (!themeDef?.colors) return;
if (primary) themeDef.colors.primary = primary;
if (secondary) themeDef.colors.secondary = secondary;
if (primary && themeDef.colors.darkprimary) themeDef.colors.darkprimary = primary;
if (secondary && themeDef.colors.darksecondary) themeDef.colors.darksecondary = secondary;
});
const resolveThemes = (): Record<string, any> | null => {
if (theme?.themes?.value) return theme.themes.value as Record<string, any>;
return null;
};

const applyThemeColors = (primary: string, secondary: string) => {
const themes = resolveThemes();
if (!themes) return;
[LIGHT_THEME_NAME, DARK_THEME_NAME].forEach((name) => {
const themeDef = themes[name];
if (!themeDef?.colors) return;
if (primary) themeDef.colors.primary = primary;
if (secondary) themeDef.colors.secondary = secondary;
if (primary && themeDef.colors.darkprimary)
themeDef.colors.darkprimary = primary;
if (secondary && themeDef.colors.darksecondary)
themeDef.colors.darksecondary = secondary;
});
};

applyThemeColors(primaryColor.value, secondaryColor.value);

watch(primaryColor, (value) => {
if (!value) return;
localStorage.setItem('themePrimary', value);
applyThemeColors(value, secondaryColor.value);
if (!value) return;
localStorage.setItem("themePrimary", value);
applyThemeColors(value, secondaryColor.value);
});

watch(secondaryColor, (value) => {
if (!value) return;
localStorage.setItem('themeSecondary', value);
applyThemeColors(primaryColor.value, value);
if (!value) return;
localStorage.setItem("themeSecondary", value);
applyThemeColors(primaryColor.value, value);
});

const wfr = ref(null);
const migrationDialog = ref(null);
const backupDialog = ref(null);
const apiKeys = ref([]);
const resetThemeColors = () => {
primaryColor.value = PurpleTheme.colors.primary;
secondaryColor.value = PurpleTheme.colors.secondary;
localStorage.removeItem("themePrimary");
localStorage.removeItem("themeSecondary");
applyThemeColors(primaryColor.value, secondaryColor.value);
};

const wfr = ref<any>(null);
const migrationDialog = ref<any>(null);
const backupDialog = ref<any>(null);
const apiKeys = ref<ApiKey[]>([]);
const apiKeyCreating = ref(false);
const newApiKeyName = ref('');
const newApiKeyExpiresInDays = ref(30);
const newApiKeyScopes = ref(['chat', 'config', 'file', 'im']);
const createdApiKeyPlaintext = ref('');
const newApiKeyName = ref("");
const newApiKeyExpiresInDays = ref<ApiKeyExpiresDays>(30);
const newApiKeyScopes = ref(["chat", "config", "file", "im"]);
const createdApiKeyPlaintext = ref("");
const apiKeyExpiryOptions = computed(() => [
{ title: tm('apiKey.expiryOptions.day1'), value: 1 },
{ title: tm('apiKey.expiryOptions.day7'), value: 7 },
{ title: tm('apiKey.expiryOptions.day30'), value: 30 },
{ title: tm('apiKey.expiryOptions.day90'), value: 90 },
{ title: tm('apiKey.expiryOptions.permanent'), value: 'permanent' }
{ title: tm("apiKey.expiryOptions.day1"), value: 1 },
{ title: tm("apiKey.expiryOptions.day7"), value: 7 },
{ title: tm("apiKey.expiryOptions.day30"), value: 30 },
{ title: tm("apiKey.expiryOptions.day90"), value: 90 },
{ title: tm("apiKey.expiryOptions.permanent"), value: "permanent" },
]);

const availableScopes = [
{ value: 'chat', label: 'chat' },
{ value: 'config', label: 'config' },
{ value: 'file', label: 'file' },
{ value: 'im', label: 'im' }
{ value: "chat", label: "chat" },
{ value: "config", label: "config" },
{ value: "file", label: "file" },
{ value: "im", label: "im" },
];

const showToast = (message, color = 'success') => {
toastStore.add({
message,
color,
timeout: 3000
});
const showToast = (message: string, color = "success") => {
toastStore.add({
message,
color,
timeout: 3000,
});
};

const formatDate = (value) => {
if (!value) return '-';
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return '-';
return dt.toLocaleString();
const formatDate = (value: string | null) => {
if (!value) return "-";
const dt = new Date(value);
if (Number.isNaN(dt.getTime())) return "-";
return dt.toLocaleString();
};

const loadApiKeys = async () => {
try {
const res = await axios.get('/api/apikey/list');
if (res.data.status !== 'ok') {
showToast(res.data.message || tm('apiKey.messages.loadFailed'), 'error');
return;
}
apiKeys.value = res.data.data || [];
} catch (e) {
showToast(e?.response?.data?.message || tm('apiKey.messages.loadFailed'), 'error');
try {
const res = await axios.get<ApiKeyListResponse>("/api/apikey/list");
if (res.data.status !== "ok") {
showToast(res.data.message || tm("apiKey.messages.loadFailed"), "error");
return;
}
apiKeys.value = res.data.data;
} catch (e: unknown) {
if (e instanceof AxiosError) {
showToast(
e?.response?.data?.message || tm("apiKey.messages.loadFailed"),
"error",
);
} else {
console.error("An unexpected error occurred while loading API keys:", e);
showToast(tm("apiKey.messages.loadFailed"), "error");
}
}
};

const tryExecCommandCopy = (text) => {
let textArea = null;
const tryExecCommandCopy = (text: string): boolean => {
let textArea: HTMLTextAreaElement | null = null;
try {
if (typeof document === "undefined" || !document.body) return false;
textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "");
textArea.style.position = "fixed";
textArea.style.opacity = "0";
textArea.style.pointerEvents = "none";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
textArea.setSelectionRange(0, text.length);
// Fallback to deprecated execCommand for older browsers
return document.execCommand("copy");
} catch (_) {
return false;
} finally {
try {
if (typeof document === 'undefined' || !document.body) return false;
textArea = document.createElement('textarea');
textArea.value = text;
textArea.setAttribute('readonly', '');
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
textArea.style.pointerEvents = 'none';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
textArea.setSelectionRange(0, text.length);
return document.execCommand('copy');
if (textArea?.parentNode) {
textArea.parentNode.removeChild(textArea);
}
} catch (_) {
return false;
} finally {
try {
if (textArea?.parentNode) {
textArea.parentNode.removeChild(textArea);
}
} catch (_) {
// ignore cleanup errors
}
// ignore cleanup errors
}
}
};

const copyTextToClipboard = async (text) => {
if (!text) return false;
if (tryExecCommandCopy(text)) return true;
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
const copyTextToClipboard = async (text: string): Promise<boolean> => {
if (!text) return false;

// 由于execCommand被弃用,改用推荐的Clipboard API,但仍保留execCommand作为兼容性回退
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
await navigator.clipboard.writeText(text);
return true;
} catch (_) {
return false;
// Clipboard API failed, try fallback method
}
}

// 回退到execCommand方法,兼容不支持Clipboard API的环境
return tryExecCommandCopy(text);
};

const copyCreatedApiKey = async () => {
if (!createdApiKeyPlaintext.value) return;
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
if (ok) {
showToast(tm('apiKey.messages.copySuccess'), 'success');
} else {
showToast(tm('apiKey.messages.copyFailed'), 'error');
}
if (!createdApiKeyPlaintext.value) return;
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
if (ok) {
showToast(tm("apiKey.messages.copySuccess"), "success");
} else {
showToast(tm("apiKey.messages.copyFailed"), "error");
}
};

const createApiKey = async () => {
const selectedScopes = availableScopes
.map((scope) => scope.value)
.filter((scope) => newApiKeyScopes.value.includes(scope));

if (selectedScopes.length === 0) {
showToast(tm('apiKey.messages.scopeRequired'), 'warning');
return;
const selectedScopes = availableScopes
.map((scope) => scope.value)
.filter((scope) => newApiKeyScopes.value.includes(scope));

if (selectedScopes.length === 0) {
showToast(tm("apiKey.messages.scopeRequired"), "warning");
return;
}
apiKeyCreating.value = true;
try {
const payload: ApiKeyCreatePayload = {
name: newApiKeyName.value,
scopes: selectedScopes,
};
if (newApiKeyExpiresInDays.value !== "permanent") {
payload.expires_in_days = Number(newApiKeyExpiresInDays.value);
}
apiKeyCreating.value = true;
try {
const payload = {
name: newApiKeyName.value,
scopes: selectedScopes
};
if (newApiKeyExpiresInDays.value !== 'permanent') {
payload.expires_in_days = Number(newApiKeyExpiresInDays.value);
}
const res = await axios.post('/api/apikey/create', payload);
if (res.data.status !== 'ok') {
showToast(res.data.message || tm('apiKey.messages.createFailed'), 'error');
return;
}
createdApiKeyPlaintext.value = res.data.data?.api_key || '';
newApiKeyName.value = '';
newApiKeyExpiresInDays.value = 30;
showToast(tm('apiKey.messages.createSuccess'), 'success');
await loadApiKeys();
} catch (e) {
showToast(e?.response?.data?.message || tm('apiKey.messages.createFailed'), 'error');
} finally {
apiKeyCreating.value = false;
const res = await axios.post<ApiKeyCreateResponse>(
"/api/apikey/create",
payload,
);
if (res.data.status !== "ok") {
showToast(
res.data.message || tm("apiKey.messages.createFailed"),
"error",
);
return;
}
createdApiKeyPlaintext.value = res.data.data?.api_key || "";
newApiKeyName.value = "";
newApiKeyExpiresInDays.value = 30;
showToast(tm("apiKey.messages.createSuccess"), "success");
await loadApiKeys();
} catch (e: unknown) {
if (e instanceof AxiosError) {
showToast(
e?.response?.data?.message || tm("apiKey.messages.createFailed"),
"error",
);
} else {
console.error("An unexpected error occurred while creating API key:", e);
showToast(tm("apiKey.messages.createFailed"), "error");
}
} finally {
apiKeyCreating.value = false;
}
};

const revokeApiKey = async (keyId) => {
try {
const res = await axios.post('/api/apikey/revoke', { key_id: keyId });
if (res.data.status !== 'ok') {
showToast(res.data.message || tm('apiKey.messages.revokeFailed'), 'error');
return;
}
showToast(tm('apiKey.messages.revokeSuccess'), 'success');
await loadApiKeys();
} catch (e) {
showToast(e?.response?.data?.message || tm('apiKey.messages.revokeFailed'), 'error');
const revokeApiKey = async (keyId: string) => {
try {
const res = await axios.post<ApiKeyActionResponse>("/api/apikey/revoke", {
key_id: keyId,
});
if (res.data.status !== "ok") {
showToast(
res.data.message || tm("apiKey.messages.revokeFailed"),
"error",
);
return;
}
showToast(tm("apiKey.messages.revokeSuccess"), "success");
await loadApiKeys();
} catch (e: unknown) {
if (e instanceof AxiosError) {
showToast(
e?.response?.data?.message || tm("apiKey.messages.revokeFailed"),
"error",
);
} else {
console.error("An unexpected error occurred while revoking API key:", e);
showToast(tm("apiKey.messages.revokeFailed"), "error");
}
}
};

const deleteApiKey = async (keyId) => {
try {
const res = await axios.post('/api/apikey/delete', { key_id: keyId });
if (res.data.status !== 'ok') {
showToast(res.data.message || tm('apiKey.messages.deleteFailed'), 'error');
return;
}
showToast(tm('apiKey.messages.deleteSuccess'), 'success');
await loadApiKeys();
} catch (e) {
showToast(e?.response?.data?.message || tm('apiKey.messages.deleteFailed'), 'error');
const deleteApiKey = async (keyId: string) => {
try {
const res = await axios.post<ApiKeyActionResponse>("/api/apikey/delete", {
key_id: keyId,
});
if (res.data.status !== "ok") {
showToast(
res.data.message || tm("apiKey.messages.deleteFailed"),
"error",
);
return;
}
showToast(tm("apiKey.messages.deleteSuccess"), "success");
await loadApiKeys();
} catch (e: unknown) {
if (e instanceof AxiosError) {
showToast(
e?.response?.data?.message || tm("apiKey.messages.deleteFailed"),
"error",
);
} else {
console.error("An unexpected error occurred while deleting API key:", e);
showToast(tm("apiKey.messages.deleteFailed"), "error");
}
}
};

const restartAstrBot = async () => {
try {
await restartAstrBotRuntime(wfr.value);
} catch (error) {
console.error(error);
try {
await restartAstrBotRuntime(wfr.value);
} catch (error: unknown) {
if (error instanceof AxiosError) {
showToast(
error?.response?.data?.message || tm("apiKey.messages.restartFailed"),
"error",
);
} else {
console.error(
"An unexpected error occurred while restarting AstrBot:",
error,
);
showToast(tm("apiKey.messages.restartFailed"), "error");
}
}
}
};

const startMigration = async () => {
if (migrationDialog.value) {
try {
const result = await migrationDialog.value.open();
if (result.success) {
console.log('Migration completed successfully:', result.message);
}
} catch (error) {
console.error('Migration dialog error:', error);
}
if (migrationDialog.value) {
try {
const result = await migrationDialog.value.open();
if (result.success) {
console.info("Migration completed successfully:", result.message);
}
} catch (error) {
console.error("Migration dialog error:", error);
}
}
}
};

const openBackupDialog = () => {
if (backupDialog.value) {
backupDialog.value.open();
}
}

const resetThemeColors = () => {
primaryColor.value = PurpleTheme.colors.primary;
secondaryColor.value = PurpleTheme.colors.secondary;
localStorage.removeItem('themePrimary');
localStorage.removeItem('themeSecondary');
applyThemeColors(primaryColor.value, secondaryColor.value);
if (backupDialog.value) {
backupDialog.value.open();
}
};

onMounted(() => {
loadApiKeys();
loadApiKeys();
});
</script>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这个组件非常大,包含了网络、侧边栏、主题、系统和 API 密钥等多个独立功能的逻辑。为了提高可维护性,建议将每个主要功能(例如 API 密钥管理、主题设置)拆分成独立的子组件。

<v-divider vertical class="mx-1"
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(var(--v-theme-primary), 0.45) !important;"></v-divider>
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
<v-btn @click="useCustomizerStore().TOGGLE_DARK_MODE()" class="theme-toggle-btn" icon variant="text" size="small">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此处直接调用 useCustomizerStore() 与文件中其他地方使用已定义的 customizer 变量不一致。为了保持代码一致性和避免不必要的 store 实例获取,建议使用 customizer 变量。

            <v-btn @click="customizer.TOGGLE_DARK_MODE()" class="theme-toggle-btn" icon variant="text" size="small">

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant