From 62fd2e6f063ef439bf2165688665ce637d187698 Mon Sep 17 00:00:00 2001 From: mt Date: Wed, 10 Jun 2026 17:51:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(brand):=20=E6=96=B0=E5=A2=9E=E5=93=81?= =?UTF-8?q?=E7=89=8C=E5=88=87=E6=8D=A2=E7=B3=BB=E7=BB=9F=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 定义 Brand 类型、BrandCopy 文案映射、BRAND_COPY 配置 - BrandProvider + useBrand hook 提供 brand/copy/rootClassName - BrandSessionInitializer 从 URL ?isSxwz= 初始化品牌会话 - sessionStorage 持久化 + URL 参数优先级解析 - parseBrandFromSearchParams 区分为 true/false/无参数三种情况 - 新增 default 品牌 Logo (coxwork.png) --- frontend/public/coxwork.png | Bin 0 -> 6548 bytes frontend/src/core/brand/brand-session.test.ts | 39 +++++++++ frontend/src/core/brand/index.ts | 76 ++++++++++++++++++ frontend/src/core/brand/provider-client.tsx | 34 ++++++++ frontend/src/core/brand/provider.tsx | 55 +++++++++++++ 5 files changed, 204 insertions(+) create mode 100644 frontend/public/coxwork.png create mode 100644 frontend/src/core/brand/brand-session.test.ts create mode 100644 frontend/src/core/brand/index.ts create mode 100644 frontend/src/core/brand/provider-client.tsx create mode 100644 frontend/src/core/brand/provider.tsx diff --git a/frontend/public/coxwork.png b/frontend/public/coxwork.png new file mode 100644 index 0000000000000000000000000000000000000000..ce15af5127c84a775efd454cef96d7895804ea4f GIT binary patch literal 6548 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 { + 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; +}