feat(brand): 新增品牌切换系统核心模块

- 定义 Brand 类型、BrandCopy 文案映射、BRAND_COPY 配置
- BrandProvider + useBrand hook 提供 brand/copy/rootClassName
- BrandSessionInitializer 从 URL ?isSxwz= 初始化品牌会话
- sessionStorage 持久化 + URL 参数优先级解析
- parseBrandFromSearchParams 区分为 true/false/无参数三种情况
- 新增 default 品牌 Logo (coxwork.png)
This commit is contained in:
mt 2026-06-10 17:51:34 +08:00
parent 63563ce6a3
commit 62fd2e6f06
5 changed files with 204 additions and 0 deletions

BIN
frontend/public/coxwork.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";
const {
BRAND_SESSION_STORAGE_KEY,
getBrandRootClassName,
parseBrandFromSearchParams,
resolveBrandSession,
} = await import(new URL("./index.ts", import.meta.url).href);
void test("parseBrandFromSearchParams returns correct brand per param value", () => {
assert.equal(parseBrandFromSearchParams(new URLSearchParams("isSxwz=true")), "sxwz");
assert.equal(parseBrandFromSearchParams(new URLSearchParams("isSxwz=false")), "default");
assert.equal(parseBrandFromSearchParams(new URLSearchParams("")), null);
});
void test("resolveBrandSession falls back to default without url or storage", () => {
assert.equal(resolveBrandSession({ urlBrand: null, storedBrand: null }), "default");
});
void test("resolveBrandSession keeps stored sxwz when later url omits the flag", () => {
assert.equal(resolveBrandSession({ urlBrand: null, storedBrand: "sxwz" }), "sxwz");
});
void test("resolveBrandSession downgrades stored sxwz when url explicitly sets isSxwz=false", () => {
const urlBrand = parseBrandFromSearchParams(new URLSearchParams("isSxwz=false"));
assert.equal(resolveBrandSession({ urlBrand, storedBrand: "sxwz" }), "default");
});
void test("resolveBrandSession upgrades to sxwz when url flag is true", () => {
const urlBrand = parseBrandFromSearchParams(new URLSearchParams("isSxwz=true"));
assert.equal(resolveBrandSession({ urlBrand, storedBrand: "default" }), "sxwz");
});
void test("getBrandRootClassName returns stable workspace hook classes", () => {
assert.equal(getBrandRootClassName("default"), "brand-default");
assert.equal(getBrandRootClassName("sxwz"), "brand-sxwz");
assert.equal(BRAND_SESSION_STORAGE_KEY, "deerflow.brand-session");
});

View File

@ -0,0 +1,76 @@
export const BRAND_SESSION_STORAGE_KEY = "deerflow.brand-session";
export const DEFAULT_BRAND = "default" as const;
const SXWZ_BRAND = "sxwz" as const;
export type Brand = typeof DEFAULT_BRAND | typeof SXWZ_BRAND;
export type BrandCopy = {
productLabel: string;
appName: string;
appLogoSrc?: string;
appLogoAlt?: string;
};
export const BRAND_COPY: Record<Brand, BrandCopy> = {
default: {
productLabel: "轻办公",
appName: "coxworker",
appLogoSrc: "/coxwork.png",
appLogoAlt: "coxworker",
},
sxwz: {
productLabel: "在线教育智能体",
appName: "coxstudy",
},
};
export function isBrand(value: string | null): value is Brand {
return value === DEFAULT_BRAND || value === SXWZ_BRAND;
}
export function parseBrandFromSearchParams(
searchParams: URLSearchParams,
): Brand | null {
const value = searchParams.get("isSxwz");
if (value === "true") return SXWZ_BRAND;
if (value === "false") return DEFAULT_BRAND;
return null;
}
export function resolveBrandSession({
urlBrand,
storedBrand,
}: {
urlBrand: Brand | null;
storedBrand: Brand | null;
}): Brand {
if (urlBrand === SXWZ_BRAND) {
return SXWZ_BRAND;
}
if (urlBrand === DEFAULT_BRAND) {
return DEFAULT_BRAND;
}
if (storedBrand === SXWZ_BRAND) {
return SXWZ_BRAND;
}
return DEFAULT_BRAND;
}
export function getBrandRootClassName(brand: Brand): string {
return brand === SXWZ_BRAND ? "brand-sxwz" : "brand-default";
}
export function readStoredBrand(storage: Pick<Storage, "getItem">): Brand | null {
const value = storage.getItem(BRAND_SESSION_STORAGE_KEY);
return isBrand(value) ? value : null;
}
export function writeStoredBrand(
storage: Pick<Storage, "setItem">,
brand: Brand,
): void {
storage.setItem(BRAND_SESSION_STORAGE_KEY, brand);
}

View File

@ -0,0 +1,34 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useLayoutEffect } from "react";
import { useBrand } from "./provider";
import {
parseBrandFromSearchParams,
readStoredBrand,
resolveBrandSession,
writeStoredBrand,
} from "./index";
export function BrandSessionInitializer() {
const searchParams = useSearchParams();
const { setBrand } = useBrand();
useLayoutEffect(() => {
const storedBrand = readStoredBrand(window.sessionStorage);
const urlBrand = parseBrandFromSearchParams(
new URLSearchParams(searchParams.toString()),
);
const resolvedBrand = resolveBrandSession({
urlBrand,
storedBrand,
});
writeStoredBrand(window.sessionStorage, resolvedBrand);
setBrand(resolvedBrand);
}, [searchParams, setBrand]);
return null;
}

View File

@ -0,0 +1,55 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import {
BRAND_COPY,
DEFAULT_BRAND,
getBrandRootClassName,
type Brand,
} from "./index";
type BrandContextValue = {
brand: Brand;
copy: (typeof BRAND_COPY)[Brand];
rootClassName: string;
setBrand: (brand: Brand) => void;
};
const BrandContext = createContext<BrandContextValue | null>(null);
function getInitialBrand(): Brand {
if (typeof window === "undefined") {
return DEFAULT_BRAND;
}
const storedBrand = window.sessionStorage.getItem("deerflow.brand-session");
return storedBrand === "sxwz" ? "sxwz" : DEFAULT_BRAND;
}
export function BrandProvider({ children }: { children: ReactNode }) {
const [brand, setBrand] = useState<Brand>(getInitialBrand);
return (
<BrandContext.Provider
value={{
brand,
copy: BRAND_COPY[brand],
rootClassName: getBrandRootClassName(brand),
setBrand,
}}
>
{children}
</BrandContext.Provider>
);
}
export function useBrand() {
const context = useContext(BrandContext);
if (!context) {
throw new Error("useBrand must be used within BrandProvider");
}
return context;
}