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:
parent
63563ce6a3
commit
62fd2e6f06
BIN
frontend/public/coxwork.png
Normal file
BIN
frontend/public/coxwork.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
39
frontend/src/core/brand/brand-session.test.ts
Normal file
39
frontend/src/core/brand/brand-session.test.ts
Normal 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");
|
||||
});
|
||||
76
frontend/src/core/brand/index.ts
Normal file
76
frontend/src/core/brand/index.ts
Normal 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);
|
||||
}
|
||||
34
frontend/src/core/brand/provider-client.tsx
Normal file
34
frontend/src/core/brand/provider-client.tsx
Normal 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;
|
||||
}
|
||||
55
frontend/src/core/brand/provider.tsx
Normal file
55
frontend/src/core/brand/provider.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user