diff --git a/frontend/public/coxwork.png b/frontend/public/coxwork.png new file mode 100644 index 00000000..ce15af51 Binary files /dev/null and b/frontend/public/coxwork.png differ diff --git a/frontend/src/core/brand/brand-session.test.ts b/frontend/src/core/brand/brand-session.test.ts new file mode 100644 index 00000000..cfc34db0 --- /dev/null +++ b/frontend/src/core/brand/brand-session.test.ts @@ -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"); +}); diff --git a/frontend/src/core/brand/index.ts b/frontend/src/core/brand/index.ts new file mode 100644 index 00000000..a8d4e90d --- /dev/null +++ b/frontend/src/core/brand/index.ts @@ -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 = { + 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): Brand | null { + const value = storage.getItem(BRAND_SESSION_STORAGE_KEY); + return isBrand(value) ? value : null; +} + +export function writeStoredBrand( + storage: Pick, + brand: Brand, +): void { + storage.setItem(BRAND_SESSION_STORAGE_KEY, brand); +} diff --git a/frontend/src/core/brand/provider-client.tsx b/frontend/src/core/brand/provider-client.tsx new file mode 100644 index 00000000..ca3c5a00 --- /dev/null +++ b/frontend/src/core/brand/provider-client.tsx @@ -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; +} diff --git a/frontend/src/core/brand/provider.tsx b/frontend/src/core/brand/provider.tsx new file mode 100644 index 00000000..d55fa267 --- /dev/null +++ b/frontend/src/core/brand/provider.tsx @@ -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(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(getInitialBrand); + + return ( + + {children} + + ); +} + +export function useBrand() { + const context = useContext(BrandContext); + + if (!context) { + throw new Error("useBrand must be used within BrandProvider"); + } + + return context; +}