Files
hunjing/frontend/src/components/layout/Sidebar.tsx
T
chenxin 7e23d6fa84 M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流
- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位
- 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起)
- 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数
- 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离
- 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换
- 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库
- 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id
- 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继
- 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
2026-08-27 20:10:11 +08:00

326 lines
9.7 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { LogoButton } from "@/components/easter-egg/LogoButton";
import {
AdminIcon,
ArchiveIcon,
MemoryIcon,
ChatIcon,
CharacterIcon,
ModelIcon,
SettingsIcon,
ToolsIcon,
WorldIcon,
} from "@/components/ui/Icons";
import { Badge } from "@/components/ui/Badge";
import { FEATURE_FLAGS } from "@/lib/config";
import { useAuthStore } from "@/lib/stores/auth-store";
import {
NEXT_THEME_LABEL,
useSettingsStore,
} from "@/lib/stores/settings-store";
import { cn } from "@/lib/utils/cn";
import type { ReactNode } from "react";
interface NavItem {
href: string;
label: string;
icon: ReactNode;
enabled?: boolean;
}
interface NavGroup {
title: string;
items: NavItem[];
}
/**
* 主导航分组:灵台(镜树)、法身(灵胚/人物)、内景(世界)、
* 藏识(记忆/归档)、百工(工具/神识阁/设置)。
* 旧人格/内驱力/五行/灵枢/轨迹页随 M6 退役(人格=道体锚点+缘契,轨迹并入镜树)。
*/
const NAV_GROUPS: NavGroup[] = [
{
title: "灵台",
items: [{ href: "/trees", label: "镜树", icon: <ChatIcon /> }],
},
{
title: "法身",
items: [
{ href: "/embryos", label: "灵胚", icon: <CharacterIcon /> },
{ href: "/characters", label: "人物", icon: <CharacterIcon /> },
],
},
{
title: "内景",
items: [
{
href: "/world",
label: "世界",
icon: <WorldIcon />,
enabled: FEATURE_FLAGS.worldPanel,
},
],
},
{
title: "藏识",
items: [
{ href: "/memories", label: "记忆", icon: <MemoryIcon /> },
{ href: "/archive", label: "归档", icon: <ArchiveIcon /> },
],
},
{
title: "百工",
items: [
{ href: "/tools", label: "工具", icon: <ToolsIcon /> },
{ href: "/models", label: "神识阁", icon: <ModelIcon /> },
{ href: "/settings", label: "设置", icon: <SettingsIcon /> },
],
},
];
export function Sidebar({
expanded = true,
onToggleFold,
onNavigate,
}: {
expanded?: boolean;
onToggleFold?: () => void;
onNavigate?: () => void;
}) {
const pathname = usePathname();
const router = useRouter();
const theme = useSettingsStore((s) => s.theme);
const toggleTheme = useSettingsStore((s) => s.toggleTheme);
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const handleLogout = () => {
logout();
router.push("/login");
};
return (
<div
className={cn(
"flex h-full w-full flex-col pt-3 transition-[padding] duration-200",
expanded ? "px-3" : "px-0",
)}
>
{/* 品牌 + 折叠钮:窄态居中、宽态两端对齐(设计稿 sidebar-topbar 同源) */}
<div
className={cn(
"flex items-center gap-1 py-2",
expanded ? "justify-between px-1" : "justify-center px-0",
)}
>
<LogoButton compact={!expanded} />
{onToggleFold && (
<button
type="button"
onClick={onToggleFold}
aria-label={expanded ? "收起侧栏" : "展开侧栏"}
title={expanded ? "收起侧栏" : "展开侧栏"}
className="flex h-11 w-11 items-center justify-center rounded-lg text-muted transition-colors hover:bg-overlay-hover hover:text-fg"
>
<FoldIcon />
</button>
)}
</div>
{/* 分组导航 */}
<nav className={cn("mt-3 flex flex-col", expanded ? "gap-4" : "gap-3")}>
{NAV_GROUPS.map((group) => {
const visible = group.items.filter((i) => i.enabled !== false);
if (visible.length === 0) return null;
return (
<div key={group.title}>
{expanded && (
<p className="mb-1 px-3 text-[10px] font-medium uppercase tracking-[0.15em] text-muted/60">
{group.title}
</p>
)}
<div
className={cn("flex flex-col", expanded ? "gap-0.5" : "gap-1")}
>
{visible.map((item) => {
const active =
pathname === item.href ||
pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
title={expanded ? undefined : item.label}
className={cn(
"flex items-center rounded-[10px] font-medium transition-all duration-200",
expanded
? "gap-2.5 px-3 py-2 text-sm"
: "justify-center px-0 py-2",
active
? "bg-patina-wash text-patina"
: "text-muted hover:bg-overlay-hover hover:text-fg",
)}
>
<span className="shrink-0 opacity-70">{item.icon}</span>
{expanded && item.label}
</Link>
);
})}
</div>
</div>
);
})}
</nav>
{/* 底部:用户卡片常驻 + 管理入口 */}
<div className="mt-auto border-t border-border-glass pt-3 pb-1">
{user && (
<div
className={cn(
"mb-2 flex items-center rounded-xl",
expanded ? "gap-2.5 px-3 py-2" : "justify-center px-0 py-2",
)}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-patina text-xs font-medium text-ink">
{user.username.charAt(0).toUpperCase()}
</div>
{expanded && (
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-fg">
{user.username}
</p>
<p className="truncate text-[11px] text-muted">
{user.is_admin ? "管理员" : user.email}
</p>
</div>
)}
</div>
)}
{user?.is_admin && FEATURE_FLAGS.adminPanel && (
<Link
href="/admin"
onClick={onNavigate}
aria-current={pathname.startsWith("/admin") ? "page" : undefined}
title={expanded ? undefined : "司命台"}
className={cn(
"flex items-center rounded-[10px] font-medium transition-all duration-200",
expanded
? "gap-2.5 px-3 py-2 text-sm"
: "justify-center px-0 py-2",
pathname.startsWith("/admin")
? "bg-patina-wash text-patina"
: "text-muted hover:bg-overlay-hover hover:text-fg",
)}
>
<span className="shrink-0 text-cinnabar opacity-90">
<AdminIcon />
</span>
{expanded && "司命台"}
{expanded && (
<Badge tone="accent" className="ml-auto text-[10px]">
管理
</Badge>
)}
</Link>
)}
<button
type="button"
onClick={toggleTheme}
title={expanded ? undefined : NEXT_THEME_LABEL[theme]}
className={cn(
"mt-1 flex items-center rounded-[10px] font-medium transition-all duration-200",
expanded
? "w-full gap-2.5 px-3 py-2 text-xs"
: "mx-auto justify-center px-0 py-2",
"text-muted hover:bg-overlay-hover hover:text-fg",
)}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<circle
cx="8"
cy="8"
r="3"
stroke="currentColor"
strokeWidth="1.3"
/>
<path
d="M8 1v2M8 13v2M2 8h2M12 8h2M3.5 3.5l1.5 1.5M11 11l1.5 1.5M3.5 12.5l1.5-1.5M11 5l1.5-1.5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
{expanded && NEXT_THEME_LABEL[theme]}
</button>
<button
type="button"
onClick={handleLogout}
title={expanded ? undefined : "退出登录"}
className={cn(
"mt-0.5 flex items-center rounded-[10px] font-medium transition-all duration-200",
expanded
? "w-full gap-2.5 px-3 py-2 text-xs"
: "mx-auto justify-center px-0 py-2",
"text-muted hover:bg-overlay-hover hover:text-cinnabar",
)}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<path
d="M6 2H3a1 1 0 00-1 1v10a1 1 0 001 1h3M11 11l3-3-3-3M14 8H6"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{expanded && "退出登录"}
</button>
</div>
</div>
);
}
/** 侧栏折叠钮图标:面板 + 左竖线(设计稿 sidebar-foldbtn 同源) */
function FoldIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<rect
x="3"
y="4"
width="18"
height="16"
rx="2"
stroke="currentColor"
strokeWidth="1.6"
/>
<path
d="M9 4v16"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}