feat(dashboard): Merge theme management from 'dev' into 'main'#7527
feat(dashboard): Merge theme management from 'dev' into 'main'#7527kyangconn wants to merge 1 commit intoAstrBotDevs:masterfrom
Conversation
feat(dashboard): add theme auto-switching and add theme management in customizer feat(dashboard): enhance types in Settings
There was a problem hiding this comment.
Hey - I've found 4 issues, and left some high level feedback:
- In
StatsPage.vuethe chart color palette now uses the staticPurpleTheme/PurpleThemeDarkdefinitions 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.vuestores and matches presets by theirnamestring (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 stableidfield 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 non‑obvious 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 and “absolute URL” handling 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 the “has 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| 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)) { |
There was a problem hiding this comment.
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"; | ||
| }, |
There was a problem hiding this comment.
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:
-
Store the preset by
idinstead 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/storeColorfor the preset write theid, notname.
- Find where the selected preset is stored, e.g. something like:
-
Look up presets by
id(with backward compatibility)- Add a helper after the
themePresetsarray: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]; };
- Add a helper after the
-
Update
applyThemePresetto useid- 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); };
- If it currently looks like:
-
Update
selectedThemePresetcomputed/use-sites- Wherever the selected preset object is derived, use
getThemePresetByKey(selectedThemePreset.value)so it works with both the newidstorage and any legacy stored names. - When rendering in the template (dropdowns, labels, etc.), use:
instead of comparing or binding against
{{ preset.name }} <!-- or i18n key derived from preset.id -->preset.namefor identification; usepreset.idfor v-model / value bindings.
- Wherever the selected preset object is derived, use
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.
| 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 { |
There was a problem hiding this comment.
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");
}
}
- Define a new i18n key
settings.messages.restartFailed(and translations) in your i18n resources, under the appropriate namespace/file for settings/system-level messages. - Optionally, if there are any other restart-related toasts in the codebase, update them to use the same
settings.messages.restartFailedkey for consistency.
| return strippedPath || "/"; | ||
| } | ||
|
|
||
| function baseEndsWithApi(baseUrl: string): boolean { |
There was a problem hiding this comment.
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:
baseEndsWithApi→normalizePathForBase→stripLeadingApiPrefixnormalizeBaseUrl→normalizeConfiguredApiBaseUrl→getApiBaseUrl/setApiBaseUrl/service.defaults.baseURLresolveApiUrlvs interceptor path handling vsresolveWebSocketUrl
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, andjoinBaseAndPath. - Keep
/apistripping 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.
There was a problem hiding this comment.
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.
| 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; | ||
| } |
|
|
||
| const theme = useTheme(); | ||
| const isDark = computed(() => theme.global.current.value.dark); | ||
| const isDark = computed(() => (useCustomizerStore()).isDarkTheme); |
| const scrollContainer = ref(null); | ||
| const renderedHtml = ref(""); | ||
| const isDark = computed(() => theme.global.current.value.dark); | ||
| const isDark = computed(() => (useCustomizerStore()).isDarkTheme); |
| @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> |
There was a problem hiding this comment.
在模板中多次调用 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>
| <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> |
| <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"> |
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.jsoni18n/*/settings.jsoni18n/*/auth.json: 统一深浅色相关的字符串键值。其他有用isDark变量的页面:采用customizer中的新的主题管理。
dashboard/src/types/api.ts: 用于Settings.vue中的api相关类型增强。dashboard/src/theme/constant.ts: 用于统一默认深浅色的变量值。dashboard/src/utils/request.ts: 用于类型增强和辅助函数(暂时没用到)。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.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.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:
Bug Fixes:
Enhancements: