feat: 业务链条配置管理导航与顶栏用户名统一

业务链条页增加配置管理入口与返回导航;配置管理页通过 aliasPaths 保持侧栏选中;顶栏用户名与设置个人信息页共用 getAccountDisplayName。
This commit is contained in:
fengxiangdebaozi committed 2026-06-16 22:42:42 +08:00
1 parent d8102e8db8
commit 318f6c0c0c
8 files changed
+101 -64

No files matched your search

@@ -6,6 +6,8 @@ import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
import { import {
defaultExpandedIds, defaultExpandedIds,
menuItemContainsPath,
menuItemMatchesPath,
type MenuItem, type MenuItem,
} from "@/core/page-layout/sidebar-menu"; } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu"; import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
@@ -53,13 +55,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}, 180); }, 180);
}; };
const hasActiveChild = (item: MenuItem): boolean => { const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname);
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
// lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步 // lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步
const [activeTopId, setActiveTopId] = useState<string>( const [activeTopId, setActiveTopId] = useState<string>(
@@ -152,9 +148,9 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
const isSidebarItemActive = (item: MenuItem) => const isSidebarItemActive = (item: MenuItem) =>
inWujieEnv inWujieEnv
? item.id === activeTopId ? item.id === activeTopId
: item.path : item.path || item.aliasPaths?.length
? pathname === item.path || pathname.startsWith(item.path + "/") ? menuItemMatchesPath(item, pathname)
: hasActiveChild(item); : menuItemContainsPath(item, pathname);
const handleSidebarItemClick = (item: MenuItem) => { const handleSidebarItemClick = (item: MenuItem) => {
if (inWujieEnv) { if (inWujieEnv) {
handleTopItemClick(item); handleTopItemClick(item);
@@ -176,7 +172,9 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
{items.map((item) => { {items.map((item) => {
const hasChildren = !!item.children?.length; const hasChildren = !!item.children?.length;
const isExpanded = expandedIds.has(item.id); const isExpanded = expandedIds.has(item.id);
const isSelected = item.path ? pathname === item.path : hasActiveChild(item); const isSelected = item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: hasActiveChild(item);
return ( return (
<div <div
+10 -13
View File
@@ -28,6 +28,8 @@ import { useUnreadCount } from "@/core/notifications";
import { import {
defaultExpandedIds, defaultExpandedIds,
getActiveThirdLevelItems, getActiveThirdLevelItems,
menuItemContainsPath,
menuItemMatchesPath,
type MenuItem, type MenuItem,
} from "@/core/page-layout/sidebar-menu"; } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu"; import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
@@ -41,12 +43,8 @@ type PageSidebarProps = {
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about"; type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean => { const hasActiveChild = (item: MenuItem, pathname: string): boolean =>
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) { menuItemContainsPath(item, pathname);
return true;
}
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
};
const findFirstRoutePath = (items?: MenuItem[]): string | null => { const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) { if (!items?.length) {
@@ -116,9 +114,9 @@ function SecondaryNavSidebar() {
const secondaryItems = activeTopItem?.children ?? []; const secondaryItems = activeTopItem?.children ?? [];
const isItemActive = (item: MenuItem) => const isItemActive = (item: MenuItem) =>
item.path item.path || item.aliasPaths?.length
? pathname === item.path || pathname.startsWith(item.path + "/") ? menuItemMatchesPath(item, pathname)
: hasActiveChild(item, pathname); : menuItemContainsPath(item, pathname);
const handleItemClick = (item: MenuItem) => { const handleItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children); const path = item.path ?? findFirstRoutePath(item.children);
@@ -211,9 +209,8 @@ function SecondaryNavSidebar() {
<nav className="light-sidebar-subnav" aria-label="三级菜单"> <nav className="light-sidebar-subnav" aria-label="三级菜单">
{thirdItems.map((child) => { {thirdItems.map((child) => {
const ChildIcon = child.icon; const ChildIcon = child.icon;
const childActive = child.path const childActive = menuItemMatchesPath(child, pathname)
? pathname === child.path || pathname.startsWith(child.path + "/") || (child.children?.length ? menuItemContainsPath(child, pathname) : false);
: hasActiveChild(child, pathname);
return ( return (
<button <button
key={child.id} key={child.id}
@@ -301,7 +298,7 @@ function LegacyAccordionSidebar({
const renderLeaf = (item: MenuItem, depth = 0) => { const renderLeaf = (item: MenuItem, depth = 0) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = !!item.path && pathname === item.path; const isActive = menuItemMatchesPath(item, pathname);
return ( return (
<button <button
@@ -6,7 +6,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { clearStoredAuth, getStoredAuth } from "@/core/auth"; import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -37,8 +37,7 @@ export function AccountSettingsPage() {
); );
} }
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? ""; const displayName = getAccountDisplayName(auth);
const displayName = usernameFromEmail || auth.user_id;
const handleLogout = () => { const handleLogout = () => {
clearStoredAuth(); clearStoredAuth();
+10 -1
View File
@@ -44,8 +44,17 @@ export function clearStoredAuth() {
window.localStorage.removeItem(AUTH_STORAGE_KEY); window.localStorage.removeItem(AUTH_STORAGE_KEY);
} }
/** 与设置 → 个人信息页「用户名」一致:邮箱 @ 前缀,否则 user_id。 */
export function getAccountDisplayName(
auth: LoginByUsernameResponse | null = getStoredAuth(),
): string {
if (!auth) return "用户";
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
return usernameFromEmail || auth.user_id || "用户";
}
/** /**
* 把登录用户名写入 localStorage.userInfo.userName(顶栏用户区据此显示)。 * 把登录用户名写入 localStorage.userInfo.userName(轻应用登录参数等场景使用)。
* 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。 * 仅在尚未设置时写入,避免覆盖外部登录(yUserId)从 consumer 拿到的用户名。
*/ */
export function setStoredUserName(name: string | null | undefined) { export function setStoredUserName(name: string | null | undefined) {
@@ -44,6 +44,8 @@ export type MenuItem = {
label: string; label: string;
icon: ElementType; icon: ElementType;
path?: string; path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */ /** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string; userPath?: string;
adminOnly?: boolean; adminOnly?: boolean;
@@ -219,6 +221,7 @@ export const sidebarMenuItems: MenuItem[] = [
label: "业务链条配置", label: "业务链条配置",
icon: Layers, icon: Layers,
path: "/page/strategy/business-chains", path: "/page/strategy/business-chains",
aliasPaths: ["/page/strategy/business-chain-mapping"],
}, },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" }, { id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, { id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
@@ -308,11 +311,21 @@ export const defaultExpandedIds: ReadonlySet<string> = new Set([
"comprehensive-management", "comprehensive-management",
]); ]);
/** 单条路由是否与 pathname 精确匹配或为其子路径。 */
function routeMatchesPathname(pathname: string, route?: string): boolean {
if (!route) return false;
return pathname === route || pathname.startsWith(route + "/");
}
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
}
/** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */ /** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean { export function menuItemContainsPath(item: MenuItem, pathname: string): boolean {
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) { if (menuItemMatchesPath(item, pathname)) return true;
return true;
}
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false; return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
} }
@@ -9,6 +9,7 @@ import { motion } from "motion/react";
import { Select } from "tdesign-react"; import { Select } from "tdesign-react";
import { import {
AlertCircle, AlertCircle,
ArrowLeft,
HelpCircle, HelpCircle,
Info, Info,
Link2, Link2,
@@ -21,6 +22,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useRouteNavigate } from "@/core/route-base";
import { listAgents } from "@/core/agents/api"; import { listAgents } from "@/core/agents/api";
import type { Agent } from "@/core/agents/types"; import type { Agent } from "@/core/agents/types";
import { listChains } from "@/roundtable-planning/api/chains"; import { listChains } from "@/roundtable-planning/api/chains";
@@ -34,6 +36,7 @@ import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
export default function BusinessChainMappingPage() { export default function BusinessChainMappingPage() {
const navigate = useRouteNavigate();
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
// 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。 // 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。
@@ -233,6 +236,20 @@ export default function BusinessChainMappingPage() {
</span> </span>
</div> </div>
</div> </div>
<button
type="button"
onClick={() => navigate("/page/strategy/business-chains")}
className={cn(
"relative z-10 inline-flex shrink-0 items-center gap-1.5 rounded-[5px] border px-3.5 py-2 text-xs font-semibold transition-all active:scale-95",
isDark
? "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-slate-100"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-900",
)}
>
<ArrowLeft className="size-3.5" />
返回业务链条
</button>
</motion.div> </motion.div>
{/* ── 表格区 ── */} {/* ── 表格区 ── */}
@@ -43,6 +43,7 @@ import {
SearchIcon, SearchIcon,
SendIcon, SendIcon,
SettingsIcon, SettingsIcon,
SlidersHorizontalIcon,
Trash2Icon, Trash2Icon,
UserIcon, UserIcon,
UsersIcon, UsersIcon,
@@ -252,17 +253,27 @@ export default function BusinessChainListPage() {
</p> </p>
</div> </div>
<button <div className="flex shrink-0 items-center gap-2 self-start md:self-auto">
type="button" <button
onClick={() => goConfigure()} type="button"
className={cn( onClick={() => navigate("/page/strategy/business-chain-mapping")}
"flex shrink-0 items-center gap-2.5 self-start rounded-xl px-4 py-2.5 text-xs font-bold shadow-sm shadow-[#CF1322]/10 transition active:scale-95 md:self-auto", className="flex items-center gap-2 rounded-xl border border-zinc-200 bg-white px-4 py-2.5 text-xs font-bold text-zinc-700 shadow-sm transition hover:bg-zinc-50 active:scale-95 dark:border-[#1A2F5A] dark:bg-[#122347] dark:text-[#C5CFE0] dark:hover:bg-[#1A2F5A]"
BRAND, >
)} <SlidersHorizontalIcon className="size-4" />
> <span>配置管理</span>
<PlusIcon className="size-4" /> </button>
<span>新建链条</span> <button
</button> type="button"
onClick={() => goConfigure()}
className={cn(
"flex items-center gap-2.5 rounded-xl px-4 py-2.5 text-xs font-bold shadow-sm shadow-[#CF1322]/10 transition active:scale-95",
BRAND,
)}
>
<PlusIcon className="size-4" />
<span>新建链条</span>
</button>
</div>
</div> </div>
</header> </header>
+15 -22
View File
@@ -1,23 +1,24 @@
import { Bell, ChevronDown, FileText, Shirt, User } from "lucide-react"; import { Bell, ChevronDown, FileText, Shirt, User } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import "@/components/page-sdebar.css"; import "@/components/page-sdebar.css";
import { NotificationDialog } from "@/components/workspace/notification-dialog"; import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth, getAccountDisplayName } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications"; import { useUnreadCount } from "@/core/notifications";
import { useMyPosition } from "@/core/positions"; import { useMyPosition } from "@/core/positions";
import { import {
getActiveThirdLevelItems, getActiveThirdLevelItems,
menuItemContainsPath,
menuItemMatchesPath,
type MenuItem, type MenuItem,
} from "@/core/page-layout/sidebar-menu"; } from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu"; import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTheme, type ThemeType } from "../contexts/ThemeContext"; import { useTheme, type ThemeType } from "../contexts/ThemeContext";
import { readUserInfo } from "./lib/light-app";
const iconButtonClass = (isDark: boolean) => const iconButtonClass = (isDark: boolean) =>
cn( cn(
@@ -135,28 +136,14 @@ const Header = ({ collapsed }: HeaderProps) => {
const { data: myPosition } = useMyPosition(); const { data: myPosition } = useMyPosition();
const positionName = myPosition?.name?.trim() || ""; const positionName = myPosition?.name?.trim() || "";
// 登录时写入的真实用户名(外部登录存于 localStorage.userInfo.userName); const displayName = getAccountDisplayName(auth);
// 缺省回退到 stored auth 的邮箱前缀。
const displayName = useMemo(() => {
const info = readUserInfo();
const name = typeof info.userName === "string" ? info.userName.trim() : "";
if (name) return name;
const email = auth?.email?.trim();
return email ? email.split("@")[0] : "用户";
}, [auth?.email]);
const showTopNav = isDark && !collapsed && !isWujieEnv(); const showTopNav = isDark && !collapsed && !isWujieEnv();
// 浅色(红/蓝)顶栏:一级导航正常横排进顶栏,不做深色那种围绕基座的特殊处理。 // 浅色(红/蓝)顶栏:一级导航正常横排进顶栏,不做深色那种围绕基座的特殊处理。
// 与 collapsed 解耦(折叠只影响侧边栏,一级导航始终可见);wujie 嵌入下隐藏, // 与 collapsed 解耦(折叠只影响侧边栏,一级导航始终可见);wujie 嵌入下隐藏,
// 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。 // 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。
const showLightTopNav = !isDark && !isWujieEnv(); const showLightTopNav = !isDark && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => { const hasActiveChild = (item: MenuItem): boolean => menuItemContainsPath(item, pathname);
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true;
}
return item.children?.some(hasActiveChild) ?? false;
};
const findFirstRoutePath = (items?: MenuItem[]): string | null => { const findFirstRoutePath = (items?: MenuItem[]): string | null => {
if (!items?.length) { if (!items?.length) {
@@ -189,7 +176,9 @@ const Header = ({ collapsed }: HeaderProps) => {
}; };
const renderTopItem = (item: MenuItem) => { const renderTopItem = (item: MenuItem) => {
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item); const isSelected = item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: hasActiveChild(item);
return ( return (
<button <button
key={item.id} key={item.id}
@@ -203,7 +192,9 @@ const Header = ({ collapsed }: HeaderProps) => {
}; };
const renderLightTopItem = (item: MenuItem) => { const renderLightTopItem = (item: MenuItem) => {
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item); const isSelected = item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: hasActiveChild(item);
return ( return (
<button <button
key={item.id} key={item.id}
@@ -388,7 +379,9 @@ const Header = ({ collapsed }: HeaderProps) => {
<nav className="third-submenu-bar" aria-label="三级菜单"> <nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list"> <div className="third-submenu-list">
{thirdSubNavItems.map((item) => { {thirdSubNavItems.map((item) => {
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item); const isSelected = item.path || item.aliasPaths?.length
? menuItemMatchesPath(item, pathname)
: hasActiveChild(item);
return ( return (
<button <button