feat: add configurable platform appearance (#385)
This commit is contained in:
37
web/src/components/brand/brand-logo.tsx
Normal file
37
web/src/components/brand/brand-logo.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appearanceLogoURL, useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { useThemeStore, type ThemeName } from "@/stores/use-theme-store";
|
||||
|
||||
type BrandLogoProps = {
|
||||
className?: string;
|
||||
fallback: ReactNode;
|
||||
alt?: string;
|
||||
theme?: ThemeName | "auto";
|
||||
};
|
||||
|
||||
export function BrandLogo({ className, fallback, alt = "", theme = "auto" }: BrandLogoProps) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
const currentTheme = useThemeStore((state) => state.theme);
|
||||
if (!appearance.logoConfigured) return <>{fallback}</>;
|
||||
return <img src={appearanceLogoURL(appearance, theme === "auto" ? currentTheme : theme)} alt={alt} className={cn("block object-contain", className)} draggable={false} />;
|
||||
}
|
||||
|
||||
export function BrandLogoFrame({ className, logoClassName, fallback, alt = "", theme = "auto" }: BrandLogoProps & { logoClassName?: string }) {
|
||||
const frameEnabled = useAppearanceStore((state) => state.appearance.logoFrameEnabled);
|
||||
const unframedStyle: CSSProperties | undefined = frameEnabled
|
||||
? undefined
|
||||
: {
|
||||
background: "transparent",
|
||||
borderColor: "transparent",
|
||||
borderRadius: 0,
|
||||
boxShadow: "none",
|
||||
color: "inherit",
|
||||
};
|
||||
return (
|
||||
<span className={cn("brand-logo-frame", className)} data-logo-frame-enabled={frameEnabled} style={unframedStyle}>
|
||||
<BrandLogo className={logoClassName} fallback={fallback} alt={alt} theme={theme} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ClientRootInit } from "@/components/layout/client-root-init";
|
||||
import { getAntThemeConfig } from "@/lib/app-theme";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { applyAppearanceMetadata, useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { fetchPluginRuntimeState, setUserPluginEnabled } from "@/services/api/plugins";
|
||||
@@ -17,6 +18,7 @@ import { useUserStore } from "@/stores/use-user-store";
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const dark = theme === "dark";
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
|
||||
const setRuntimeStatuses = usePluginStore((state) => state.setRuntimeStatuses);
|
||||
const setPluginStates = usePluginStore((state) => state.setPluginStates);
|
||||
@@ -38,8 +40,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
let cancelled = false;
|
||||
void fetchPluginRuntimeState()
|
||||
.then(async ({ statuses, states }) => {
|
||||
const legacyEnabledIds = usePluginStore.getState().installations
|
||||
.filter((installation) => installation.enabled && states[installation.manifest.id]?.canToggle && !states[installation.manifest.id]?.userConfigured)
|
||||
const legacyEnabledIds = usePluginStore
|
||||
.getState()
|
||||
.installations.filter((installation) => installation.enabled && states[installation.manifest.id]?.canToggle && !states[installation.manifest.id]?.userConfigured)
|
||||
.map((installation) => installation.manifest.id);
|
||||
if (legacyEnabledIds.length) {
|
||||
try {
|
||||
@@ -69,7 +72,8 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}, [dark, theme]);
|
||||
applyAppearanceMetadata(appearance);
|
||||
}, [appearance, dark, theme]);
|
||||
|
||||
// DEV 复现台必须是同源本地确定性场景:AuthSessionHydrator 会打 /api/auth/session,
|
||||
// ClientRootInit 会打 /api/model-catalog,没有后端时产生真实 502,与导演台无关却会污染判据。
|
||||
|
||||
@@ -2,12 +2,14 @@ import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, Pane
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { BrandLogoFrame } from "@/components/brand/brand-logo";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
|
||||
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
export type WorkspaceNavItem = {
|
||||
id: string;
|
||||
@@ -53,11 +55,7 @@ function buildNav(features: FeatureAvailability, balance: string, isAdmin: boole
|
||||
},
|
||||
{
|
||||
heading: "工作台管理",
|
||||
items: [
|
||||
toolItem("skills", "/skills"),
|
||||
...(features.pluginCenterEnabled || isAdmin ? [toolItem("plugins", "/plugins")] : []),
|
||||
...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : []),
|
||||
],
|
||||
items: [toolItem("skills", "/skills"), ...(features.pluginCenterEnabled || isAdmin ? [toolItem("plugins", "/plugins")] : []), ...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : [])],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -79,6 +77,7 @@ function buildNav(features: FeatureAvailability, balance: string, isAdmin: boole
|
||||
}
|
||||
|
||||
function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void }) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -91,13 +90,7 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="app-workspace-sidebar-rail-header shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="app-workspace-sidebar-rail-button"
|
||||
aria-label="展开侧栏菜单"
|
||||
title="展开侧栏菜单"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<button type="button" className="app-workspace-sidebar-rail-button" aria-label="展开侧栏菜单" title="展开侧栏菜单" onClick={onExpand}>
|
||||
<PanelLeftOpen className="size-4" strokeWidth={1.7} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -114,11 +107,9 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
|
||||
className="group flex w-full items-center justify-between rounded-[var(--r-sm)] px-2 py-2 text-left transition-colors select-none hover:bg-surface-hover"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm">
|
||||
<InfinityIcon className="size-4" strokeWidth={2} />
|
||||
</span>
|
||||
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2} />} />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-medium">影策</span>
|
||||
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-medium">{appearance.brandName}</span>
|
||||
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/42">创作工作台</span>
|
||||
</span>
|
||||
</span>
|
||||
@@ -130,7 +121,7 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||
<div className="app-workspace-nav-popover absolute left-3 right-3 top-full z-50 mt-1 overflow-hidden rounded-lg border border-[var(--workspace-border)] bg-[var(--workspace-surface-strong)] py-1 animate-in fade-in zoom-in-95 duration-100">
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="truncate text-[var(--fs-body)] font-semibold">影策</div>
|
||||
<div className="truncate text-[var(--fs-body)] font-semibold">{appearance.brandName}</div>
|
||||
<div className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/45">创作工作台</div>
|
||||
</div>
|
||||
<div className="mx-2 my-1 h-px bg-[var(--workspace-border)]" />
|
||||
@@ -139,21 +130,12 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
|
||||
{ label: "画布", to: "/canvas" },
|
||||
{ label: "设置", to: "/settings" },
|
||||
].map((entry) => (
|
||||
<button
|
||||
key={entry.to}
|
||||
type="button"
|
||||
onClick={() => go(entry.to)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/80 transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<button key={entry.to} type="button" onClick={() => go(entry.to)} className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/80 transition-colors hover:bg-surface-hover hover:text-foreground">
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="mx-2 my-1 h-px bg-[var(--workspace-border)]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("/canvas?mode=new")}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/45 transition-colors hover:bg-surface-hover hover:text-foreground"
|
||||
>
|
||||
<button type="button" onClick={() => go("/canvas?mode=new")} className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/45 transition-colors hover:bg-surface-hover hover:text-foreground">
|
||||
<Plus className="size-3.5" /> 新建画布
|
||||
</button>
|
||||
</div>
|
||||
@@ -163,7 +145,15 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0, collapsed = false }: {
|
||||
function NavItem({
|
||||
item,
|
||||
activeId,
|
||||
onSelect,
|
||||
onOpenSearch,
|
||||
onLogout,
|
||||
level = 0,
|
||||
collapsed = false,
|
||||
}: {
|
||||
item: WorkspaceNavItem;
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
@@ -200,14 +190,8 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0,
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
) : null}
|
||||
{item.badge !== undefined ? (
|
||||
<span className="flex min-w-5 items-center justify-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium tabular-nums text-primary">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
{hasChildren ? (
|
||||
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/40 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} />
|
||||
) : null}
|
||||
{item.badge !== undefined ? <span className="flex min-w-5 items-center justify-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium tabular-nums text-primary">{item.badge}</span> : null}
|
||||
{hasChildren ? <ChevronRight className={cn("size-3.5 shrink-0 text-foreground/40 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} /> : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -254,15 +238,7 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0,
|
||||
{rowContent}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={rowClassName}
|
||||
style={rowStyle}
|
||||
aria-label={collapsed ? item.title : undefined}
|
||||
title={collapsed ? item.title : undefined}
|
||||
onClick={handleClick}
|
||||
aria-expanded={hasChildren ? isOpen : undefined}
|
||||
>
|
||||
<button type="button" className={rowClassName} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
|
||||
{rowContent}
|
||||
</button>
|
||||
)}
|
||||
@@ -272,16 +248,7 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0,
|
||||
<div className="relative flex min-h-0 flex-col gap-0.5 overflow-hidden pt-0.5">
|
||||
<span className="app-workspace-nav-guide-line" style={{ left: `${(level + 1) * 12 + 12.5}px` }} />
|
||||
{item.children!.map((child) => (
|
||||
<NavItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
activeId={activeId}
|
||||
onSelect={onSelect}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={onLogout}
|
||||
level={level + 1}
|
||||
collapsed={false}
|
||||
/>
|
||||
<NavItem key={child.id} item={child} activeId={activeId} onSelect={onSelect} onOpenSearch={onOpenSearch} onLogout={onLogout} level={level + 1} collapsed={false} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -290,14 +257,7 @@ function NavItem({ item, activeId, onSelect, onOpenSearch, onLogout, level = 0,
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collapsed }: {
|
||||
group: WorkspaceNavGroup;
|
||||
activeId: string;
|
||||
onNavigate: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onLogout: () => void;
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collapsed }: { group: WorkspaceNavGroup; activeId: string; onNavigate: () => void; onOpenSearch: () => void; onLogout: () => void; collapsed: boolean }) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const hasActive = group.items.some((item) => item.id === activeId || (item.id === "settings" && activeId.startsWith("settings:")));
|
||||
|
||||
@@ -309,15 +269,7 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
|
||||
const content = (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
activeId={activeId}
|
||||
onSelect={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={onLogout}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={onLogout} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -329,17 +281,9 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
aria-expanded={isOpen}
|
||||
className="app-workspace-nav-group-toggle select-none"
|
||||
>
|
||||
<button type="button" onClick={() => setIsOpen((open) => !open)} aria-expanded={isOpen} className="app-workspace-nav-group-toggle select-none">
|
||||
<span className="app-workspace-nav-group-label">{group.heading}</span>
|
||||
<ChevronRight
|
||||
className={cn("size-3.5 shrink-0 text-foreground/35 transition-transform duration-200", isOpen && "rotate-90")}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/35 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} />
|
||||
</button>
|
||||
<div className={cn("grid transition-[grid-template-rows,opacity] duration-300 ease-in-out", isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0")}>
|
||||
<div className="min-h-0 overflow-hidden pt-0.5">{content}</div>
|
||||
@@ -357,9 +301,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const { handleLogout } = useWorkspaceLogout();
|
||||
|
||||
const balance = availableMicrocredits === null
|
||||
? "--"
|
||||
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
const balance = availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const { groups, footer } = useMemo(() => buildNav(features, balance, user?.role === "admin"), [features, balance, user?.role]);
|
||||
|
||||
@@ -405,38 +347,17 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className={cn(
|
||||
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 pb-3 pt-2",
|
||||
collapsed && "is-collapsed",
|
||||
scrollState.hasTopFade && "has-top-fade",
|
||||
scrollState.hasBottomFade && "has-bottom-fade",
|
||||
)}
|
||||
className={cn("app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 pb-3 pt-2", collapsed && "is-collapsed", scrollState.hasTopFade && "has-top-fade", scrollState.hasBottomFade && "has-bottom-fade")}
|
||||
>
|
||||
{groups.map((group, index) => (
|
||||
<NavGroup
|
||||
key={index}
|
||||
group={group}
|
||||
activeId={activeId}
|
||||
onNavigate={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={() => void handleLogout()}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavGroup key={index} group={group} activeId={activeId} onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="app-workspace-sidebar-footer shrink-0 px-3 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{footer.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
activeId={activeId}
|
||||
onSelect={onNavigate}
|
||||
onOpenSearch={onOpenSearch}
|
||||
onLogout={() => void handleLogout()}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useWorkspaceTopBarContent } from "@/components/layout/workspace-top-bar
|
||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
home: "首页",
|
||||
@@ -21,6 +22,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen: boolean; onToggleSidebar: () => void }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
@@ -28,7 +30,7 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
|
||||
const extension = useWorkspaceTopBarContent();
|
||||
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const pageTitle = (slug && PAGE_TITLES[slug]) || "影策";
|
||||
const pageTitle = (slug && PAGE_TITLES[slug]) || brandName;
|
||||
|
||||
return (
|
||||
<header className={`app-workspace-topbar flex shrink-0 items-center justify-between gap-2 px-3 sm:px-4 ${extension ? "has-extension" : ""}`}>
|
||||
@@ -37,7 +39,7 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
|
||||
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
|
||||
</button>
|
||||
<nav className={`${extension ? "hidden 2xl:flex" : "flex"} min-w-0 items-center gap-2 text-[var(--fs-caption)] text-foreground/50`} aria-label="当前位置">
|
||||
<Link to="/" className="shrink-0 font-medium text-foreground/70 transition-colors hover:text-foreground">影策</Link>
|
||||
<Link to="/" className="shrink-0 font-medium text-foreground/70 transition-colors hover:text-foreground">{brandName}</Link>
|
||||
<span className="shrink-0 text-foreground/30">/</span>
|
||||
<span className="truncate font-medium text-foreground">{pageTitle}</span>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user