feat: 公开嵌入页主题支持外部指定 + 排行榜入口移至左上角
- 公开页(/share、/public、/roundtable-share)新增 ?theme= 与 deerflow:set-theme postMessage 两种外部主题指定方式,经 usePublicEmbedTheme 接成 next-themes forcedTheme,不写 localStorage - 排行榜入口从欢迎语旁移到 workspace-header 左上角应用名旁 - 新增 docs/embed-public-pages-theme.md 嵌入主题使用文档 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57a87d6322
commit
81f2a48c6d
5 files changed
+245
-24
No files matched your search
@@ -13,6 +13,7 @@ import {
|
||||
resolveEmbedRoundtableTheme,
|
||||
resolveEmbedTheme,
|
||||
} from "@/core/embed/params";
|
||||
import { usePublicEmbedTheme } from "@/core/embed/background";
|
||||
import { readWujieProps } from "@/core/embed/wujie";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
@@ -31,9 +32,13 @@ import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
// 公开页路由前缀:这些页面会被第三方 iframe 嵌入,主题可由外部指定。
|
||||
const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"];
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const publicEmbedTheme = usePublicEmbedTheme();
|
||||
// 嵌入路由的主题用 forcedTheme 强制:只影响当前渲染,不写 localStorage。
|
||||
// 千万不要在嵌入页里调 ThemeContext.setTheme —— 它会持久化主题并打上
|
||||
// 「用户已显式选择」标记。无界子应用与主站同域共享 localStorage,宿主
|
||||
@@ -52,8 +57,11 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const raw = new URLSearchParams(location.search).get("theme")?.trim();
|
||||
return raw ? resolveEmbedTheme(raw) : undefined;
|
||||
}
|
||||
if (PUBLIC_EMBED_PREFIXES.some((p) => location.pathname.startsWith(p))) {
|
||||
return publicEmbedTheme;
|
||||
}
|
||||
return undefined;
|
||||
}, [location.pathname, location.search]);
|
||||
}, [location.pathname, location.search, publicEmbedTheme]);
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Trophy } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
@@ -8,7 +7,6 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AuroraText } from "../ui/aurora-text";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
let waved = false;
|
||||
|
||||
@@ -47,20 +45,6 @@ export function Welcome({
|
||||
{isUltra ? "🚀" : "👋"}
|
||||
</div>
|
||||
<AuroraText colors={colors}>{t.welcome.greeting}</AuroraText>
|
||||
{/* 用户排行榜入口:紧挨欢迎语的"CM助手",新标签打开免登录的公开排行榜页。 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open("#/public/leaderboard", "_blank", "noopener")
|
||||
}
|
||||
title="打开用户排行榜公开页"
|
||||
className="text-muted-foreground hover:text-foreground h-8 shrink-0 gap-1.5 px-2 text-xs font-medium"
|
||||
>
|
||||
<Trophy className="size-4" />
|
||||
<span>排行榜</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Trophy } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -86,6 +86,20 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
) : (
|
||||
titleBlock
|
||||
)}
|
||||
{/* 用户排行榜入口:紧挨左上角的应用名,新标签打开免登录的公开排行榜页。 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open("#/public/leaderboard", "_blank", "noopener")
|
||||
}
|
||||
title="打开用户排行榜公开页"
|
||||
className="text-muted-foreground hover:text-sidebar-foreground h-8 shrink-0 gap-1 px-1.5 text-xs font-medium"
|
||||
>
|
||||
<Trophy className="size-4" />
|
||||
<span>排行榜</span>
|
||||
</Button>
|
||||
<SidebarCollapseButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,19 +2,23 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* 公开页(被第三方 iframe 嵌套)的可配置背景色。
|
||||
* 公开页(被第三方 iframe 嵌套)的可配置外观:背景色 + 整体明暗主题。
|
||||
*
|
||||
* 这些 `/public/*`、`/share/*`、`/roundtable-share/*` 页面会被别的系统用
|
||||
* `<iframe>` 嵌进去。嵌套方希望背景色跟自己的站点保持一致,所以我们把
|
||||
* 「背景色 / 前景文字色」抽成可外部传入的参数,两种传法都支持:
|
||||
* `<iframe>` 嵌进去。嵌套方希望外观跟自己的站点保持一致,所以我们把
|
||||
* 「背景色 / 前景文字色 / light·dark 主题」抽成可外部传入的参数,两种传法都支持:
|
||||
*
|
||||
* 1. URL 查询参数(嵌套时一次性指定,最常用):
|
||||
* `<iframe src=".../#/public/leaderboard?bg=%23ffffff&text=%23111111">`
|
||||
* —— `bg`/`background` 设背景,`text`/`fg` 设文字色。
|
||||
* `<iframe src=".../#/public/leaderboard?theme=dark&bg=%23ffffff&text=%23111111">`
|
||||
* —— `theme` 切整体明暗(`dark`/`light`,兼容 `dark-blue`/`red` 等别名),
|
||||
* `bg`/`background` 设背景,`text`/`fg` 设文字色。
|
||||
* 2. `postMessage`(嵌套后动态切换,比如宿主切主题时同步):
|
||||
* `iframe.contentWindow.postMessage({ type: "deerflow:set-bg", bg: "#fff", text: "#111" }, "*")`
|
||||
* `iframe.contentWindow.postMessage({ type: "deerflow:set-theme", theme: "dark" }, "*")`
|
||||
*
|
||||
* 不传时返回空对象,页面沿用自带的默认配色(Tailwind 类),互不影响。
|
||||
* 不传时沿用页面自带的默认配色(Tailwind 类)与站内主题,互不影响。
|
||||
* 主题经 `usePublicEmbedTheme` 在 App 层接成 next-themes 的 `forcedTheme`,
|
||||
* 只影响当前渲染、不写 localStorage(原因见 App.tsx 内注释)。
|
||||
*/
|
||||
export type PublicBackground = {
|
||||
backgroundColor?: string;
|
||||
@@ -53,6 +57,63 @@ function readFromSearch(search: string): PublicBackground {
|
||||
* `window.location.search`(`?bg=` 在 `#` 前面);路由内的更贴近当前页,优先级更高。
|
||||
* 同时监听 `postMessage` 以支持嵌套后动态改色。
|
||||
*/
|
||||
/**
|
||||
* 把外部传入的主题标识规整成 light/dark;不认识或为空返回 undefined(沿用站内主题)。
|
||||
* 兼容嵌入聊天页已有的取值习惯:`dark-blue`/深蓝 → 深色,`red`/红白 → 浅色。
|
||||
*/
|
||||
export function normalizePublicTheme(
|
||||
raw: string | null | undefined,
|
||||
): "dark" | "light" | undefined {
|
||||
const value = raw?.trim().toLowerCase();
|
||||
if (!value) return undefined;
|
||||
if (value === "dark" || value === "dark-blue" || value === "深蓝" || value === "深色") {
|
||||
return "dark";
|
||||
}
|
||||
if (value === "light" || value === "red" || value === "红白" || value === "浅色") {
|
||||
return "light";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readThemeFromSearch(search: string): "dark" | "light" | undefined {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
return normalizePublicTheme(params.get("theme"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取公开页的外部主题(`?theme=` 或 `deerflow:set-theme` postMessage)。
|
||||
*
|
||||
* 与 `usePublicBackground` 同样合并两处查询串(hash 路由内优先),
|
||||
* postMessage 的动态值优先级最高。返回 undefined 表示外部未指定。
|
||||
*/
|
||||
export function usePublicEmbedTheme(): "dark" | "light" | undefined {
|
||||
const location = useLocation();
|
||||
|
||||
const fromUrl = useMemo(() => {
|
||||
const outer = readThemeFromSearch(
|
||||
typeof window !== "undefined" ? window.location.search : "",
|
||||
);
|
||||
const inner = readThemeFromSearch(location.search);
|
||||
return inner ?? outer;
|
||||
}, [location.search]);
|
||||
|
||||
const [override, setOverride] = useState<"dark" | "light" | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
const data = event.data as { type?: string; theme?: string } | null;
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (data.type !== "deerflow:set-theme") return;
|
||||
const theme = normalizePublicTheme(data.theme);
|
||||
if (theme) setOverride(theme);
|
||||
}
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
return override ?? fromUrl;
|
||||
}
|
||||
|
||||
export function usePublicBackground(): PublicBackground {
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
Reference in new issue
Block a user