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