mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
feat: sync dashboard theme to plugin pages (#8390)
* feat(plugin): pass theme through plugin page asset URLs and initial context
Add theme query parameter propagation through the plugin page asset pipeline
so that the bridge SDK initial context includes isDark.
* feat(plugin): inject data-theme and color-scheme into plugin page HTML
Set data-theme on <html> and add color-scheme meta tag server-side
to prevent flash when entering plugin pages in dark mode.
* feat(webui): add isDark getter to customizer store
* feat(webui): sync theme to plugin iframe via URL param and postMessage
Append theme query parameter to iframe src URL and include isDark
in the postMessage context. Watch uiTheme changes to re-send context.
* feat(bridge): auto-apply data-theme from context in plugin bridge SDK
Set data-theme attribute on document.documentElement when context
includes isDark, enabling live theme switching via postMessage.
* docs: add light/dark theme adaptation guide for plugin pages
Add theme adaptation section to existing plugin-pages docs in both
Chinese and English, covering CSS variables and onContext() usage.
* test: add theme sync tests for plugin page bridge and content
Verify isDark in bridge SDK initial context with various theme params,
and verify data-theme and color-scheme injection in rewritten HTML.
* fix(plugin): use case-insensitive regex for head tag in HTML rewrite
Replace string match for <head> with case-insensitive regex to handle
uppercase tags and tags with attributes, preventing duplicate injection
of color-scheme meta tag.
* refactor(webui): generalize isDark getter to support any dark theme
Replace hardcoded PurpleThemeDark check with suffix-based detection
so all dark theme variants are recognized automatically.
* refactor(plugin): extract theme helpers for HTML rewriting
Extract _get_request_theme and _apply_theme_to_html to eliminate
duplicate theme-parsing logic and isolate HTML mutation. Use case-
insensitive regex for head tag detection to prevent duplicate
injection when tags use mixed casing.
* style: apply ruff formatting to plugin page tests
Wrap long function call lines for consistency with project style.
* fix(plugin): handle existing data-theme and case-sensitive fallback in HTML rewrite
Strip any existing data-theme attribute before adding the new one to
prevent duplicate attributes. Use case-insensitive regex for the
<head> fallback insertion to avoid corrupting <html> tag attributes.
* fix(webui): add null guard to isDark getter
Guard against undefined uiTheme to prevent TypeError when the
theme config has not been initialized.
* fix(webui): centralize theme mapping and preserve hash in plugin page URL
Extract themeParam computed to avoid drift between URL and context.
Include hash fragment in iframe URL to support SPA hash routing.
* fix(webui): address CR feedback - deduplicate color-scheme meta, harden isDark getter, consolidate theme source
- _apply_theme_to_html: strip existing color-scheme meta before injecting to
avoid duplicates; merge data-theme strip+add into single-pass regex callback
- customizer.ts: replace brittle endsWith('Dark') with explicit theme name check
- _rewrite_plugin_page_html: use _get_request_theme() directly instead of
reading theme from extra_query_params
* fix(webui): 简化 isDark 推导、优化查询参数构建、使用暗色主题集合
- isDark 推导简化为 theme == "dark"(None == "dark" 为 False,去掉多余三元表达式)
- _prepare_plugin_page_query_params 改为线性构建,先计算再构建字典
- isDark getter 改用显式 DARK_THEMES Set,替代硬编码单值比较
---------
Co-authored-by: lxfight <lxfight@192.168.5.50>
This commit is contained in:
@@ -125,6 +125,12 @@
|
||||
...(context || {}),
|
||||
...nextContext,
|
||||
};
|
||||
if (typeof nextContext.isDark === "boolean") {
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
nextContext.isDark ? "dark" : "light",
|
||||
);
|
||||
}
|
||||
if (resolveReady) {
|
||||
resolveReady(context);
|
||||
resolveReady = null;
|
||||
|
||||
@@ -229,6 +229,55 @@ class PluginRoute(Route):
|
||||
return default
|
||||
return locale
|
||||
|
||||
@staticmethod
|
||||
def _get_request_theme() -> str | None:
|
||||
theme = request.args.get("theme", "").strip()
|
||||
return theme if theme in ("dark", "light") else None
|
||||
|
||||
@staticmethod
|
||||
def _apply_theme_to_html(html: str, theme: str) -> str:
|
||||
def _replace_html_tag(m: re.Match) -> str:
|
||||
attrs = m.group(1) or ""
|
||||
attrs = re.sub(
|
||||
r'\s+data-theme\s*=\s*["\'][^"\']*["\']',
|
||||
"",
|
||||
attrs,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return f'<html{attrs} data-theme="{theme}">'
|
||||
|
||||
html = re.sub(
|
||||
r"<html(\b[^>]*)>",
|
||||
_replace_html_tag,
|
||||
html,
|
||||
count=1,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
meta_tag = f'<meta name="color-scheme" content="{theme}">'
|
||||
|
||||
html = re.sub(
|
||||
r'<meta\s[^>]*name\s*=\s*["\']color-scheme["\'][^>]*>',
|
||||
"",
|
||||
html,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
head_match = re.search(r"<head\b[^>]*>", html, re.IGNORECASE)
|
||||
if head_match:
|
||||
html = html.replace(
|
||||
head_match.group(0), f"{head_match.group(0)}{meta_tag}", 1
|
||||
)
|
||||
else:
|
||||
html = re.sub(
|
||||
r"(<html\b[^>]*>)",
|
||||
rf"\1<head>{meta_tag}</head>",
|
||||
html,
|
||||
count=1,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
return html
|
||||
|
||||
def _get_plugin_page_initial_context(self) -> dict | None:
|
||||
asset_token = request.args.get("asset_token", "").strip()
|
||||
if not asset_token:
|
||||
@@ -277,6 +326,8 @@ class PluginRoute(Route):
|
||||
self._get_by_path(locale_data, f"pages.{page_name}.title") or page_name
|
||||
)
|
||||
|
||||
theme = self._get_request_theme()
|
||||
|
||||
return {
|
||||
"pluginName": plugin.name,
|
||||
"displayName": display_name,
|
||||
@@ -284,6 +335,7 @@ class PluginRoute(Route):
|
||||
"pageTitle": page_title,
|
||||
"locale": locale,
|
||||
"i18n": plugin_i18n,
|
||||
"isDark": theme == "dark",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -590,6 +642,9 @@ class PluginRoute(Route):
|
||||
return match.group(0)
|
||||
|
||||
rewritten_html = _HTML_ASSET_ATTR_RE.sub(replace_attr, html_text)
|
||||
theme = self._get_request_theme()
|
||||
if theme:
|
||||
rewritten_html = self._apply_theme_to_html(rewritten_html, theme)
|
||||
if "/api/plugin/page/bridge-sdk.js" not in rewritten_html:
|
||||
bridge_tag = f'<script src="{self._get_plugin_page_bridge_sdk_url(extra_query_params)}"></script>'
|
||||
if "</body>" in rewritten_html:
|
||||
@@ -774,7 +829,17 @@ class PluginRoute(Route):
|
||||
asset_token = (
|
||||
self._issue_plugin_page_asset_token(plugin_name, page_name) or ""
|
||||
)
|
||||
return {"asset_token": asset_token} if asset_token else None
|
||||
theme = self._get_request_theme()
|
||||
|
||||
if not asset_token and not theme:
|
||||
return None
|
||||
|
||||
params: dict[str, str] = {}
|
||||
if asset_token:
|
||||
params["asset_token"] = asset_token
|
||||
if theme:
|
||||
params["theme"] = theme
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
async def _plugin_page_error_response(status_code: int, message: str):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import config from '@/config';
|
||||
|
||||
const DARK_THEMES: ReadonlySet<string> = new Set(['PurpleThemeDark']);
|
||||
|
||||
export const useCustomizerStore = defineStore("customizer", {
|
||||
state: () => ({
|
||||
Sidebar_drawer: config.Sidebar_drawer,
|
||||
@@ -12,7 +14,9 @@ export const useCustomizerStore = defineStore("customizer", {
|
||||
chatSidebarOpen: false // chat mode mobile sidebar state
|
||||
}),
|
||||
|
||||
getters: {},
|
||||
getters: {
|
||||
isDark: (state) => state.uiTheme ? DARK_THEMES.has(state.uiTheme) : false,
|
||||
},
|
||||
actions: {
|
||||
SET_SIDEBAR_DRAWER() {
|
||||
this.Sidebar_drawer = !this.Sidebar_drawer;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { usePluginI18n } from "@/utils/pluginI18n";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
|
||||
const BRIDGE_CHANNEL = "astrbot-plugin-page";
|
||||
|
||||
@@ -15,6 +16,7 @@ const {
|
||||
pluginName: pluginDisplayName,
|
||||
pluginPageTitle,
|
||||
} = usePluginI18n();
|
||||
const customizer = useCustomizerStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
@@ -36,6 +38,7 @@ const localizedPageTitle = computed(() =>
|
||||
),
|
||||
);
|
||||
const getIframeWindow = () => iframeRef.value?.contentWindow || null;
|
||||
const themeParam = computed(() => customizer.isDark ? "dark" : "light");
|
||||
|
||||
const toPostMessageData = (value, fallback = null) => {
|
||||
try {
|
||||
@@ -202,6 +205,7 @@ const sendIframeContext = () => {
|
||||
pageTitle: localizedPageTitle.value,
|
||||
locale: locale.value,
|
||||
i18n: toPostMessageData(plugin.value.i18n, {}),
|
||||
isDark: customizer.isDark,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -432,7 +436,9 @@ const loadPluginPage = async () => {
|
||||
|
||||
plugin.value = pluginData;
|
||||
page.value = pageEntry;
|
||||
iframeSrc.value = pageEntry.content_path;
|
||||
const contentUrl = new URL(pageEntry.content_path, window.location.origin);
|
||||
contentUrl.searchParams.set('theme', themeParam.value);
|
||||
iframeSrc.value = contentUrl.pathname + contentUrl.search + contentUrl.hash;
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
error?.response?.data?.message ||
|
||||
@@ -456,6 +462,9 @@ watch([pluginName, pageName], loadPluginPage, { immediate: true });
|
||||
watch(locale, () => {
|
||||
sendIframeContext();
|
||||
});
|
||||
watch(() => customizer.uiTheme, () => {
|
||||
sendIframeContext();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -116,7 +116,8 @@ The current `ready()` context looks like this:
|
||||
"pageName": "bridge-demo",
|
||||
"pageTitle": "Bridge Demo",
|
||||
"locale": "en-US",
|
||||
"i18n": {}
|
||||
"i18n": {},
|
||||
"isDark": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -168,6 +169,56 @@ If an inline script needs to access `window.AstrBotPluginPage` synchronously, mo
|
||||
<script src="/api/plugin/page/bridge-sdk.js"></script>
|
||||
```
|
||||
|
||||
## Light/Dark Theme Adaptation
|
||||
|
||||
When the user toggles light/dark mode in AstrBot, the theme state is automatically synced to Plugin Pages. The bridge SDK maintains a `data-theme` attribute on the `<html>` element:
|
||||
|
||||
- Light mode: `<html data-theme="light">`
|
||||
- Dark mode: `<html data-theme="dark">`
|
||||
|
||||
### CSS Adaptation
|
||||
|
||||
CSS variables are the recommended approach:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #1a1a1a;
|
||||
--text: #e0e0e0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
```
|
||||
|
||||
AstrBot injects the `data-theme` attribute on the `<html>` tag server-side when serving HTML, so there is no initial flash.
|
||||
|
||||
### Responding to Theme Changes in JavaScript
|
||||
|
||||
The `isDark` field in the context indicates whether dark mode is active. Use `onContext()` to listen for theme changes:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
|
||||
function render() {
|
||||
if (bridge.getContext()?.isDark) {
|
||||
// JS logic for dark mode (e.g. chart filters)
|
||||
}
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
bridge.onContext(render);
|
||||
```
|
||||
|
||||
When the theme is toggled, the Dashboard sends the updated context to the iframe via the bridge. As long as the Page listens with `onContext()`, it will be notified.
|
||||
|
||||
## Asset Path Rules
|
||||
|
||||
AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Write normal relative paths and do not hardcode `/api/plugin/page/content/...` yourself.
|
||||
@@ -232,7 +283,8 @@ The context usually contains:
|
||||
"pageName": "bridge-demo",
|
||||
"pageTitle": "Bridge Demo",
|
||||
"locale": "en-US",
|
||||
"i18n": {}
|
||||
"i18n": {},
|
||||
"isDark": false
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -116,7 +116,8 @@ class MyPlugin(Star):
|
||||
"pageName": "bridge-demo",
|
||||
"pageTitle": "Bridge Demo",
|
||||
"locale": "zh-CN",
|
||||
"i18n": {}
|
||||
"i18n": {},
|
||||
"isDark": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -168,6 +169,56 @@ bridge.onContext(render);
|
||||
<script src="/api/plugin/page/bridge-sdk.js"></script>
|
||||
```
|
||||
|
||||
## 亮暗主题适配
|
||||
|
||||
AstrBot 切换亮色/暗色模式时,会自动将主题状态同步给插件 Page。bridge SDK 会在 `<html>` 元素上维护 `data-theme` 属性:
|
||||
|
||||
- 亮色模式:`<html data-theme="light">`
|
||||
- 暗色模式:`<html data-theme="dark">`
|
||||
|
||||
### CSS 适配
|
||||
|
||||
推荐使用 CSS 变量:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #1a1a1a;
|
||||
--text: #e0e0e0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
```
|
||||
|
||||
AstrBot 在服务端返回 HTML 时,会自动在 `<html>` 标签上注入 `data-theme` 属性,不会出现初始闪烁。
|
||||
|
||||
### JavaScript 响应主题变化
|
||||
|
||||
上下文中的 `isDark` 字段表示当前是否为暗色模式。通过 `onContext()` 可以监听主题切换:
|
||||
|
||||
```js
|
||||
const bridge = window.AstrBotPluginPage;
|
||||
|
||||
function render() {
|
||||
if (bridge.getContext()?.isDark) {
|
||||
// 暗色模式下的 JS 逻辑(如图表滤镜等)
|
||||
}
|
||||
}
|
||||
|
||||
await bridge.ready();
|
||||
render();
|
||||
bridge.onContext(render);
|
||||
```
|
||||
|
||||
切换亮暗模式时,Dashboard 会通过 bridge 将更新后的上下文发送给 iframe;只要 Page 监听了 `onContext()`,就会收到通知。
|
||||
|
||||
## 静态资源路径规则
|
||||
|
||||
AstrBot 会重写相对资源路径,并自动补上短期 `asset_token`。你只需要正常写相对路径,不要自己拼接 `/api/plugin/page/content/...`。
|
||||
@@ -232,7 +283,8 @@ console.log(context.pluginName, context.pageName, context.locale);
|
||||
"pageName": "bridge-demo",
|
||||
"pageTitle": "Bridge Demo",
|
||||
"locale": "zh-CN",
|
||||
"i18n": {}
|
||||
"i18n": {},
|
||||
"isDark": false
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1593,6 +1593,119 @@ async def test_plugin_page_content_issues_scoped_asset_token(
|
||||
assert cross_page_response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_page_bridge_sdk_includes_is_dark_when_theme_param_provided(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
registered_plugin_page: StarMetadata,
|
||||
):
|
||||
"""Bridge SDK initial context should include isDark based on ?theme= query param."""
|
||||
authorized_client = app.test_client()
|
||||
response = await authorized_client.get(
|
||||
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
html_text = (await response.get_data()).decode("utf-8")
|
||||
bridge_sdk_url = re.search(
|
||||
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
|
||||
html_text,
|
||||
)
|
||||
assert bridge_sdk_url is not None
|
||||
|
||||
anonymous_client = app.test_client()
|
||||
|
||||
# theme=dark → isDark: true
|
||||
dark_response = await anonymous_client.get(bridge_sdk_url.group(1) + "&theme=dark")
|
||||
assert dark_response.status_code == 200
|
||||
dark_js = (await dark_response.get_data()).decode("utf-8")
|
||||
assert '"isDark": true' in dark_js
|
||||
|
||||
# theme=light → isDark: false
|
||||
light_response = await anonymous_client.get(
|
||||
bridge_sdk_url.group(1) + "&theme=light"
|
||||
)
|
||||
assert light_response.status_code == 200
|
||||
light_js = (await light_response.get_data()).decode("utf-8")
|
||||
assert '"isDark": false' in light_js
|
||||
|
||||
# no theme param → isDark: false (default)
|
||||
base_response = await anonymous_client.get(bridge_sdk_url.group(1))
|
||||
assert base_response.status_code == 200
|
||||
base_js = (await base_response.get_data()).decode("utf-8")
|
||||
assert '"isDark": false' in base_js
|
||||
|
||||
# invalid theme value → should NOT be treated as dark
|
||||
invalid_response = await anonymous_client.get(
|
||||
bridge_sdk_url.group(1) + "&theme=invalid"
|
||||
)
|
||||
assert invalid_response.status_code == 200
|
||||
invalid_js = (await invalid_response.get_data()).decode("utf-8")
|
||||
assert '"isDark": false' in invalid_js
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_page_content_propagates_theme_in_rewritten_urls(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
registered_plugin_page: StarMetadata,
|
||||
):
|
||||
"""Theme query param should be propagated through rewritten asset and bridge URLs."""
|
||||
test_client = app.test_client()
|
||||
response = await test_client.get(
|
||||
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
|
||||
"?asset_token=&theme=dark",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
html_text = (await response.get_data()).decode("utf-8")
|
||||
|
||||
# Verify theme=dark appears in bridge SDK URL in rewritten HTML
|
||||
bridge_sdk_url_match = re.search(
|
||||
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
|
||||
html_text,
|
||||
)
|
||||
assert bridge_sdk_url_match is not None
|
||||
bridge_query = parse_qs(urlsplit(bridge_sdk_url_match.group(1)).query)
|
||||
assert bridge_query.get("theme") == ["dark"]
|
||||
|
||||
# Verify theme=dark appears in CSS asset URL in rewritten HTML
|
||||
css_url_match = re.search(
|
||||
r'href="([^"]+/base\.css[^"]*)"',
|
||||
html_text,
|
||||
)
|
||||
assert css_url_match is not None
|
||||
css_query = parse_qs(urlsplit(css_url_match.group(1)).query)
|
||||
assert css_query.get("theme") == ["dark"]
|
||||
|
||||
# Verify data-theme is injected on <html> tag to prevent flash
|
||||
assert 'data-theme="dark"' in html_text
|
||||
# Verify color-scheme meta tag is injected for browser-level default styles
|
||||
assert '<meta name="color-scheme" content="dark">' in html_text
|
||||
|
||||
# theme=light → data-theme="light" on <html> and color-scheme meta
|
||||
light_response = await test_client.get(
|
||||
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
|
||||
"?asset_token=&theme=light",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert light_response.status_code == 200
|
||||
light_html = (await light_response.get_data()).decode("utf-8")
|
||||
assert 'data-theme="light"' in light_html
|
||||
assert '<meta name="color-scheme" content="light">' in light_html
|
||||
|
||||
# no theme param → no data-theme or color-scheme meta on <html>
|
||||
no_theme_response = await test_client.get(
|
||||
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
|
||||
"?asset_token=",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert no_theme_response.status_code == 200
|
||||
no_theme_html = (await no_theme_response.get_data()).decode("utf-8")
|
||||
assert "data-theme=" not in no_theme_html
|
||||
assert "color-scheme" not in no_theme_html
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plugin_page_assets_require_dashboard_auth(
|
||||
app: Quart,
|
||||
|
||||
Reference in New Issue
Block a user